---
title: "Twelve lines, three rows"
description: "What a wide event tells you that a line per step cannot, shown with the output of three concurrent checkouts."
---

> Documentation Index
> Fetch the complete documentation index at: https://widelog-py.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Twelve lines, three rows

Three users check out at the same time. One payment fails. Here is what a line per step
leaves in your log:

```text
checkout started user_id=u_1
checkout started user_id=u_2
checkout started user_id=u_3
cart loaded user_id=u_2 items=3
cart loaded user_id=u_3 items=3
charging card last4=4242
cart loaded user_id=u_1 items=3
payment failed
charging card last4=4242
charging card last4=4242
order created id=o_u_3
order created id=o_u_1
```

Nothing in those twelve lines says which user's payment failed. `payment failed` sits between
`u_2` and `u_3`, and it is not next to either one for any reason except scheduling. You can
work it out by elimination, since three checkouts produced two `order created` lines and `u_2`
is the one missing, but that is reasoning from absence. It works here because there are three
requests. At four hundred it stops working.

None of those lines is badly written, and every one of them carries a real fact. They were
written separately, and the log has no way to put them back together.

## One row per operation

The same three checkouts, logged as wide events:

```json
{"level":"error","service":"checkout","duration_ms":3.27,"op":"checkout","user":{"id":"u_2"},"cart":{"items":3},"payment":{"last4":"4242"},"error":{"message":"Payment failed","status":402,"code":"CARD_DECLINED","why":"Issuer declined the charge","fix":"Try a different payment method","type":"WidelogError"}}
{"level":"info","service":"checkout","duration_ms":6.45,"op":"checkout","user":{"id":"u_3"},"cart":{"items":3},"payment":{"last4":"4242"},"order":{"id":"o_u_3"}}
{"level":"info","service":"checkout","duration_ms":9.87,"op":"checkout","user":{"id":"u_1"},"cart":{"items":3},"payment":{"last4":"4242"},"order":{"id":"o_u_1"}}
```

Twelve lines became three rows, and the failed one names itself. `u_2` failed, the issuer
declined it, the cart had three items, it took 3.27ms to find out, and whoever reads this at
3am is told what to try next.

Nothing was stitched together to get there. Four different functions wrote those fields during
the request, and they landed in one object because [`use_logger()`](/reference/api) reads a
`contextvars` slot instead of taking a logger argument. Concurrency is what makes line logs
ambiguous, and keeping concurrent work straight is what `contextvars` is for.

> **This is the same log volume**
>
> Three rows instead of twelve lines is the same information with the joins already done,
> written once when the operation ends rather than in pieces along the way.

## The failure nobody reported

```json
{"level": "warn", "op": "checkout", "retries": 2, "status": 200}
```

That user got a clean 200. Their order went through, they noticed nothing, and they will not
be filing a ticket. The pricing service reset the connection twice on the way, and the row
says so.

A wide event covers the whole operation, so the row exists whether the checkout succeeded,
failed, or succeeded on the third try. You get all of this without writing extra
instrumentation:

- a denominator, so an error rate is `errors / operations` rather than `errors / guesswork`
- degraded paths that still returned 200, such as retries and fallbacks
- `duration_ms` on every row, so a latency regression shows up without a separate metric

A line-per-step setup records that retry only if somebody thought to log the retry. Here it is
in the row because the row covers the operation either way.

## Two bugs wearing the same face

Both of these reached the user as `500`, and both say `RuntimeError: checkout failed`:

```text
outer=RuntimeError   root=KeyError       at services/cart.py:35
outer=RuntimeError   root=TimeoutError   at services/gateway.py:35
```

One is a cache miss and one is a payment gateway that stopped answering. At the surface they
look identical, and they need different people woken up.

`error.causes` is the chain behind the error, following `__cause__` before `__context__`, so
`raise ... from exc` gives you the real origin rather than whatever happened to be in flight.
Because `causes[-1].type` is a field rather than a substring of a message, you can group on it,
count it, and page on it. That means alerting on the root cause instead of on a symptom that
fifty unrelated bugs all produce.

The `stack` entries point at your code, since widelog filters its own frames out before
recording them. The first line you read is never the logging library's.

## Handing it to a model

Everything above applies as much to a model reading the log as to a person. Two of these
properties matter more when the reader is a model.

A model asked to explain the twelve-line version will answer. It will pick a user, describe
the failure with some confidence, and be wrong a fraction of the time, because interleaved
lines are exactly the shape that invites a plausible guess. One row leaves nothing to guess
at.

Redaction matters for a different reason. Look back at the wide events: `payment.last4` is
there and the idempotency token is not, because keys ending in `token`, `password`, `secret`,
`authorization`, `apikey`, and `cookie` are replaced at any depth before anything is written.
Pasting logs into a model that somebody else hosts is a decision about where your credentials
end up, and that decision gets much easier when the credentials were never in the log.

`why` and `fix` help as well, because you wrote them. A model reading `CARD_DECLINED` with
`why: Issuer declined the charge` is repeating something you already knew rather than forming
a hypothesis about it.

## What this does not do

widelog produces the evidence and never looks at it. There is no alerting, no aggregation, no
anomaly detection, and no sampling. Those belong to whatever you point `sink` at.

There is also no correlation identifier across services. Within one process the `contextvars`
slot keeps a request's fields together, and on Lambda the X-Ray `trace_id` is picked up for
you, but a failure spanning three services gives a reader no key to join on unless you set one
yourself. If that is your shape, put a request id on the event at the edge and thread it
through, and the rows will line up.

> **Where to start**
>
> [Quickstart](/quickstart) is four lines of setup. [Requests](/guides/requests) covers the
> ASGI middleware, and [Errors](/guides/errors) covers catalogs, which is where `why` and `fix`
> stop being something you retype at every raise site.

Source: https://widelog-py.pages.dev/blog/twelve-lines-three-rows/index.mdx
