@easyweb/logging
v1.0.2
Published
Shared structured logging for Easyweb microservices: one pino configuration, request-scoped context over AsyncLocalStorage, an HTTP access log, and the redaction every service needs before its logs leave the pod
Downloads
5,655
Readme
@easyweb/logging
One pino configuration for every Easyweb service, request-scoped context that does not have to be threaded by hand, an HTTP access log, and the redaction a log needs before it leaves the pod.
Why it exists
src/lib/logger.ts was copy-pasted into 21 services. It had drifted into two
variants; domain and moderation both defaulted their service name to
"billing-service"; and seven services were missing the guard that stops
pino-pretty's worker thread making Jest exit 1 under a green report.
The bigger problem was correlation. getLogger(req) returned a child bound to
requestId and had to be passed down as an explicit log parameter. Roughly
100 call sites did that; the other ~1,300 logged through the module-level root
logger and carried no request id at all.
The mixin
createServiceLogger installs a pino mixin that reads an AsyncLocalStorage
store. Every existing call site gains the ambient fields with no edit:
// unchanged call site
log.info({ projectId }, "Project created");
// what it now emits
{ "service": "project-service", "requestId": "…", "userId": "…", "projectId": "…" }pino merges the mixin UNDER the object the call site passed, so an explicit field always wins over an ambient one.
The mixin hands pino a COPY of the scope (1.0.2). pino's default merge is
Object.assign(mixinObject, mergeObject), and 1.0.1 returned the live store —
so every call's fields were written into the request's context and appeared on
every later line of that request, an err included. It also skips any key the
logger already binds, which is why a getLogger(req) line or a subscriber's
child({ requestId, queue }) line now carries requestId once, not twice.
Usage
// src/lib/logger.ts
import { createServiceLogger } from "@easyweb/logging";
import config from "../config";
const { logger, getLogger } = createServiceLogger({
serviceName: config.serviceName,
logLevel: config.logLevel,
});
export { getLogger };
export default logger;// src/app.ts — the access log needs the scope, so it goes after requestContext
app.use(requestContext);
app.use(createHttpLogger(logger));Opening a scope outside a request — a BullMQ job, a broker handler:
await runWithContext({ jobId: job.id, job: job.data.type }, () => handle(job));Adding to a scope already running — authenticate does this once it has
verified the token:
bindContext({ userId: decoded.sub });The access log
One line per finished request, msg: "request".
route is the route pattern (/billing/me/invoices/:invoiceId), and that
is a contract rather than a convenience: the RED metrics are recording rules
over it, so a per-id value would make the series unbounded. path carries the
real path beside it, from req.originalUrl — Express strips the mount prefix
off req.path during router dispatch, so reading that in the finish callback
names a route that does not exist.
The query string is never logged. ?token=… on the verification route is a live
credential.
/health, /livez, /readyz and /metrics are skipped — the kubelet would
otherwise make the readiness probe the largest log stream in the cluster.
Redaction
Two mechanisms, covering different shapes:
REDACT_PATHS— pino's fixed-path redaction, for our own payloads. Passwords, tokens, cookies and auth headers, at the top level and one level down.scrubResponseData— a recursive, depth- and size-capped walk overerr.response.data, for payloads we do not control. Xendit, Stripe, Resend, Meta, Apify, Cloudflare and Gitea all put personal data in an error body, and 407logger.error({ err })call sites fed it into the log verbatim and uncapped.
err.response.data is capped at 2 KB after scrubbing, not instead of it —
truncating first would keep whatever fitted, which for a failed charge is the
payer block.
What is deliberately NOT redacted
Email addresses. auth-service logs one on three lines — signup, an
operator-create refusal, and a Google registration — and they are the signup
audit trail: userId alone cannot answer "which address did they sign up
with". The exposure is real and bounded by log retention. See
docs/platform/ADR-0001.
