chiplog

One wide log event per operation, instead of scattered lines you have to reassemble.

chiplog accumulates named stages through a whole operation — at any depth, with nothing threaded through your call signatures — and flushes a single record on the way out. Zero dependencies. Bring your own logger.

Quick start
$npm install chiplog
GitHubnpm

The problem

Your logs are already structured. That is not the problem.

Every line below is fine on its own. Together they are a puzzle: two checkouts interleaved, the correlation id missing from the lines that came from deeper in the stack, no timings, and the failure eight lines from the request that caused it.

Beforenine lines, two operations, one of them failed
{"level":30,"reqId":"a1","msg":"checkout received"}
{"level":30,"reqId":"b2","msg":"checkout received"}
{"level":30,"userId":"usr_221","items":3,"msg":"cart loaded"}
{"level":30,"reqId":"b2","msg":"cart loaded"}
{"level":30,"reqId":"a1","warehouse":"iad","msg":"reserved"}
{"level":30,"reqId":"b2","msg":"inventory reserved"}
{"level":30,"provider":"stripe","amount":4200,"msg":"gateway"}
{"level":50,"err":"card_declined","msg":"charge failed"}
{"level":30,"reqId":"b2","msg":"checkout completed"}
Afterone record, the whole attempt
{
  "message": "flow checkout.submit failed at gateway_request",
  "level": "error",
  "flow": "checkout.submit",
  "outcome": "failed",
  "correlationId": "3c148c65f9d8e74a3dcac0a993b605e5",
  "traceparent": "00-3c148c65…-560112cbcfb925df-01",
  "durationMs": 173,
  "stageCount": 4,
  "stages": [
    { "name": "received", "atMs": 1, "durationMs": 1 },
    { "name": "cart_loaded", "atMs": 1, "durationMs": 0,
      "meta": { "userId": "usr_221", "email": "[redacted]" } },
    { "name": "inventory_reserved", "atMs": 19, "durationMs": 18,
      "meta": { "warehouse": "iad" } },
    { "name": "gateway_request", "atMs": 51, "durationMs": 32,
      "meta": { "provider": "stripe", "amount": 4200 } }
  ],
  "failedStage": "gateway_request",
  "error": {
    "name": "Error", "message": "card_declined: insufficient funds"
  },
  "orgId": "org_7f3a",
  "userId": "usr_221"
}

One object. The whole attempt, in order, with timings, with the failing step named and the business identifiers attached. A person reads it top to bottom. A query filters on outcome:failed AND failedStage:gateway_request. An agent gets enough to reproduce.

Setup

Three lines, and the logger you already run

chiplog ships no transport of its own. It builds a plain object and hands it to your sink — which is what makes adoption three lines rather than a migration.

import { createChiplog } from "chiplog";

export const chiplog = createChiplog({
  sink: (event) => logger.info(event.message, event),
});

await chiplog.run("checkout.submit", async (flow) => {
  flow.stage("received");
  const cart = await loadCart();
  flow.stage("cart_loaded", { items: cart.items.length });
  await charge(cart.total);
  flow.stage("charged");
});

The hard part

No context threaded through your signatures

This pattern gets abandoned over plumbing, not over the idea. stage() finds the flow in scope through AsyncLocalStorage, so it works at any depth with nothing passed in — and is a silent no-op outside a flow, because logging must never be the thing that throws.

import { stage, set } from "chiplog";

// four files below the route handler — no parameters added anywhere
async function chargeCard(amount: number) {
  stage("gateway_request", { provider: "stripe", amount });
  const result = await stripe.charges.create({ amount });
  set({ chargeId: result.id });
  return result;
}

Correctness

A flow that threw cannot report ok

run() is a wrapper rather than a start/end pair for one reason. The exception is caught, attributed to the stage that was running, and rethrown unchanged. With a manual flush in a finally, one forgotten markFailed() in one catch produces a log that says a 500 succeeded — and you find out from the log that lied.

await chiplog.run("checkout.submit", async (flow) => {
  flow.stage("charged");
  throw new Error("card declined");
});
// outcome: "failed", failedStage: "charged" — and the error still propagates

The rest

What else is in the box

Everything below is on by default or one option away.

W3C traceparent

No bespoke carrier format. A flow continues across an HTTP hop or a queue and interoperates with OpenTelemetry.

Bounded output

Stage cap with first/last retention, plus depth, width and string limits. A retry storm cannot produce a record your backend drops.

Redaction hook

A function, not a key list — the shape of sensitive data is application-specific. Runs over everything on its way into the event.

Nested flows

A run() inside a run() emits its own event, sharing the correlation id and pointing at its parent.

Hono and Elysia adapters

Wrap every request, seed from inbound headers, label by matched route pattern. Writing one for another framework is a few lines.

Loud about collisions

A set() key that clashes with a reserved name is reported in shadowedFields, never silently dropped.

Docs

Read next