Recently at Canterly, we migrated from Next.js 14 to Next.js 16. In that process, we also switched from webpack to turbopack. This was a great speed improvement that came with some surprises. In particular, log instrumentation via next-logger now triggered a failure during compilation. I had been wanting to replace it anyway, since all it does is to patch console to use Pino instead (It also patches log functions in Next.js next/dist/build/output/log, which is redundant since these functions use console too). So instead, I patched console on my own. This is simpler and allows us to keep control over the instrumentation, rather than obfuscating the process with an additional dependency.
This is what we have now in instrumentation.ts:
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const instrumentation = await import('./instrumentation-nodejs');
await instrumentation.register();
}
}We’ve replaced next-logger.config.cjs with our own logger.ts which is a straight forward port:
import { LoggerOptions, pino } from 'pino';
let pinoOptions: LoggerOptions = {
level: 'warn',
};
if (process.env.NODE_ENV !== 'production') {
pinoOptions = {
level: 'info',
transport: {
target: 'pino-pretty',
},
};
}
export const logger = pino(pinoOptions);And in instrumentation-nodejs.ts:
import type { LogFn } from 'pino';
async function instrumentLogging() {
// NextJS also patches `console` functions during development for better
// debugging; bypass instrumentation unless the environment
// variable CANTERLY_INSTRUMENTATION is "true"
if (
process.env.NODE_ENV === 'development' &&
process.env.CANTERLY_INSTRUMENTATION !== 'true'
) {
return;
}
const loggerModule = await import('./logger');
const wrapLogger = (method: LogFn, ...args: unknown[]) => {
if (args.length === 0) {
return;
}
if (args.length === 1) {
return method(args[0]);
}
// Converts usual Console usage to Pino's meta patterns.
if (
args.length === 2 &&
typeof args[0] === 'object' &&
args[0] !== null &&
typeof args[1] === 'string'
) {
return method(args[0], args[1]);
}
if (typeof args[0] === 'string') {
return method({ consoleArgs: args.slice(1) }, args[0]);
}
return method({ consoleArgs: args });
};
const consoleLogger = loggerModule.logger.child({ name: 'console' });
(['error', 'warn', 'debug', 'info', 'log', 'trace'] as const).forEach(
(level) => {
if (level === 'log') {
console[level] = (...args) =>
wrapLogger(consoleLogger.info.bind(consoleLogger), ...args);
} else {
console[level] = (...args) =>
wrapLogger(consoleLogger[level].bind(consoleLogger), ...args);
}
},
);
}
export async function register() {
await instrumentLogging();
}And that’s it. This instrumentation does not just replace next-logger, it improves on it. With next-logger, the following console call…
console.log("Log without interpolation", valueA, valueB, valueC)…would be patched to the Pino call…
logger.log("Log without interpolation", valueA, valueB, valueC)…but that would lose logging data: when the first argument is a string message, Pino will only keep other arguments when they get interpolated. This new instrumentation preserves the additional arguments, by treating them as a mergingObject.
Now, when our application starts, we can immediately see the additional arguments in the logs, as soon as instrumentation kicks in (We use pino-pretty as the default transport during development):
npm run dev
> ...
> env TZ=UTC next dev
▲ Next.js 16.1.1 (Turbopack)
- Local: http://localhost:3000
- Network: http://...
- Environments: .env.local
✓ Starting...
[03:23:14.265] INFO (console/28071): ✓ Ready in 1439ms
[03:23:14.447] WARN (console/28071): [auth][warn][debug-enabled]
consoleArgs: [
"Read more: https://warnings.authjs.dev"
]