Structured logging with trace propagation, PII masking, and error classification — the log contract as code.
Zero runtime dependencies. TypeScript, Node 20+.
Logging degrades in a predictable way once a system is split into services. A request crosses several of them and there is no way to follow it end to end. Formats diverge, so search stops working. Some services log every variable and others log almost nothing, because "what should I log?" has no answer anyone can point at. Retention gets set once and forgotten, and then nobody can delete safely because last month's incident might still need investigating.
The usual response is a wiki page. Wiki pages do not hold — the next service still logs its own way, and the page is stale within a quarter.
This package takes the other approach: put the contract in the code. The shape of a log
line is enforced by the type system rather than by review. Trace context is ambient, so call
sites cannot forget to propagate it. Masking runs before emit, not as a downstream filter.
And the ESLint rule that bans console.log ships inside the package that replaces it.
npm install # dev dependencies only (typescript, @types/node)
npm test # builds and runs the test suite (node:test)dist/ is included for reviewer convenience (usable without a build step) — npm test rebuilds it from src/ anyway.
To verify it works as an installable package:
npm pack
cd /tmp && mkdir -p try-logger && cd try-logger && npm init -y && npm i <path-to>/log-contract-0.1.0.tgz
node -e "import('log-contract').then(m => { const log = m.createLogger({ service: 'demo' }); log.info('demo.started', { ok: true }); })"import { createLogger, traceMiddleware, withTrace, outboundCall, markExpected } from 'log-contract';
const log = createLogger({ service: 'verify-api', mod: 'providers' });
// `service` is required — by the type system, not by review.
// `version` defaults to env SERVICE_VERSION; level to env LOG_LEVEL (prod default: info).
// Every level has the SAME signature: (event, data?, msg?)
log.info('verification.started', { country: 'FR' });
log.warn('provider.retry', { provider: 'fr-connect', attempt: 2 }, 'retrying after timeout');
log.error('verification.failed', { err, verificationId }, 'unrecoverable');
log.error('verification.failed', err); // an Error alone is promoted into `err`
const vlog = log.child({ verificationId }); // bound fields: shallow merge, call-site wins
// HTTP boundary — trace context + automatic request.completed (4xx=warn, 5xx=error).
// Internal hop (trusts upstream traceparent):
app.use(traceMiddleware(log));
// At the public edge use { edge: true } instead — client-supplied IDs are never trusted.
// { sampleSuccessRate: 0.1 } keeps ~10% of successful completions (sampling, not
// demotion — warn/error are never sampled).
// Non-Express entry points (Lambda, jobs): each invocation gets a fresh context
export const handler = withTrace(rawHandler);
// A deliberate 5xx (circuit breaker / load shedding) is marked on the SERVER response
// before it is sent — the request.completed log demotes to warn on the log axis.
// Platform 5xx-rate alarms still see it: mass load-shedding is a state a human
// should know about, by design.
app.get('/verify', (req, res) => {
if (circuitOpen) {
markExpected(res);
return res.status(503).send('try again later');
}
// ...
});
// External calls: header injection + logging + retry + classification, in one place.
// The call site declares domain knowledge once; the judgment is automatic.
const res = await outboundCall('fr-connect', headers => fetch(url, { headers }), {
operation: 'verifyDocument',
expectedStatuses: [400, 422], // "the user's document was rejected" — expected, warn
retries: 0, // default 0: non-idempotent calls are safe by default
logger: log,
});Tip: keep provider names as shared constants (a small provider registry module) — the
provider field drives per-provider aggregation, and a one-off typo silently splits it.
ESLint config ships with the package — installing the logger installs the rule CI runs:
// eslint.config.js
import logContract from 'log-contract/eslint';
export default [...logContract /* no-console: error */, ...yourConfig];node examples/request-flow.mjsOne request crossing two services. Real output, unedited except for a trimmed stack:
{"ts":"2026-09-10T07:20:33.471Z","level":"info","service":"gateway","event":"request.received","traceId":"3d4f60566c9039cc1fe5c802ba98a13b","spanId":"6c101db9581cbcd9","version":"1.4.2","data":{"path":"/orders/1042/refund"}}
{"ts":"2026-09-10T07:20:33.473Z","level":"info","service":"gateway","event":"customer.identified","traceId":"3d4f60566c9039cc1fe5c802ba98a13b","spanId":"6c101db9581cbcd9","version":"1.4.2","data":{"customerId":"c-88213","email":"[REDACTED]","card":"[REDACTED]"}}
{"ts":"2026-09-10T07:20:33.473Z","level":"info","service":"billing","event":"refund.started","mod":"settlement","traceId":"3d4f60566c9039cc1fe5c802ba98a13b","spanId":"6c101db9581cbcd9","data":{"orderId":1042,"amount":12900,"currency":"KRW"}}
{"ts":"2026-09-10T07:20:33.504Z","level":"warn","service":"billing","event":"provider.call_rejected","mod":"settlement","traceId":"3d4f60566c9039cc1fe5c802ba98a13b","spanId":"6c101db9581cbcd9","provider":"pg-provider","operation":"refund","status":409,"data":{"orderId":1042,"durMs":31,"classification":"expected"}}
{"ts":"2026-09-10T07:20:33.505Z","level":"error","service":"billing","event":"provider.call_failed","mod":"settlement","traceId":"3d4f60566c9039cc1fe5c802ba98a13b","spanId":"6c101db9581cbcd9","err":{"name":"Error","message":"socket hang up","stack":"..."},"provider":"pg-provider","operation":"refund","data":{"orderId":1042,"durMs":1,"attempt":1,"classification":"external"}}
{"ts":"2026-09-10T07:20:33.506Z","level":"info","service":"billing","event":"refund.completed","mod":"settlement","traceId":"3d4f60566c9039cc1fe5c802ba98a13b","spanId":"6c101db9581cbcd9","data":{"orderId":1042,"settledAmount":12900}}Three things to notice. traceId is identical across both services and no call site ever
touched it. email and card are masked — the redaction ran before the line was written,
not in a downstream filter. And the two provider calls differ only in whether the status was
declared: 409 was expected so it is warn with classification:"expected", while the socket
error is error with classification:"external" and the provider tagged for aggregation.
One JSON line per event on stdout. level is the only severity signal.
| Field | Meaning |
|---|---|
ts, level, service, mod |
when, how severe, who |
event |
machine-readable name, domain.verb — shape-checked by the type system |
msg |
for humans (never key-maskable — so PII never goes here) |
traceId, spanId |
W3C trace context; span ID is a correlation identifier only |
provider, region, operation, status, version |
indexable dimensions, promoted out of data |
data |
everything free-form — masked before emit |
err |
serialized by the module: name, message, stack, cause chain (bounded) |
truncated |
set when data exceeded the 16 KB cap |
Example:
{"ts":"2026-08-30T03:12:45.123Z","level":"error","service":"verify-api","mod":"providers",
"event":"provider.call_failed","msg":"timeout after 2 retries",
"traceId":"4bf92f3577b34da6a3ce929d0e0e4736","spanId":"00f067aa0ba902b7",
"provider":"fr-connect","version":"1.4.2","status":504,
"data":{"country":"FR","attempt":2,"classification":"external"},
"err":{"name":"TimeoutError","message":"upstream 5s timeout","stack":"..."}}| Design decision | Where in code |
|---|---|
One JSON line, level as the only severity signal |
logger.ts — single stdout writer |
| Same signature for every level; required fields by type | types.ts (Logger, LoggerOptions, EventName) |
| Trace context is ambient — call sites never touch trace IDs | context.ts (AsyncLocalStorage), middleware.ts |
W3C minimal participation: new span ID per hop, logged as spanId; tracestate/flags preserved, never interpreted |
context.ts |
| Errors serialized by the module, cause chain bounded | logger.ts (serializeError) |
| Masking before emit; blocklist = safety net, minimization = the defense | redact.ts |
The logger never throws — circular refs, BigInt, throwing toJSON, oversized payloads |
logger.ts (toJsonSafe, emit), proven in test/logger.test.mjs |
| Classification decided by code: declared statuses → expected/warn; undeclared 4xx → unexpected/error; 5xx/timeout → external/error + provider | middleware.ts (outboundCall, statusToLevel) |
| Enforcement ships with the package | eslint.ts (log-contract/eslint) |
The contract this package owns ends at stdout. What sits on either side of it is deliberately out of scope:
- Collection, storage, alerting — the platform pipeline's job, not this module's (the contract ends at stdout).
- Queue transport (
traceMessageAttributes,withMessageTrace) — designed but not implemented here; the exported signatures throw a clear error pointing at this section. - OpenTelemetry export, sampling automation, backoff/circuit-breaking —
outboundCallrecords and classifies; retry policy beyond a bounded loop belongs to the caller.
npm install
npm test # builds from src/ and runs node:test — 56 testsThe suite covers what the logger must survive rather than only what it should print:
circular references, BigInt, a toJSON that throws, payloads past the size cap, and
trace context across async boundaries. The logger never throws — a logging call must
not be able to take down the request it is describing.
MIT © Wookeun Sim