@polygonlabs/logger
v1.0.1
Published
Pino-based logger with Sentry integration, configured for Datadog ingestion and prettified output capable.
Downloads
499
Keywords
Readme
@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.
// entry point
const logger = await createLogger({ sentry });
// handler / service layer
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:
| Option | Effect |
|--------|--------|
| 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' }
);
// Inject `logger` (not `base`) into the rest of the app.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);
// {
// level: 'error', message: 'query failed', err: { ... },
// traceId: 'xyz', ← call-site context, top level
// error_info: { requestId: 'abc', table: 'users' } ← error info, always nested
// }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');
// { level: 'error', message: 'connection refused', err: { message, stack, type } }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);
// { level: 'error', message: 'query failed', err: { ... }, error_info: { requestId: 'abc', table: 'users' } }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);
// { level: 'error', message: 'connection refused', err: { ... }, requestId: 'abc' }
// 'could not load user' is NOT logged — the cause is what mattersThe 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.
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:
| Field | Value |
|-------|-------|
| 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:
| Key | Written by | Conflicting value preserved as |
|-----|-----------|-------------------------------|
| timestamp | timestamp function | callerTimestamp |
| error_info | VError info extractor | callerErrorInfo |
