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.
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.
{"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"}{
"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 propagatesThe 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
Quick start
Install, wire a sink, record your first flow.
Stages and fields
Record steps at any depth. Promote fields to the top level.
Failures
Why run() is a wrapper, and how a flow gets attributed.
Correlation
Across services, queues and nested flows.
Redaction and limits
Keep PII out, and keep one bad flow from breaking the pipeline.
Adapters
Hono, Elysia, and writing your own in a few lines.
API reference
Every export, every option, the event shape.