Cloudflare WorkersのspanContext()でtraceIdをレスポンスに入れる:setStatus・updateNameとあわせて挙動を確かめる

Cloudflare WorkersのspanContext()でtraceIdをレスポンスに入れる:setStatus・updateNameとあわせて挙動を確かめる

Workersのカスタムspanに追加されたspanContext()、setStatus()、updateName()を試し、traceIdがダッシュボードのトレースと一致するか、名前とstatusが本番とLocal Explorerでどう見えるかを確かめました。
2026.10.29

どうも!オペ部の西村祐二です!

Cloudflare Workersで500エラーを返したとき、「このリクエストのトレースはどれか」をすぐに引けると調査が楽になります。2026年10月26日のCloudflare Changelogで、Workersのカスタムspanに spanContext()、setStatus()、updateName() の3つのメソッドが追加されました。spanContext() を使うと、Workerのコードから traceId と spanId を読めます。

この記事では、3つのメソッドを、Wranglerの wrangler dev によるローカル環境と、Workers FreeプランのアカウントにデプロイしたWorkerの両方で動かした結果をまとめます。あわせて、本番のWorkers Observabilityのダッシュボードと、ローカルのLocal Explorerで、何が見えて何が見えないかも確かめました。

今回の変更点

  • 日付: 2026年10月26日
  • 対象: カスタムspanのAPI(import { tracing } from "cloudflare:workers" または ctx.tracing)が返す Span
  • 追加されたメソッド:
メソッド できること 主な使いどころ
span.spanContext() traceId(16進32桁)、spanId(16進16桁)、traceFlags を返す ログやエラーレスポンスにtraceIdを入れ、トレースと突き合わせる
span.setStatus(status) spanのstatusを "unset" / "ok" / "error" にする。"error" のときはmessageも付けられる 例外を投げずに失敗を返した処理を、トレース上で失敗として扱う
span.updateName(name) 開始後のspanの名前を変える。ルートspanも変えられる ルーティングが決まってから GET /users/:id のような名前にする

ChangelogとCustom spansのドキュメントによると、statusはOpenTelemetryの送信先へexportするトレースに含まれます。ただし、Workers Observabilityのダッシュボードにはまだ表示されず、statusで絞り込むこともできません。

試してみる

環境

  • macOS 26.6.1、Node.js v24.22.1
  • wrangler 4.153.0(workerd 1.20261023.1)。ローカルとデプロイの両方で同じバージョンを使いました
  • compatibility_date は 2026-10-27

カスタムspanを使うには、トレースを有効にしておく必要があります。

wrangler.jsonc
{
  "name": "span-context-demo",
  "main": "src/index.js",
  "compatibility_date": "2026-10-27",
  "observability": { "traces": { "enabled": true } }
}

検証に使ったWorker

Changelogのサンプルをもとに、ルートspanの名前をルーティングで決まったパターン名に変え、失敗時はtraceIdをレスポンスに入れるWorkerを用意しました。/inspect と /status-only は確認用のエンドポイントです。/inspect は各メソッドの戻り値をJSONで返し、/status-only はダッシュボードとLocal Explorerでの見え方を確かめるためのspanを作ります。

src/index.js
import { tracing } from "cloudflare:workers";

const routes = [
  { pattern: "/users/:id", re: /^\/users\/[^/]+$/ },
  { pattern: "/fail", re: /^\/fail$/ },
];

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const root = tracing.getActiveSpan();
    const route = routes.find((r) => r.re.test(url.pathname));
    // ルーティングが決まってからルートspanの名前を変える
    root?.updateName(`${request.method} ${route?.pattern ?? "unknown"}`);

    if (url.pathname === "/status-only") {
      // setStatusだけを呼び、例外は記録しないspan
      tracing.enterSpan("status-error-no-exception", (span) => {
        span.setStatus({ code: "error", message: "set by setStatus only" });
      });
      // コールバックの中で名前を変えるspan
      tracing.enterSpan("rename-me", (span) => {
        span.updateName("renamed-inside-callback");
      });
      return new Response("ok");
    }

    if (url.pathname === "/inspect") {
      const out = {};
      out.root = root?.spanContext();
      const s = tracing.startSpan("probe");
      out.probe = s.spanContext();
      try {
        s.setStatus({ code: "OK" });
        out.upperOK = "no error";
      } catch (e) {
        out.upperOK = `${e.name}: ${e.message}`;
      }
      try {
        s.setStatus({ code: 1 });
        out.numericOK = "no error";
      } catch (e) {
        out.numericOK = `${e.name}: ${e.message}`;
      }
      s.setStatus({ code: "ok" });
      s.setStatus({ code: "error", message: "after ok" });
      s.updateName("probe renamed");
      s.end();
      s.updateName("ignored after end");
      out.probeAfterEnd = s.spanContext();
      out.isTracedAfterEnd = s.isTraced;
      return Response.json(out);
    }

    return tracing.startActiveSpan("handle", async (span) => {
      try {
        if (route?.pattern === "/fail") throw new Error("boom");
        span.setStatus({ code: "ok" });
        return Response.json({ ok: true, traceId: root?.spanContext().traceId });
      } catch (err) {
        span.recordException(err);
        span.setStatus({ code: "error", message: "Route handler failed" });
        return Response.json(
          { error: "Internal error", traceId: root?.spanContext().traceId },
          { status: 500 },
        );
      } finally {
        span.end();
      }
    });
  },
};

1. spanContext()でtraceIdを取り出す

まず npx wrangler deploy でデプロイし、/inspect、/users/42、/fail、/status-only にリクエストを送りました。<subdomain> には自分のworkers.devサブドメインが入ります。

npx wrangler deploy
curl -s https://span-context-demo.<subdomain>.workers.dev/inspect
curl -s https://span-context-demo.<subdomain>.workers.dev/users/42
curl -s -w '\nstatus=%{http_code}\n' https://span-context-demo.<subdomain>.workers.dev/fail
curl -s https://span-context-demo.<subdomain>.workers.dev/status-only

デプロイしたWorkerの /inspect のレスポンス(見やすく整形しています)は次の通りです。

{
  "root": { "traceId": "6e0da3b4d885342c176779762cd8ad62", "spanId": "5d7c853b01467569", "traceFlags": 0 },
  "probe": { "traceId": "6e0da3b4d885342c176779762cd8ad62", "spanId": "9563fb68e394c262", "traceFlags": 0 },
  "upperOK": "TypeError: Span status code must be 'unset', 'ok', or 'error'.",
  "numericOK": "TypeError: Span status code must be 'unset', 'ok', or 'error'.",
  "probeAfterEnd": { "traceId": "6e0da3b4d885342c176779762cd8ad62", "spanId": "9563fb68e394c262", "traceFlags": 0 },
  "isTracedAfterEnd": false
}

/fail はステータスコード500で、本文にtraceIdが入りました。

{"error":"Internal error","traceId":"bd70802ea981dc3218b669ff4cf6599d"}
  • ルートspanと、そこから作ったspanは同じtraceIdを返し、spanIdは別の値でした
  • span.end() の後も spanContext() は同じIDを返しました。一方で、spanが記録の対象かを表す isTraced は false になりました
  • traceFlags はデプロイ先でもローカル(2で後述)でも 0 でした。Custom spansのドキュメントによると、現時点の traceFlags はサンプリングの有無を表しません。そのため、下流サービスへ渡す traceparent ヘッダーのflagsに、この値をそのまま使うことはできません

upperOK と numericOK は、3で説明します。

2. 返したIDがトレースと一致するかを確かめる

デプロイしたWorkerの「Observability」タブを開き、表示を「Traces」に切り替えました。以下の画像では、メールアドレス、アカウントID、workers.devのサブドメインを伏せています。

/fail のトレースを開くと、画面上部のトレースIDが、500レスポンスに入れた bd70802ea981dc3218b669ff4cf6599d と一致しました。

Workers ObservabilityのTraces画面。GET /failのトレースのトレースIDがレスポンスのtraceIdと同じで、handle spanがエラーとして赤く表示されている

/inspect のトレースも、トレースIDがレスポンスの 6e0da3b4d885342c176779762cd8ad62 と同じでした。子spanの詳細を開くと、スパンIDは probe の spanId(9563fb68e394c262)、親スパンIDはルートspanの spanId(5d7c853b01467569)と一致しました。

/inspectのトレースで子spanの詳細を開いた画面。span名はprobe renamedで、スパンID、親スパンID、トレースIDがspanContext()の戻り値と一致している

ローカルの wrangler dev でも、同じようにIDを照合しました。wrangler dev を起動するとLocal Explorerが使えます。今回は既定の8787番ではなく8799番で起動しました。

npx wrangler dev --port 8799
curl -s http://localhost:8799/inspect

ローカルの /inspect は、上のデプロイ先とは別のIDを返しました。

{
  "root": { "traceId": "80b4bb1f3a5dbea3e0e51fe7e441cbcb", "spanId": "0656b932b4d27cbe", "traceFlags": 0 },
  "probe": { "traceId": "80b4bb1f3a5dbea3e0e51fe7e441cbcb", "spanId": "2363150df2c77110", "traceFlags": 0 }
}

このtraceIdでLocal ExplorerのAPIからspanを取得しました。

curl -s -X POST http://localhost:8799/cdn-cgi/local/explorer/api/local/observability/query \
  -H 'Content-Type: application/json' \
  -d '{"sql":"SELECT trace_id, span_id, parent_id, name FROM spans WHERE trace_id = '\''80b4bb1f3a5dbea3e0e51fe7e441cbcb'\''"}'
["80b4bb1f3a5dbea3e0e51fe7e441cbcb","0656b932b4d27cbe",null,"GET"]
["80b4bb1f3a5dbea3e0e51fe7e441cbcb","2363150df2c77110","0656b932b4d27cbe","probe"]

レスポンスのspanIdは、Local Explorerに記録された span_id と同じ値でした。probe の parent_id も、ルートspanのspanIdと同じ値です。

ローカルで /fail を送ったときも、500レスポンスに入っていたtraceIdを使って、Local Explorerの画面から該当のトレースを開けました。

Local ExplorerのTraces画面。GET /failのトレースを開くと、ルートspanとhandle spanがエラーとして赤く表示されている

3. setStatus()の指定値とトレースでの見え方

code は小文字の文字列で指定します。大文字の setStatus({ code: "OK" }) も、数値の setStatus({ code: 1 }) も、デプロイ先とローカルの両方で TypeError になりました(1の /inspect のレスポンスの upperOK と numericOK)。OpenTelemetry JSの SpanStatusCode.OK は数値の 1 なので、既存のOpenTelemetryのコードはそのままでは動きません。

statusの見え方は、/status-only で確かめました。status-error-no-exception spanでは、setStatus({ code: "error" }) だけを呼んでいます。本番のダッシュボードでは、このspanはエラー表示にならず、詳細でも「完了」と表示されました。属性の一覧にもstatusを表す項目はありませんでした。

/status-onlyのトレース。status-error-no-exception spanはエラー表示になっておらず、2つ目のspanはrenamed-inside-callbackと表示されている

status-error-no-exception spanの詳細。状態は「完了」と表示されている

一方、recordException() も呼んだ /fail の handle spanは、詳細で「エラー」と表示され、例外のメッセージ boom が出ました。トレース一覧のErrors列にも boom と表示されていました。

handle spanの詳細。状態は「エラー」で、メッセージboomが表示されている

ローカルのLocal Explorerでも同じ傾向でした。

spanで呼んだメソッド ダッシュボードの表示 Local Explorerのoutcome Local Explorerのerror列
setStatus({ code: "error" }) のみ 完了 ok なし
recordException(err) と setStatus({ code: "error" }) エラー(boom) error Error: boom

2の最後に載せたLocal Explorerの画像ではルートspanも赤く表示されていますが、APIで取得したルートspanのoutcomeは ok でした。画面がどの条件でルートspanを赤くするかは確かめていません。本番のダッシュボードでは、ルートspanはエラー表示になりませんでした。

2026年10月28日時点では、ダッシュボード(トレースの一覧とspanの詳細の属性)にも、Local Explorer(画面とAPIで取得したspan)にも、statusを表す項目は見当たりませんでした。ダッシュボードについては、Changelogに「まだ表示されない」とあるため、今後表示されるようになる可能性があります。Local Explorerについては、statusに対応していないのか、今回の確認方法では見えていないだけなのかは切り分けていません。確かめられたのは「例外にならずに呼べること」までです。statusを確認するには、OpenTelemetryへのexportを設定し、送信先で見る必要があります。

Custom spansのドキュメントには、次の挙動も書かれています。

  • "ok" を設定した後の setStatus は無視される
  • "unset" を設定しても、先に設定した "error" は消えない
  • message は code が "error" のときだけ残る
  • statusを設定してもspanは終わらず、例外も記録されない

今回は値を観測する手段がなかったため、"ok" の後に "error" を設定しても例外にならないことだけを確かめました。

4. updateName()の反映範囲

updateName() は、本番のダッシュボードとローカルのLocal Explorerで結果が分かれました。

本番のダッシュボードでは、変えた名前が反映されました。

  • ルートspan: Traces一覧のOperation列が GET /users/:id、GET /fail、GET unknown になりました。画面では名前の後ろにリクエストのURLが付いて表示されます
  • startSpan("probe") の後に updateName("probe renamed") を呼んだspanは、probe renamed と表示されました。end() の後に呼んだ updateName("ignored after end") は、ドキュメントの記述どおり反映されていません(上の /inspect の画像)
  • enterSpan("rename-me", ...) のコールバック内で変えた名前も、renamed-inside-callback と表示されました(上の /status-only の画像)

Traces一覧。Operation列にGET /users/:idやGET /failなど、updateName()で変えた名前が表示されている

一方、wrangler 4.153.0のLocal Explorerでは、画面とAPIの name 列を見た範囲では、updateName() で変えた名前は記録されていませんでした。

  • ルートspanの名前を GET /fail に変えた後も、記録された名前は GET のままでした。画面ではURLが付いて GET http://localhost:8799/fail と表示されます
  • startSpan("probe") の後に updateName("probe renamed") を呼んでも、記録された名前は probe でした
  • /status-only の enterSpan("rename-me", ...) のコールバック内で名前を変えても、rename-me と記録されました

同じコードで本番のダッシュボードには反映されたので、ローカルで名前が変わらなくても、コードの誤りとは限りません。Local Explorerが updateName() に対応していないのか、今回の確認方法に足りないところがあるのかは切り分けていません。今後のwranglerのアップデートで変わる可能性もあります。ドキュメントでは、変更後の名前はexportされたトレースにも反映され、64バイトで切り詰められると説明されています。

検証結果の考察

表は、検証で確かめた範囲(確認済み)と、ドキュメントの記述によるものを分けています。

メソッド Workerのレスポンス Local Explorer ダッシュボード OpenTelemetry export
spanContext() 確認済み(デプロイ先・ローカル) IDの一致を確認済み IDの一致を確認済み 未確認
setStatus() 不正な code の TypeError を確認済み 見当たらなかった(4.153.0) 表示されなかった(10月28日時点) 含まれる(Changelog・ドキュメント)
updateName() - 反映されなかった(4.153.0) 反映された(確認済み) 反映される(ドキュメント)

「見当たらなかった」「表示されなかった」「反映されなかった」は、2026年10月28日時点で、今回の方法で確認した結果です。今後のアップデートで変わる可能性があります。

  • ドキュメントによると、spanContext() は、トレースが実際に保存されたかどうかに関係なくIDを返します
    • トレースが無効な場合や、サンプリングで記録の対象から外れたリクエストでも、Workers Logsが有効なら0以外のIDが返ります。このIDはログに付くtraceIdと同じですが、spanは保存されません。そのため、レスポンスに入れたtraceIdでダッシュボードのTracesを探しても、トレースは見つかりません。この場合は、同じtraceIdでWorkers Logsを探すことになります
    • observability自体が無効な場合は、すべて0のIDが返ります
    • 今回はトレースを有効にし、サンプリングの設定も既定のまま、少数のリクエストしか送っていません。この2つのケースは実機では確かめておらず、ドキュメントの記述をもとに書いています
  • 既存のOpenTelemetryのコードから SpanStatusCode の数値を渡している箇所は、文字列の "ok" / "error" に書き換えます
  • ダッシュボードでエラーとして表示させるには、recordException() も呼びます。今回確かめたのは setStatus({ code: "error" }) と併用した場合だけです

まとめ

Cloudflare Workersのカスタムspanに追加された spanContext() を使うと、traceIdをレスポンスやログに入れて、リクエストとトレースを結び付けられます。返したIDは、本番のWorkers Observabilityダッシュボードでも、ローカルのLocal Explorerでも、トレースのIDと一致しました。

updateName() で変えた名前は、本番のダッシュボードには反映されましたが、wrangler 4.153.0のLocal Explorerには反映されませんでした。setStatus() で設定したstatusは、2026年10月28日時点ではどちらの画面にも見当たりませんでした。statusを確認するなら、OpenTelemetryの送信先で見ることになります。ダッシュボードでエラーとして見るには、recordException() も呼びます。

誰かの参考になれば幸いです。


参考リンク:

この記事をシェアする

関連記事