2026-08-24 — Reading 330 production log records
The day after 1.2.0 deployed, an anonymized export of 330 log records from several Workers over five and a half hours. Almost everything below came out of reading that file rather than out of a design discussion.
What the export said
Half of every line was already in $metadata. Cloudflare wraps each stored record in an envelope carrying the
script name as service, the severity as level, the message, and trigger — the method and path joined, e.g.
POST /. Of those, exactly two come from us: the pipeline lifts the top-level level and message keys off the object
we log, and knows the rest on its own.
That is a fixed contract, and the numbers pin it:
| Field | $metadata vs source |
|---|---|
message |
matched on 171 / 171 rows |
level |
matched on 307 / 307 rows |
event |
matched 0 / 136 — never crosses |
$metadata.service also appears on 152 rows that have no source object at all, which is what proves it is the
platform's rather than ours.
So the Message column had been blank since 1.2.0 renamed message to event. No other key reaches $metadata, and
there is no documented way to add one.
46% of rows carried none of our fields. 152 of 330 were cf-worker-event invocation logs, which restate what
request.completed already says while leaving every application column empty. Another 23 were trace spans.
And a third of the client addresses were fake. See below.
LOG_FIELDS, and where the judgement lives
The line is now configurable, defaulting to what Cloudflare does not record itself:
{ "vars": { "LOG_FIELDS": "requestId,method,path,ip,status" } }
The mechanism is the kernel's — LogFields, resolving LOG_FIELDS over logging.fields over *. What is in this
package is the judgement: cloudflareDefaults.logging narrows the list, and CloudflareConfiguration.logging is the
seam that lets it. That entry is the only non-binding member of the surface and the only place this package overrides a
kernel default rather than adding to it.
The split matters more than it looks. Knowing that a runtime stamps the script name on every record is exactly the kind
of thing an adapter exists to hold, and exactly the kind of thing that would be wrong to carry to another platform. Drop
cloudflareDefaults and the kernel emits everything again — no stranded assumption.
The default is right only while lines stay inside the Workers Logs console. A deployment shipping through Logpush,
exporting OTel, or writing to a file adds service back, because a record that only means something inside one vendor's
dashboard is not a record. That sentence used to be this guide's opening argument for putting service on every line
unconditionally; it is now the reason the field is one word away rather than absent.
A message worth reading
logToAnalytics composes a sentence:
Level Message
info Request completed: POST / (200)
warn Request completed: POST /api/v1/search (400)
error Request failed: POST /api/v1/resource (500)
It restates the method, path and status that are already fields, and that is the point: you read the message, you filter
on the fields. event is untouched.
The ambient fields moved from the payload into the logger's context, which is what puts them under LOG_FIELDS at all —
a payload is never filtered. { error: thrown } stayed a payload for exactly that reason: a failure line that cannot
say why is not worth writing, whatever a deployment has configured.
The client address was synthetic for a third of traffic
15 of 28 distinct IPs we had logged fell inside 240.0.0.0/4, which is reserved space:
cf-connecting-ip 251.130.12.42 ← what we logged
cf-pseudo-ipv4 251.130.12.42 ← identical, so it is synthetic
cf-connecting-ipv6 2a09:bac1:7680:1258::272:48 ← the actual client
On 50 of 150 invocation rows cf-connecting-ip was byte-identical to cf-pseudo-ipv4, and no IPv6 address appeared
anywhere in our lines. The zone has Pseudo-IPv4 set to overwrite headers, so CF-Connecting-IP — the header this
package chose precisely because it is the trustworthy one — is replaced for every IPv6 client.
The replacement routes nowhere, geolocates to nothing, and need not be stable between requests from one client. That is worse than logging no address, because it reads as a real answer: an abuse investigation follows it and finds nobody.
CloudflareRequestMetadata now reads cf-connecting-ipv6 first. Fixing the zone setting would also work, and the code
should not depend on it staying fixed.
Smaller
authentication.failedlogged atinfo. Four production failures that never reached a severity filter. The sharedSecurityMiddlewarewas already atwarn; theinfowas in one Worker's own bearer/API-key middleware.AnalyticsBindinglines lostcomponentunder the new default, and the test was updated rather than the default. Nothing is lost:analytics.erroralready names what complained, and the binding name is in the payload, which is never filtered.invocation_logs: falseacross the sampled deployments, removing 46% of rows. The cost is honest and worth restating —outcome,cpuTimeMsandwallTimeMsexist only on those rows and leave Workers Logs with them. The events cover the outcome; traces cover the timing.
Tests
105 specs across nine suites, up from 103. The new ones cover the Pseudo-IPv4 preference and the composed sentence, and
the field-selection cases were rewritten from asserting that service and colo are always present to asserting that
they are absent by default and return when asked for — which is the actual contract now.