About observability-triage
A skill for diagnosing production errors in Cloudflare Workers Observability. It guides developers through accurate invocation counts, workflow failure investigation, profiling, and filtering previously investigated noise.
Key features
- 01Query Workers Observability
- 02Count invocations accurately
- 03Inspect workflow instance failures
- 04Capture CPU and heap profiles
- 05Filter known runtime noise
Use cases
- 01Triage production error spikes
- 02Trace failed workflow steps
- 03Compare errors across deployments
- 04Profile a busy production isolate
MCP support
Uses the cloudflare-api MCP server to query Cloudflare Workers Observability; the README describes an authentication flow if its tools are unavailable.
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ every-app/ open-seo --skill observability-triageAfter installing, type/observability-triagein Claude Code to confirm it loaded.
Details
From the project READMEObservability triage
Query Cloudflare Workers Observability for prod errors, count them correctly, and skip the noise that has already been investigated to a dead end. Apply the known-noise list below without re-investigating those entries.
Access
- Resolve the account at runtime — never hardcode it: the
cloudflare-apiMCP server pre-bindsaccountIdinmcp__cloudflare-api__execute, andnpx wrangler whoamiprints it. The workers to triage are the ones this repo deploys (seedeploy/alchemy/alchemy.run.ts): the main app worker plus the aux workers (audit engine, landing, self-host). - Query via the
cloudflare-apiMCP server (mcp__cloudflare-api__execute). If its tools are absent, run its authenticate flow and give the user the URL — wrangler's OAuth token gets a 403 on the observability API (missing scope), so don't burn time on curl-with-wrangler-token. - PostHog is the second error source but cannot see
exceededMemory/canceled/responseStreamDisconnectedoutcomes — worker-outcome questions are answerable only here.
Query recipes (verified shapes)
POST /accounts/{account_id}/workers/observability/telemetry/query. All of these are load-bearing; the API's 400s are opaque:
-
queryId: "adhoc"is required. Timeframe is epoch milliseconds:timeframe: { from, to }. -
Count invocations by outcome:
{ "queryId": "adhoc", "timeframe": { "from": 0, "to": 0 }, "parameters": { "datasets": ["cloudflare-workers"], "filters": [ { "key": "$metadata.type", "operation": "eq", "value": "cf-worker-event", "type": "string" } ], "calculations": [{ "operator": "count", "alias": "count" }], "groupBys": [ { "value": "$workers.scriptName", "type": "string" }, { "value": "$workers.outcome", "type": "string" } ], "limit": 100 }, "view": "calculations", "limit": 100 } -
The
cf-worker-eventfilter is what makes counts mean invocations; without it you count every log line. -
Raw samples:
view: "events", emptycalculations/groupBys, smalllimit. Events carry$metadata(service,trigger,level,message,fingerprint,requestId) and$workers(outcome,scriptName,scriptVersion,wallTimeMs,cpuTimeMs,eventType). -
Error-level app logs grouped by message: filter
$metadata.level eq error, groupBy$metadata.message. -
Verified filter operations:
eq,neq. Percentiles: the API wants"median", not"p50". Filter for substrings client-side on fetched events. -
Useful drill-downs: groupBy
$metadata.trigger(route),$workers.scriptVersion(did a deploy change the rate mid-window),$workers.event.request.headers.user-agent.
Counting gotchas
- Grouped results are unsorted and effectively capped (~10 rows returned regardless of
limit). A missing group ≠ zero. To see error outcomes, add$workers.outcome neq okinstead of hoping the error rows make the cut; sort client-side. - One isolate death fans out. An OOM kills every request pinned to the isolate at the same instant — cluster raw OOM events by timestamp before reading the count as user impact.
- Message prefixes fragment groups. Logs with leading timestamps (better-auth's format) split one error into N single-count groups; grouped-by-message counts badly understate them. Sample events and merge client-side.
- Prod deploys are manual — main being fixed doesn't mean prod runs the fix. Check
$workers.scriptVersionand the scripts'modified_onbefore concluding a fix didn't work.
Workflow failures
- An Error logged as an argument loses its message.
console.error("x failed:", error)lands in Workers Logs asx failed: at async …(frames only, noError: messageline), and an Error inside a logged object serializes to{remote: true}or{}. Grep the stack for the call site, then read the real message fromdescribeError(error)in the newer log lines (src/server/lib/errors.ts) or from the Workflows instance below. - The engine records why a step attempt failed.
GET /accounts/{account_id}/workflows/{workflow_name}/instances?status=errored&date_start=<iso>lists errored instances (workflow names are inwrangler.jsonc:ai-visibility-workflow,rank-check-workflow,site-audit-workflowon the audit worker,local-rank-workflow);GET …/instances/{id}returnserrorplus every step with its attempts and each attempt'serror.name/error.message. This is the only place the message of a step failure survives. WorkflowInternalError: Attempt failed due to internal workflows erroron an attempt is the engine's own failure, not app code: the invocation running the step is reportedcanceledabout 30 s later. It comes in bursts that line up with Cloudflare status incidents (Workflows, Durable Objects, or a network incident in the Ashburn region that hosts prod). The same bursts make the binding'screate()reject (at async WorkflowImpl.create (cloudflare-internal:workflows-api…)). Checkcloudflarestatus.com/historyfor the window before reading code.canceledis normal for workflow invocations. The engine cancels an invocation after its steps finished or while it sleeps (hundreds a day per workflow with no errored instance behind them), so count errored instances, not canceled invocations.
Profiling (CPU and heap flamegraphs)
Cloudflare captures an on-demand CPU or allocation profile from a live production isolate (shipped 2026-10-09). Reach for it only when the question is "where does the CPU or memory go", which logs and invocation metrics cannot answer; routine error triage never needs it. A capture only samples one already-running isolate for the requested window; keep windows at 10 to 15 s.
-
Capture with the
cfCLI (npm i -g cf,cf loginonce). It writes the pprof protobuf to stdout (the raw API response is gzipped; the reader below accepts both):cf workers versions profile latest --worker-id open-seo --duration-ms 10000 --profile-type cpu > open-seo-cpu.pprof cf workers versions profile latest --worker-id open-seo --duration-ms 15000 --profile-type heap > open-seo-heap.pprofAPI equivalent:
POST /accounts/{account_id}/workers/workers/{worker_id}/versions/latest/profilewith{ "duration_ms": 10000, "profile_type": "cpu" }(worker ids fromGET /accounts/{account_id}/workers/workers). The dashboard shows the same capture as a flamegraph: Worker → Observability → Flamegraph view. -
Read it without Go:
node .agents/skills/observability-triage/scripts/pprof-top.mjs open-seo-cpu.pprofprints the hottest self and inclusive frames. Google'spprofopens the file too. -
It only samples an isolate that is already serving traffic; nothing is started for you. The app worker always qualifies. The audit worker returns 0 samples unless an audit is running, so start one first. Idle preview and self-host workers fail with "The Worker has no loaded isolate in its recent execution locations" or "No recent executions were found".
-
One capture is one isolate in one colo, chosen among the ones that ran recently, so repeat a capture before trusting a small frame. A 10 s CPU capture under normal load yields ~2,000 samples at 1 ms intervals.
-
Function names are readable without source maps (the Vite build keeps them); file names are chunk hashes like
index-OuwxKDQa.js. The production deploy uploads its source maps to PostHog error tracking and deletes them before deploying, so Cloudflare never has them and chunk names here stay hashes. -
A heap profile shows allocations made during the window (one stack per ~512 kB allocated), not retained memory: it finds churn, not leaks.
-
Pair it with invocation data to see which route pays for the frames: group
cf-worker-eventby$metadata.triggerwithsum,median, andp90of$workers.cpuTimeMs. A 10 s capture of one isolate weights whatever that isolate happened to serve, so convert a hot frame's share into milliseconds against the route's mean before acting on it: a frame at a third of a profile dominated by 2 ms auth failures is worth well under 1 ms per request.
Known noise — filter these out, do not re-investigate
Entries land here only after an investigation proved there is no first-party emit site to fix or demote. Each keeps the one condition that would make it real signal again.
1. SAM chat Durable Object lifecycle (close code 1006)
- Messages (one phenomenon, counted three ways):
Connection closed: this Durable Object instance is no longer active. Reconnect or retry the request.(hibernation/eviction),Durable Object reset because its code was updated.(deploy), plus the paired invocation summary whose$metadata.errorisclose. - Identify by:
eventType: "hibernatableWebSocket", entrypointSamChatAgent/OnboardingChatAgent,webSocketType: "close", code: 1006, wasClean: false,outcome: "exception", single-digitwallTimeMs,cpuTimeMs: 0, no stack. Fingerprints3aa4cac26653d09a0a41100a33d413ae(exception),0ae15457af49b4d9a117eeecf66b040a(summary). - Why unfixable: the DO is destroyed under the JS — its IoContext is already aborted when workerd delivers
webSocketClose, so the firstawaitnever settles.partyserveralready try/catches the whole close path; anonCloseoverride would catch nothing. - Nothing breaks: transcripts persist per message in DO SQLite, PartySocket reconnects unconditionally, and credit metering (
onChatResponse) never fires on an aborted turn. - Real signal: a sustained rise that does not correlate with a deploy (would mean mid-conversation evictions beyond hibernation).
2. Network connection lost.
- Identify by: fingerprint
be89d4ff7a64cb4d4dceae0f51cfe708, or the message verbatim. Runtime-generated shape:source.levelabsent,source.exceptionpresent,$metadata.origin: "fetch"— the opposite of every app log (source.levelpresent, noexception). - Why unfixable: workerd's own record of a client disconnecting mid-stream; the exception never enters app code — the sibling request event for the same
requestIdhasoutcome: "ok". No emit site exists;wrangler.jsoncobservability config has no per-message filter. Do not add try/catch around the stream handlers (dead ceremony). - Real signal: if the
/mcpshare of this group grows, treat it as a tool-call-latency symptom (clients timing out and cancelling) and route it to the /mcp performance track — not to this log group.
Runtime-vs-app litmus test
Before investigating any unfamiliar error event: source.level absent + source.exception present ⇒ the Workers runtime wrote it, not the app. There is no call site to grep for; judge it by the sibling request's outcome.
Adding an entry
Add to this list only after establishing there is no first-party emit site (grep the message; check the runtime-vs-app litmus above) and nothing is left in a wrong state. Every entry must include identify-by markers (fingerprint if stable) and its real-signal condition.