Observability:logs、traces 與 tail
第 38 章結尾列了一長串「只能上 staging 驗」。這一章處理的是:上去了之後,你看不看得到。
39.1 observability 的完整形狀(官方 wrangler 文件是殘缺的)
Section titled “39.1 observability 的完整形狀(官方 wrangler 文件是殘缺的)”Wrangler configuration 參考頁只寫了兩個 key:enabled 與 head_sampling_rate。實測 wrangler 4.118.0 附的 JSON schema,真正的形狀是:
{ "observability": { "enabled": true, "head_sampling_rate": 1, "logs": { "enabled": true, "head_sampling_rate": 1, // ← schema 接受,但官方文件沒有任何一頁提到 "invocation_logs": true, "persist": true, "destinations": [] }, "traces": { "enabled": true, "head_sampling_rate": 0.05, "persist": true, "destinations": [] } }}而且 Observability、logs、traces 三層全部是 "additionalProperties": false —— 打錯 key 會被 wrangler 擋下來,這點比第 38 章那個 z.strip 好很多。
🔴
head_sampling_rate出現在三個地方,但文件只承認兩個。 官方 wrangler 參考頁與 Workers Logs 頁把它放在頂層;Traces 頁把它放在traces裡。沒有任何一頁提到logs.head_sampling_rate—— 但 schema 接受它。 這是本系列第 N 次的「schema 比文件寬」(對照第 21、23 章)。我沒有辦法驗證logs.head_sampling_rate在 production 是否真的生效,所以:用頂層的那個。
persist 兩處都預設 true,意思是「送到 Cloudflare 的 observability 平台,能在 dashboard 查」。destinations: [] 是送去別的地方(自架 OTLP 收集器之類)。
還有一個 schema 裡有、官方文件完全查不到的東西:
"streaming_tail_consumers": [{ "service": "my-streaming-tail" }]TailConsumer 有 service + environment,StreamingTailConsumer 只有 service。文件站上搜不到 streaming_tail_consumers 或 tailStream。不要在正式文件裡宣稱它存在 —— 但知道 schema 裡有這個東西,遇到時不會嚇到。
39.2 Workers Logs:結構化才查得到
Section titled “39.2 Workers Logs:結構化才查得到”Workers Logs 2025-04-09 GA。
| 方案 | 額度 | 保留 |
|---|---|---|
| Free | 200,000 events / 天 | 3 天 |
| Paid | 每月含 20M,超出 $0.60 / M | 7 天 |
兩個硬限制值得記:
- 單筆 log 上限 256 KB,超過會被截斷並把
$cloudflare.truncated設成true。(注意是單筆,不是每次 invocation。) - 每帳號每天 50 億筆,超過之後當天剩下的時間全部套用 1% head sampling。
結構化 vs 不結構化,實測差在哪
Section titled “結構化 vs 不結構化,實測差在哪”wrangler dev 有一個很多人不知道的東西:本機 observability 查詢 API。
curl -X POST http://localhost:8787/cdn-cgi/local/explorer/api/local/observability/query \ -H 'Content-Type: application/json' \ -d '{"sql":"SELECT level, message FROM logs ORDER BY rowid LIMIT 12"}'範例的 /probe/logs 送出五筆不同寫法,查回來長這樣:
| 你寫的 | 存進去的 message |
|---|---|
console.log("plain string log, ...") | ["plain string log, not queryable by field"] |
console.log(JSON.stringify({ event: "stringified", slug: "abc" })) | ["{\"event\":\"stringified\",\"slug\":\"abc\"}"] |
console.log({ event: "structured", slug: "abc", ms: 12, ok: true }) | [{"event":"structured","slug":"abc","ms":12,"ok":true}] |
console.log({ event: "nested", link: { slug, tenant } }) | [{"event":"nested","link":{"slug":"abc","tenant":"t1"}}] |
console.error({ event: "boom", code: "E_DEMO" }) | level 是 error,內容同上 |
關鍵在第二列與第三列的差別。 JSON.stringify() 之後傳進去的是字串,存起來也是字串 —— 欄位不會被抽出來建索引,Query Builder 查不到 slug。傳物件才會。
官方的說法比較保守:文件是「建議」結構化,說 Workers Logs 會「automatically extracts the fields and indexes them intelligently」。它沒有說非 JSON 就不行 —— 純文字 log 是支援的,只是不能按欄位查。所以正確的講法是:物件會被抽欄位建索引,字串不會。
而且本機的 logs 表順便告訴你 invocation log 長什麼樣:
{"trace_id":"99cc…","span_id":"fc41…","seq":0,"ts_ms":…,"level":"info", "message":"\"GET http://localhost:8813/boom\"","operation":null}每一筆 log 都帶 trace_id 與 span_id。 這就是 logs 與 traces 串起來的方式,不需要你自己塞 correlation id。invocation_logs: false 關掉的就是上面那筆 "GET …"。
39.3 Traces:自動 instrument,以及兩個要小心的價格數字
Section titled “39.3 Traces:自動 instrument,以及兩個要小心的價格數字”狀態:open beta(2025-11-07 宣布)。2026-03-01 起計費。
⚠️ 官方 Traces 頁到現在還寫著「currently free during beta」,同一頁又寫著 2026-03-01 開始計費。 這一頁自相矛盾,而且矛盾的時間點已經過去五個月。不要引用「免費」那句。
零程式碼自動追蹤的範圍(官方原文):
- Fetch calls —— 所有 outbound HTTP 請求,含時間、狀態碼、request metadata
- Binding calls —— KV 讀寫、R2 操作、Durable Object 呼叫等
- Handler calls —— 每次 invocation 的完整生命週期,含 fetch / scheduled / queue handler
2026-05-07 起,Durable Object 與 Worker 之間的 subrequest 也會自動串起分散式追蹤(第 14、18 章那些跨 DO 的呼叫終於看得到全貌了)。
🔴 兩個價格數字不是矛盾,是兩個產品
Section titled “🔴 兩個價格數字不是矛盾,是兩個產品”我查到兩個常被混在一起的數字,這裡講清楚:
| 是什麼 | 價格 | |
|---|---|---|
| $0.60 / M | observability events(Workers Logs,traces 與它共用計價) | Paid 方案 traces 每月含 10M(logs 是 20M),超出 $0.60/M |
| $0.05 / M | Workers Trace Events Logpush 的 request | 每月含 10M,超出 $0.05/M |
名字撞在一起了:「Workers Trace Events Logpush」是 Logpush,不是 tracing。看到 $0.05 就說「追蹤很便宜」是搞錯產品。
另外注意:Workers pricing 頁上根本沒有 Workers Traces 這一列。 價格只出現在 Traces 文件頁。所以不是兩頁互相矛盾,是 pricing 頁沒寫。文章裡不要把數字寫死。
已知限制(官方明列,全部值得記)
Section titled “已知限制(官方明列,全部值得記)”- span 名稱與 attribute 名稱「尚未定案」,beta 期間可能改,會往 OpenTelemetry semantic conventions 靠。不要在 alert 規則裡寫死 span 名稱。
- 非 I/O 操作可能顯示
0 ms—— 這是 Spectre 緩解:Workers runtime 在沒有 I/O 之前不推進時鐘。範例實測的probe-span、outer、inner全部是duration_ms = 0。 - 依 Worker 名稱篩選時要用
$metadata.service,不要用service.name—— 前者在 logs 與 traces 都一致。 - trace ID 不會傳播到 Cloudflare 以外的服務。
39.4 自訂 span:enterSpan 與(文件還不承認的)startActiveSpan
Section titled “39.4 自訂 span:enterSpan 與(文件還不承認的)startActiveSpan”import { tracing } from "cloudflare:workers";實測 tracing 的原型上有三個東西:enterSpan、startActiveSpan、Span。
interface Tracing { enterSpan<T, A extends unknown[]>(name: string, cb: (span: Span, ...args: A) => T, ...args: A): T; startActiveSpan<T, A extends unknown[]>(name: string, cb: (span: Span, ...args: A) => T, ...args: A): T; Span: typeof Span;}declare abstract class Span { get isTraced(): boolean; setAttribute(key: string, value?: boolean | number | string): void; end(): void;}🔴 官方 custom-spans 頁的「Limitations」到現在還寫著: “No manual span lifetime management. Spans are always scoped to the
enterSpancallback. You cannot start a span and end it later.”這句話在 2026-07-28 就過期了。 那天的 changelog 標題是「write custom spans with new
startActiveSpan()andspan.end()runtime APIs」。實測型別與 runtime 都有。引用 changelog,不要引用那一頁的 Limitations。(enterSpan沒有被棄用。)
第三個參數是變長參數,會原樣傳進 callback:
const result = tracing.enterSpan("outer", (outer, a: number, b: number) => { outer.setAttribute("ch39.depth", 0); return tracing.enterSpan("inner", (inner) => { inner.setAttribute("ch39.depth", 1); return a + b; });}, 20, 22);// → 42,而且 outer / inner 都被記錄成巢狀 spanenterSpan 的回傳值就是 callback 的回傳值,直接往外傳。
🔴 setAttribute() 的三個沉默行為(全部實測)
Section titled “🔴 setAttribute() 的三個沉默行為(全部實測)”範例故意做了四件不該做的事,沒有一件會 throw:
span.setAttribute("ch39.kind", "probe"); // 正常span.setAttribute("ch39.cleared", undefined); // 文件說是 no-op,實測不 throwspan.setAttribute("ch39.bad", { a: 1 }); // 型別不允許,但不 throwspan.end();span.setAttribute("ch39.late", 1); // end() 之後,不 throwspan.end(); // 再 end 一次,不 throw從本機 observability 表查回實際存了什麼:
{"ch39.kind":"probe","ch39.bad":"[object Object]"}三件事:
- 傳物件不會報錯,會被
String()成"[object Object]"。 型別寫的是boolean | number | string,runtime 只是沉默強制轉型。你以為記了一個結構,實際上記了一個沒用的字串。要記結構就自己JSON.stringify()。 end()之後的setAttribute被安靜丟掉 ——ch39.late不在結果裡,也沒有任何警告。- 重複
end()不會出錯。
這一組是很典型的「你寫錯了,但沒有人告訴你」。加一條 lint 規則比什麼都有用。
🔴 isTraced 在 wrangler dev 永遠是 true
Section titled “🔴 isTraced 在 wrangler dev 永遠是 true”官方對 isTraced 的說明是:
“A
readonly booleanindicating whether this invocation is being traced. When the request is not sampled (based on yourhead_sampling_rate),isTracedisfalseandenterSpanstill runs the callback but does not record any telemetry.”
所以正確的寫法是拿它當昂貴計算的守門員:
tracing.enterSpan("expensive", (span) => { if (span.isTraced) { span.setAttribute("payload.digest", expensiveHash(body)); // 只在真的被記錄時算 } return handle(body);});但這條路徑在本機測不到。 我把設定改了三次,每次都重新量:
| 設定 | span.isTraced |
|---|---|
traces.head_sampling_rate: 0.05 | true |
traces.head_sampling_rate: 0 | true |
traces.enabled: false | true |
observability.enabled: false | true |
wrangler dev 底下 isTraced 恆為 true,而且 span 照樣被寫進本機的表(observability.enabled: false 之後仍然累積到 18 筆)。
加進第 38 章那張表:
if (span.isTraced)的 false 分支在本機永遠不會執行。 昂貴的那條路每次都會跑,你不會發現效能問題;反過來,如果 false 分支有 bug,本機也永遠不會暴露。只能靠 code review 加 staging。
39.5 🔴 未捕捉的例外在本機的 trace 裡是 outcome: "ok"
Section titled “39.5 🔴 未捕捉的例外在本機的 trace 裡是 outcome: "ok"”這是本章最需要放進 alert 規則的一條。
範例的 /boom 就是 throw new Error("ch39 deliberate failure")。HTTP 回 500,wrangler 主控台也印了錯誤。但本機 traces 表裡:
SELECT name, outcome, error FROM spans WHERE parent_id IS NULL ORDER BY rowid DESC LIMIT 1;-- GET | ok | NULLroot span 的 attributes:
{ "faas.trigger": "http", "http.request.method": "GET", "url.full": "http://localhost:8813/boom", "faas.invocation_id": "1f5cf7b4…", "http.response.status_code": 500, "cloudflare.outcome": "ok", "cpu_time_ms": 0, "wall_time_ms": 0}outcome 是 "ok",error 是 NULL,例外訊息完全不在 trace 裡。 唯一的訊號是 http.response.status_code: 500。
Tail Worker 那邊也一樣(下一節),outcome: "ok"、exceptionCount: 0。
⚠️ 這是本機(miniflare + local explorer)的量測。 production 的 Workers Traces 有沒有不同,我沒有帳號可以驗,所以這條要當成「本機行為」看待,不要寫成 production 事實。
但實務上的建議是一樣的,而且成本很低:alert 條件寫
http.response.status_code >= 500,不要寫outcome != "ok"。 前者在兩邊都對。
39.6 Tail Workers:本機真的跑得起來
Section titled “39.6 Tail Workers:本機真的跑得起來”Tail Worker 的 handler 是:
export default { async tail(events: TraceItem[], env: Env, ctx: ExecutionContext): Promise<void> { /* ... */ }};events 是陣列 —— 一次 invocation 一個 TraceItem,會批次送過來。
官方 Tail Workers 頁的範例只寫了
async tail(events)與async tail(events, env),完整簽章要看 runtime handler 參考頁。這不是矛盾,是範例省略。
計價是按 CPU time,不是按請求數。
wrangler dev 支援多 config,可以把 Worker 和它的 Tail Worker 一起起來:
npx wrangler dev -c wrangler.jsonc -c tail-worker/wrangler.jsonc// 主 Worker"tail_consumers": [{ "service": "ch39-tail" }]實測 TraceItem 的完整 key(本機量的,18 個):
cpuTime, diagnosticsChannelEvents, dispatchNamespace, durableObjectId,entrypoint, event, eventTimestamp, exceptions, executionModel, logs,outcome, preview, scriptName, scriptTags, scriptVersion, tailAttributes,truncated, wallTime幾個值得注意的:executionModel、tailAttributes、entrypoint、scriptVersion、truncated 在官方頁面上都不顯眼。event 對 HTTP 請求來說是 { request, response }。
本機的兩個差異要記住:
scriptName是null(production 才有值)。- 例外不會出現。
/boom那筆是outcome: "ok"、exceptionCount: 0、exceptions: []。你的 Tail Worker 錯誤處理路徑在本機開發不出來,只能靠 staging。
wrangler tail 的定位
Section titled “wrangler tail 的定位”- 最多 10 個同時觀察者(dashboard session 與
wrangler tail共用這個額度) - 流量高會進入取樣模式,會丟訊息並在 log 裡出現警告 —— 官方沒有寫門檻數字
- 完全不留存:「Real-time logs does not store Workers Logs.」
它只適合 live debugging。 要留存就用 Workers Logs / Logpush / Tail Workers。
39.7 Source maps 與 Logpush
Section titled “39.7 Source maps 與 Logpush”{ "upload_source_maps": true }- 需要 wrangler 3.46.0 以上
- 上限 15 MB(gzip 後) —— 注意是壓縮後
- 不影響 CPU 與效能:「The source map is retrieved after your Worker invocation completes — it’s an asynchronous process that does not impact your Worker’s CPU utilization or performance.」
沒有理由不開。
Logpush:
{ "logpush": true }schema 的說明特別提醒:「This will not configure a corresponding Logpush job automatically.」 開了這個 flag 只是允許送,job 要另外建。
- dataset:
workers_trace_events logs與exceptions兩個欄位共用 16,384 字元的額度,超過開始截斷- 只有 Workers Paid 方案有
39.8 Query Builder 只查 logs
Section titled “39.8 Query Builder 只查 logs”Query Builder 和 Workers Logs 同一天(2025-04-09)GA,不需要啟用。
“The Query Builder searches the Workers Observability dataset, which currently includes all logs stored by Workers Logs.”
「currently」這個字留了空間,但目前它查不到 traces。 traces 要在 dashboard 的 Traces 分頁看。
39.9 LinkForge:結構化 log 規範
Section titled “39.9 LinkForge:結構化 log 規範”把上面所有實測結果變成一組可以直接抄的規範:
// 1. 一律傳物件,不要 JSON.stringify(39.2)// 2. 固定的 top-level 欄位,方便建索引// 3. 不要塞 PIItype LogEvent = { event: string; // "link.created" / "link.redirect" / "link.miss" tenant?: string; slug?: string; ms?: number; status?: number; err?: string;};
const log = (e: LogEvent) => console.log(e);const logError = (e: LogEvent) => console.error(e);span 的規範:
// 1. attribute 一律 string | number | boolean,物件自己 stringify(39.4)// 2. 昂貴的計算包在 isTraced 裡(但知道本機測不到)// 3. 不要在 alert 規則裡寫死 span 名稱(官方說會改)tracing.enterSpan("linkforge.resolve", (span) => { span.setAttribute("linkforge.slug", slug); span.setAttribute("linkforge.cache_hit", hit); if (span.isTraced) span.setAttribute("linkforge.debug", JSON.stringify(debugPayload)); return result;});告警規則:
| 想抓什麼 | 條件 | 為什麼不用別的 |
|---|---|---|
| 未捕捉例外 | http.response.status_code >= 500 | outcome 在本機是 "ok"(39.5) |
| 特定 Worker | $metadata.service = "linkforge" | service.name 在 logs / traces 不一致 |
| 慢請求 | wall_time_ms 的分位數 | 但要知道非 I/O 可能是 0 ms |
抽樣策略:
{ "observability": { "enabled": true, "head_sampling_rate": 1, // log 全收:便宜,而且出事時你需要它 "traces": { "enabled": true, "head_sampling_rate": 0.05 } // trace 抽 5%:span 比較貴 }}39.10 本章實測結論彙整
Section titled “39.10 本章實測結論彙整”| # | 結論 | 影響 |
|---|---|---|
| 1 | wrangler schema 的 observability 有 logs / traces 兩層巢狀;官方 wrangler 參考頁只寫了頂層兩個 key | 不要拿那一頁當完整定義 |
| 2 | 三層都是 additionalProperties: false | 打錯 key wrangler 會擋(比第 38 章的 z.strip 好) |
| 3 | 🔴 schema 接受 logs.head_sampling_rate,但官方文件沒有任何一頁提到它 | 無法驗證是否生效;用頂層的 |
| 4 | schema 有 streaming_tail_consumers(只有 service),官方站上完全查不到 | 不要宣稱它存在,但知道有這回事 |
| 5 | Workers Logs 2025-04 GA;Free 3 天 / Paid 7 天;Paid 含 20M,超出 $0.60/M | 7 天是硬上限 |
| 6 | 單筆 log 上限 256 KB(不是每 invocation),截斷時 $cloudflare.truncated = true | |
| 7 | 每帳號每天 50 億筆,超過後當天套 1% head sampling | |
| 8 | 🔴 實測:console.log(物件) 存成 JSON、console.log(JSON.stringify(物件)) 存成字串 | 後者欄位不會被抽出來,查不到 |
| 9 | 官方說法是「建議」而非「必須」JSON | 純文字支援,只是不能按欄位查 |
| 10 | 每筆 log 自帶 trace_id / span_id | logs↔traces 自動關聯,不用自己塞 correlation id |
| 11 | Traces open beta(2025-11 宣布),2026-03-01 起計費 | 🔴 官方頁到現在還寫「currently free during beta」—— 自相矛盾,不要引用 |
| 12 | 🔴 $0.60/M 是 observability events;$0.05/M 是 Workers Trace Events Logpush | 兩個產品撞名,不是矛盾 |
| 13 | Traces Paid 每月含 10M(logs 是 20M) | |
| 14 | Workers pricing 頁沒有 Traces 這一列 | 價格只在 Traces 文件頁 |
| 15 | 自動 instrument:所有 outbound fetch、所有 binding 呼叫、handler 生命週期;2026-05 起跨 DO/Worker subrequest | 第 14、18 章的跨服務呼叫終於看得到 |
| 16 | 官方明說 span/attribute 名稱尚未定案 | 不要在 alert 規則寫死 |
| 17 | 非 I/O 操作可能 0 ms(Spectre 緩解);實測自訂 span 全是 duration_ms = 0 | |
| 18 | 篩 Worker 名稱用 $metadata.service,不是 service.name | 官方列為 known bug |
| 19 | tracing 原型上有 enterSpan、startActiveSpan、Span | |
| 20 | 🔴 官方 custom-spans 頁的 Limitations 仍寫「You cannot start a span and end it later」,但 startActiveSpan() / span.end() 在 2026-07-28 就上了 | 引用 changelog,不要引用那一頁 |
| 21 | enterSpan(name, cb, ...args) 的變長參數原樣傳進 callback;回傳值直接往外傳 | 實測巢狀 + 20, 22 → 42 |
| 22 | 🔴 setAttribute(key, {物件}) 不 throw,存成 "[object Object]" | 型別說 boolean|number|string,runtime 沉默轉型 |
| 23 | 🔴 end() 之後 setAttribute 被安靜丟掉;重複 end() 不報錯 | 加 lint 規則 |
| 24 | 🔴 wrangler dev 底下 span.isTraced 恆為 true —— 改 head_sampling_rate: 0、traces.enabled: false、observability.enabled: false 都一樣 | if (isTraced) 的 false 分支本機永遠測不到 |
| 25 | observability.enabled: false 之後本機仍持續累積 span | 本機的 explorer 不理會這個設定 |
| 26 | 🔴 本機實測:未捕捉的例外 → root span outcome: "ok"、error: NULL,例外訊息不在 trace 裡 | alert 用 http.response.status_code >= 500 |
| 27 | Tail Worker 那邊同樣 outcome: "ok"、exceptionCount: 0(本機) | Tail Worker 的錯誤路徑本機開發不出來 |
| 28 | wrangler dev -c a.jsonc -c b.jsonc 可以本機跑 Worker + Tail Worker | Tail Worker 本身是可以本機開發的 |
| 29 | 實測 TraceItem 有 18 個 key,含 executionModel / tailAttributes / entrypoint / scriptVersion / truncated | 官方頁沒全列 |
| 30 | 本機 scriptName 是 null | production 才有值 |
| 31 | Tail Workers 按 CPU time 計價,不是請求數 | |
| 32 | wrangler tail:最多 10 個同時觀察者、會取樣(無公開門檻數字)、完全不留存 | 只適合 live debugging |
| 33 | source maps 上限 15 MB gzip 後;擷取是非同步,不影響 CPU | 沒理由不開 |
| 34 | logpush: true 不會自動建 job(schema 說明明講) | 要另外建 |
| 35 | Logpush dataset workers_trace_events;logs + exceptions 共用 16,384 字元 | |
| 36 | Query Builder 2025-04 GA,只查 logs 不查 traces | 官方用字是「currently includes」 |
| 37 | wrangler dev 有本機 observability SQL API:POST /cdn-cgi/local/explorer/api/local/observability/query,表是 spans 與 logs | 本章大部分數字都是這樣量到的 |
39.11 動手練習
Section titled “39.11 動手練習”- 用本機的
/cdn-cgi/local/explorer/api/local/observability/query把spans的建表 SQL 查出來(SELECT sql FROM sqlite_master WHERE name='spans'),看看有哪些欄位是你原本不知道的。 - 同一段資料分別用
console.log(obj)與console.log(JSON.stringify(obj))送,從logs表比對message的差別。 - 把
head_sampling_rate設成 0,確認span.isTraced還是true。然後想想你要怎麼保證 production 的 false 分支是對的。 - 故意
span.setAttribute("x", { a: 1 }),從表裡查出"[object Object]"。 - 用
wrangler dev -c起一個 Tail Worker,把TraceItem整包印出來,和官方文件的欄位表比對。 - 打一個會 throw 的路由,確認 trace 的
outcome是"ok",然後把你的 alert 規則改成看http.response.status_code。
- Workers Logs · Traces · Custom spans · Known limitations
- Query Builder · Tail Workers · tail handler · Real-time logs
- Source maps · Workers Logpush · Wrangler configuration
- changelog: automatic tracing open beta(2025-11-07) · changelog:
startActiveSpan()(2026-07-28) · changelog: 跨 DO/Worker subrequest 追蹤(2026-05-07) - Schema 與型別來源:
node_modules/wrangler@4.118.0/config-schema.json(Observability/TailConsumer/StreamingTailConsumer)、wrangler types產生的worker-configuration.d.ts(Tracing/Span/TraceItem) - 所有 span、log、attribute 的實際值取自本機
wrangler dev的 observability SQL API(examples/ch39-observability),2026-08-01
下一章:第 40 章 CI/CD 與發佈策略 —— 把第 38 章的「要有 smoke test」和本章的「要看得到」串成一條可回滾的 pipeline。