ADR-0041: Observability on Cloudflare — a sleeping Loki + Grafana box, OTLP inward, Sentry for uncaught errors
Status: Accepted (2026-10-21) — design approved 2026-09-23 (revision 3), implemented,
local end-to-end walkthrough, every sandbox probe and the production verification
(2026-09-29 to 2026-10-21, DEV-3093) complete (§L "Results"). 14 of 15 exit criteria pass
with real evidence, criteria 5, 9, 11 and 13 also in production; criterion 8 (Volume) is
Mixed, not a pass — Analytics Engine points and the raw Workers Logs pool both pass at
10× with real margin, but the exported-logs allotment does not (§D above has the numbers
and the fallback). The design's own §L trigger (criterion 1, 2 or 7 failing) is not
engaged. The last pending reading, criterion 13's real-object retention expiry on the
production inbox bucket, passed on 2026-10-21 (DEV-3178).
The §E.3 Sentry scope switch was flipped to uncaught on 2026-10-13, held for a week
(checked 2026-10-21) and was then removed (DEV-3099): §E.1's split is now fixed in code.
Supersedes ADR-0040 decisions A, B, C.2 and C.3; amends ADR-0022 (o11y spend cap,
per-script billing rows), ADR-0038 (WAF exception extended to /telemetry/*); adds
routes under ADR-0020. No longer deviates from ADR-0007: /grafana/* gates
through the same Handsontable login broker as every other internal surface, not
Cloudflare Access — see §H. ADR-0042 ships with this
ADR and was accepted with it.
ADR-0043 follows after launch (not yet dispatched).
The runner reports faults through Sentry and nothing else. Both SDKs run errors-only,
Workers Logs keeps seven days at a 10 % head sample, and every number the team looks
at is a D1 counter aggregated at write time (usage_daily, analytics_daily,
cost_ledger) and rendered by /admin. There are no traces, no metric history, no
browser performance data, and the only join between a Sentry issue and a Worker log is
the cf-ray header, promoted to a Sentry tag by hand.
The missing thing is the ability to answer, from data, the questions the runner is run
on: how long an example takes to show a grid, by tier and Handsontable major; how full
the container pool is at 14:00 UTC; whether the last release raised compile errors on
the next bucket; what the AI assistant costs per answer; which docs guides people
reach for a live example of (ADR-0042).
Constraints:
- Self-hosted, on Cloudflare, next to the app, in the main account (ADR-0010). No VPS, no Grafana Cloud, no SaaS beyond Sentry.
- OpenTelemetry protocol as the contract from the Worker inward.
- Grafana as the UI, Grafana Faro as the browser SDK.
- Sentry stays connected for uncaught errors, and those errors also land in the new stack.
- Cost at app scale. The runner's containers cost $1–13 a month because they sleep; observability follows the same pattern.
- Anonymous by construction stays in force for analytics (
analytics.ts, AGENTS.md). Operational logs are governed by the explicit rule in §E.4. - Deploy only after localhost tests, from one feature branch (decision 2026-09-23). Facts that exist only on real Cloudflare are measured with throwaway probes on the sandbox account, never the production account (§L).
Platform facts that shaped the design (verified 2026-09-22/23, including Loki, Sentry and Faro source):
- Workers tracing and export (open beta): auto spans for fetch, D1, KV, R2, DO,
handlers, RPC; custom spans via
tracing.enterSpan; OTLP export of traces and logs to any endpoint with custom headers. From 2026-10-01 spans are billed as events in the same 20 M/month Workers Logs pool as log lines, and export includes 10 M events per signal. NospanContext()at the time of writing (it landed later, see §C.4), so no browser-to-workertraceparentjoin. No metrics export. Sampling is per invocation and per Worker, never per route. Export retry semantics are undocumented. Fetch-handler spans carryurl.full,user_agent.original, city and ASN; preview hostnames carry the sandbox token. - Workers Logs: every sampled invocation writes an invocation log plus each
console.*line;observability.logs.invocation_logs: falseremoves the former. The Sandbox SDK logs a warning on every request to a stale preview URL, and Tier-2 container stdout lands in the API worker's logs. - Containers bill CPU on use and memory/disk on provisioned size, nothing while
asleep. Disk is ephemeral. Inbound only via Worker/DO.
jurisdiction: "eu"placement exists since 2026-04-05.onStopreports a host loss the DO did not observe as{ exitCode: 0, reason: "exit" }, indistinguishable from a clean exit. Cloudflare "does not guarantee that any container instance will run for any set period of time." - R2 is S3-compatible; EU jurisdiction on buckets; lifecycle rules work by key prefix.
- Loki 3.x ingests OTLP natively and synchronously;
otlp_configcan label only resource attributes. Query-time dedupe needs same stream, same timestamp, same line and same structured metadata (TestMergeIteratorNoDedupDifferentStructuredMetadata), and metric queries dedupe only across iterators, so two copies inside one chunk are both counted. When an OTLP record has no timestamp, Loki stampstime.Now().POST /flushreturns before anything is written. The TSDB index head rotates every 15 minutes and uploads afterwards. The compactor rewrites index only, and its retention markers live on local disk. Default push limits are 4 MB/s with a 6 MB burst;shard_streamsis on by default with shard numbers from in-memory state;query_ingesters_withindefaults to 3 h. - Grafana Faro v2.12:
faro.receiverstamps every entry withtime.Now(), answers 202 even when its exporter fails, drops after a 2 s timeout, rate-limits at 50 req/s, and looks maps up by URL path.persistent: falsesession tracking usessessionStorage. The Performance and CSP instrumentations send full URLs with query strings. - Workers Analytics Engine: 20 blobs + 20 doubles + 1 index per point, 3-month
retention, 10 M data points and 1 M read queries per month included (currently not
billed), SQL API; it samples at write time and read time (
_sample_interval). Grafana reads it through the Altinity ClickHouse plugin. No local emulation. - Sentry: Team plan, so issue-alert webhooks only, no per-event
error.created. The API worker's fetch catch-all converts every throw into an explicitcaptureExceptionplus a 500, and the snapshot-job alarm reports without rethrowing, so almost no Worker error is "uncaught" in the SDK's sense. Spend alerts reach anyone only throughSentry.captureMessageinreconcile.ts. React 19 render crashes caught bySentry.ErrorBoundarynever reachwindow.onerror. - Edge: the zone rule behind ADR-0038 403s any body containing
<scriptoutside/api/*;workers_devdefaults to on and bypasses zone rules.
Alternatives considered and rejected:
- VPS with the LGTM stack — outside constraint 1.
- Serverless store (Pipelines → Iceberg → R2 SQL) — no LogQL, two open-beta services. Kept as the escape hatch (§L).
- Cloudflare's own Workers Observability as the store — 7-day, non-EU, not Grafana.
Kept as the temporary safety net (
persist: true, §H). - Alloy with
faro.receiverin the box (revision 2's design) —time.Now()stamping makes every browser record land at drain time and never dedupe on re-replay; 202-on-failure makes the drain's ledger lie; its rate limit refuses backlog replays; its path-based map lookup breaks per-release storage. Once the Worker converts Faro to OTLP itself (§C.1), Alloy has nothing left to do: the drain pushes straight to Loki. - Vanilla OpenTelemetry JS in the browser — experimental SDKs, no bundled error or vitals capture. Faro stays as the capture SDK; only its wire format stops at the Worker.
- Mimir — a WAL on ephemeral disk and ingestion only while awake. Analytics Engine ingests while the box sleeps.
- Tempo, and exporting traces at all, in the first release — no browser join exists, span attributes carry URLs, user agents, geo and preview tokens, and spans compete with logs for the same 20 M pool. Dropped (§C.4).
- A Queue in front of the inbox — billed per message operation and a second write path next to the inbox Durable Object, which already batches.
- Self-hosted Sentry or GlitchTip;
@microlabs/otel-cf-workers— not the Grafana stack; deprioritised by its maintainer.
A new Worker handsontable-demos-o11y (workers/o11y/, own wrangler.jsonc with
workers_dev: false and preview_urls: false, own deploy job) owns two Durable Object
classes: InboxWriter (§B) and GrafanaBox, a Container class running Loki and
Grafana on standard-1, pinned with jurisdiction: "eu". There is no collector in the
box.
- Loki, single binary, TSDB single store in an EU R2 bucket, two tenants:
browser(browser and beacon streams) andworker(Worker streams). Configuration in §B.4. - Grafana served from
/grafana/, provisioned from git (one Loki datasource per tenant viaX-Scope-OrgID, the Altinity plugin against the Analytics Engine SQL API, dashboards),auth.proxybehind the Worker (§B.5), Grafana Live disabled, sqlite state disposable. - Secrets reach the container only as
envVarsset byGrafanaBoxat start from Worker secrets (LOKI_S3_*). In productionAE_SQL_TOKENand the Slack webhook never enter the box (local mode still passes the token viaHEADER2_VALUE). - Analytics Engine SQL goes through an outbound handler. A container's own egress to
api.cloudflare.comis refused with Cloudflare error 1000 (dns_loop, HTTP 403), so the ClickHouse datasource points athttp://ae.internal/client/v4/accounts/<id>/analytics_engine/sql.GrafanaBox.outboundByHost(src/ae-outbound.ts) intercepts that host, accepts only GET/POST on that one path, addsAuthorization: Bearer ${AE_SQL_TOKEN}in the Worker and makes the real request. The Worker must exportContainerProxyfor the interception to work. If the interception setup fails at wake, the box still starts, without theae.internalroute (the ClickHouse datasource errors, Grafana and Loki work), and the Worker logso11y.ae_outbound.degraded.
Wake. Two triggers only:
- A Grafana visit through the login broker (ADR-0007 — not Cloudflare Access). The
Worker renews the activity timer on every HTTP
request to
/grafana/*, including the waking page's ownmeta refreshpoll while the box is still booting (counting a request only once the box is ready would let a visit wake with nothing yet in the backlog SIGTERM itself around 20s into boot, before the person who opened it ever saw Grafana); the box stops after 15 idle minutes, and after 4 hours awake regardless (the next request shows the waking page). - A backlog: a
*/10cron in the o11y worker asksInboxWriter.backlog()and wakes the box when the oldest uncommitted object is older than 60 minutes or the backlog exceeds 64 MB. The cron never wakes the box when drains are paused (§G).
Stop protocol, the same for every stop the Worker initiates: the drain finishes; if
no Grafana request arrived in the last 10 minutes the Worker calls stop() (otherwise
the idle timer does, later, so a drain never SIGTERMs someone reading a dashboard); the
container's shutdown script stops Loki gracefully, confirms the index is uploaded to R2,
and only then writes a clean-shutdown marker state/wakes/<wakeId>/clean into the Loki
bucket, the one bucket its credentials reach. The o11y worker reads state/ through an R2
binding on the same bucket; no lifecycle rule touches that prefix except a 30-day expiry.
The marker, not onStop, is what the ledger trusts (§B.3). Exception: a
wake that ends without ever having a provisional key (an empty backlog, or a
Grafana-visit-only wake with nothing to drain) never gets an index upload and so never
gets a marker — that is expected, not an unclean stop, and the ledger now resolves such a
wake as clean without requiring one. A wake that did push data still requires the real
marker. A box that stays not ready for 10 minutes after its wake began (Loki never
answers its readiness probe) is a further stop trigger: the drain reports one error and
runs the same stop decision, unless a stop for that wake is already in flight.
Stop grace bound: the container side is bounded step by step: Loki's wait is
O11Y_STOP_GRACE_SECONDS (120 s in production), each R2 call has a 15 s limit, and the
Grafana wait is limited to 30 s (O11Y_GRAFANA_STOP_GRACE_SECONDS), after which the script logs
and exits with the marker decision it already made. The worst case is 240 s (pre-SIGTERM
snapshot) + 120 s + 240 s (post-exit snapshot) + 30 s (marker PUT and HEAD) + 30 s ≈ 660 s.
The Worker schedules a one-shot destroy() 780 s after stop(), 120 s above that worst case.
Cloudflare documents a 15-minute (900 s) SIGTERM-to-SIGKILL window, but that is unverified for a
Worker-initiated signal()/stop() and may apply only to platform-initiated shutdowns. The
backstop is correct either way: it is a Worker-side destroy() that does not depend on that
window, so the container is gone by 780 s whatever the platform does. It fires only if that wake's
container is still running and no newer wake has started. A destroy before the marker leaves
the wake unclean, so the ledger replays it, which is the safe direction.
Waking page: the Handsontable logo, one line of text, <meta http-equiv="refresh" content="3">, no script, served by the Worker while the box is not ready.
Cost model: drain wakes × measured drain-wake duration + visit hours, at
$0.038–0.074 per awake hour on standard-1. The target is ≈ $5–8/month at current
traffic; exit criterion L.7 recomputes it from the measured drain-wake duration and
fails above $10. Measured on the real sandbox platform, corrected 1×/10× traffic
scale (§L.7): $0.21/month at 1×, $0.33/month at 10× — the design's own $5–8 target was itself a
conservative upper estimate; drain-wake frequency is capped by the 60-minute backlog-age
trigger, not by traffic volume, so 20× more records only adds ~16s of drain time per wake,
not 20× the awake-hour cost. Per forgotten tab: every provisioned dashboard ships with
auto-refresh off, so an open tab left idle costs nothing beyond the visit that opened it; a
viewer who turns refresh back on keeps the box awake for as long as the tab stays open, up
to the 4-hour hard cap — $0.15–$0.30 per forgotten tab at the awake-hour rate above.
B.1 Routes, all owned by the o11y worker on the main hostname, passed as --routes
flags (ADR-0020), beside the API worker's /api/*, /d/*, /embed/*:
| Route | Source |
|---|---|
POST /telemetry/collect |
Faro from the authoring app |
POST /telemetry/lite |
lite beacon from /d and /embed |
POST /telemetry/v1/logs |
Cloudflare OTLP log export |
POST /telemetry/deploy |
CI deploy events |
POST /telemetry/hooks/sentry |
Sentry issue-alert webhook |
/grafana/* |
Grafana, waking page |
POST /grafana/_o11y/reopen |
manual ledger re-open (§B.3) |
ADR-0038's WAF exception is extended from /api/* to /telemetry/*: Faro errors and
Sentry payloads legitimately contain <script. The compensating controls are the gates
in §B.5.
B.2 InboxWriter: one owner of the inbox lifecycle. One instance, named main,
addressed through .jurisdiction("eu"). The CPU-heavy steps run in the stateless route
handler, so ingest is not serialised through one object; InboxWriter only checks,
stores and packs. For each accepted request, in this order:
- Decode and scrub in the route handler into OTLP log records (§C.1): convert Faro
and beacon payloads; decode Cloudflare's export (protobuf or JSON, whichever spike (b)
observes); keep only allowlisted attributes; hoist
hot.*andservice.*to resource attributes; run the server-side scrubber (§E.4). Nothing derived from the arrival time is added yet. Records over 256 KB are dropped. - Hash each record (SHA-256) at this point, before any arrival-time value exists, so a redelivered body hashes identically.
- Stamp timestamps (§C.2), which may use the arrival time as a clamp or fallback.
The arrival time itself never becomes part of a stored record;
InboxWriterkeeps it on the storage row. - Deduplicate in
InboxWriter: each hash is checked against a 24-hour set in DO storage; a record already seen is dropped. This is what makes a duplicate Cloudflare delivery a non-event. - Append the records to DO SQLite storage in rows of at most 1 MB, and answer
2xxonly after the transaction commits. Nothing is held in memory across requests. - Pack, from a 60-second alarm or at 4 MB stored: write one gzipped NDJSON object
per tenant to
inbox/<tenant>/<yyyy-mm-dd>/<hh>/<seq>.ndjson.gz, where<seq>is a counter persisted in DO storage, incremented in the same transaction that records the key, zero-padded to 12 digits. The key's state is recorded aswritten; the packed rows are deleted. "4 MB stored" is a real, enforced bound on the packed object's own decompressed NDJSON size (PACK_OBJECT_MAX_DECOMPRESSED_BYTES), not only a flush-cadence hint — the alarm takes pending rows in arrival order up to that budget (always at least one row, even if a single row alone is over budget) and commits that object; a tenant with more pending rows than fit in one object is packed across several objects, looping within the same alarm invocation up to a per-invocation cap on packed objects and rescheduling itself immediately when rows remain, rather than one unbounded in-memory gzip per alarm. The alarm's own read of pending rows is bounded too:row:<n>is zero-padded (12 digits, matching<seq>'s own width), so native ascending key order equals arrival order without an in-memory sort, and the alarm pagesrow:in small chunks, accumulated up to one packed object's own byte budget per round, rather than one unboundedlist()— see contract §8 andworkers/o11y/src/inbox/pack.ts.
One writer means key order equals arrival order; storage-backed buffering means a deploy, eviction or host restart between two alarms loses nothing.
Ingest deadline. The v1/logs, deploy and hooks/sentry routes race the
InboxWriter.ingest call against a 10 s deadline (INGEST_DEADLINE_MS,
workers/o11y/src/inbox/ingest.ts; an AbortSignal cannot cancel a Durable Object RPC, so the
call is abandoned, not stopped). A deadline miss answers 503 with Retry-After: 30 and writes
an o11y.ingest point with outcome dropped, reason ingest_timeout; a rejected call answers
the same 503 with reason ingest_error. Accounting caveat: if the object commits and only the
reply is lost, that batch is counted as ingest_timeout, and its redelivery is deduplicated and
counted as duplicate, so a committed batch can go uncounted as accepted. Cloudflare's OTLP
exporter is undocumented on its timeout, retry count and backoff, whether it back-pressures the
source Worker, and what it treats as a failure; that stays a known unknown, watched passively
through the "Observability self" dashboard's o11y.ingest outcome panel. /telemetry/collect
uses the same deadline; its own catch (500, invalid_item) remains only as a backstop for
Faro body processing. Invalid and oversize records and Analytics Engine points for items without
an inbox item are written before ingest, so a Faro retry after a timeout can double-count them.
Points for items with an inbox item (browser metrics, hash-only example.* events) are written
only for items the ingest reports accepted. When the deadline fires, the abandoned call is kept
alive with ctx.waitUntil and, if it commits, writes the points of exactly its accepted items; the
client's retry then comes back duplicate and writes none, so they are counted once. A call that
rejects later writes none.
Analytics Engine points for browser metrics are written by the route handler after step 3
(§F.1), so they exist while the box sleeps. example.* events (ADR-0042) produce Analytics Engine points only
and are never packed into the inbox. The exact first-seen registry for error fingerprints
(§F.3) is updated at step 4.
B.3 Drain and ledger. The ledger lives in InboxWriter storage, next to the keys it
describes, so backlog and state are readable without starting the container. Each key is
written → provisional(wakeId) → committed, or rejected.
- At each cron tick and at the start of each wake,
InboxWriterresolves every wake that still owns provisional keys and is over — a newerwakeIdhas started, orGrafanaBox's container state reports it not running: marker present → those keys becomecommitted; marker absent → they go back towritten(re-opened). A wake that is still running is left alone.POST /grafana/_o11y/reopenre-opens a time window by hand. backlog()counts onlywrittenkeys, after that resolution step, so a box kept awake by a visitor never triggers wake attempts for its own provisional keys, and a crashed wake's keys count again as soon as the crash is noticed.- The drain runs in
GrafanaBox, as a Durable Object alarm loop that handles a bounded number of objects per invocation and reschedules itself, under the o11y worker'slimits.cpu_ms(set to the value spike (b) needs, at most 300 000). It runs only on a freshly woken box, before anything else is pushed: it replays re-opened keys first, then newwrittenkeys, in key order, each object's records pushed to Loki's/otlp/v1/logswith the tenant header in requests of at most 1 MB decompressed. A key becomesprovisional(wakeId)only after every one of its requests returned2xx.429and5xxare retried with backoff within the wake; a400(for exampletoo_far_behind) is logged with Loki's message. Two cases defer a key instead (it stayswritten, nothing is rejected, the batch goes on): a429whose body names Loki's stream limit, which is never retried, and an inbox object that cannot be read (a failing source-map read defers the same way). A stream-limited tenant's keys, and every deferred key, are skipped for the rest of the wake so the keys behind them keep draining (contract §8, "Drain refusals"). A single too-old record inside an otherwise-good packed object does not 400 (and so reject) the whole key —drainKeydrops individual log records older thanreject_old_samples_max_ageminus a margin before pushing, counts the dropped ones on theo11y.drainpoint, and still pushes the good siblings in the same key. A key is markedrejectedonly when the push itself still 400s after that filtering (a genuine, not-just-stale, rejection), which raises an alert (§F.3). Within one wake, a per-record hash set guarantees no record is pushed twice. - What an unclean stop costs. The whole wake's keys are replayed on the next wake.
Data that Loki had already indexed before the crash is then stored twice, in different
chunks; queries return it once, because timestamps, labels and structured metadata are
deterministic (§C.2) and
shard_streamsis off. The duplicate storage stays until R2 lifecycle deletes it, not until compaction. Re-opening across a Loki configuration or label change is not allowed, because it would break that determinism.
B.4 Loki configuration: ingester.wal.flush_on_shutdown: true;
limits_config.shard_streams.enabled: false; max_chunk_age: 2h (a 1-hour out-of-order
window, enough because the drain replays in key order into an empty ingester);
reject_old_samples_max_age: 7d; query_ingesters_within: 168h, so backfilled data is
queryable while the box is awake; ingestion_rate_mb and ingestion_burst_size_mb
raised to what spike (b) measures, at least 16/32; max_line_size: 256KB;
otlp_config promoting the hot.* resource attributes to labels. Retention through
R2 lifecycle, not the compactor: one rule per tenant chunk prefix (browser 30 days,
worker 90 days) and the index prefix at 90 days, with max_query_lookback per tenant so
nothing past retention is queried. For days 31–90 the shared index still lists browser
chunks that lifecycle has already deleted; max_query_lookback: 30d on that tenant means
no query ever reaches those entries, and the index bytes they cost are negligible, so the
index is not split per tenant. Compactor retention stays off: its markers would live
on ephemeral disk, and retention_delete_delay outlasts any wake.
The schema has two periods. The first set index.prefix: "index/"; a slash in the
prefix makes the compactor fail to map the table back to a schema period, so it never
compacted. The second period (from: 2026-10-03, same store, schema and 24h period)
uses index_, so new tables are index/index_<day>/… where the old ones are
index/index/<day>/…. Both periods stay in the config to keep old data readable, and
the stop check lists both prefixes per day because records up to 7 days old still land
in the old-prefix table after from. The new period must be live in the deployed image
before from (2026-10-03 00:00 UTC): if the deploy slips past it, move from to at least
one day after the real deploy date before merging, otherwise index entries written under
index/index/<day> for days at or after from become unreachable. Until from, runs
write the old prefix; the stop-roundtrip script covers the new one with a seeded key.
The stop check's worst case is 16 listings of up to 15 s each, 240 s per snapshot before
and after SIGTERM; with the 120 s Loki grace that is about 600 s, inside the platform's
15-minute SIGTERM-to-SIGKILL window. The old
period can be dropped once nothing older than the index/ lifecycle rule (90 days) is
left under index/index/ (follow-up, not before 90 days after from).
B.5 Gates, every route authenticated or gated:
| Route | Gate |
|---|---|
collect, lite |
Origin/Referer host is the production host (or localhost in the local environment), payload environment matches, BOT_RE user-agent filter, size caps, item-kind allowlist, unknown attributes dropped, the Workers rate-limiting binding, then the server-side scrubber. navigator.webdriver is a browser-side check only (§E.4). |
v1/logs |
x-o11y-secret header set on the export destination, constant-time compare |
deploy |
GitHub OIDC token (issuer, audience, repository, workflow), secret fallback |
hooks/sentry |
sentry-hook-signature HMAC |
/grafana/*, reopen |
the Worker's own HMAC-signed session cookie (O11Y_SESSION_SECRET), minted once from a Handsontable login broker token (ADR-0007, K1 — not Cloudflare Access); a client-sent auth.proxy header is stripped |
Every drop writes an o11y.ingest point with the gate as the reason.
B.6 The observer does not observe itself: the o11y worker exports no Workers Logs and
no traces, with invocation logs off and its own lines persisted in Cloudflare's dashboard
only. Its self-metrics (o11y.*) go to Analytics Engine like every other metric, which is
not a loop: they never pass through its own ingest routes.
C.1 Hops.
| Hop | Format |
|---|---|
| Authoring app → o11y worker | Faro JSON (the SDK's native transport) |
| Embeds → o11y worker | the §C.5 beacon payload |
| API worker → o11y worker | OTLP logs, Cloudflare's export |
| o11y worker (ingest) | everything normalised to OTLP log records, stored as OTLP JSON in the inbox |
| drain → Loki | OTLP /otlp/v1/logs, synchronous |
| metrics | Analytics Engine points, written at ingest by the o11y worker (browser) and directly by the API worker (server) |
C.2 Attributes, identity and time. Every record carries service.name,
service.version, deployment.environment.name and the hot.* set (surface, tier,
framework, ht_major, outcome) as resource attributes. Per exit criterion 15's
own precise wording, Loki labels
service.name, deployment.environment.name and every hot.* key — seven of the eight —
from containers/o11y/loki/loki-config.yaml's own otlp_config.resource_attributes
promotion list. service.version is a resource attribute (queryable, present on every
record) but is deliberately never promoted to a label: it is per-deploy-SHA, and a
label with that cardinality would fragment Loki's index into one stream per deploy. Exit
criterion 15 checks exactly this seven-key set arrives as labels, confirmed live against
the real committed config for all four sources (§L). The exact names, allowed values and
Analytics Engine slots live in
docs/observability-contract.md.
hot.demo_id, session.id and cf.ray are structured metadata only; never labels, never
Analytics Engine indexes. The user pseudonym, emails, IPs, user-agent strings, query
strings, authored code, chat text and console output are never sent to the o11y stack.
session.idis an in-memory id minted per page load by the app (§E.4). It joins a page load's browser records with the API calls that page made (sent asx-hot-session); it is not a visitor session and does not survive a reload.service.versionis the deployingGITHUB_SHAon each deployable. Authoring and API deploy independently (path-gated inmaster.yml), so their versions usually differ; each deploy job posts{service, sha, cf_version_id}to/telemetry/deploy, which becomes a Loki line and a Grafana annotation.- Timestamps are event time, deterministically: browser item timestamps are clamped
to the envelope's
received_at± 5 minutes; beacon timestamps likewise; OTLP records keeptime_unix_nano, falling back toobserved_time_unix_nano, then toreceived_at, so no record ever reaches Loki without one.
C.3 Symbolication in the Worker, at drain. CI uploads the authoring build's maps to
the EU maps bucket under sourcemaps/<sha>/<original asset path>.map before deleting
them from dist (the Sentry Vite plugin's in-build deletion is turned off; one CI step
uploads to both destinations, then deletes). At drain, for exception records only, the
o11y worker resolves app-chunk frames with @jridgewell/trace-mapping against the map for the
record's service.version, parsing lazily per file and caching in the isolate within a
fixed memory budget; frames from the Babel compiler chunk and third-party files are left
as they are. Maps expire with the browser tenant (30 days). Symbolication never runs on
the public ingest route. Exit criterion L.5 bounds its cost.
C.4 Traces are not exported, and Tempo is not built. No trace destination is configured.
Worker traces are sampled at 1 % into Cloudflare's own dashboard for the 7-day view while
persist: true holds (§H).
Span.spanContext() shipped in workerd 1.20261009.1 (wrangler 4.151.0 bundles 1.20261012.1) with
no compatibility flag. It is read-only, and an untraced span returns an all-zero context, so
at 1 % sampling most requests have no usable id. The browser join is therefore a correlation,
not a parent/child link: the API worker puts the active span's trace id, when real, on its
api.request line as trace.id, next to session.id. That is the whole feature. A sandbox
Worker with traces at 100 % returned a distinct, non-zero trace id per request, and the same id
after an await inside ctx.waitUntil.
Decision: Tempo and trace export are dropped. Cloudflare's dashboard already covers the
7-day trace view, and the trace.id / session.id pair in Loki already joins a browser
session to a Worker trace. Tempo would add a container and a new traces ingest route on the
o11y worker (the span-attribute allowlist could not run on Cloudflare's native export path,
which bypasses our Workers), and spans bill from the same 20 M pool as log lines, whose
exported-logs half §D already projects as exceeded at 10x (about 6.6 M against 5 M). No
production measurement of span volume exists, and the gain does not justify the cost and
risk. traceparent propagation from the authoring app is dropped too: spanContext() is
read-only, so the browser cannot parent a Worker span. Reopen this only with a concrete need
for trace retention beyond 7 days, and then with measured production volumes.
C.5 Lite beacon for /d and /embed. The existing ES5 reporter in monitor.ts gains
a standalone mode: with no parent runner frame, it sends uncaught errors (up to the
existing ceiling) and sampled web vitals (10 % of page views) with navigator.sendBeacon
to same-origin /telemetry/lite, under 2 KB per payload. It is injected at the serve seam
in share.ts with the monitor-inject.ts guards and the DEV-2580 rules (self-removing
tag, no whitespace). Docs pages link with noreferrer and send
strict-origin-when-cross-origin, so no embed payload carries a docs page path;
embeds are identified by demo id.
observability.logs:head_sampling_rate: 1.0,invocation_logs: false,persist: true,destinations: ["o11y-logs"].observability.traces:head_sampling_rate: 0.01,persist: true, no destination.- One structured JSON line per non-proxy request (route class, status, duration,
cf.ray,session.id,hot.demo_id,service.version), replacing the bracketed-prefix convention, plus anapi.requestAnalytics Engine point. The preview proxy path emits nothing per request; stale-preview requests are answered before the Sandbox SDK where the code can recognise them, so its per-request warning does not fire. - Every error that escapes a handler is logged as a structured line by our code: the fetch catch-all, the snapshot-job alarm's report path, the Durable Object alarms and the cron handler. The Worker half of constraint 4 therefore does not depend on how Cloudflare exports an uncaught exception with invocation logs off.
- Custom spans (
tracing.enterSpan) around session start, container boot, snapshot build, chat, theme AI, import and payload boot, for the dashboard view. - A
*/5cron in the API worker, which owns the KV session meters and D1, writespool.gauge,budget.gauge(tier, percent of ceiling) and checks the o11y heartbeat (§F.3). - Volume budget (exit criterion L.8): at current traffic, Workers Logs events (lines
plus spans) stay under half of the 20 M pool, exported log events under half of the 10 M
logs allotment, Analytics Engine points under half of 10 M. Tier-2 container stdout is
counted in the first. Because every count and alert reads Analytics Engine, which is not
sampled at ingest, lowering the log sampling rate is the fallback that costs text, never
alerts.
Measured (projected from real per-session/per-request counts ×
traffic-baseline.md, at the ADR's own required 10× headroom): Analytics Engine points pass comfortably (≈4.14M of the 10M dataset, well under half). The raw Workers Logs pool also passes (≈6.6M of 20M, under half) — but the exported-logs allotment does not clear its own half at 10×: real measured Tier-2 container stdout (12–22 lines for a Vite-family starter's boot alone, ~22 for a webpack/Angular-family starter's boot, plus 2 lines per 60-second keepalive poll — Cloudflare's own Sandbox SDK's structured logging of its own health checks, not the dev server's own output) pushes the projected 10× total to ≈6.6M/month against the 5M half of the 10M exported-logs allotment. This is exactly the situation this paragraph's own fallback exists for: lowerhead_sampling_ratebefore or during launch if real production volume confirms this projection, which drops exported/logged text but never drops a count, an Analytics Engine point, or an alert. Seedocs/run-and-deploy.md's Launch plan for the concrete pre-launch measurement and the sampling-rate action. - The comment at
wrangler.jsonc:10-13("full fidelity is a spike amplifier") is answered byinvocation_logs: false, the silent proxy path and the budget above, not reversed.
E.1 Definition. Uncaught means an error that escapes a handler:
- Browser:
window.onerror,unhandledrejection, and render crashes caught bySentry.ErrorBoundary. - Worker: anything that escapes a fetch, alarm or scheduled handler, including errors the fetch catch-all turns into a 500 and snapshot-job failures the alarm reports without rethrowing.
These stay in Sentry, keep its grouping and regression detection, and also reach the new stack (§E.2).
Moves to the new stack only: diagnostic reports about handled conditions — upstream
failures reported with tags (npm registry, import URL), the preview boot-window report,
reportError calls for recoverable UI failures, and demo-runtime preview events. They
become Faro reports or structured lines with an error.handled point, and Sentry no
longer receives them at all; the exact new-fingerprint registry (§F.3) replaces its "new issue" signal as a
dashboard table, not a push notification. The
Outcome of each implementing task lists every call site and its classification.
Stays in Sentry, unconverted: the budget-alert captureMessage in reconcile.ts and
its rehomeBudgetAlert hook. It is the spend alert channel.
E.2 Tee. Faro's errors instrumentation sees window.onerror and rejections;
Sentry.ErrorBoundary's onError also calls the facade, so render crashes reach Faro.
Worker errors reach Loki through the structured lines of §D. Sentry's beforeSend
pushes the Sentry event id as a Faro event, and the Faro page-load id becomes a Sentry
tag. The issue-alert webhook (new issue, regression, resolved) becomes a Loki line with
issue id, title, release and link.
E.3 Switch (removed). The trim shipped behind SENTRY_SCOPE (API worker var) and
VITE_SENTRY_SCOPE (authoring build), full by default, where moved reports went to
both Sentry and the new stack. Both were flipped to uncaught on 2026-10-13, after
data had been seen end to end in Grafana, alerts had fired and volume sat inside the
budget. The flip held for a week, and the switch and its full code path were then
deleted (DEV-3099): the authoring Sentry client always runs with only the global
handlers and dedupe, and reportDiagnostic never calls Sentry. Rolling back means
reverting that change.
E.4 Faro configuration and the operational-log rule.
- Faro: session tracking disabled; the facade mints the page-load id in memory and
sets it on every item; no
usermeta; only the errors and web-vitals instrumentations (Performance, CSP, console and view instrumentations off, because they send full URLs or console text); transport to same-origin/telemetry/collect. - One scrubber,
scrubTelemetry, runs in the browser and authoritatively at ingest for Faro, beacon and OTLP records alike: strip query strings and fragments from every URL-valued field;redactPreviewHostson every string; reduce any browser meta to the device and browser classesanalytics.tsuses; remove Babel code frames explicitly (stripCodeFrame: the gutter-numbered source lines and caret markers), becausenormalizeMonitorMessagedoes not; drop unknown attributes. - The rule: operational logs may carry a page-load id, a demo id and a cf-ray as
structured metadata, and nothing else that identifies a person or a request's content.
Browser streams are kept 30 days, Worker streams 90 days. This is a separate class from
analytics, which keeps its rule unchanged:
example.*events are counts only and never reach Loki. - Local testing: Faro runs on a local path only when the build was made with
VITE_TELEMETRY_LOCAL=1and the host islocalhost/127.0.0.1, with environmentlocal. That path checks neitherimport.meta.env.DEVnornavigator.webdriver, because Playwright serves a productionvite previewunder automation. The production gate (resolveReporting) is unchanged and stays closed under automation; a post-build check fails if the local path survives into a production bundle.
F.1 Store. Counts and latencies go to Analytics Engine; Loki holds the text. (Contract
§6's own table matches this ruling — a Faro measurement or web-vitals
item is AE-only, never a stored Loki record.) The
positional slot layout and the full metric registry, with allowed outcomes, are in
docs/observability-contract.md §4–§5; this section
names the signals, the contract fixes their shape. Every
count is SUM(_sample_interval * count), every percentile a weighted quantile, and every
query goes through one helper that allowlists Analytics Engine's documented functions.
Browser metrics are extracted from Faro measurements at ingest (§B.2); server metrics are
written by the API worker.
F.2 Catalogue.
| Journey | Signals |
|---|---|
| Play | preview.ready_ms (pick → data-preview-status="ready", by tier/framework/ht_major — the headline metric); sandpack.compile_ms; sandpack.compile_error (fingerprint, no code); sandpack.bundler_unreachable; preview.runtime_error groups (ladder-deduped); version.switch; bucket.resolve_ms |
| Edit live | session.start (server; outcomes ready, at_capacity, container_starting, boot_timeout, budget_denied, error) and session.start_ms (client, cold/warm); container.boot_ms; hmr.roundtrip_ms where a reliable hook exists; session.end with reason and awake seconds; pool.gauge every 5 min |
| Share & build | snapshot.build; serve.share, serve.d, serve.embed (demo id as a blob); web vitals on /share and /d |
| Embed on docs | beacon error.uncaught and web_vital by demo id and ht_major; broken-embed and slow-embed lists by demo id |
| Assist | chat.answer (model, tokens, USD, latency, outcome), chat.edit, theme.ai, import.url, payload.boot |
| Examples | ADR-0042 |
| Platform | api.request (route class, status class, duration); error.handled; budget.gauge every 5 min; reconcile.run; o11y self: o11y.ingest, o11y.drain, o11y.wake, o11y.backlog, o11y.alert |
Sampling: authoring Faro 100 %; beacon errors 100 %, vitals 10 %; Worker lines 100 %; Worker traces 1 % into Cloudflare's dashboard only.
Dashboards, provisioned from git: Runner overview (with deploy annotations), Tier-2
sessions, Tier-1 playground, Version health (framework × ht_major, next highlighted),
Docs embeds, AI assist, Examples & features (ADR-0042), Observability self. Cost moves to
ADR-0043, because spend truth is D1.
F.3 Alerting runs outside the box, with state. Grafana holds no alert rules: its state would not survive a sleep.
| Signal | Owner | Latency |
|---|---|---|
| Uncaught error, new issue, regression | Sentry | seconds |
| Spend thresholds (200/500/800) | reconcile.ts captureMessage to Sentry, as today |
nightly |
at_capacity rate, 5xx rate from api.request, preview-ready rate per tier, Tier-2 liveness (no ready preview in 6 h), Tier-2 slow previews (ready p95), session start p95, embed error rate per demo id, compile-error rate per ht_major day over day, snapshot-build failed rate per framework, a wake or reload that ran without the ae.internal interception (o11y.ae_degraded), LiteLLM error rate (chat.answer + theme.ai), inbox backlog age, a rejected inbox key, the o11y spend cap |
o11y worker */10 cron over Analytics Engine and InboxWriter → Slack |
minutes |
| New handled-error fingerprint | the exact first-seen registry in InboxWriter (not sampled data), excluding surface = demo-runtime, whose keystroke ladders are authored-code output. Not a page: the */10 cron writes one o11y.new_fingerprint point per new fingerprint and the Observability self dashboard lists them in a table; nothing goes to Slack |
minutes, dashboard only |
| The o11y stack itself stale (no cron tick or ingest for 30 min) | the API worker's */5 cron reads the o11y heartbeat over a service binding and sends captureMessage to Sentry |
minutes |
Alert state (firing, resolved, last notified) lives in Durable Object storage: a rule
notifies once when it fires and once when it resolves, never on every tick. Thresholds are
starting values, tuned after launch: preview-ready below 97 % (Tier-1) or 95 % (Tier-2)
over 6 h, evaluated per tier only with at least 10 non-abandoned previews and at least 3 failed (error or timeout) in that window (preview.ready_ms carries no demo or session id, so the failures cannot be required to span distinct sources); Tier-2 liveness fires when no Tier-2 preview reached ready in 6 h with at least 5 non-abandoned attempts; Tier-2 slow previews fire when the ready time-to-ready p95 is above 180 s over 6 h with at least 10 ready previews (a starting value above today's worst per-framework p95 of 167 s);
session start p95 above 20 s, evaluated only with at least 20 ready starts in the hour
(below either floor the rule is not firing, so a firing alert resolves through the normal path); at_capacity above 5/h; 5xx above 1 % over
1 h, evaluated only with at least 100 requests after the exclusions (at 1 % one error exceeds the threshold only below 100 requests, so a single 500 cannot page);
LiteLLM errors above 5 % over 1 h, evaluated only with at least 20 non-denied calls (same reasoning at 5 %); compile errors on one ht_major doubling day over
day, with at least 20 today; any o11y.ae_degraded point in 2 h (written only at a wake or reload, so a short window would resolve before anyone read the page); snapshot builds failing above 50 % per framework over 30 min with at least 10
failed; an embed above 20 % errors with at least 50 views in 7 days; backlog older
than 2 h.
recordContainerUsagetakes the SKU as a parameter;o11y_containercarries the box's awake seconds, reported by the o11y worker to the API worker over theAPIservice binding (the API worker owns D1 and KV; the o11y worker binds neither).reconcile.tsiterates over the scripts it reconciles,handsontable-demos-apiandhandsontable-demos-o11y, and writes each script's billing rows under distinct SKUs (o11y_container,o11y_workers), so the per-SKU upsert never overwrites the app's rows./adminshows app, observability and total.O11Y_BUDGET_USD(default $15/month) joins the guardrail settings. When month-to-date observability spend crosses it, drains pause, visit wakes still work, and one Slack line is posted. Metrics and alerts keep working, because they read Analytics Engine, not Loki. Log text is kept only while the pause is shorter than the inbox's 7-day lifecycle and Loki's 7-dayreject_old_samples_max_age; a longer pause loses the oldest log text, and that is the price of the cap.- Why the cap ($15) sits above the exit ceiling ($10): the ceiling is a design check at current traffic, measured once in exit criterion 7; the cap is a runtime brake. A cap at the ceiling would pause drains on the first busy month or heavy week of Grafana use, which is exactly when the logs matter. The $5 gap is headroom for traffic growth; a month that reaches it is a signal to revisit the design, not normal operation. Product tiers keep acting on the total. The bound is honest: drains are capped by the pause; visits are bounded by the 15-minute idle stop and the 4-hour limit (§A), not by construction.
- Login broker, not Cloudflare Access: every
@handsontable.comaccount, as for/admin— the same Handsontable login broker (ADR-0007). A callback page under/grafana/_o11y/reads the broker's fragment token once and exchanges it for the Worker's own HMAC-signed session cookie (gates/session.ts); Grafana Viewer viaauth.proxy. This conforms to ADR-0007 rather than deviating from it. The earlier claim in this section — that the broker "hands a JWT to a SPA and cannot gate a proxied third-party HTML application" — was wrong: hot-mcp's owncreate_appruntime gates a proxied app the identical way, and a point-in-time production probe (the §M access-broker delta has the exact curl command and its302result, dated 2026-09-24) confirmed the callback host was allowed as of that date — expected to still hold, but re-run that probe before launch rather than assuming it (see the §M access-broker delta also for the one risk this design inherits rather than fixes, DEV-3088). - EU-pinned: the container (
jurisdiction: "eu"), both Durable Objects, the inbox, Loki and maps buckets. - Deploy order for the mutual service bindings: the o11y worker first (binding the
existing API worker), then the API worker with its
O11Ybinding. - Not EU-pinned, stated plainly: Cloudflare's own Workers Observability store keeps
full lines and 1 % of spans for 7 days while
persist: true, which is switched off 30 days after this ADR is accepted; Analytics Engine; the Workers edge; Sentry, whose ingest host isingest.us.sentry.io(unchanged); D1, which has an EEUR location hint, not a jurisdiction, and cannot gain one later. - Retention: Loki
browser30 days,worker90 days (§B.4); inbox objects 7 days; maps 30 days; Analytics Engine 3 months (platform); D1 rollups unbounded;analytics_visitors180 days as today.
Layout: workers/o11y/ (Worker, both DOs), containers/o11y/ (Dockerfile, Loki and
Grafana config, provisioning, compose.yml), pipeline/fixtures/otlp/,
pipeline/o11y-*.test.mjs. The whole new stack runs locally with Docker: the box through
wrangler dev or compose.yml; the o11y worker with Miniflare's R2, DO and cron; Loki on
Miniflare's local S3 endpoint for R2 or MinIO; Analytics Engine replaced by a ClickHouse
container holding an AE-shaped runner_events table, queried with the same SQL through
the allowlisting helper; the login broker's session check by a fail-closed DEV_ADMIN
bypass in .dev.vars.
Cloudflare's OTLP export does not run locally; its fixtures are real bodies captured by
the sandbox probe, scrubbed, plus hand-built edge cases. pnpm o11y:dev starts the box,
the Worker and the fixture replay.
ADR-0042 (example analytics) ships with this ADR: it needs the ingest path and Grafana,
and its events are counts only. ADR-0043 (/admin reads in Grafana) follows after launch;
the manual ledger re-open does not wait for it and lives on /grafana/_o11y/reopen.
Every implementing change carries tests that fail with the change reverted
(docs/TESTING.md, the presence gate): gate tests per §B.5 row; InboxWriter tests for
normalisation, dedupe, storage-backed buffering across a simulated restart, sequence
persistence and key order; ledger tests for provisional/committed/re-opened/rejected
transitions and marker handling; a label test asserting the Loki series for each source
carry exactly the contract labels (exit criterion 15); scrubber tests per rule on real inputs (a Babel code
frame, a preview-host URL, a user-agent string); a config test pinning the Loki keys of
§B.4 and the observability block of §D; symbolication against a real vite build;
beacon injection and an acorn ES5 parse; e2e/o11y-local.spec.ts, which drives a
real browser against the real o11y worker and asserts the resulting points land in the
local Analytics Engine stand-in (ClickHouse rows) — not a Loki query. Loki queryability
itself is proven separately: containers/o11y/local/stop-roundtrip.mjs's own
query_range calls against a real local Loki (the clean-stop/reopen roundtrip, criteria
1–2), and the local end-to-end walkthrough's live Grafana Logs-panel checks
(docs/run-and-deploy.md's local-dev section).
Probes. Cloudflare-only facts are measured with throwaway resources on the sandbox
account: separate Worker names, a probe-only config, wrangler whoami before every
deploy, synthetic traffic only, every resource deleted afterwards and listed.
Order: shared contract and scaffolds; the box and its stop protocol (spike a);
ingest and InboxWriter (spike b); drain, ledger and Grafana access; API worker signals;
Faro and the beacon; alerts and cost; ADR-0042; dashboards; CI and runbook; the local
end-to-end walkthrough and launch.
Exit criteria, each a pass/fail with recorded evidence:
- Clean stop: lines pushed, clean stop, fresh wake → 100 % of lines queryable and the marker present, written with the production-scoped R2 token, not a local all-bucket credential. Plan B if Loki does not upload the index on graceful shutdown: the stop protocol waits for the next 15-minute index rotation and its upload before stopping, and wakes are thinned to backlog > 3 h or > 128 MB, which keeps the cost model within criterion 7; if that fails too, the serverless store replaces Loki.
- Unclean stop: SIGKILL mid-drain, next wake → re-open →
count_over_timeand a log query both equal a single clean replay. - Event time: stored timestamps equal event time (clamped for browsers) for Faro,
beacon and OTLP records, including OTLP records without
time_unix_nano. - Duplicate delivery: the same export body delivered twice produces one copy.
- Symbolication: an exception from a real
vite buildresolves tosrc/…file and line using at most 500 ms CPU and 64 MB of isolate memory; Babel-chunk frames are skipped, not parsed. - Cold start: wake-to-ready at most 90 s, worst of five runs on Cloudflare.
- Drain wake: one hour of production-shaped traffic drains in at most 5 minutes of
wall time, no alarm invocation exceeds the configured
cpu_ms, CPU per object is recorded, and the cost model recomputed from the measured duration stays at or under $10/month at current traffic. - Volume: each allotment of §D under half, projected from measured per-session and per-request counts times production traffic.
- Idle tab: an open, idle Grafana tab lets the box stop at the 15-minute idle timeout.
- Placement: the Container's Durable Object namespace accepts
.jurisdiction("eu")and the instance runs in an EU region. - Worker errors: an error thrown in a fetch handler, a DO alarm and the cron handler
each produce a structured line in Loki with
invocation_logs: false. - Stop semantics: what
onStopreports for our ownstop()is recorded, and a Worker-initiated stop does not escalate to SIGKILL before the clean marker is written. A stop that outlasts the 780 s bound is destroyed and resolved unclean. - Retention: R2 lifecycle deletes expired chunk prefixes per tenant, and queries past
max_query_lookbackreturn nothing. - Image: compressed size recorded and at most 1 GB, and it fits the instance disk.
- Labels: records from each source (Faro, beacon, Cloudflare export, deploy events)
arrive in Loki with every
hot.*,service.nameanddeployment.environment.namelabel populated, andhot.demo_id,session.idandcf.raypresent as structured metadata only, never as labels. Checked with Loki's label and series APIs, locally and on the sandbox probe with a real Cloudflare export.
If criterion 1, 2 or 7 fails with its plan B, the design is rewritten toward the serverless store before more is built.
Results (local end-to-end pass plus every sandbox probe):
| # | Criterion | Result |
|---|---|---|
| 1 | Clean stop, production-scoped token | PASS — sandbox probe with a real bucket-scoped R2 token (T03B), local compose/wrangler dev both pass (T03-D2 fixed: the drain now posts a real OTLP resourceLogs envelope, not bare NDJSON) |
| 2 | Unclean stop, reopen | PASS, fully — real SIGKILL mid-drain on the sandbox platform (T03B): reopen, replay, count_over_time and a log query both equal one clean replay. Independently reproduced locally (T11): an interrupted wake's canary record reopens and replays to exactly one Loki line, both query forms agreeing |
| 3 | Event time | PASS (local) — clamped browser timestamps, un-clamped OTLP time_unix_nano (T02); a real, old-dated captured OTLP fixture was genuinely rejected by Loki's 7-day window this pass, which is only possible if its stored timestamp preserved real event time |
| 4 | Duplicate delivery | PASS (local) — the same body delivered twice produces one copy; re-confirmed live and repeatedly this pass (o11y.ingest outcome duplicate) |
| 5 | Symbolication | PASS in production (2026-10-01) — browser exceptions in Loki (browser) resolve to src/App.tsx:2659:34 and src/App.tsx:327:20. Symbolication runs in the GrafanaBox drainStep, a Durable Object alarm: 497 alarm invocations (2026-09-29 12:30 to 2026-09-30 10:30 UTC) had CPU p99.9 22.6 ms and a per-minute peak of 22.6 ms, and a wrangler tail of a backlog drain showed 25 ms CPU, against the 500 ms budget. The only invocations over 500 ms are the first GrafanaBox.isAwake poll after a box start (520–661 ms; 580 ms in the tail), which is not on the symbolicator path. Memory: the o11y Worker peaked at 9.04 MB (limit 64 MB), but the Durable Object dataset has no memory field, so the alarm's own memory is not measured; no out-of-memory outcome was observed |
| 6 | Cold start | PASS — sandbox: 46.5s worst-of-5 (T01), 3–22s after the T03-D3 fix (T03) |
| 7 | Drain wake (time + cost) | PASS at the corrected traffic scale — sandbox (T03B): 28s wake-to-drain-complete at 1× (≈432 records/hr, T05's own per-session line count), 44s at 10× (≈4325/hr); cost $0.21/month at 1×, $0.33/month at 10× — both far under the $10 ceiling |
| 8 | Volume | Mixed, measured, not a breakeven guess — Analytics Engine points and the raw Workers Logs pool both pass at 10× with real margin; the exported-logs allotment does not (§D above has the numbers and the fallback); production, 2026-10-01/02: the API Worker's export is not affected by the o11y Worker's "only 1% of events are being recorded" banner (a per-day cap that reset; Loki (worker) held 3569 api.request lines and Analytics Engine a weighted 3563 for the same hour). The monthly count for o11y-logs is at most ~0.85M events/month at current traffic (Observability → Usage per service, since Cloudflare exposes no per-destination count: 84,225 handsontable-demos-api log events in the 7 days to 2026-10-02, export live since 2026-09-29; ~0.36M/month if spread over all 7 days), against the ~5M/month half-allotment. That confirms the §D projection: at 10× it is 3.6M to 8.5M, straddling the line, so the row stays Mixed and lowering head_sampling_rate stays the named fallback, needed only if traffic grows |
| 9 | Idle tab | PASS in production (2026-09-30) — /grafana/dashboards loaded at 10:40:09 UTC and the tab was left untouched; the production GrafanaBox was running until 10:54:26 and stopped at 10:55:29 UTC, 14.3–15.3 min after the last request. Sandbox (T01): 17.65 minutes with zero HTTP requests |
| 10 | Placement | PASS — sandbox: EU region mxp04 (Milan) |
| 11 | Worker errors → structured line | PASS — fetch-handler and cron paths confirmed live (T05, T11); in production (2026-09-30) one anonymous GET /api/versions/exists?v=<600 characters> returned 500 and produced both a Sentry event (api-production) and the fetch-catch-all error line plus the api.request 500 line in Loki (worker). The DO-alarm path is unit/pipeline-tested (T01–T03) but not independently reproduced live |
| 12 | Stop semantics | PASS — onStop is recorded and, by design, claims nothing about cleanliness (T01); "no SIGKILL before the clean marker" is the same platform behaviour criteria 1 and 2 already confirm |
| 13 | Retention | PASS in production (2026-10-21) — at 2026-10-21 09:27 UTC the production handsontable-demos-o11y-inbox held 8,476 objects (8,227 inbox/worker/, 249 inbox/browser/); the oldest, inbox/worker/2026-10-14/09/000000014907.ndjson.gz, was written 2026-10-14T09:16:55Z, 7 days and 11 minutes earlier, and every lower sequence number written since 2026-09-29 is gone. 10 objects were 0–11 minutes past 7 days, within R2's asynchronous lifecycle lag. Loki's own 30/90-day expiry and max_query_lookback cannot be read in production until the oldest data is that old (from 2026-10-29 for browser/); they rest on the read-back rules below and the sandbox/local evidence. Earlier: R2 lifecycle rules apply and read back correctly (T01, T10); the production rules were read back on 2026-09-30: inbox inbox-7d, loki browser/ 30 d, worker/ 90 d, index/ 90 d, state/ 30 d, maps maps-30d. Real expiry on the sandbox (2026-10-02): o11y-probe-t03-loki still has its 1-day rule and holds 0 objects, 0 B, where the test objects were written on 2026-09-23 (consistent with expiry; bucket info alone can't rule out a manual delete, and the bucket's own delete reported "not empty" at 0 objects, likely leftover multipart uploads). The sandbox test (t03-retention-clock-test/) started 2026-09-23T14:15:22Z |
| 14 | Image size | PASS — 212.9 MB compressed, real linux/amd64 build (T01), under the 1 GB bound; uncompressed size against the standard-1 8 GB disk was not separately recorded by any task |
| 15 | Labels | PASS, all four sources, both tenants — confirmed live against the real committed loki-config.yaml: Faro (demos-authoring) and the lite beacon (demos-embed), browser tenant; the Cloudflare export (demos-api) and deploy events (demos-o11y), worker tenant — all seven labels populated, service.version present as a resource attribute but deliberately never promoted to a label (see §C.2), hot.demo_id/session.id/cf.ray never labels |
§L's own trigger (criterion 1, 2 or 7 failing its plan B) is not engaged — all three pass.
The last production reading, criterion 13's real retention expiry on the production inbox
bucket, passed on 2026-10-21 (DEV-3178), so this ADR is Accepted.
Criterion 8's exported-logs finding is real and measured, not a missing-evidence gap: the
monthly count (about 0.36M to 0.85M events/month today) is recorded in the row above and
confirms the §D projection, so lowering head_sampling_rate stays the named fallback, to be
used only if traffic grows toward the 10× case. The row is carried as a named action in
docs/run-and-deploy.md, not as a blocker to this ADR's status.
the deleted runner/tasks/o11y/)
Deltas already folded as direct edits above (§A cost, §A wake/stop, §B.3 drain-rejection, §C.2 labels, §D volume, §L results) are not repeated here. The rest, grouped by section, where they add information beyond what §A–§L already say:
-
§B.2 ingest. Hashing (step 2) uses each record's own raw, un-clamped source timestamp alongside its body and attributes — not the clamped
time_unix_nanoa later step computes — so two real deliveries of the same content at different real times still hash differently, and the same body redelivered still dedupes. One aggregatedo11y.ingestpoint is written per request (not per record), so a batch of N duplicate records reads as oneduplicatepoint withcount = N, not N separate points. Every §3 resource attribute a source has no natural value for defaults to"none"("unknown"forservice.versionspecifically, confirmed against real Cloudflare export samples that never carry it at all) — this default is what makes exit criterion 15 pass for worker-origin sources, not a defensive fallback. A real Cloudflare OTLP export's ray id arrives ascloudflare.ray_id, remapped to the contract's owncf.ray.HotAttrsfields with no dottedhot.*resource- attribute counterpart (bucket,reason,fingerprint, and others with no browser call site emitting them yet) travel over an AE-only channel, read from a Faro item's rawcontextbefore the browser's own scrub allowlist would otherwise drop them — this channel needed its own allowlist extension (AE_ONLY_ATTRIBUTE_KEYS) before it worked for real. -
§B.2 pack. The pack alarm's "at 4 MB stored" trigger (step 6 above) is a real, enforced upper bound on a single packed object's decompressed size (
PACK_OBJECT_MAX_DECOMPRESSED_BYTES = 4 MB), not only a flush-cadence hint. An over-threshold burst is capped by splitting into extra keys, not by cutting the alarm's own accumulation short:packTenanttakes pending rows in arrival order up to the budget and returns only that prefix (consumedRowKeys);InboxWriter.alarm()loopspackTenant/commitPackedObjectper tenant over the leftover rows until nothing remains or a 25-packed-object per-invocation cap is hit, rescheduling the alarm immediately (setAlarm(Date.now())) when objects still remain. A single row over budget on its own is still packed alone (row size is already bounded toINBOX_ROW_MAX_BYTES, ~1 MB, well under the 4 MB object budget) rather than blocking progress. -
§B storage API, DO 128-key batch limit (confirmed platform fact). Cloudflare's SQLite-backed Durable Object storage API caps
get/put/deleteat 128 keys/key-value pairs per call (https://developers.cloudflare.com/durable-objects/api/storage-api/, fetched 2026-09-24: "Supports up to 128 keys at a time" / "up to 128 key-value pairs at a time"). Localworkerdaccepts 500+ in one call with no error, so this codebase's own test doubles (workers/o11y/src/inbox/storage.ts#memoryStorage(),pipeline/fixtures/o11y-harness.mjs) enforce the same cap explicitly rather than relying onworkerdto catch a violation. Every multi-key call inInboxWriter— dedupe'scheckDuplicates, the fingerprint registry's writes/prune,pruneLedger, wake resolution,markKeysProvisional, manual reopen, the pack commit, andingest's own transactionput— chunks throughstorage.ts'sgetManyChunked/putChunked/deleteChunked.finalizeWakeResolutionandreopenWindowrun their whole put+delete sequence inside onestorage.transaction()— chunking alone, without that, would let a crash between chunks leave a partial write (an orphanedprovisional:<wakeId>key whosewake:<id>is already gone). -
§B.3 drain reads whole objects. Each drained key's packed object is read into memory whole before its records are pushed to Loki — this is bounded, not unbounded, because the object it reads was itself capped at write time (
PACK_OBJECT_MAX_DECOMPRESSED_BYTES, ~4 MB), plus at most one further oversized single row (INBOX_ROW_MAX_BYTES, ~1 MB) packed alone when it alone exceeds the object budget. So one drain-time read is bounded to roughly 4–5 MB, never the whole tenant's backlog at once. This bound is a property of the PACK side (inbox/pack.ts), restated here as the drain-side consequence, not as a claim aboutdrain.ts's own internals. -
§B.2 ingest, worker tenant. A Worker's own
console.log(JSON.stringify(...))line (the structured request/error lines §D describes) arrives through Cloudflare's real OTLP log export as opaque body text, not as OTLP attributes — confirmed with a real captured export. The o11y worker parses a JSON-object body and merges its keys into the same attribute bag a real OTLP attribute would land in, through the existing allowlist, with every §3 resource-attribute key stripped from the parsed body first and given the lowest merge priority — a body key cannot spoofservice.name/deployment.environment.name/ anyhot.*label. -
§B.2 ingest, worker tenant — fingerprint (live at merge). The API worker's own handled-error lines (
reportDiagnostic,workers/api/src/telemetry/diagnostic.ts) carryhot.fingerprint(contract §3 AE-only key) in the same structured JSON body the bullet above describes. The read half (workers/o11y/src/normalise/otlp.ts#toIngestItem/apiFingerprintFeed) readsbodyJsonAttrs["hot.fingerprint"](the pre-hoistAttributesbagtryParseJsonBodyAttrsalready builds) and feeds it into thefp:registry only when ALL of: the REAL resourceservice.name === "demos-api"(read fromfinalResourceAttrs, the resource attribute after hoisting/defaults — never frombodyJsonAttrs, the same anti-spoof ruleRESOURCE_ATTR_KEY_SETalready enforces for every other resource attribute); the parsed body'slog.kind === "error"; the value matches contract §7's own shape, viaisValidFingerprint— ONE shared validator, also used by the browser path'sresolveFingerprint, never a second, independently drifting copy: a validator anchored on the FIRST:would reject the:-joined call-site pathsreportDiagnostic's own real callers send —"npm-registry:version-exists","npm-registry:versions"— so neither could ever satisfy this condition, gate aside.Two preconditions, both must hold:
- Service-name remap: a real Cloudflare OTLP export's resource
service.nameis the deployed script's own name (handsontable-demos-api), not the contract's shortdemos-api—normalise/otlp.ts#remapCloudflareServiceNamestrips the sharedhandsontable-script-name prefix whenever what remains is one of the contract's ownSERVICE_NAMES, applied beforehoistAttributes, general across every deployable. Confirmed against the captured real-export fixtures (pipeline/fixtures/otlp/json/console-log-line*.json,cloudflare-invocation-log.json), every one of which carries the raw script name. - Shared fingerprint validator: it accepts a
:-joinedcontext, soreportDiagnostic's own real call sites' fingerprints pass the shape check at all — see above.
Without BOTH, this gate is a correctly-gated no-op against real production traffic, not a silent bypass; with both, it fires for real. This makes it LIVE at merge (the o11y worker's
*/10new-fingerprint cron runs unconditionally) —docs/run-and-deploy.md's runbook keeps this as a standing check.The fourth condition — not Tier-2 container stdout — is attempted by construction, not guaranteed: the B cross-note fix (two bullets below) makes
tryParseJsonBodyAttrsrefuse to parse ANY body whose ownlog.kindisn't one of this worker's trusted shapes, sobodyJsonAttrsis empty for a body with no matching sentinel — but the sentinel is body TEXT, not a resource attribute, and (known gap, not fully resolved — seenormalise/otlp.ts#tryParseJsonBodyAttrs's own doc comment for the full analysis) nothing in this pipeline can currently tell a genuinelines.tsline apart from a Tier-2 container's own authored stdout that happens to print the same shape, since both would share this Worker'sservice.nameonce the service-name remap normalises it. Accepted, bounded residual: a forged line can only mint afp:entry and a notify-only, mrkdwn-escaped Slack line, the same noise class already accepted for the browser path — never Sentry, PII or code execution. Deliberately NOThot.surface !== "demo-runtime"(the browser path's own rule) — a worker-tenant record'shot.surfaceresource attribute defaults to"none"when nothing sets it, which would admit any body reaching/telemetry/v1/logs, forged or not. - Service-name remap: a real Cloudflare OTLP export's resource
-
§C.1 hops. Faro's real browser transport posts a
TransportBody({meta, exceptions?, logs?, measurements?, events?, traces?}), not an array of self-contained items the way every contract function's own types assume — the ingest route reconstructs items from the four typed arrays. -
§C.3 symbolication. A Faro exception's stack trace reaches the drain as V8-shaped text in the record body — the pre-implementation contract had no field carrying frame data for this to resolve at all.
-
§D Worker signals.
container.boot_ms(notsession.start's ownboot_timeoutoutcome) is what fires when the Tier-2 boot window is exceeded — the original design would have double-counted a session that later times out after already reportingsession.startreadyonce (a design correction made before shipping, not after). Two §5 reasons have no emitter at all:pool.gaugereason="builder"(no signal tracksBuilderSandboxconcurrency the way live sessions are tracked;telemetry/cron.tsemitsliveonly) andsession.endreason="sleep_after"(nothing observes the Sandbox SDK's own idle-timeout stop) — named gaps, not silently dropped, and no data will appear for them.snapshot.buildreason="inline"is emitted by the synchronous create/update path inshare.tsand is observed in production; an earlier draft of this note listed it as unobserved. A cron failure insidectx.waitUntil()is structurally unreachable by@sentry/cloudflare's own auto-capture (itsscheduledinstrumentation only wraps the synchronous handler invocation) — every cron branch callsSentry.captureExceptionexplicitly in its own catch (confirmed live: a forced cron failure without this produced zero Sentry envelopes; with it, exactly one). -
§E Sentry. The full call-site inventory found one real §11 violation:
App.tsx'sversions-fetchdiagnostic was unconditional before this ADR's switch existed, exactly the shape §E.1 already names as "handled." Demo-runtime preview events kept their pre-ADR Sentry path (including theDEMO_SURFACEenvironment re-homing) until the §E.3 switch was removed; that path, the re-homing and its relay budgets are deleted. -
§B.3 drain, a key with a mixed 400/2xx outcome ("accepted chunks skip §B.3"). Pushing every chunk of a key even after an earlier one 400'd, while still classifying the whole key
rejectedif ANY chunk 400'd — including when another chunk landed 2xx — would be wrong: arejectedkey never becomesprovisional, so those already-accepted bytes never pass the §B.3 marker/commit check any wake's clean stop confirms durability through: an unclean stop right after the push, before Loki's own local flush, could lose them with no automatic replay (only a manual reopen, which — being a full key replay — would re-derive the identical classification anyway). Instead: a key with at least one accepted (2xx) chunk staysprovisional, following the normal durability path; only a key with ZERO accepted chunks staysrejected. The permanent chunk loss stays operator-visible via arejectedEvent:audit log (ledger.ts#recordPartialReject, contract §8) rather than the key's own ledger state. -
§B.3/§F.3 storage housekeeping, remainder ("prune ceiling"). Three gaps in the prune mechanism: (1) a 500-row/tick batch limit falls behind ADR §D's own 10× traffic projection at roughly 3× today's traffic — raised to 5,000/tick (still chunked to the real 128-key limit per call, see the DO storage batch-limit bullet above), with the exact arithmetic in
dedupe.ts#HASH_PRUNE_BATCH_LIMIT's doc comment; (2)newFingerprintsSincelisted the entire (alphabetically, not chronologically, ordered)fp:prefix every ten-minute alert tick — a newfpts:<firstSeenMs>:<fingerprint>time-ordered secondary index (contract §8) makes this a bounded range read instead, with a truncation-safe cursor inalerts/rules.ts#newFingerprintRule(never skips an unread fingerprint, at the cost of a bounded duplicate report under sustained flood — the same trade-off already accepted for the cursor's grace window); (3)rejected-inbox-keyfired onrejectedKeyCount() > 0and never resolved (rejectedkey:entries are deliberately never pruned by date alone — see the row-19 bullet'srejectedEvent:log and the §B storage API bullet above) — it now fires on a RECENT (last hour) count from that same log instead, resolving once new rejections stop. -
§F metering. ADR-0042's
example.*events needed the same AE-only attribute-channel extension as §B.2 above (kind→hot.metric_kind, sincehot.kindis reserved for the Faro item kind,ref,area) beforekind/ref/areasurvived the browser scrub at all. A post-fork landing needs a one-shot, non-storage URL marker (?fork=1, stripped viahistory.replaceStateon read) to classify asentry="fork"rather than"deep-link", becauseonFork's navigation is a full page reload — the same hard-navigation pattern the rest of the app already uses for every route change, which destroys any in-memory alternative. -
§H access.
ACCESS_AUDis still the committed""placeholder as of this ADR's own fold — no Access application was ever minted;docs/run-and-deploy.md's Launch plan names this as the first pre-condition to confirm before any real deploy. -
§H access (supersedes the bullet above). The
/grafana/*Access application named above was never created before launch. It was replaced with the Handsontable login broker (ADR-0007) instead of finishing it —gates/session.ts(session cookie,DEV_ADMINbypass),gates/broker.ts(the one-time/broker/userinfocall),grafana/login.ts(login/callback/session/logout). A real production probe (curl againstmcp-auth-proxy-j0tb.onrender.com/broker/login, 2026-09-24) confirmed302to Google forreturn_to=https://demos.handsontable.com/grafana/_o11y/callback?n=…, so the callback path is allowed today (seedocs/run-and-deploy.md's step 5 for the exact command; a separate local round trip against a stubbed broker proves the Worker's own code, not the real broker's live configuration, and should not be read as a second production probe). This widens DEV-3088's blast radius, it does not just inherit it: the broker'sreturn_toallowlist is host-suffix-only, so it also admits anonymous Tier-2 preview hosts under*.demos.handsontable.com, letting anyone harvest a team member's 1h broker token. Before this change, a stolen token could not reach Grafana at all (ACCESS_AUDwas"", so Access refused everything); now, it can be exchanged for a Grafana session. This narrows the exposure:gates/session.ts#computeSessionTtlSecondscaps the session atmin(now + 12h, brokerTokenExp)(falling back to 1h when the token carries no readableexp) instead of a flat 12h, so a stolen token buys close to its own remaining lifetime, not up to 11 extra hours — narrows, does not close. DEV-3088 itself remains open and is filed and tracked separately from this ADR. -
§I local development.
wrangler dev's local Container reachescompose.yml's standaloneminio/clickhouseservices (started without theboxservice) via Docker's ownhost.docker.internal, since the two are never on the same Docker network.
- ADR-0040 decisions A (hour dimension) and B (
usage_hourly) are not built; awake-seconds per hour and sampled peak concurrency become Analytics Engine points (C.2, C.3). C.1 stands as written:at_capacityis ausage_dailycounter, and is also emitted as asession.startoutcome. D (privacy) stands. - ADR-0022 gains a subordinate o11y ceiling and per-script billing rows;
recordContainerUsagetakes a SKU. - ADR-0038's WAF exception grows by one path,
/telemetry/*. - ADR-0007: no longer deviated from —
/grafana/*gates through the same Handsontable login broker as every other internal surface (§H). - ADR-0020: more route patterns on the main hostname, still in deploy commands.
- Sentry keeps uncaught errors (as §E.1 defines them) and spend alerts; handled
diagnostics moved and lost grouping; the per-event
environmentre-homing was removed with the §E.3 switch, so no new events reach thedemo-runtimeenvironment. - Source maps are no longer deleted inside
vite build; CI uploads them to Sentry and to R2, then deletes them before deploy. - We own: the Faro-to-OTLP converter, the scrubber, the symbolicator, the drain and its ledger, and the alert evaluator. That is more code than revision 2, in exchange for synchronous acknowledgements, event-time timestamps, and one path for every record.
- New operational surface: one image (Loki + Grafana), three EU buckets, two Durable
Object classes, provisioning in git, a fixture set, one Slack webhook, one
O11Y_SESSION_SECRET(no Access application;/grafana/*gates through the existing login broker instead). - Accepted limits: no traces in Grafana (dropped, §C.4;
trace.idonapi.requestis the browser-to-worker correlation); no alert rules or durable UI state in Grafana; the first visit after a sleep waits behind the waking page; an unclean stop costs a replay and duplicate storage until lifecycle; embeds have no docs page attribution; handled errors have no issue grouping; the non-EU items listed in §H. - Cost: ≈ $5–8/month target, $10 exit ceiling, reported separately, capped separately, summed under the same product ceiling. Measured (real platform, §A/§L.7): $0.21/month at 1× traffic, $0.33/month at 10× — both far under target.
- Volume: Analytics Engine and the raw Workers Logs pool both pass exit criterion 8 at
10× with real margin; the exported-logs allotment does not, measured (§D) —
docs/run-and-deploy.md's Launch plan carries the pre-launch action (a real Tier-2 stdout measurement to confirm or refine the projection, and thehead_sampling_ratefallback if it holds).