2026-08-24 — What the log lines actually looked like
1.2.0 shipped the day before this. This session started by exporting 330 real log records from five deployed services and reading them, which is how most of what follows was found rather than reasoned about.
Three things in that file drove the changes here. Two were defects. One was a decision the kernel had made on behalf of consumers it knows nothing about.
The data wrapper
Logger.emit nested every payload under a data key, so a field arrived at a collector as data.status, data.ip,
data.colo. That name describes a nesting level rather than a value: a dashboard column reads data.status, a query
filters data.status = 500, and the prefix carries no information at any point.
The payload is now flattened onto the line. No call site changed — logger.info('article.created', { id: 7 }) is
written the same way — but what it produces is { …, "id": 7 }.
The obvious objection is collision, and it is a real one: a payload carrying level or message would otherwise
displace the line's own. A forwarded upstream record is the case that actually happens, because those are exactly what
its fields are called. RESERVED holds the three keys the line owns and re-asserts them against every merged key, so a
payload can carry a level and lose it rather than filing an error line as info where no severity filter will ever
find it again.
The missing message
1.2.0 renamed the top-level message key to event. That was right for machines and wrong for people: event is what
a query filters on, and message is the field most log viewers render as their one visible column.
The export settled how the two relate on Cloudflare specifically. $metadata.message matched source.message on 171 of
171 rows and source.event on 0 of 136 — the platform lifts the key named message and nothing else. So the column
had been blank since the rename, on every application line, across every service.
Both keys now go on the line. debug, info, warn and error take an optional third argument:
log.info('notification.sent', { provider }, `Notification sent via ${provider}`)
Omit it and message falls back to the event name. Every existing call site is unaffected and none of them are blank.
This is in the kernel rather than the adapter, and the reason is not Cloudflare. message is the near-universal name
for this field — pino and bunyan call it msg, Elastic ECS calls it message, OTel calls it body. Cloudflare
promoting it is a consequence of picking the conventional name, not the motive for picking it.
LogFields
The third thing was not a defect. Roughly half of every line was a field the runtime had already recorded — and whether that is waste or insurance depends entirely on where the line is going, which the kernel cannot know.
LogFields resolves an allow-list over the context, from LOG_FIELDS in the environment first, logging.fields on
the configuration surface second, and * last.
The kernel default is *, deliberately. Narrowing means knowing what a runtime records for itself, which is an
adapter's knowledge; a kernel that shipped a curated list would be deciding for platforms it has never heard of.
@bayudwiyansatria/cloudflare supplies its own, and a kernel-only consumer sees no field disappear.
Two details worth writing down:
- An empty declaration is not an absent one.
LOG_FIELDS=""means "no ambient fields", and must not fall through to the surface. The check is for the key being declared, not for the value being truthy. - The list governs context only. A payload passed to one log call is always emitted. Ambient fields are stamped by middleware and are the ones a deployment might reasonably decline; a payload was typed out for that call, and filtering it would discard the answer somebody logged the line to get.
time moved behind the list. It is still computed at emit rather than carried in the context, so it records when the
line was written and not when the logger was built.
redact was masking the answer
The worst thing in the export. Every field on every authentication.failed line came back masked:
{
"verificationConfigured": true,
"tokenPresented": "[redacted]",
"tokenLength": "[redacted]",
"fixedTokenConfigured": "[redacted]",
"apiKeyPresented": "[redacted]"
}
Three of those four are booleans, and they are the entire diagnosis — whether a token was sent, whether verification was
configured, whether an API key was present. The substring match on token and apikey hit them without looking at what
it was masking, so the line recorded a failure and withheld its cause. A security feature defeating a security log.
Booleans now pass. The reasoning is narrow and does not generalise: there are two of them and neither is a secret. The name-based rule is otherwise unchanged, because guessing from values would mask real data and still miss anything opaque.
ALWAYS is the exemption to the exemption — cardnumber, cvv, cvc, pincode, otp, iban — which mask whatever
type they arrive as, since those genuinely appear as numbers and digits in a log stream are as damaging unquoted as
quoted.
tokenLength is a number and stays masked. That is a small real loss, and it was checked against the call site it came
from: the three cases that middleware exists to tell apart are all distinguishable from the booleans alone.
Tests
148 specs, up from 122, at 100% statement coverage on Logger and LogFields. The new ones pin the things most likely
to rot: that the kernel's default drops nothing, that an empty declaration differs from an absent one, that a payload
named level cannot displace the line's, and that redaction still masks a string under a name it now spares as a
boolean.
Also
2026-08-23 was missing from this directory — the session that wrote 1.2.0 left no entry. It has been
reconstructed from CHANGELOG.md and the release notes, and is marked as written after the fact.