Logging

One Logger seam and two adapters. The interface is the same in the CLI, in a Worker running locally, and in a deployed Worker — only the adapter behind it changes.

A log call is a record, not a line

Reading your logs is the practical page, errors is what a failure carries instead of a sentence, and audit is the separate trail for things somebody may have to answer for later.

log.info("served", { status, elapsed });

The message is one short line; every value goes in a field, where it stays queryable. Both adapters serialize the same record shape.

That is the whole distinction, and it is what separates a log you can query from a log you can only read one line at a time.

Four levels and a namespaced child:

log.debug(msg, fields?)
log.info(msg, fields?)
log.warn(msg, fields?)
log.error(msg, fields?)      // pass { error } to carry a PithyError's full payload
log.child(name, fields?)     // c.var.log.child("auth")

Levels rank in the obvious order, and a logger drops anything below its threshold.

Never console

Reach for c.var.log in a request. Everywhere else — a Workflow, a Durable Object, a scheduled handler — build one, and inside a Workflow bind it to the run so every record carries which instance produced it.

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

A lint rule enforces it across the kit’s own source, and pithy init scaffolds the same rule into a new project scoped to your Worker’s program. Two files in the kit are exempt, and in both console is the implementation — banning the call there would ban the logger.

There is a matching rule for writing straight to a process stream, with the opposite scope: the CLI is the one place writing to stdout is correct, because that is how a CLI emits and --json on stdout is a contract it owes whoever is driving it.

Both rules match the member access rather than the call, so passing the function as a callback or aliasing it into a variable is caught alongside the ordinary call.

Those scaffolded rules are yours. Narrow them, widen them, or drop one and delete its plugin with it.

Mode 1: local diagnostics

One diagnostic layer for the CLI process and for a Worker under pithy dev. Human-readable and colorized for a person at a terminal, or a structured line stream under --json for agents and CI.

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

This is diagnostic logging only. Prompts, spinners, Done. and error rendering are a different layer — two layers, one boundary.

Mode 2: structured records in Workers Logs

Each record emits as one per-line structured entry Cloudflare indexes and can query — rather than one buried per-request blob.

pithy init scaffolds the observability block on, so structured logs are queryable in the dashboard with zero setup. Lower the sampling rate to sample under heavy traffic.

Every Worker-side record correlates itself

Resolved from context, with no caller effort:

FieldWhat it is
requestThe Cloudflare ray id
method, pathThe request
envWhich environment, from the var Pithy stamps into every deployed Worker
versionThe deployed Worker version

And createBackend emits one access-log record per request carrying the status and the elapsed time.

version answers the first question anyone asks when a deploy goes wrong: which build produced this line? The same id reaches four other places — the control-plane manifest, a header on every control-plane response, every audit event, and the check pithy deploy runs to prove the Worker it shipped is the one answering.

A Worker that does not declare the binding still logs; the field is simply absent, which reads as cannot say rather than as a build to trust. pithy upgrade adds it to a project scaffolded before it existed.

A log is an internal surface

The logger carries the full error 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.

The logger must never be wired to a client-facing surface, and a test pins it: the HTTP and terminal error surfaces do not import the logger.

Getting records off the Worker

The Worker adapter takes a transport hook — a function called with every finished record after it is emitted:

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

Attach one to fan the same structured records to a tail-consumer Worker or to Logpush. The record shape is unchanged, and every call site stays as it is.

Route them off-Worker by adding a tail-consumers block to your wrangler.jsonc or enabling Logpush. Records are ready for both by construction; what ships is the hook and the generated wrangler support rather than a turnkey consumer.

Configuring the base logger

createBackend defaults to the Worker adapter at info. Pass your own to change the level, bind base fields, or attach a transport:

createBackend({
  capabilities: [...],
  logger: createWorkerLogger({ level: "info", transport }),
})

Local dev defaults to debug; a deployed Worker defaults to info.

What a log is not

It is not the audit trail. An audit event is a decision — who did what, to whom, and whether it succeeded — with different retention, different volume and a different reader. A log is a diagnostic.

It is not metrics. Counting from log lines works until it does not.

ESC