Something is wrong in production. You open the logs, and the first question you ask is not “what happened” — it is “which build is this?”
If your log lines are strings, you cannot answer that. You can read them one at a time and guess. That is the difference this post is about.
A record, not a line
A log call in Pithy produces a structured record:
log.info("served", { status, elapsed })The message is one short line. Every value goes in a field, where it stays queryable. There are four levels — debug, info, warn, error — and a child for namespacing a sub-logger to a capability.
There is one Logger seam and two adapters behind it. The interface is identical in the CLI, in a Worker running locally, and in a deployed Worker. Only the adapter changes, so you never write code that knows where it is running.
Why not console.log?
Because of what happens to it on the other side.
Cloudflare Workers collects your output into Workers Logs, which indexes structured records and lets you query them. Hand it a console.log string and it takes it — as a string. You get no level to filter on, no name to scope to a capability, no request id to correlate by, and a caught error arriving as prose instead of a typed field carrying its payload.
You end up with a line you can read one at a time and cannot query. At three in the morning, across a few thousand requests, that is the same as having nothing.
So the kit does not let you write one. A Biome plugin written in GritQL flags console across every package, and its message names the replacement rather than just the prohibition: reach for c.var.log inside a request, or createWorkerLogger() outside one — in a Workflow, a Durable Object, a scheduled handler.
Two files are exempt, and the reason is worth knowing: in both of them, console is the implementation. The local adapter sinks to console.error, and the Worker adapter emits through console.log, which is how a record reaches Workers Logs at all. Banning the call there would ban the logger.
When you run pithy init, you get both plugins and their config scaffolded into your own project, scoped to your Worker’s source. Those files are yours. Narrow them, widen them, or delete an entry and its plugin together.
What you get without asking
This is the part that pays off the setup cost, because you do not do any of it.
Every Worker-side record carries request-correlation fields resolved from context automatically:
| field | what it is |
|---|---|
request | the Cloudflare ray id — one request, end to end |
method, path | what was called |
env | dev, staging or prod, from the deployed Worker’s own var |
version | the Cloudflare version id of the build that produced the line |
createBackend also emits one access-log record per request, carrying status and elapsed. You write no logging code to get that.
And wrangler.jsonc is scaffolded with Workers Logs already on:
"observability": {
"enabled": true,
"head_sampling_rate": 1
}So the records are queryable in the Cloudflare dashboard with no setup at all. If traffic gets heavy, lower the sampling rate.
Which build produced this line?
Back to the question you opened with.
version is the Cloudflare version id, read from a version_metadata binding the scaffold declares for you. It is on every record, so the answer is a filter rather than an investigation.
The same id shows up in four other places, which is what makes it trustworthy rather than decorative: the control-plane manifest, a pithy-worker-version header on every control-plane response, a version column on every audit event, and the check pithy deploy runs to prove the Worker it just shipped is the one now answering at your domain.
One detail worth copying if you build something similar. A Worker that does not declare the binding still logs — the field is simply absent. It reads as “cannot say”, which is honest, rather than defaulting to something that reads as a build you can trust.
One rule about what a log may carry
A log is an internal surface, so it carries more than the wire does.
Pithy’s errors have operator-only fields — a remediation hint, and internal detail — and the HTTP codec strips both before anything reaches a client. The logger does the opposite: it carries the full payload, detail included, because a log is read by exactly the person those fields were written for.
That only holds if the logger never ends up wired to something client-facing. A meta-test pins it: the HTTP and terminal error surfaces do not import the logger, and the test fails if that ever changes.
Getting records out of the Worker
The Worker adapter takes a transport hook, called with every finished record after it is emitted:
createWorkerLogger({ transport: (record) => forward(record) })Attach one to fan the same records to a tail-consumer Worker or to Logpush, into R2 or somewhere external. The record shape does not change and no call site moves.
Here is the honest limit. Records are tail-ready and Logpush-ready by construction, and the scaffolded wrangler.jsonc supports both — but v1 ships the hook, the generated config and the documentation. It does not ship a turnkey consumer. If you want records in a warehouse, you are still writing the consumer that puts them there.