跳到內容

Observability:logs、traces 與 tail

查證日期
驗證環境wrangler@4.118.0·compatibility_date: 2026-07-24
前置章節38. 測試

第 38 章結尾列了一長串「只能上 staging 驗」。這一章處理的是:上去了之後,你看不看得到。


39.1 observability 的完整形狀(官方 wrangler 文件是殘缺的)

Section titled “39.1 observability 的完整形狀(官方 wrangler 文件是殘缺的)”

Wrangler configuration 參考頁只寫了兩個 keyenabledhead_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": []
}
}
}

而且 Observabilitylogstraces 三層全部是 "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" }]

TailConsumerservice + environmentStreamingTailConsumer 只有 service。文件站上搜不到 streaming_tail_consumerstailStream不要在正式文件裡宣稱它存在 —— 但知道 schema 裡有這個東西,遇到時不會嚇到。


Workers Logs 2025-04-09 GA

方案額度保留
Free200,000 events / 天3 天
Paid每月含 20M,超出 $0.60 / M7 天

兩個硬限制值得記:

  • 單筆 log 上限 256 KB,超過會被截斷並把 $cloudflare.truncated 設成 true。(注意是單筆,不是每次 invocation。)
  • 每帳號每天 50 億筆,超過之後當天剩下的時間全部套用 1% head sampling

結構化 vs 不結構化,實測差在哪

Section titled “結構化 vs 不結構化,實測差在哪”

wrangler dev 有一個很多人不知道的東西:本機 observability 查詢 API

Terminal window
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_idspan_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 / Mobservability events(Workers Logs,traces 與它共用計價)Paid 方案 traces 每月含 10M(logs 是 20M),超出 $0.60/M
$0.05 / MWorkers 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-spanouterinner 全部是 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 的原型上有三個東西:enterSpanstartActiveSpanSpan

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 enterSpan callback. You cannot start a span and end it later.”

這句話在 2026-07-28 就過期了。 那天的 changelog 標題是「write custom spans with new startActiveSpan() and span.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 都被記錄成巢狀 span

enterSpan 的回傳值就是 callback 的回傳值,直接往外傳。

🔴 setAttribute() 的三個沉默行為(全部實測)

Section titled “🔴 setAttribute() 的三個沉默行為(全部實測)”

範例故意做了四件不該做的事,沒有一件會 throw

span.setAttribute("ch39.kind", "probe"); // 正常
span.setAttribute("ch39.cleared", undefined); // 文件說是 no-op,實測不 throw
span.setAttribute("ch39.bad", { a: 1 }); // 型別不允許,但不 throw
span.end();
span.setAttribute("ch39.late", 1); // end() 之後,不 throw
span.end(); // 再 end 一次,不 throw

從本機 observability 表查回實際存了什麼:

{"ch39.kind":"probe","ch39.bad":"[object Object]"}

三件事:

  1. 傳物件不會報錯,會被 String()"[object Object]" 型別寫的是 boolean | number | string,runtime 只是沉默強制轉型。你以為記了一個結構,實際上記了一個沒用的字串。要記結構就自己 JSON.stringify()
  2. end() 之後的 setAttribute 被安靜丟掉 —— ch39.late 不在結果裡,也沒有任何警告。
  3. 重複 end() 不會出錯。

這一組是很典型的「你寫錯了,但沒有人告訴你」。加一條 lint 規則比什麼都有用。

🔴 isTracedwrangler dev 永遠是 true

Section titled “🔴 isTraced 在 wrangler dev 永遠是 true”

官方對 isTraced 的說明是:

“A readonly boolean indicating whether this invocation is being traced. When the request is not sampled (based on your head_sampling_rate), isTraced is false and enterSpan still 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.05true
traces.head_sampling_rate: 0true
traces.enabled: falsetrue
observability.enabled: falsetrue

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 | NULL

root 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"errorNULL,例外訊息完全不在 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 一起起來:

Terminal window
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

幾個值得注意的:executionModeltailAttributesentrypointscriptVersiontruncated 在官方頁面上都不顯眼。event 對 HTTP 請求來說是 { request, response }

本機的兩個差異要記住:

  • scriptNamenull(production 才有值)。
  • 例外不會出現。 /boom 那筆是 outcome: "ok"exceptionCount: 0exceptions: []你的 Tail Worker 錯誤處理路徑在本機開發不出來,只能靠 staging。
  • 最多 10 個同時觀察者(dashboard session 與 wrangler tail 共用這個額度)
  • 流量高會進入取樣模式,會丟訊息並在 log 裡出現警告 —— 官方沒有寫門檻數字
  • 完全不留存:「Real-time logs does not store Workers Logs.」

它只適合 live debugging。 要留存就用 Workers Logs / Logpush / Tail Workers。


{ "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
  • logsexceptions 兩個欄位共用 16,384 字元的額度,超過開始截斷
  • 只有 Workers Paid 方案有

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 分頁看。


把上面所有實測結果變成一組可以直接抄的規範:

// 1. 一律傳物件,不要 JSON.stringify(39.2)
// 2. 固定的 top-level 欄位,方便建索引
// 3. 不要塞 PII
type 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 >= 500outcome 在本機是 "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 比較貴
}
}

#結論影響
1wrangler schema 的 observabilitylogs / traces 兩層巢狀;官方 wrangler 參考頁只寫了頂層兩個 key不要拿那一頁當完整定義
2三層都是 additionalProperties: false打錯 key wrangler 會擋(比第 38 章的 z.strip 好)
3🔴 schema 接受 logs.head_sampling_rate,但官方文件沒有任何一頁提到它無法驗證是否生效;用頂層的
4schema 有 streaming_tail_consumers(只有 service),官方站上完全查不到不要宣稱它存在,但知道有這回事
5Workers Logs 2025-04 GA;Free 3 天 / Paid 7 天;Paid 含 20M,超出 $0.60/M7 天是硬上限
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_idlogs↔traces 自動關聯,不用自己塞 correlation id
11Traces open beta(2025-11 宣布),2026-03-01 起計費🔴 官方頁到現在還寫「currently free during beta」—— 自相矛盾,不要引用
12🔴 $0.60/M 是 observability events;$0.05/M 是 Workers Trace Events Logpush兩個產品撞名,不是矛盾
13Traces Paid 每月含 10M(logs 是 20M)
14Workers 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
19tracing 原型上有 enterSpanstartActiveSpanSpan
20🔴 官方 custom-spans 頁的 Limitations 仍寫「You cannot start a span and end it later」,但 startActiveSpan() / span.end() 在 2026-07-28 就上了引用 changelog,不要引用那一頁
21enterSpan(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: 0traces.enabled: falseobservability.enabled: false 都一樣if (isTraced) 的 false 分支本機永遠測不到
25observability.enabled: false 之後本機仍持續累積 span本機的 explorer 不理會這個設定
26🔴 本機實測:未捕捉的例外 → root span outcome: "ok"error: NULL,例外訊息不在 trace 裡alert 用 http.response.status_code >= 500
27Tail Worker 那邊同樣 outcome: "ok"exceptionCount: 0(本機)Tail Worker 的錯誤路徑本機開發不出來
28wrangler dev -c a.jsonc -c b.jsonc 可以本機跑 Worker + Tail WorkerTail Worker 本身是可以本機開發的
29實測 TraceItem 有 18 個 key,含 executionModel / tailAttributes / entrypoint / scriptVersion / truncated官方頁沒全列
30本機 scriptNamenullproduction 才有值
31Tail Workers 按 CPU time 計價,不是請求數
32wrangler tail:最多 10 個同時觀察者、會取樣(無公開門檻數字)、完全不留存只適合 live debugging
33source maps 上限 15 MB gzip 後;擷取是非同步,不影響 CPU沒理由不開
34logpush: true 不會自動建 job(schema 說明明講)要另外建
35Logpush dataset workers_trace_eventslogs + exceptions 共用 16,384 字元
36Query Builder 2025-04 GA只查 logs 不查 traces官方用字是「currently includes」
37wrangler dev 有本機 observability SQL API:POST /cdn-cgi/local/explorer/api/local/observability/query,表是 spanslogs本章大部分數字都是這樣量到的

  1. 用本機的 /cdn-cgi/local/explorer/api/local/observability/queryspans 的建表 SQL 查出來(SELECT sql FROM sqlite_master WHERE name='spans'),看看有哪些欄位是你原本不知道的。
  2. 同一段資料分別用 console.log(obj)console.log(JSON.stringify(obj)) 送,從 logs 表比對 message 的差別。
  3. head_sampling_rate 設成 0,確認 span.isTraced 還是 true。然後想想你要怎麼保證 production 的 false 分支是對的。
  4. 故意 span.setAttribute("x", { a: 1 }),從表裡查出 "[object Object]"
  5. wrangler dev -c 起一個 Tail Worker,把 TraceItem 整包印出來,和官方文件的欄位表比對。
  6. 打一個會 throw 的路由,確認 trace 的 outcome"ok",然後把你的 alert 規則改成看 http.response.status_code

下一章:第 40 章 CI/CD 與發佈策略 —— 把第 38 章的「要有 smoke test」和本章的「要看得到」串成一條可回滾的 pipeline。