Read your logs

You need: nothing. Structured logs are on in a scaffolded project.

Locally

Logging is what a log call produces, diagnosing a broken project is the configuration half, and audit is the separate trail for things somebody may have to answer for.

pithy dev labels and colorizes every Worker’s output, and tees everything to the terminal and to a log file — so what scrolled past is still there.

The CLI’s own diagnostic logger is quiet by default and verbose under --debug, and it writes to stderr so a command’s machine-readable stdout stays clean.

In production

pithy init scaffolds the observability block on, so structured records are queryable in the Cloudflare dashboard with zero setup.

Lower the sampling rate to sample under heavy traffic.

Query by field, not by grepping a line

A log call is a record, not a line. The message names what happened; every value is a field.

So the useful queries are field queries: this request, this level, this capability, this environment, this deployed version.

Every record correlates itself

Resolved from context with no caller effort:

FieldAnswers
requestWhich request. The ray id
method, pathWhat was called
envWhich environment
versionWhich build

And one access-log record per request carries the status and the elapsed time.

version is the one people underuse. It answers the first question anyone asks when a deploy goes wrong, and the same id reaches four other places — the control-plane manifest, a header on every control-plane response, every audit event, and the check deploy runs to prove the Worker it shipped is the one answering.

Filter by it the moment you suspect a release. A Worker that does not declare the binding still logs; the field is simply absent, which reads as cannot say.

Scope a logger to a capability

const log = c.var.log.child("payments");

Then every record carries that name, and everything payments did on this request is one filter.

Errors carry their whole payload

Pass the error rather than its message:

c.var.log.error("could not resolve credential", { error });

The logger carries the full payload — the operator’s remediation hint and the internal detail included — because a log lives on the same side of the boundary as the audit trail.

That is the exact inverse of the HTTP codec, which strips both at the one client boundary. A log is read by the operator both were written for, and a test pins it: the client-facing error surfaces do not import the logger.

Never console

A console line reaches the log as an unstructured string: no level to filter on, no name to scope it, no request id to correlate by, and a caught error arriving as prose rather than lifted into the typed field with its payload.

A lint rule enforces it, and pithy init scaffolds the same rule into your project. Those rules are yours — narrow them, widen them, or drop one.

Getting records off the Worker

createWorkerLogger({ transport: (record) => forward(record) })

A hook called with every finished record. The shape is unchanged and every call site stays as it is.

Route them to a tail consumer or to Logpush by adding the block to your wrangler config. What ships is the hook and the generated support rather than a turnkey consumer.

A log is not the audit trail

Different question, different retention, different reader.

An audit event is a decision — who did what, to whom, whether it succeeded. A log is a diagnostic.

Who changed that is a query against your own audit table, in SQL, joinable against your own users. Why was that request slow is a log query.

Reaching for logs to answer the first is how people end up with a retention policy that is really an accident.

When something is wrong

Start with pithy doctor. Most production surprises are a contradiction between config and wiring rather than a runtime failure, and doctor names those without you reading a line.

Then filter by version, then by request id.

ESC