A log line per step tells you what the code did, never what happened to one specific request. Building a single context-rich "wide event" over the life of a request, and emitting it once at the end, turns a grep-and-guess search into one structured query, at the cost of discipline: every field you didn't think to add is a field you don't have during the one incident that needed it.
Here's the problem in one line: your logs are lying to you, not maliciously, they're just not equipped to tell the truth. That lands because almost every engineer has already lived it. A customer says their payment failed, you open the log search, you type their email, and you get back a handful of lines in three different formats, none of which say why. This post walks through why the normal way of logging a request produces exactly that outcome, and what replaces it, using a checkout endpoint as the example throughout.
What normal logging looks like
Here's a checkout handler instrumented the way most services are: a log statement at each meaningful step, so you can watch a request move through the function.

Run this for one user at a time and it reads fine, a story with a beginning, middle, and end. Run it for real traffic and the story disappears. Two users hit checkout a few hundred milliseconds apart and their lines interleave in the order the events actually happened, not the order either request cares about, and this is genuinely what it looks like in the log viewer:
Tag every line with a request ID and you can now reconstruct one
request out of the interleaved mess, which is real progress and also
not enough. You still have thirteen rows to JOIN in your head, in
whatever tool you're searching, and you still only get the fields
someone remembered to log on that specific line. The decline reason,
the card's last four digits, which attempt number succeeded, the
user's subscription tier, none of that is on the line that says
payment attempt 1: declined unless someone thought to put it there
on that line, that day. Miss one field and the information existed
in memory when the log statement ran and is gone the moment the
process moves on.
Why "just use OpenTelemetry" doesn't fix it
#opentelemetry is a real answer to a
different question: how do you get logs, traces, and metrics off a box
and into a vendor without hand-rolling an exporter for each one. It
says nothing about what a log line should contain. Wrap the same
step-by-step logging in OTel spans and you get the same thirteen
disconnected fragments, now shipped over a standard protocol instead
of stdout. The problem was never the transport.
The fix: one wide event per request
The fix is a wide event: instead of a log line per step, build a single event object over the life of the request, adding fields to it as things happen, and emit it exactly once, at the end. Stripe calls the same idea a canonical log line: one row per request that's the authoritative record of what happened to it.

Re-run the two-users-at-once trace against this version. Each request
builds its own event object, a plain variable in that request's own
closure, so there's no interleaving to untangle. When it's done, one
logger.info call emits every field that mattered: user, cart, both
payment attempts with their decline codes, which one succeeded, the
resulting order, all as one structured row.

Run this against real traffic and the log viewer looks completely
different: still one row per request, no matter how many requests come
through, and the level tells you the outcome before you've even read
the message: error means it never completed, warn means it
completed but something was off, debug means it was uninteresting
enough not to page anyone. Here's a few seconds of it:
That support ticket from earlier is now one query: userId = usr_412,
sorted by time. The row says the first attempt declined for
insufficient funds, the second succeeded, and the order was created.
No JOIN, no scanning past seventeen unrelated rows to find it,
because there's exactly one row per checkout, regardless of how it
went.
The field is only useful if you thought to add it
The createEvent helper above (the full implementation is in the
Wide Event Logger snip) is deliberately
thin: an object, a .set() that merges fields in, and an .emit()
that logs once. All the actual value is in which fields get set, and
that's a judgment call made by whoever writes the handler, not
something the library can supply for you. Cardinality and
dimensionality are the two axes worth knowing by name here, because
they explain why that judgment call goes wrong in a predictable
direction: cardinality is how many distinct values a field can hold
(userId is high, httpMethod is low), dimensionality is how many
fields the event carries at all. Teams under-log by reflexively
excluding exactly the high-cardinality fields (user ID, order ID,
decline code) that are the ones worth searching by, because those are
also the fields older, string-search-oriented logging platforms
historically charged the most to index. A
#clickhouse-style column store doesn't have
that problem, so the old instinct to drop those fields no longer earns
its keep.
- 1
Logging inside a loop or a helper, out of habit
A
logger.infocall insidevalidateStockor the retry loop recreates the interleaving problem one function at a time. Callevent.set()there instead, and let the one emit at the top carry it. - 2
Only setting fields on the success path
The decline code, the retry count, the partial cart state: everything useful for debugging shows up on the failure path. Set fields as you learn them, before you know whether the request will succeed.
- 3
Treating this as a logging-library swap
Nothing about
createEventrequires a specific vendor. The mistake is installing it and leaving the call sites logging step-by-step exactly as before; the object is only as good as what gets set on it. - 4
Emitting 100% of traffic forever
Wide events are wider, which means more bytes per request. At real volume, tail-sample: keep every error and every slow request in full, sample the boring, fast, successful ones down to a few percent.
The checkout handler at the top of this post isn't wrong the way a bug is wrong. It runs, it returns the right response, it even logs something at every step. It's wrong the way a filing cabinet with no folders is wrong: the information is in the building somewhere, and finding it during an incident is on you. One event per request, built up as the request happens and written once at the end, is what turns that filing cabinet into an index.

