Custom logging in Next.js
Installation
This guide assumes you already have Next.js set up.
First, install the required packages. You can use any transport you prefer - we'll use Pino in this example:
npm i loglayer @loglayer/transport-pino pino serialize-error
pnpm add loglayer @loglayer/transport-pino pino serialize-error
yarn add loglayer @loglayer/transport-pino pino serialize-error
Setup
// logger.ts
import { LogLayer } from 'loglayer'
import { PinoTransport } from '@loglayer/transport-pino'
import { serializeError } from 'serialize-error'
import pino from 'pino'
// Create a Pino instance (only needs to be done once)
const pinoLogger = pino({
level: 'trace' // Set to desired log level
})
const log = new LogLayer({
errorSerializer: serializeError,
transport: new PinoTransport({
logger: pinoLogger
})
})
export function getLogger() {
return log;
}
We expose a function called getLogger()
to get the logger instance. We do this in the event that you want to mock the logger in your tests, where you can override getLogger()
to return the LogLayer mock, MockLogLayer.
At this point you should be able to call getLogger()
anywhere in your Next.js app to get the logger instance and write logs.
// pages.tsx
import { getLogger } from './logger'
export default function Page() {
const log = getLogger()
log.withMetadata({
some: "data"
}).info('Hello, world!')
return <div>Hello, world!</div>
}
Distinguish between server and client logs
If you use transports that are only client-side or server-side (such as the DataDog and DataDog Browser Transports), you can conditionally enable them based on the environment.
Add a const to detect if the code is running on the server or client:
const isServer = typeof window === 'undefined'
Modify your transport to run only on the server:
const isServer = typeof window === 'undefined'
const log = new LogLayer({
errorSerializer: serializeError,
transport: new PinoTransport({
enabled: isServer, // runs server-side only
logger: pinoLogger
}),
plugins: [
{
// Add a plugin to label the log entry as coming from the server or client
onBeforeMessageOut(params: PluginBeforeMessageOutParams) {
const tag = isServer ? "Server" : "Client";
if (params.messages && params.messages.length > 0) {
if (typeof params.messages[0] === "string") {
params.messages[0] = `[${tag}] ${params.messages[0]}`;
}
}
return params.messages;
},
},
]
})
// Can also add to context data too; would be stamped on every log entry
log.withContext({
isServer
})
Handling server-side uncaught exceptions and rejections
Next.js does not have a way to use a custom logger for server-side uncaught exceptions and rejections.
To use LogLayer for this, you will need to create an instrumentation file in the root of your project.
Here's an example using the Pino and DataDog transports:
// instrumentation.ts
import { LogLayer, type ILogLayer } from 'loglayer';
import { DataDogTransport } from "@loglayer/transport-datadog";
import { PinoTransport } from "@loglayer/transport-pino";
import pino from "pino";
import { serializeError } from "serialize-error";
/**
* Strip ANSI codes from a string, which is something Next.js likes to inject.
*/
function stripAnsiCodes(str: string): string {
return str.replace(
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
"",
);
}
/**
* Create a console method that logs to LogLayer
*/
function createConsoleMethod(log: ILogLayer, method: "error" | "info" | "warn" | "debug" | "log") {
let mappedMethod = method;
if (method === "log") {
mappedMethod = "info";
}
return (...args: unknown[]) => {
const data: Record<string, unknown> = {};
let hasData = false;
let error: Error | null = null;
const messages: string[] = [];
for (const arg of args) {
if (arg instanceof Error) {
error = arg;
continue;
}
if (typeof arg === "object" && arg !== null) {
Object.assign(data, arg);
hasData = true;
continue;
}
if (typeof arg === "string") {
messages.push(arg);
}
}
let finalMessage = stripAnsiCodes(messages.join(" ")).trim();
// next.js uses an "x" for the error message when it's an error object
if (finalMessage === "⨯" && error) {
finalMessage = error?.message || "";
}
if (error && hasData && messages.length > 0) {
log.withError(error).withMetadata(data)[mappedMethod](finalMessage);
} else if (error && messages.length > 0) {
log.withError(error)[mappedMethod](finalMessage);
} else if (hasData && messages.length > 0) {
log.withMetadata(data)[mappedMethod](finalMessage);
} else if (error && hasData && messages.length === 0) {
log.withError(error).withMetadata(data)[mappedMethod]("");
} else if (error && messages.length === 0) {
log.errorOnly(error);
} else if (hasData && messages.length === 0) {
log.metadataOnly(data);
} else {
log[mappedMethod](finalMessage);
}
};
}
export async function register() {
const logger = new LogLayer({
errorSerializer: serializeError,
transport: [
new PinoTransport({
logger: pino(),
}),
new DataDogTransport(...),
]
})
if (process.env.NEXT_RUNTIME === "nodejs") {
console.error = createConsoleMethod(logger, "error");
console.log = createConsoleMethod(logger, "log");
console.info = createConsoleMethod(logger, "info");
console.warn = createConsoleMethod(logger, "warn");
console.debug = createConsoleMethod(logger, "debug");
}
}
If you threw an error from page.tsx
that is uncaught, you should see this in the terminal:
{"err":{"type":"Object","message":"test","stack":"Error: test\n at Page (webpack-internal:///(rsc)/./src/app/page.tsx:12:11)","digest":"699232626","name":"Error"},"msg":"test"}