Cloudflare Workers OpenTelemetry:OTLPでオブザーバ비리티コスト90%削減

エッジでオブザーバビ리티が難しい理由
従来のサーバー環境では、ログをファイルに書き出し、APMエージェントをサイドカーとして配置し、長いリクエストのライフサイクル全体でコンテキストを維持できます。しかし、Cloudflare Workersはまったく異なる世界です。
Workersは、ライフサイクルがミリ秒単位である分離されたV8コンテキストで実行されます。ファイルシステムはなく、永続的なバックグラウンドプロセスもありません。console.logでデバッグしていて本番環境で原因不明のエラーに遭遇した場合、wrangler tailでリアルタイムログを確認する以外に明確な手段がありませんでした。
しかし2024年末から、CloudflareがWorkersにネイティブOpenTelemetryサポートを組み込み始めました。今ではコードを1行も書くことなく、wrangler.jsoncに数行の設定を追加するだけで、すべてのfetchリクエスト、D1クエリ、KV読み取り/書き込み、Durable Object呼び出しの分散トレースが自動的に収集されます。
この記事では、ネイティブOTel自動トレーシングの設定、カスタムスパンの追加、AxiomやHoneycombへのOTLPエクスポート、그리고サンプリング戦略によって月間のオブザーバビ리티コストを90%以上削減する方法について解説します。
OpenTelemetryの基本概念
設定前にOTelの3つのコア概念を理解しておきましょう。
Trace(トレース)
└── Span(スパン): 1つの作業単位
├── Span: D1クエリ(子スパン)
├── Span: KV読み取り(子スパン)
└── Span: 外部API呼び出し(子スパン)
各Spanには以下が含まれます:
- 開始/終了時刻
- 継続時間(duration)
- 属性(attributes): key-valueメタデータ
- イベント(events): 特定時点の記録
- 状態(status): OK / Error
- Trace: 1つのリクエストがシステムを通過する全体の旅程
- Span: トレース内の単一作業単位(DBクエリ、API呼び出しなど)
- OTLP: OpenTelemetry Protocol — 標準テレメトリ送信フォーマット
Workersにおけるトレースは、Workersリクエスト → D1照会 → 外部API呼び出し全体を1つのウォーターフォール図として視覚化します。
オブザーバビ리티コスト構造の理解
設定前にコスト構造をまず理解しておく必要があります。設定を誤ると、トレースコストがWorkersの実行コストを上回ってしまうことがあります。
| コンポーネント | 無料枠 | 有料 |
|---|---|---|
| Cloudflare Workersダッシュボードトレース | 月2,000万イベント | 超過分 $0.60/100万 |
| Axiomデータ収集 | 月500GB | 超過分 $1/GB |
| Grafana Cloudトレース | 月50GB | 超過分 $0.55/GB |
| Honeycomb | 月2,000万イベント | 超過分 約$1/100万 |
主な問題: Workersはトラフィックが多いと、1日に数億件のリクエストが発生します。リクエストごとにトレースを収集すると、月間イベント数が爆発的に増加します。
解決策: Head Sampling(ヘッドサンプリング)。全体のリクエストの5%のみをサンプリングすれば、イベント数を95%削減しながらも統計的に意味のあるデータを取得できます。エラーは100%キャプチャするように設定することで、デバッグ能力を維持したままコストを90%以上削減できます。
ステップ1:ネイティブ自動トレーシングの有効化
コード変更なしでwrangler.jsoncのみを修正します。
// wrangler.jsonc
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-01-01",
"compatibility_flags": ["nodejs_compat"],
"observability": {
"traces": {
"enabled": true,
// Head Sampling: 全体リクエストの5%のみサンプリング
"head_sampling_rate": 0.05,
// Cloudflareダッシュボードにも保存(7日間保持)
"persist": true
},
"logs": {
"enabled": true,
"head_sampling_rate": 0.1
}
},
"d1_databases": [
{
"binding": "DB",
"database_name": "my-db",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
],
"kv_namespaces": [
{
"binding": "CACHE",
"id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
]
}
この設定だけで、以下が自動的にトレーシングされます:
- すべての
fetch()ハンドラーのエントリーポイント - D1データベースクエリ(SQLステートメント含む)
- KV読み取り/書き込み操作
- R2オブジェクトストレージ操作
- Durable Object呼び出し
- アウトバウンド
fetch()HTTPリクエスト - Queueコンシューマー/プロデューサー
wrangler deployを実行すると、すぐにCloudflareダッシュボード → Workers → Observabilityでトレースを確認できます。
ステップ2:カスタムスパンによるアプリケーションロジックのトレーシング
ネイティブ自動トレーシングでプラットフォーム操作はカバーされますが、ビジネスロジック内部にはカスタムスパンが必要です。
2025年最新:cloudflare:workers ネイティブトレーシングAPI
// src/index.ts
import { tracing } from "cloudflare:workers";
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/d1";
import { users, posts } from "./db/schema";
type Env = {
DB: D1Database;
CACHE: KVNamespace;
};
const app = new Hono<{ Bindings: Env }>();
app.get("/api/posts/:slug", async (c) => {
const slug = c.req.param("slug");
// カスタムスパン:記事取得ロジック全体を1つのスパンとしてまとめる
return tracing.enterSpan("get-post-by-slug", async (span) => {
// スパンにビジネスコンテキスト属性を追加
span.setAttribute("post.slug", slug);
span.setAttribute("app.feature", "blog");
const db = drizzle(c.env.DB);
// KVキャッシュ照会(自動的に子スパンを生成)
const cacheKey = `post:${slug}`;
const cached = await c.env.CACHE.get(cacheKey, "json");
if (cached) {
span.setAttribute("cache.hit", true);
// スパンイベント:特定の時点を記録
span.addEvent("cache-hit", { key: cacheKey });
return c.json(cached);
}
span.setAttribute("cache.hit", false);
// D1照会(自動的に子スパン을 生成 — SQLステートメント含む)
const post = await db
.select()
.from(posts)
.where(eq(posts.slug, slug))
.get();
if (!post) {
// エラー状態の記録
span.setStatus({ code: "ERROR", message: "Post not found" });
return c.json({ error: "Not found" }, 404);
}
span.setAttribute("post.id", post.id);
span.setAttribute("post.published", post.published);
// KVキャッシング(fire-and-forget)
c.executionCtx.waitUntil(
c.env.CACHE.put(cacheKey, JSON.stringify(post), {
expirationTtl: 300,
})
);
return c.json(post);
});
});
export default app;
ネストされたスパン:複雑なワークフローのトレーシング
// 複数のステップを持つ複雑なビジネスロジック
async function processOrder(
env: Env,
ctx: ExecutionContext,
orderId: string
) {
return tracing.enterSpan("process-order", async (orderSpan) => {
orderSpan.setAttribute("order.id", orderId);
// ステップ1:在庫確認
const inventory = await tracing.enterSpan(
"check-inventory",
async (span) => {
span.setAttribute("order.id", orderId);
const db = drizzle(env.DB);
return db.select().from(inventoryTable)
.where(eq(inventoryTable.orderId, orderId))
.get();
}
);
if (!inventory || inventory.stock < 1) {
orderSpan.setStatus({
code: "ERROR",
message: "Out of stock",
});
throw new Error("Out of stock");
}
// ステップ2:決済処理(外部API)
const payment = await tracing.enterSpan(
"process-payment",
async (span) => {
span.setAttribute("payment.provider", "stripe");
// アウトバウンドfetchは自動的にスパンを生成
const res = await fetch("https://api.stripe.com/v1/charges", {
method: "POST",
// ...
});
span.setAttribute("payment.status", res.status);
return res.json();
}
);
orderSpan.setAttribute("payment.id", payment.id);
orderSpan.addEvent("order-completed", {
orderId,
paymentId: payment.id,
});
return payment;
});
}
tracing.enterSpan()はコールバックが返されるかプロミスが完了すると、自動的にスパンを終了します。手動でspan.end()を呼び出す必要はありません。
ステップ3:OTLP外部ダッシュボード連携
Cloudflareダッシュボードの7日間保持制限を超えるには、外部のOTLPエンドポイントにエクスポートする必要があります。
Axiom連携(無料 500GB/月)
// wrangler.jsoncにdestinationsを追加
{
"observability": {
"traces": {
"enabled": true,
"head_sampling_rate": 0.05,
"persist": false, // Cloudflareダッシュボード保存を無効化
"destinations": ["axiom-traces"]
},
"logs": {
"enabled": true,
"head_sampling_rate": 0.1,
"destinations": ["axiom-logs"]
}
}
}
CloudflareダッシュボードでDestinationを追加:
- Workers → Observability → Destinations → Add Destination
- Name:
axiom-traces - Type:
OTLP - Endpoint:
https://api.axiom.co/v1/traces - Headers:
Authorization: Bearer <AXIOM_API_TOKEN>,X-Axiom-Dataset: my-workers
Honeycomb連携
Endpoint: https://api.honeycomb.io/v1/traces
Headers:
x-honeycomb-team: <HONEYCOMB_API_KEY>
x-honeycomb-dataset: cloudflare-workers
Grafana Cloud連携
Endpoint: https://otlp-gateway-prod-<region>.grafana.net/otlp/v1/traces
Headers:
Authorization: Basic <base64(instanceId:apiToken)>
@microlabs/otel-cf-workers:コードレベルのカスタマイズ
ネイティブOTelだけでは不十分で、より細かな制御が必要な場合:
// src/index.ts — @microlabs/otel-cf-workersを使用
import { instrument, ResolveConfigFn } from "@microlabs/otel-cf-workers";
import { trace, context } from "@opentelemetry/api";
type Env = {
DB: D1Database;
OTEL_EXPORTER_OTLP_ENDPOINT: string;
OTEL_EXPORTER_OTLP_HEADERS: string;
};
const handler = {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const tracer = trace.getTracer("my-worker");
return tracer.startActiveSpan("handle-request", async (span) => {
try {
span.setAttribute("http.method", request.method);
span.setAttribute("http.url", request.url);
const url = new URL(request.url);
const response = await routeRequest(request, env, ctx);
span.setAttribute("http.status_code", response.status);
return response;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
});
},
};
// 環境変数からOTLP設定を読み込む
const config: ResolveConfigFn = (env: Env, trigger) => ({
exporter: {
url: env.OTEL_EXPORTER_OTLP_ENDPOINT,
headers: Object.fromEntries(
new URLSearchParams(env.OTEL_EXPORTER_OTLP_HEADERS)
),
},
service: {
name: "my-cloudflare-worker",
version: "1.0.0",
},
});
export default instrument(handler, config);
ステップ4:サンプリング戦略 — コスト90%削減の要
サンプリングは単に割合を下げるだけではありません。エラーは100%キャプチャしつつ、正常なリクエストのみをサンプリングする戦略が極めて重要です。
Head Sampling(ヘッドサンプリング)
リクエスト開始時点にトレースするかどうかを決定します。最もシンプルでパフォーマンスオーバーヘッドが最小化されます。
{
"observability": {
"traces": {
"enabled": true,
"head_sampling_rate": 0.05 // 5% サンプリング
}
}
}
デメリット:エラーが発生したリクエストも95%の確率でドロップされる可能性があります。
Tail Sampling(テールサンプリング):エラー100%保持
@microlabs/otel-cf-workersとカスタムサンプラーを使用することで、テールサンプリングの実装が可能です:
// src/sampling.ts — エラーは100%、正常は5%サンプリング
import { Sampler, SamplingResult, SamplingDecision } from "@opentelemetry/sdk-trace-base";
export class ErrorAlwaysSampler implements Sampler {
private baseRate: number;
constructor(baseRate = 0.05) {
this.baseRate = baseRate;
}
shouldSample(
context: any,
traceId: string,
spanName: string,
spanKind: any,
attributes: any,
links: any
): SamplingResult {
// HTTPエラー状態は常にサンプリング
const statusCode = attributes["http.status_code"];
if (statusCode && statusCode >= 400) {
return { decision: SamplingDecision.RECORD_AND_SAMPLED };
}
// エラー属性があれば常にサンプリング
if (attributes["error"] === true) {
return { decision: SamplingDecision.RECORD_AND_SAMPLED };
}
// 残りはbaseRateの確率でサンプリング
return {
decision:
Math.random() < this.baseRate
? SamplingDecision.RECORD_AND_SAMPLED
: SamplingDecision.NOT_RECORD,
};
}
toString() {
return `ErrorAlwaysSampler(${this.baseRate})`;
}
}
// instrument設定にカスタムサンプラーを適用
const config: ResolveConfigFn = (env: Env) => ({
exporter: { url: env.OTEL_ENDPOINT },
service: { name: "my-worker" },
sampler: new ErrorAlwaysSampler(0.05),
});
サンプリング率別のコストシミュレーション
月間10億件のリクエストを処理するWorkersがあると仮定します:
| サンプリング率 | 月間イベント | Axiomコスト | Honeycombコスト |
|---|---|---|---|
| 100%(サンプリングなし) | 10億件 | ~$500/月 | ~$1,000/月 |
| 10% | 1億件 | ~$50/月 | ~$100/月 |
| 5% | 5,000万件 | ~$25/月 | ~$50/月 |
| 1% | 1,000万件 | 無料 | ~$10/月 |
5%のサンプリングだけでコストを95%削減しつつ、1秒あたり数千件の統計的に代表的なサンプルを取得できます。
ステップ5:トレース活用 — 実際のプロダクションシナリオ
低速なD1クエリの特定
Axiomで以下のクエリを使用して、100ms以上かかっているD1クエリを検索します:
// Axiom APLクエリ
['cloudflare-workers']
| where ['span.kind'] == "client"
| where ['db.system'] == "cloudflare.d1"
| where duration > 100ms
| summarize count(), avg(duration) by ['db.statement']
| order by avg_duration desc
| limit 20
エラーパターンの分析
// 5xxエラーが発生したトレースのみをフィルター
['cloudflare-workers']
| where ['http.status_code'] >= 500
| where ['span.is_root'] == true
| project _time, ['http.url'], ['http.method'], duration, ['error.message']
| order by _time desc
P99レイテンシーの追跡
// 特定のエンドポイントのP50/P95/P99レイテンシー
['cloudflare-workers']
| where ['http.route'] == "/api/posts/:slug"
| summarize
p50 = percentile(duration, 50),
p95 = percentile(duration, 95),
p99 = percentile(duration, 99)
by bin(_time, 5m)
| order by _time desc
ステップ6:Durable Objectsのトレーシング
Durable ObjectsはWorkersよりも複雑なライフサイクルを持つため、個別のトレーシングが必要です。
ネイティブ自動トレーシング(推奨)
wrangler.jsoncにobservability.traces.enabled = trueを設定するだけで、DO呼び出しが親Workersトレースの子スパンとして自動的に連携されます。@microlabs/otel-cf-workersは不要です。
Workersリクエストトレース
└── fetch handler (root span)
├── D1クエリ(自動スパン)
└── Durable Object呼び出し(自動子スパン)
├── DO fetch handler
└── DO内部D1クエリ
@microlabs/otel-cf-workers:DO手動計装
// src/rate-limiter.ts — Durable Object with OTel
import { instrumentDO } from "@microlabs/otel-cf-workers";
import { trace } from "@opentelemetry/api";
class RateLimiterBase {
private state: DurableObjectState;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
const tracer = trace.getTracer("rate-limiter-do");
return tracer.startActiveSpan("rate-limit-check", async (span) => {
const key = new URL(request.url).searchParams.get("key") ?? "global";
span.setAttribute("rate_limit.key", key);
const count = (await this.state.storage.get<number>(key)) ?? 0;
const limit = 100;
if (count >= limit) {
span.setAttribute("rate_limit.exceeded", true);
span.setStatus({ code: "ERROR", message: "Rate limit exceeded" });
span.end();
return new Response("Rate limited", { status: 429 });
}
await this.state.storage.put(key, count + 1);
span.setAttribute("rate_limit.count", count + 1);
span.end();
return new Response("OK");
});
}
}
// Durable ObjectクラスをOTelでラッピング
export const RateLimiter = instrumentDO(RateLimiterBase, config);
ステップ7:CI/CDとアラート統合
GitHub Actionsでのデプロイ後自動検証
# .github/workflows/deploy.yml
- name: Deploy Worker
run: npx wrangler deploy
- name: Verify Observability (デプロイ後のトレース収集確認)
run: |
sleep 30 # トレース収集待機
# Axiom APIで直近5分間のエラー数を確認
ERRORS=$(curl -s "https://api.axiom.co/v1/datasets/my-workers/query" \
-H "Authorization: Bearer $AXIOM_TOKEN" \
-d '{"apl":"[\"my-workers\"] | where status >= 500 | where _time > ago(5m) | count"}' \
| jq '.matches[0].data._count // 0')
if [ "$ERRORS" -gt "10" ]; then
echo "❌ デプロイ後にエラー急増: $ERRORS errors in 5min"
exit 1
fi
echo "✅ デプロイ後のエラー数正常: $ERRORS"
Axiomでのアラート設定
Axiom MonitorでP99レイテンシーのしきい値超過時にSlack通知を設定:
{
"name": "Workers P99 Latency Alert",
"query": {
"apl": "['my-workers'] | where ['span.is_root'] == true | summarize p99 = percentile(duration, 99) by bin(_time, 1m) | where p99 > 2000ms"
},
"frequencyMinutes": 5,
"durationMinutes": 5,
"notifiers": ["slack-webhook"]
}
ベンダー選定ガイド
| 状況 | 推奨ベンダー | 理由 |
|---|---|---|
| 初期スタートアップ、予算削減 | Axiom | 無料500GB/月、ログ+トレース統合 |
| Grafanaスタック利用中 | Grafana Cloud | 既存ダッシュボード統合、無料50GB |
| 複雑なクエリ/分析が必要 | Honeycomb | BubbleUp異常検知、高度なクエリ |
| オンプレミス要件 | SigNoz(セルフホスティング) | オープンソース、Kubernetesデプロイ |
| 単純なログのみ必要 | Cloudflareダッシュボード | 無料、追加設定不要 |
コスト最適化チェックリスト
実際のプロダクション環境で適用すべき最適化リスト:
サンプ링戦略:
-
head_sampling_rate: 0.05(正常リクエスト5%サンプリング) - エラー発生時は100%サンプリング(カスタムサンプラー)
- 低速リクエスト(>1s)100%サンプリング
データ削減:
- 不要な属性の削除(個人情報、大容量ペイロード)
- スパン名のカーディナリティ制限(URLパラメーターの正規化)
-
persist: falseでCloudflareダッシュボードの重複保存を防止
コストモニタリング:
- ベンダーダッシュボードで月間イベント数のアラート設定
- Cloudflare Workersアナリティクスでリクエスト数を毎週確認
- サンプリング率をトラフィック増加に応じて動的に調整
移行パス:wrangler tailからOTelへ
これまでwrangler tailやconsole.logのみに依存していた場合は、段階的に移行しましょう:
1週目:wrangler.jsoncにobservability.traces.enabled: trueを追加し、Cloudflareダッシュボードでトレースを確認
2週目:Axiom Destinationを追加し、30日間保持の無料枠を活用
3週目:カスタムスパンでコアビジネスロジックを計装
4週目:サンプリング率の調整およびアラートを設定
既存のconsole.logは削除しなくても構いません。Workersログもobservability.logs.enabled: trueで一緒にエクスポートできます。
まとめ
Cloudflare WorkersのOpenTelemetryサポートは2024年末以降、急速に成熟しました。ネイティブ自動トレーシングによりコード変更なしですべてのプラットフォーム操作が計装され、cloudflare:workersのtracing.enterSpan() APIでビジネスロジックを精密に追加できます。
コストはサンプリングによって完全に制御できます:
- 5%ヘッドサンプリング:コスト95%削減、統計的可視性を維持
- エラー100%キャプチャ:デバッグ能力の損失なし
- Axiom無料500GB:中規模のプロダクションまで無料で運用可能
ブラックボックスなエッジ関数から、完全に見える化された分散システムへと移行するのにかかる時間は、wrangler.jsoncに5行を追加する1分だけです。
関連記事:Cloudflare WorkflowsとDurable Execution AIエージェントでDurable Objectsのステート管理パターンを確認できます。