Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

log-contract

CI

Structured logging with trace propagation, PII masking, and error classification — the log contract as code.

Zero runtime dependencies. TypeScript, Node 20+.

Why

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.

Setup

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 }); })"

Usage

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];

Example

node examples/request-flow.mjs

One 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.

Log line shape

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.verbshape-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 decisions this code demonstrates

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)

Not implemented here (by design)

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-breakingoutboundCall records and classifies; retry policy beyond a bounded loop belongs to the caller.

Tests

npm install
npm test      # builds from src/ and runs node:test — 56 tests

The 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.

License

MIT © Wookeun Sim

About

Structured logging with trace propagation, PII masking, and error classification — the log contract as code. Zero dependencies, TypeScript, Node 20+.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages