Background
Wide events are a logging style where each unit of work (an HTTP request, a background job, a scheduled task) produces one structured log record carrying everything you'd want to know about that unit. Instead of fifteen log lines saying “received request”, “user X authenticated”, “cart Y created”, “payment Z succeeded”, you build up a single record over the lifetime of the work and emit it once at the end. Records grow wide on purpose: 50+ fields covering request metadata, user context, business data, outcome, errors, durations, feature flags.
The case for this style is laid out at https://loggingsucks.com/, and the evlog library at https://www.evlog.dev/logging/wide-events ships an API explicitly built around it. The pattern came up for LogTape in #152.
LogTape can express this style if you assemble it from primitives, but it's not a first-class concept. The docs don't mention it, and the existing integrations don't give users a natural place to build this kind of record.
Problem situations to cover
These cases come up repeatedly.
The most common is per-request accumulation across async boundaries. An HTTP middleware starts a request-scoped event with method and path. An auth middleware further down the chain adds user and tier. Business logic, often several call levels deep, adds cart, paymentProvider, couponCodes. An error handler, possibly running inside a try/catch far from the entry point, adds errorType and errorCode. All of these contributions need to land in the same record, and that record should emit exactly once when the response completes.
Background jobs and scheduled tasks have the same shape without the HTTP framing. The unit of work is a function execution, the lifecycle starts and ends in user code, and the same accumulate-then-emit pattern applies.
Forked sub-units are a related case. A request handler spawns background work whose lifetime extends past the response. That work usually wants to produce its own wide event with a correlation field pointing back at the parent (parentRequestId or similar), so traces stay connected.
Sealing matters too. Once the wide event has been emitted, late callbacks, deferred work, or stray references shouldn't silently mutate the record after the fact, or worse, emit it a second time with half the fields filled in.
There is also scope metadata that should probably be automatic: duration from scope start to emit, error capture if the scope throws, a generated request id when none was supplied, and the category of the originating logger.
Why the current APIs make this awkward
Logger.with({...}) is the closest existing primitive, but it returns a new logger. Accumulation through with() looks like this:
let log = getLogger(["api"]).with({ requestId });
log = log.with({ user: { id: 1, tier: "pro" } });
log = log.with({ cart: { items: 3 } });
log.info("Request handled");
That works in a single function, but let reassignment doesn't cross async boundaries. A handler called three layers deep cannot add a field to the logger that the outer middleware will eventually emit through, unless the handler returns the new logger up the stack by hand.
withContext() propagates downward across async boundaries via AsyncLocalStorage, which solves the propagation problem, but its semantics are “merge these properties into every record inside this scope,” not “accumulate fields into one record that emits at scope end.” It's also lexically scoped. By the time an outer middleware fires its emit at res.on("finish"), any context that nested handlers added inside their own withContext blocks has already gone out of scope.
The workaround is to put a mutable plain object inside withContext and emit at the end:
const event: Record<string, unknown> = {};
await withContext({ event }, async () => {
await handler(); // mutates event.user, event.cart, ...
});
logger.info("HTTP request", event);
This works. Every caller has to set it up by hand, and the workaround skips everything you'd want to automate, from sealing the record after emit to capturing duration and errors.
The HTTP framework integrations (@logtape/express, @logtape/hono, @logtape/koa, @logtape/fastify) already emit one record per request, which sounds like a natural home for this. They only carry the metadata they themselves see at the middleware layer (method, status, duration), and there's no hook for handlers to enrich that record with business data. Users currently have to accept records without application data or write their own framework integration.
What this issue is asking for
A first-class API for the wide events pattern in a future LogTape version, designed to handle the situations above without requiring users to assemble them from primitives every time.
The shape of that API is intentionally left open here. Concrete proposals belong in follow-up issues and PRs once the problem space is agreed on. This issue is about requirements, not design.
Background
Wide events are a logging style where each unit of work (an HTTP request, a background job, a scheduled task) produces one structured log record carrying everything you'd want to know about that unit. Instead of fifteen log lines saying “received request”, “user X authenticated”, “cart Y created”, “payment Z succeeded”, you build up a single record over the lifetime of the work and emit it once at the end. Records grow wide on purpose: 50+ fields covering request metadata, user context, business data, outcome, errors, durations, feature flags.
The case for this style is laid out at https://loggingsucks.com/, and the evlog library at https://www.evlog.dev/logging/wide-events ships an API explicitly built around it. The pattern came up for LogTape in #152.
LogTape can express this style if you assemble it from primitives, but it's not a first-class concept. The docs don't mention it, and the existing integrations don't give users a natural place to build this kind of record.
Problem situations to cover
These cases come up repeatedly.
The most common is per-request accumulation across async boundaries. An HTTP middleware starts a request-scoped event with
methodandpath. An auth middleware further down the chain addsuserandtier. Business logic, often several call levels deep, addscart,paymentProvider,couponCodes. An error handler, possibly running inside atry/catchfar from the entry point, addserrorTypeanderrorCode. All of these contributions need to land in the same record, and that record should emit exactly once when the response completes.Background jobs and scheduled tasks have the same shape without the HTTP framing. The unit of work is a function execution, the lifecycle starts and ends in user code, and the same accumulate-then-emit pattern applies.
Forked sub-units are a related case. A request handler spawns background work whose lifetime extends past the response. That work usually wants to produce its own wide event with a correlation field pointing back at the parent (
parentRequestIdor similar), so traces stay connected.Sealing matters too. Once the wide event has been emitted, late callbacks, deferred work, or stray references shouldn't silently mutate the record after the fact, or worse, emit it a second time with half the fields filled in.
There is also scope metadata that should probably be automatic: duration from scope start to emit, error capture if the scope throws, a generated request id when none was supplied, and the category of the originating logger.
Why the current APIs make this awkward
Logger.with({...})is the closest existing primitive, but it returns a new logger. Accumulation throughwith()looks like this:That works in a single function, but
letreassignment doesn't cross async boundaries. A handler called three layers deep cannot add a field to the logger that the outer middleware will eventually emit through, unless the handler returns the new logger up the stack by hand.withContext()propagates downward across async boundaries viaAsyncLocalStorage, which solves the propagation problem, but its semantics are “merge these properties into every record inside this scope,” not “accumulate fields into one record that emits at scope end.” It's also lexically scoped. By the time an outer middleware fires its emit atres.on("finish"), any context that nested handlers added inside their ownwithContextblocks has already gone out of scope.The workaround is to put a mutable plain object inside
withContextand emit at the end:This works. Every caller has to set it up by hand, and the workaround skips everything you'd want to automate, from sealing the record after emit to capturing duration and errors.
The HTTP framework integrations (@logtape/express, @logtape/hono, @logtape/koa, @logtape/fastify) already emit one record per request, which sounds like a natural home for this. They only carry the metadata they themselves see at the middleware layer (
method,status,duration), and there's no hook for handlers to enrich that record with business data. Users currently have to accept records without application data or write their own framework integration.What this issue is asking for
A first-class API for the wide events pattern in a future LogTape version, designed to handle the situations above without requiring users to assemble them from primitives every time.
The shape of that API is intentionally left open here. Concrete proposals belong in follow-up issues and PRs once the problem space is agreed on. This issue is about requirements, not design.