@polygonlabs/logger
Shared pino-based logger for Polygon Apps Team services. Pre-configured for Datadog
ingestion with automatic VError-aware error handling and optional Sentry capture.
Why this package exists
Every service in the team needs the same pino configuration: message key instead of
msg, ISO 8601 timestamps, string level labels, no pid/hostname. Getting this right
in each service individually leads to drift — one service logs "msg" while another logs
"message", breaking Datadog log parsing.
This package provides one factory and a consistent output shape across all services.
VError/WError handling and Sentry capture are wired in at the pino level, so every log
call benefits automatically — no special method required.
Usage
import { createLogger } from '@polygonlabs/logger';
const logger = await createLogger();
logger.info({ requestId: '123' }, 'request received');
logger.error({ err }, err.message);
Do not import as a module-level singleton. Construct once at the service entry point
and pass down via constructor arguments or function parameters. Module-level singletons
make it impossible to add scoped bindings per request, swap the logger in tests, or
integrate Sentry cleanly.
const logger = await createLogger({ sentry });
class UserService {
constructor(private readonly logger: Logger) {}
async getUser(id: string) {
const log = this.logger.child({ userId: id });
}
}
createLogger returns pino's Logger type directly — import it from pino.
Customisation via child loggers
createLogger() intentionally does not accept options for the output shape — that
consistency is the point of the package. All customisation happens through child().
child(bindings, options?) takes two arguments. The first attaches context fields; the
second (pino's ChildLoggerOptions) changes behaviour for that subtree:
level | Minimum log level for this child and all its descendants |
serializers | Add or override field serializers (e.g. custom req formatting) |
redact | Strip sensitive field paths before they reach the transport |
Service-level setup — create one child immediately after construction with the fields
and options that should apply everywhere in the service:
const base = await createLogger({ sentry });
const logger = base.child(
{ service: 'user-api', version: process.env.npm_package_version, env: process.env.NODE_ENV },
{ level: process.env.LOG_LEVEL ?? 'info' }
);
Serializers and redaction — scope them to a subtree so they only apply where needed:
const httpLogger = logger.child(
{ component: 'http' },
{
serializers: { req: (req) => ({ method: req.method, url: req.url }) },
redact: ['req.headers.authorization', 'req.headers.cookie']
}
);
Request/handler-scoped fields — create further children inside handlers:
async function handleRequest(req: Request, logger: Logger) {
const log = logger.child({ requestId: req.id, method: req.method });
log.info('handling request');
log.error({ err }, err.message);
}
Child bindings and options merge at any depth — grandchild loggers carry all ancestor
bindings, and all VError/WError behaviour is preserved at every level.
VError and WError handling
VError/WError handling is automatic for every log level. Pass err in the merge object
as you would any other pino log call:
logger.error({ err }, err.message)
logger.warn({ err, requestId }, 'degraded — retrying')
logger.error({ err, requestId, userId }, 'optional message override')
error_info
VError info fields from the full cause chain are always emitted under the reserved
error_info key — never spread at the top level. This keeps error-carried context clearly
separated from call-site context, with no collision risk:
const err = new VError('query failed', { info: { requestId: 'abc', table: 'users' } });
logger.error({ err, traceId: 'xyz' }, err.message);
If a VError has no info, the error_info key is omitted entirely.
error_info is a reserved key — do not include it in merge objects. If a caller
supplies it, the logger emits a warn-level diagnostic with the conflicting value under
callerErrorInfo, then overwrites the key with the real VError info.
Behaviour by err type
Plain Error — logged with the error under err (serialised via pino's built-in
stdSerializers.err) and the error message as the log message. No error_info key:
logger.error({ err: new Error('connection refused') }, 'connection refused');
VError — same as plain Error, plus VError info nested under "error_info":
const err = new VError('query failed', { info: { requestId: 'abc', table: 'users' } });
logger.error({ err }, err.message);
WError — the wrapper is discarded entirely; only the cause is logged. Call-site
context is carried through to the cause's entry:
const root = new Error('connection refused');
const err = new WError('could not load user', { cause: root });
logger.error({ err, requestId: 'abc' }, err.message);
The cause is processed by the same rules, so a WError wrapping a VError with info
will emit the VError's error_info alongside call-site context.
RPC fetch-error sanitisation
Ethers (v5 JsonRpcProvider, v6 FetchRequest) and viem
(RpcRequestError, HttpRequestError) embed the full request URL —
including any ?token=<secret> query string — in err.message,
err.stack, and (where the library exposes it) structured fields like
err.info.requestUrl or top-level err.url. Without intervention, any
logger.debug({ err }) / logger.error({ err }) call that receives one
of these errors propagates the token into log output via pino's default
err serialiser.
This package's pino err serializer detects RPC fetch errors
structurally (duck-typed on the ethers v5/v6 and viem fingerprints — no
runtime dependency on either library) and replaces them with a sanitised
clone before emission. Every { err } log call is protected
automatically: HTTP request handlers, cron ticks, background workers,
unhandledRejection catches, startup failures, anywhere.
The full .cause chain is preserved: a service that wraps an RPC
failure with new VError('fetching block number', { cause: rpcErr })
still sees both the "what was being attempted" wrapper and the
sanitised RPC node. URL-stripping runs across every node's message
and stack as defence in depth; the detected node's info is rebuilt
to the structured form the library exposes ({ requestUrl: origin, responseStatus? } for ethers; {} for viem, which doesn't expose the
URL as a discrete field); wrappers' own info is preserved unchanged.
Persistence paths (Firestore writes, status routes, Sentry events,
JSON.stringify(err)) are sanitised by the same machinery on the
@polygonlabs/verror side — serializeError and VError.toJSON both
invoke the sanitiser at their entry, so any code path that runs an error
through them is safe by default with no per-call wiring. Prefer
serializeError for any serialisation work — it is the canonical
sanitisation entry point. The lower-level sanitiseRpcFetchError
function is re-exported here but marked @internal: direct calls are
appropriate only for pipelines like pino's pre-sanitise hop that need
Error-in/Error-out semantics.
Sentry
If a Sentry client was passed to createLogger, captureException fires automatically
for every logger.error({ err }) call. It does not fire for warn, info, or other
levels — only error.
For a WError, the cause is captured rather than the wrapper, consistent with what is
logged.
import * as Sentry from '@sentry/node';
const base = await createLogger({ sentry: Sentry });
The sentry option accepts any object satisfying { captureException, captureMessage }.
@sentry/node is not imported directly, so it stays an optional peer dependency. Sentry
is propagated automatically to all child loggers.
Development output
Pass { pretty: true } for human-readable output. Requires pino-pretty to be installed
as a peer dependency:
const logger = await createLogger({ pretty: process.env.NODE_ENV !== 'production' });
Output format
The logger is pre-configured for Datadog ingestion:
message | log message (pino's default msg is renamed) |
level | string label: "info", "error", etc. |
timestamp | ISO 8601: "2024-01-01T12:00:00.000Z" |
pid, hostname | suppressed |
err | serialised via pino's built-in stdSerializers.err |
Reserved keys
Two keys in the merge object are reserved and will trigger a warn-level diagnostic if
supplied by a caller:
timestamp | timestamp function | callerTimestamp |
error_info | VError info extractor | callerErrorInfo |