{"slug": "your-worker-returned-500-and-the-log-says-outcome-ok", "title": "Your Worker Returned 500 and the Log Says `outcome: \"ok\"`", "summary": "A developer running AI Change Watch on Cloudflare Workers discovered that the platform's telemetry API silently truncates query results, returning only about 10% of events when querying a 24-hour window in one request. By slicing the time range into four-hour chunks, they retrieved 956 events instead of nine, revealing multiple unrelated causes for 500 errors. The developer also warns that the 'outcome' field indicates runtime success, not HTTP status, so filtering on 'outcome' misses application errors.", "body_md": "I run [ AI Change Watch](https://aichangewatch.com/?src=devto), a small independent project that\n\nIt runs on Cloudflare Workers, which means that when someone tells me \"your site 500'd an hour ago\",\n\nthe obvious tool is useless. `wrangler tail`\n\nis a **live stream**. It shows you what is happening now.\n\nIt cannot show you an hour ago.\n\nThere is a way to read the past, and there are four traps in it that cost me most of a day.\n\nWorkers can write their invocation logs to a queryable store, and a REST endpoint reads it back. First\n\nthe worker has to be opted in — this is the whole config:\n\n```\n// wrangler.jsonc\n{\n  \"observability\": { \"enabled\": true }\n}\n```\n\nThen you can ask for events in a time range:\n\n```\ncurl -sX POST \\\n  \"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/workers/observability/telemetry/query\" \\\n  -H \"Authorization: Bearer $CF_API_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"queryId\": \"anything\",\n    \"timeframe\": { \"from\": 1786000000000, \"to\": 1786003600000 },\n    \"limit\": 500,\n    \"view\": \"events\",\n    \"parameters\": {\n      \"datasets\": [\"cloudflare-workers\"],\n      \"filters\": [\n        { \"id\": \"f1\", \"key\": \"$workers.event.response.status\",\n          \"type\": \"number\", \"operation\": \"eq\", \"value\": 500 }\n      ]\n    }\n  }'\n```\n\n`from`\n\nand `to`\n\nare **epoch milliseconds**, not ISO strings. A read-scoped API token is enough — the\n\none I already had for deploys worked unchanged.\n\nEach event carries more than you would guess:\n\n```\n$workers.outcome                              ok | exceededCpu | canceled\n$workers.cpuTimeMs  /  $workers.wallTimeMs\n$workers.event.request.path  /  .search\n$workers.event.request.headers['user-agent']\n$workers.event.request.cf.asOrganization      the ASN owner\n$workers.event.response.status\n$metadata.error\n```\n\nThat is the tool. Now the traps.\n\nI started by filtering on the field that sounds right:\n\n```\n{ \"key\": \"$workers.outcome\", \"operation\": \"eq\", \"value\": \"exceededCpu\" }\n```\n\nand found nothing, repeatedly, while the site was demonstrably returning 500s.\n\nBecause for every one of them:\n\n```\n$workers.outcome = \"ok\"\n```\n\nThe worker ran. It produced a response. It returned it. That the response was an error page is not the\n\nruntime's problem — the invocation succeeded. `outcome`\n\ndescribes the worker, not the HTTP result.\n\nSo `outcome`\n\nis the wrong axis for application errors. Filter on `$workers.event.response.status`\n\nfor\n\nwhat the user saw, and read `$metadata.error`\n\nfor the throw. `outcome`\n\nis for failures the runtime\n\nitself noticed: CPU limit, cancellation.\n\nThis is worth internalising because it inverts the usual relationship. In most stacks \"the request\n\nfailed\" and \"the handler failed\" are the same event. At the edge they are two different fields — and\n\nthe one with the friendlier name is the one that will not tell you.\n\nThis is the one that actually cost me the day.\n\nI asked for 5xx across a 24-hour window, got nine events, and concluded I was chasing a single bug. The\n\nsame 24 hours, walked in 4-hour slices and concatenated, returned **956** — across 92 URLs and three\n\nunrelated causes.\n\nI re-ran the comparison today, on 404s, to check it was not a one-off:\n\n```\n24h asked as one query      →  26 events\nsame 24h in 4h slices       → 266 events\n```\n\n**The single query returned 10% of what was there.** Not a rounding difference — a different\n\nconclusion. And nothing in the response says so: no truncation flag, no \"results were sampled\" field.\n\nYou get a well-formed answer that happens to be mostly missing.\n\nSo the loop, not the query:\n\n``` js\nconst out = [];\nfor (let h = 24; h > 0; h -= 4) {\n  const from = Date.now() - h * 3600_000;\n  const to   = Date.now() - (h - 4) * 3600_000;\n  const ev = await queryEvents({ from, to, limit: 500 });\n  if (ev.length >= 500) console.warn(`slice ${h}h hit the limit — narrow it`);\n  out.push(...ev);\n}\n```\n\nThe `ev.length >= 500`\n\ncheck matters as much as the slicing. A slice that returns exactly your limit is\n\ntruncated, and you have to narrow *that* slice further. Without the warning you cannot tell \"500 events\n\nhappened\" from \"500 events fit\".\n\n`exists`\n\nmatches empty strings, and `includes`\n\nignores case\nTwo smaller ones, both of which produced confidently wrong numbers before I noticed.\n\n** operation: \"exists\" matches a key that is present but empty.** I wanted requests Cloudflare had\n\n```\n{ \"key\": \"$workers.event.request.cf.verifiedBotCategory\", \"operation\": \"exists\" }\n```\n\nThat field is present on every request and is `\"\"`\n\non almost all of them, so the filter matched the\n\nentire dataset and I briefly believed the whole site was bot traffic. Use `exists`\n\nonly for keys\n\ngenuinely absent on what you are excluding — `sec-fetch-mode`\n\nis a real example, since non-browsers do\n\nnot send it.\n\n** operation: \"includes\" is case-insensitive.** Filtering user agents for\n\n`bot`\n\nand for `Bot`\n\nreturnedThe events view and the dashboard's invocation count disagree, and both are right. On one day my web\n\nworker showed **24,085 telemetry events against 8,772 invocations** — roughly 2.7 events per\n\ninvocation.\n\nSo:\n\n`workersInvocationsAdaptive`\n\nin the GraphQL analytics API, not from counting eventsMixing them gives a number that is wrong by a factor you cannot see.\n\n```\n// Past-tense debugging: \"what 500'd between 3am and 4am\".\nasync function queryEvents({ from, to, limit = 500, filters }) {\n  const r = await fetch(\n    `https://api.cloudflare.com/client/v4/accounts/${ACCOUNT}/workers/observability/telemetry/query`,\n    { method: 'POST',\n      headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },\n      body: JSON.stringify({\n        queryId: 'q', timeframe: { from, to }, limit, view: 'events',\n        parameters: { datasets: ['cloudflare-workers'], filters },\n      }) });\n  const d = await r.json();\n  return d?.result?.events?.events ?? [];\n}\n```\n\n…called from the slicing loop above, with the results grouped in plain JavaScript rather than by asking\n\nthe API to group them. (`view: \"calculations\"`\n\nwith a `groupBy`\n\non a high-cardinality key returns only a\n\nfew groups, quietly — the same failure mode as trap 2: a well-formed answer that is mostly missing.)\n\nThe retention window is limited. I have reliably queried three days back and would not build a workflow\n\nthat assumes more; for anything you need to keep, pull it out and store it yourself.\n\nOne more, learned the embarrassing way: ** cf.asOrganization is the ASN owner, not the bot.** Requests\n\n`wrangler tail`\n\nis for watching. For asking, use the telemetry API — and remember that ** outcome: \"ok\"\nmeans the worker succeeded, not that your user did**, and that a query covering a wide window will hand\n\n*The tracker this came out of is at aichangewatch.com — it watches AI\nvendor docs for changes, and the 500s that started all this were a REST detail endpoint quietly falling\nthrough to its collection endpoint.*", "url": "https://wpnews.pro/news/your-worker-returned-500-and-the-log-says-outcome-ok", "canonical_source": "https://dev.to/ai_changewatch/your-worker-returned-500-and-the-log-says-outcome-ok-2dla", "published_at": "2026-08-21 12:00:00+00:00", "updated_at": "2026-08-21 12:15:36.022032+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Cloudflare Workers", "AI Change Watch"], "alternates": {"html": "https://wpnews.pro/news/your-worker-returned-500-and-the-log-says-outcome-ok", "markdown": "https://wpnews.pro/news/your-worker-returned-500-and-the-log-says-outcome-ok.md", "text": "https://wpnews.pro/news/your-worker-returned-500-and-the-log-says-outcome-ok.txt", "jsonld": "https://wpnews.pro/news/your-worker-returned-500-and-the-log-says-outcome-ok.jsonld"}}