A universal structured logging kit for Next.js — patches Next.js'
internal logger and the global console.* sink, routing all server-side
output through a single level-controllable consola
instance with pluggable reporters for structured JSON logging, Sentry,
and beyond. Works with the App Router, Turbopack, and Node.js
instrumentation — no custom server, no module monkey-patching.
Wraps the global console.* — the same sink Next.js' own internal logger
funnels through — so all diagnostic output flows through a single
level-controllable consola instance, with
pluggable reporters for structured JSON and more. No custom server, no
module monkey-patching (which is unreachable under Turbopack anyway).
Inspired by sainsburys-tech/next-logger,
which does the same with pino. This package swaps pino
for consola and delivers configuration through an idiomatic withLogger()
config wrapper.
npm install @vsfedorenko/next-logger consola
# or
bun add @vsfedorenko/next-logger consolaconsola is a peer dependency — install it alongside this package.
Two steps.
1. Wrap your Next.js config (next.config.ts):
import { withLogger } from "@vsfedorenko/next-logger";
export default withLogger({ consola: { level: 4 } })({
// ...your other next config
});2. Call init() from instrumentation (instrumentation.ts, project root):
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { init } = await import("@vsfedorenko/next-logger");
init();
}
}Done. Every console.* call on the server now flows through consola. Next.js'
own logs (build output, route compilation, etc.) are captured too — they share
the same console.* sink.
A runnable example app lives in
examples/basic/.
withLogger(options) serialises options into the NEXT_LOGGER_CONFIG
environment variable via Next.js' validated env config key — inlined at
build time, read back at runtime. No "Unrecognized key" warning, works under
both webpack and Turbopack.
withLogger({
consola: {
level: 4, // debug
formatOptions: { date: false }, // consola format options
},
})Only serialisable consola options are supported (level, formatOptions, …).
init({ console: false }) builds the logger without wrapping console.*.
Use this if you want the configured consola instance (via getLogger()) for
manual logging but prefer to leave the global console untouched.
When console patching is active, a logger implementation that itself prints
via console.log/console.error (a custom pretty-print backend, for example)
does not loop: re-entrant calls made from inside the logger are forwarded to
the ORIGINAL console methods. Output is preserved; the recursion is broken.
@vsfedorenko/next-logger is backend-agnostic. The default backend is
consola, but you can register your own logging backend adapter — winston,
pino, loglevel, or anything that satisfies the Logger interface.
Register your own logging backend:
import { defineBackend, withLogger } from "@vsfedorenko/next-logger";
defineBackend("winston", (opts) => {
const winston = createMyWinstonLogger(opts);
return {
level: winston.level,
trace: (...a) => winston.verbose(...a),
debug: (...a) => winston.debug(...a),
info: (...a) => winston.info(...a),
warn: (...a) => winston.warn(...a),
error: (...a) => winston.error(...a),
fatal: (...a) => winston.error(...a),
log: (...a) => winston.info(...a),
withTag: (tag) => winston.child({ tag }),
};
});
// Use it:
withLogger({ backend: "winston" })({
// ...your next config
});| Backend | Package | Description |
|---|---|---|
consola |
consola |
Default — pretty output, pluggable reporters (JSON, Sentry). |
pino |
pino |
JSON-first, high performance. Optional peer dependency. |
winston |
winston |
Versatile, transport-based. Optional peer dependency. |
Use backend in withLogger to select:
withLogger({ backend: "pino", backendOptions: { name: "api" } })The level resolves in order:
consola.levelfromwithLoggerLOG_LEVEL(numeric or named)NEXT_PUBLIC_LOG_LEVEL(numeric or named)3(info) — default
Named levels: silent (-∞), fatal (0), error (0), warn (1),
log (2), info (3), success (3), debug (4), trace (5), verbose (∞).
Server-side output format, controlled by env var:
LOG_FORMAT(textorjson)NEXT_PUBLIC_LOG_FORMAT(same values)
Falling back to text (consola's default pretty reporter).
Human-readable, coloured in TTY, with timestamps — consola's built-in reporter. Best for local development.
Newline-delimited JSON to stdout (errors → stderr), suitable for structured-log aggregators (Loki, Datadog, CloudWatch, Elasticsearch). Best for production.
{"level":"info","type":"log","tag":"console","msg":"API /api/hello hit","date":"2026-07-12T10:00:00.000Z"}Each line contains:
| Field | Description |
|---|---|
level |
Named level (error/warn/info/debug/trace) |
type |
Consola log type (e.g. error, warn, info, success, ready, event) |
tag |
The consola tag (next.js, console, …) |
msg |
The message string (multi-arg strings joined with space) |
date |
ISO 8601 timestamp |
args |
Additional structured arguments (omitted when none) |
Errors are serialised as { name, message, stack }. Circular references become
[Circular]. BigInts become strings.
The redaction reporter is a middleware that strips sensitive data from log entries before they reach a wrapped reporter. It protects every downstream sink — JSON, pretty console, Sentry breadcrumb — with a single decorator.
// instrumentation.ts
import { init, getLogger, createJsonReporter, createRedactionReporter } from "@vsfedorenko/next-logger";
init();
const logger = getLogger();
const json = createJsonReporter();
const redacting = createRedactionReporter({ reporter: json });
logger.setReporters([redacting]);Two redaction strategies run together:
-
Pattern-based — regexes matched against every string in a log entry (string args, error messages, object values). Ships with sensible defaults: emails, credit-card numbers, JWT tokens, and long hex/base64 API keys.
-
Key-based — when a plain-object arg contains a key whose name matches a known sensitive key (
password,token,apiKey, …), the value is replaced, regardless of type. The original log object is never mutated — a sanitised shallow copy is forwarded.
| Input | Output |
|---|---|
logger.info("ping admin@example.com") |
ping [REDACTED] |
logger.info("user", { name: "alice", password: "x" }) |
user + { name: "alice", password: "[REDACTED]" } |
| Option | Type | Default | Description |
|---|---|---|---|
reporter |
ConsolaReporter |
(required) | The wrapped reporter that receives sanitised log objects. |
patterns |
(RegExp | string)[] |
built-in defaults | Regex patterns applied to strings. Replaces the defaults. |
keys |
string[] |
built-in defaults | Sensitive object-key names (case-insensitive substring). |
replacement |
string |
"[REDACTED]" |
Replacement text for every match. |
// Custom patterns + keys + replacement
const redacting = createRedactionReporter({
reporter: createJsonReporter(),
patterns: [/ORD-\d{6}/g], // order numbers only (replaces defaults)
keys: ["ssn", "taxId"], // replaces default keys
replacement: "***",
});Pass patterns or keys to replace the defaults (merging is intentional
opt-in — a caller that supplies them owns the full set).
The pino reporter bridges every consola log entry into a
pino logger. For teams already invested in pino —
transports, destinations, pipelines, tooling — it lets next-logger feed
pino without giving up consola's level control, console patching, or other
reporters. Consola remains the single sink; pino becomes one of its outputs.
// instrumentation.ts
import { init, getLogger } from "@vsfedorenko/next-logger";
import { createPinoReporter } from "@vsfedorenko/next-logger/reporters/pino";
init();
const logger = getLogger();
logger.addReporter(createPinoReporter({ options: { name: "api" } }));pino is an optional peer dependency — install it only when you use this
reporter:
npm install pinoIf pino isn't installed, the reporter resolves its dynamic import once,
caches the failure, and becomes a silent no-op — safe to attach
unconditionally.
Consola log levels map onto pino levels as follows:
| Consola level | Pino level |
|---|---|
error / fatal (0) |
error |
warn (1) |
warn |
log (2) |
info |
info / success (3) |
info |
debug (4) |
debug |
trace / verbose (5) |
trace |
Each entry is forwarded as logger.<level>(mergeContext, msg):
msg— string arguments andlogObj.messagejoined into the primary message.tag— the consola tag, passed as atagfield in the merge context.- structured args —
Errorinstances ({ name, message, stack }) and plain objects are merged into the context keyed by argument position.
| Option | Type | Default | Description |
|---|---|---|---|
options |
PinoOptions |
(none) | Options forwarded to the lazily-resolved pino() factory. |
logger |
PinoLogger |
(none) | A pre-built pino instance. Skips the factory call when supplied. |
Pass options to let the reporter build its own pino logger, or logger to
inject one you've already configured (transports, destinations, custom
levels). The two are mutually exclusive — logger wins.
The winston backend replaces the default consola sink with a
winston logger. For teams already
invested in winston — transports, formats, custom levels, log files — it lets
next-logger route all output through winston directly. Winston becomes the
single sink instead of consola.
// instrumentation.ts
import { init } from "@vsfedorenko/next-logger";
import { registerWinstonBackend } from "@vsfedorenko/next-logger/backends/winston";
registerWinstonBackend();
init();Select the backend in your Next config:
// next.config.ts
import { withLogger } from "@vsfedorenko/next-logger";
withLogger({ backend: "winston", backendOptions: { level: "info" } })({
// ...your next config
});winston is an optional peer dependency — install it only when you use
this backend:
npm install winstonIf winston isn't installed, the adapter throws a clear error with install
instructions when the backend is selected.
Consola log levels map onto winston levels as follows:
| Consola level | Winston level |
|---|---|
error / fatal (0) |
error |
warn (1) |
warn |
log (2) |
info |
info / success (3) |
info |
debug (4) |
debug |
trace / verbose (5) |
verbose |
All arguments are passed through to the underlying winston level method verbatim — no serialisation or string-joining is applied, so winston's own formatting, splat handling, and transport pipelines receive the original values.
withTag(tag) creates a child logger via winston.child({ tag }), carrying
the tag as a persistent binding on every subsequent log entry.
The Datadog Logs reporter batches log entries and ships them to the
Datadog Logs intake over plain fetch — zero dependencies: no @datadog/* packages are installed or required. The API key is read from the environment (DATADOG_API_KEY / DD_API_KEY) at reporter creation, never from config.
// instrumentation.ts
import { init, getLogger } from "@vsfedorenko/next-logger";
import { createDatadogLogsReporter } from "@vsfedorenko/next-logger/reporters/datadog";
init();
const logger = getLogger();
logger.addReporter(
createDatadogLogsReporter({
service: "my-next-app",
env: process.env.NODE_ENV,
ddtags: "team:web",
}),
);Entries buffer and flush when batchSize entries accumulate or every
flushIntervalMs — whichever comes first. The reporter exposes an
explicit flush() — call it from a shutdown hook (SIGTERM, serverless
freeze) so entries buffered below the threshold are shipped instead of
lost at process exit. Failures never throw from log(): a failed batch
is dropped with a single stderr warning. Without an API key the reporter
warns once and becomes a silent no-op.
| Option | Type | Default | Description |
|---|---|---|---|
site |
string | datadoghq.com |
Datadog site — the <site> in http-intake.logs.<site>. |
service |
string | (none) | The service attribute on every entry. |
env |
string | (none) | Added as env:<value> tag on every entry. |
ddtags |
string | (none) | Comma-separated key:value tags on every entry. |
intakeUrl |
string | https://http-intake.logs.<site>/api/v2/logs |
Full intake URL override (self-hosted / tests). |
batchSize |
number | 50 |
Entries per flush batch. |
flushIntervalMs |
number | 5000 |
Max wait before a partial batch flushes. |
The OTLP logs reporter batches log records and ships them to any
OpenTelemetry Collector via OTLP/HTTP JSON (/v1/logs) over plain fetch
— zero dependencies: no @opentelemetry/* packages are installed or
required. The endpoint is resolved from the spec-defined environment
variables at reporter creation, never from config.
// instrumentation.ts
import { init, getLogger } from "@vsfedorenko/next-logger";
import { createOtlpLogsReporter } from "@vsfedorenko/next-logger/reporters/otlp";
init();
const logger = getLogger();
logger.addReporter(
createOtlpLogsReporter({
serviceName: "my-next-app", // or set OTEL_SERVICE_NAME
}),
);Environment resolution (spec-compliant):
| Variable | Meaning |
|---|---|
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT |
Full logs endpoint, wins over the generic base. |
OTEL_EXPORTER_OTLP_ENDPOINT |
Generic base — /v1/logs is appended per spec. |
OTEL_SERVICE_NAME |
Resource service.name when serviceName is unset. |
Entries map to OTLP LogRecords: consola's numeric level becomes
severityNumber/severityText, string args join into body, Error
args land as structured exception attributes, and service.name rides
the resource. Records buffer and flush when batchSize accumulate or
every flushIntervalMs; the explicit flush() ships tail records from a
shutdown hook. Failures never throw from log(): a failed batch is
dropped with a single stderr warning. Without an endpoint the reporter
warns once and becomes a silent no-op.
| Option | Type | Default | Description |
|---|---|---|---|
endpoint |
string | (env resolution) | Full collector endpoint override. |
serviceName |
string | OTEL_SERVICE_NAME |
Resource service.name. |
resourceAttributes |
object | (none) | Extra resource attributes. |
scopeName |
string | @vsfedorenko/next-logger |
Scope name for the emitted scopeLogs. |
headers |
object | (none) | Extra headers (vendor gateway auth etc.). |
batchSize |
number | 50 |
Records per flush batch. |
flushIntervalMs |
number | 5000 |
Max wait before a partial batch flushes. |
A development-only log viewer: a ring-buffer reporter captures everything
flowing through the logger and a ready-made route handler serves it at
/__logs. Zero dependencies, zero client-side JS.
// instrumentation.ts — attach the capture reporter in dev only
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs" && process.env.NODE_ENV !== "production") {
const { init } = await import("@vsfedorenko/next-logger");
const logger = init();
logger.addReporter(createLogViewerReporter());
}
}// app/__logs/route.ts — serve the page
import { logViewerHandler } from "@vsfedorenko/next-logger/log-viewer";
export const GET = logViewerHandler;GET /__logs— dependency-free HTML table (auto-refresh every 5s, level colors, expandable extras).GET /__logs?format=json— the raw entries as JSON for tooling or a custom UI.
The buffer lives on globalThis (registered symbol) so the
instrumentation bundle and the route bundle — which Turbopack keeps as
separate module instances — share one ring. It is bounded (default 500
entries; capacity option) and returned entries are copies. In
production the handler answers 404 without touching the store — attach
the reporter in dev only and the buffer never fills.
The logger config crosses the build→runtime boundary as JSON, so it can only carry serialisable values — but reporters are live objects. The plugin system bridges the gap: register behaviour at runtime under a stable name, then reference it by name from the serialisable config. Functions cannot cross the build→runtime boundary — names can.
Register a named reporter factory (in instrumentation.ts, where real code
runs). The factory receives the serialisable options from the config and
returns a consola reporter; read secrets from the environment inside the
factory (same policy as built-in reporters). Re-registering replaces.
// instrumentation.ts
import {
defineReporter,
definePreset,
init,
} from "@vsfedorenko/next-logger";
import { createDatadogLogsReporter } from "@vsfedorenko/next-logger/reporters/datadog";
defineReporter("datadog", (options) =>
createDatadogLogsReporter(options as { service?: string }),
);
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
init();
}
}// next.config.ts — reference the reporter by name (serialisable)
import { withLogger } from "@vsfedorenko/next-logger";
export default withLogger({
reporters: [{ name: "datadog", options: { service: "my-app" } }],
})({ /* … */ });At init() the specs resolve into live reporters and are appended to the
built consola instance. The "json" reporter factory ships built-in
({ name: "json" }); network reporters stay on their subpath entries to keep
optional peers tree-shakeable. Unknown names fail loudly at init() with the
list of registered factories — a typo never silently drops logs.
Reporters attach only to consola-based loggers; other backends (pino/winston) bring their own destinations and ignore reporter specs.
Bundle a backend + reporter list under one name, then select the whole stack
with a single string in next.config.ts:
// instrumentation.ts
definePreset("production", {
consola: { level: 3, formatOptions: { date: true } },
reporters: [{ name: "json" }, { name: "datadog", options: { service: "my-app" } }],
// A bare factory-name string is shorthand for { name }: reporters: ["json"]
// works when there are no options to pass.
});
definePreset("development", {
consola: { level: 5 },
});// next.config.ts
export default withLogger({ preset: "production" })({ /* … */ });Preset expansion rules:
- Explicit keys in
withLogger({...})win over the preset's. - The preset's
consolaoptions fill gaps under the raw config's own (raw wins on conflicting keys). - A live consola instance/factory in the raw config wins outright.
reportersfrom the raw config replaces the preset's list wholesale.- Unknown preset names throw at
init()— typos fail loudly.
Presets are just data: keep per-environment stacks ("production",
"development", "ci") in version control and switch environments by
changing one string.
Register factories and presets from an npm package, then reference them by name — your whole logging setup becomes a one-line import:
// my-logger-kit.ts — an npm package of your logging setup
import { defineReporter, definePreset } from "@vsfedorenko/next-logger";
import { createDatadogLogsReporter } from "@vsfedorenko/next-logger/reporters/datadog";
// A reusable reporter factory: options come from config, secrets from env.
defineReporter("datadog", (options) =>
createDatadogLogsReporter(options as { service: string }),
);
// A named bundle of config: backend + level + reporter references.
definePreset("acme-prod", {
consola: { level: 1 },
reporters: [{ name: "datadog", options: { service: "web" } }],
});// next.config.ts — reference everything by name
import { withLogger } from "@vsfedorenko/next-logger";
export default withLogger({ preset: "acme-prod" })({ /* … */ });// instrumentation.ts — runtime side: import the kit so factories exist
// before init() resolves the config
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./my-logger-kit");
const { init } = await import("@vsfedorenko/next-logger");
init();
}
}Registry helpers: getReporter / hasReporter / removeReporter,
resolveReporters (spec → live reporter), hasPreset / removePreset /
getPreset (all exported from the root).
The server entry patches console.*, which only makes sense in Node.js. For
Client Components or any browser-side code, use the
@vsfedorenko/next-logger/browser subpath:
"use client";
import { logger } from "@vsfedorenko/next-logger/browser";
export function MyComponent() {
logger.info("rendered");
logger.warn("deprecation notice");
return <div>…</div>;
}This entry builds a consola instance from env-driven defaults (same level
resolution: LOG_LEVEL → NEXT_PUBLIC_LOG_LEVEL → 3), without any
server-side patching. For build-time-inlined levels visible in the browser
bundle, use NEXT_PUBLIC_LOG_LEVEL.
-
Config wrapper (
withLogger, build time) — serialises logger options intoNEXT_LOGGER_CONFIGvia Next'senvkey. Next inlines this at build time, so the runtime reads it asprocess.env.NEXT_LOGGER_CONFIGwith no file-system or Next.js-internal imports. -
Console-sink patch (
patches/console.ts, runtime) — wrapsconsole.{log,debug,info,warn,error}so every call routes through the shared consola instance.logandinfoboth map to consolainfo. -
Next-log classifier (
patches/next.ts, runtime) — inspects eachconsole.*call: if the first argument looks like Next.js output, the line is taggednext.js; otherwise it's taggedconsole. A line counts as Next.js when, after ANSI stripping, it either carries a marker symbol (▲,✓,⚠,●,✗, …), optionally behind anℹinfo prefix, or matches the dev-server request-log shape (GET /path 200 in 716ms …), or opens with a bracketed component prefix ([MDX] …,[console 6:12 PM] …). This works under Turbopack, where the oldrequire.cache-based monkeypatch is dead (Next's logger lives in a separate bundled instance).
The patch skips printing when a message is empty — no arguments, or only
undefined/null/"" (values that carry no diagnostic value and would
render as a bare tag line under consola). This mirrors Next.js' own behaviour,
where prefixedLog drops the prefix when the message is empty.
Falsy-but-present values (0, false) are not considered empty and are
printed normally.
Next.js' startup banner (▲ Next.js, ✓ Ready, …) prints before the
instrumentation hook runs, so those specific lines are not captured. Any log
emitted after boot — route compilation, request-time output, your own
console.* calls — flows through the patch normally.
High-volume loggers (per-request logs, hot-loop debug traces, chatty dependencies) can drown a log aggregator. Log sampling drops a deterministic fraction of log calls so you see a representative sample instead of every single entry.
Set LOG_SAMPLE_RATE (a float between 0.0 and 1.0) to configure the
default sampling ratio. The default is 1.0 — log everything.
# Keep ~10% of sampled log calls.
LOG_SAMPLE_RATE=0.1 next devResolve the configured rate at runtime:
import { resolveSampleRate } from "@vsfedorenko/next-logger";
const rate = resolveSampleRate(); // 0.1 (or 1.0 when unset)Wrap any {@link Logger} so each log call is sampled at the given rate. The
returned logger preserves withTag — a child logger is sampled independently
with its own counter, so tagging doesn't change the effective ratio.
import { getLogger, sampleLogger } from "@vsfedorenko/next-logger";
// Keep ~10% of entries from a noisy logger.
const noisy = sampleLogger(getLogger(), 0.1);
noisy.info("request handled", { path: "/healthz" });
noisy.withTag("db").debug("query"); // still ~10%For non-logger use cases, createSamplingWrapper returns a low-level sampler
you can wrap any side-effecting function in. Sampling is deterministic
(counter-based, no RNG): rate = 0.1 calls the wrapped function exactly once
every 10 invocations, so the long-run ratio tracks the target exactly and
tests are reproducible.
import { createSamplingWrapper } from "@vsfedorenko/next-logger";
const sample = createSamplingWrapper(0.1); // keep 1 in 10
sample(() => sendAnalytics("pageview"));rate |
behaviour |
|---|---|
≥ 1 |
always calls |
≤ 0 |
never calls |
0–1 |
deterministic fraction (e.g. 0.1 → 1/10) |
Every request gets a unique correlation ID (or reuses the one carried by the
X-Request-ID header) that flows through the same AsyncLocalStorage used by
the request-scoped logger — so it appears in every log entry for that request
with zero manual threading.
Drop-in Next.js middleware that reads X-Request-ID (generating a UUIDv4 when
missing) and establishes the active log context for the downstream handler.
// middleware.ts
import { correlationMiddleware } from "@vsfedorenko/next-logger";
export const middleware = correlationMiddleware();
export const config = { matcher: ["/((?!_next).*)"] };Downstream route handlers can read the ID directly:
import { getCorrelationId } from "@vsfedorenko/next-logger";
export function GET() {
const id = getCorrelationId(); // "3f2504e0-..."
return Response.json({ ok: true, correlationId: id });
}Because the ID is stored in the LogContext, createRequestLogger
automatically appends it to every log entry — no extra wiring.
Returns the correlation ID for the current scope, generating and caching a UUIDv4 when none exists yet. Repeat calls within the same scope return the same value.
import { runWithLogContext, getOrCreateCorrelationId } from "@vsfedorenko/next-logger";
runWithLogContext({}, () => {
const id = getOrCreateCorrelationId(); // generated + cached
const again = getOrCreateCorrelationId(); // same id
});Explicit access. setCorrelationId writes into the active scope (must be
called inside runWithLogContext); getCorrelationId is read-only and
returns null when no scope is active.
import { runWithLogContext, setCorrelationId, getCorrelationId } from "@vsfedorenko/next-logger";
runWithLogContext({}, () => {
setCorrelationId("my-trace-id");
getCorrelationId(); // "my-trace-id"
});
getCorrelationId(); // null (no scope)| Function | Generates? | Behaviour when no scope active |
|---|---|---|
getCorrelationId |
no | returns null |
getOrCreateCorrelationId |
yes | returns an ephemeral UUID (not persisted) |
setCorrelationId |
n/a | throws |
Attach a fixed bag of structured fields to every log entry produced by a
logger, without threading them into every call site by hand. This is the
logger.with({ requestId, userId }) fluent API — a base context that every
downstream log call inherits.
Wrap any Logger so each log call carries the metadata:
import { getLogger, withMetadata } from "@vsfedorenko/next-logger";
const logger = withMetadata(getLogger(), { requestId: "abc", userId: 42 });
logger.info("processing"); // → info("processing", { requestId: "abc", userId: 42 })
logger.info("done", { ms: 12 }); // → info("done", { requestId: "abc", userId: 42, ms: 12 })
logger.withTag("db").info("query"); // child logger preserves the metadataMerge rules (applied to each argument):
| Argument type | Behaviour |
|---|---|
| String / number / boolean | Metadata appended as a trailing object argument |
| Plain object | Metadata keys merged in (per-call keys override metadata on collision) |
Error / Date / array |
Forwarded verbatim; metadata appended as a trailing object argument |
Empty metadata {} |
Arguments forwarded unchanged (no trailing object) |
withTag(tag) returns a child logger that preserves the metadata — tagging
never drops the base context. The original argument and metadata objects are
never mutated; new objects are produced per call.
Set deployment-wide metadata (service name, version, region) once via the
LOG_METADATA environment variable — a JSON object:
# Apply at boot without touching application code.
LOG_METADATA='{"service":"api","version":"1.0"}' next startimport { getLogger, withMetadata, resolveMetadataFromEnv } from "@vsfedorenko/next-logger";
const logger = withMetadata(getLogger(), resolveMetadataFromEnv());
logger.info("boot"); // → info("boot", { service: "api", version: "1.0" })resolveMetadataFromEnv() is non-fatal: a missing, empty, malformed, or
non-object value (arrays, primitives) returns {} rather than crashing boot.
| Concern | sainsburys-tech (pino) | this package (consola) |
|---|---|---|
| Backend | pino (JSON to stdout) | consola (pretty by default) |
| Config delivery | next-logger.config.js + preload |
withLogger() wrapper (idiomatic, type-safe) |
| Interception | patches next/dist/build/output/log |
wraps the console.* sink — no Next internals patched |
| Arg normalisation | custom hooks.logMethod |
not needed — consola handles console-style args |
| Child logger | logger.child({ name }) |
consola.withTag(tag) |
trace level |
falls back to debug (Winston has no trace) |
native — consola has trace |
| Default level | hardcoded debug |
env-driven (LOG_LEVEL) |
| Turbopack | require.cache patch breaks |
console-sink — works |
| Language | plain JS (CommonJS) | TypeScript (CJS output) |
Contributions are welcome! Please read the Contributing guide for the dev setup, project structure, and conventions before opening a pull request. By participating you agree to follow the Code of Conduct.
MIT