最小構成で OpenTelemetry を組み込む
まずは Claude Code を呼び出すラッパースクリプト側に OpenTelemetry SDK を入れます。Node.js を例に、最も小さい初期化コードを示します。
// otel-init.mjs — エージェント実行時に最初に読み込む
import { NodeSDK } from "@opentelemetry/sdk-node" ;
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" ;
import { Resource } from "@opentelemetry/resources" ;
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions" ;
const sdk = new NodeSDK ({
resource: new Resource ({
[SemanticResourceAttributes. SERVICE_NAME ]: "claude-code-agent" ,
[SemanticResourceAttributes. SERVICE_VERSION ]: process.env. AGENT_VERSION ?? "dev" ,
"deployment.environment" : process.env. NODE_ENV ?? "development" ,
}),
traceExporter: new OTLPTraceExporter ({
url: process.env. OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://localhost:4318/v1/traces" ,
headers: process.env. OTEL_AUTH_HEADER
? { Authorization: process.env. OTEL_AUTH_HEADER }
: undefined ,
}),
});
try {
sdk. start ();
console. error ( "[otel] SDK started" );
} catch (err) {
// 観測性の初期化失敗で本処理を止めない(fail-open が原則)
console. error ( "[otel] init failed, continuing without tracing:" , err);
}
process. on ( "SIGTERM" , async () => {
try {
await sdk. shutdown ();
} catch (err) {
console. error ( "[otel] shutdown error:" , err);
}
});
ここでわざと try/catch で囲んでいるのは重要なポイントです。観測性の初期化失敗で本番処理を巻き添えにしない という原則で、いわゆる fail-open 設計です。観測性は「あれば嬉しい」レイヤーであり、止めるべきは本番処理ではなくテレメトリ送信側です。この考え方が抜けていると、Honeycomb や Tempo のメンテ時間にエージェントが全滅するという冗談みたいな事故が起きます。
期待する動作は、OTEL_EXPORTER_OTLP_ENDPOINT を設定した状態で実行すれば [otel] SDK started が stderr に出力され、未設定でも処理が止まらないことです。
Claude Code フックでルートスパンを発行する
次に、Claude Code 側のイベントを SDK に流し込みます。最もシンプルなのは、SessionStart フックでルートスパンを開始し、SessionEnd フックで終了するパターンです。Claude Code のフック仕様の全体像はClaude Code Hooks 完全実践ガイド で詳しく扱っているので、未読の方は先に目を通してください。
// hooks/session-start.mjs
import { trace, context } from "@opentelemetry/api" ;
import { writeFileSync } from "node:fs" ;
import { tmpdir } from "node:os" ;
import { join } from "node:path" ;
import "./otel-init.mjs" ; // SDK 初期化を必ず最初に行う
const tracer = trace. getTracer ( "claude-code-session" );
const span = tracer. startSpan ( "claude_code.session" , {
attributes: {
"session.id" : process.env. CLAUDE_SESSION_ID ?? "unknown" ,
"session.cwd" : process. cwd (),
"session.user" : process.env. USER ?? "unknown" ,
},
});
// span context を後続フックに渡すため、ファイルに spanId/traceId を書き出す
const ctx = trace. setSpan (context. active (), span);
const spanCtx = span. spanContext ();
const handoff = {
traceId: spanCtx.traceId,
spanId: spanCtx.spanId,
startedAt: Date. now (),
};
const stateFile = join ( tmpdir (), `claude-otel-${ process . env . CLAUDE_SESSION_ID }.json` );
writeFileSync (stateFile, JSON . stringify (handoff));
console. error ( `[otel] root span started: ${ spanCtx . traceId }` );
// セッションが続く間スパンを生かしておくため、明示終了は SessionEnd フックで行う
Claude Code のフックはプロセスをまたいで実行されるため、トレースコンテキストをファイル経由で受け渡す工夫が要ります。同期通信の HTTP サービスのように traceparent ヘッダーを引き継げないので、/tmp/claude-otel-{sessionId}.json のような状態ファイルで traceId と spanId を共有するのが現実的です。
SessionEnd フックでは、状態ファイルから traceId/spanId を読み戻し、対応するスパンを終了します。実装パターンは後段の落とし穴セクションで詳しく触れます。
ツール呼び出しを子スパンに分解する
エージェント観測の真価は、PreToolUse / PostToolUse フックでツール呼び出し1件ずつをスパン化したときに出ます。
// hooks/pre-tool-use.mjs — ツール実行直前
import { trace, context } from "@opentelemetry/api" ;
import { readFileSync, writeFileSync } from "node:fs" ;
import { tmpdir } from "node:os" ;
import { join } from "node:path" ;
import "./otel-init.mjs" ;
const tracer = trace. getTracer ( "claude-code-tool" );
const stateFile = join ( tmpdir (), `claude-otel-${ process . env . CLAUDE_SESSION_ID }.json` );
const state = JSON . parse ( readFileSync (stateFile, "utf8" ));
// 親スパンを再構築(trace.setSpanContext で復元)
const parentCtx = trace. setSpanContext (context. active (), {
traceId: state.traceId,
spanId: state.spanId,
traceFlags: 1 ,
isRemote: true ,
});
const toolName = process.env. CLAUDE_TOOL_NAME ?? "unknown" ;
const toolInput = process.env. CLAUDE_TOOL_INPUT_PREVIEW ?? "" ;
const span = tracer. startSpan (
`tool.${ toolName }` ,
{
attributes: {
"tool.name" : toolName,
"tool.input.length" : toolInput. length ,
// プロンプトの全文は流さず、長さだけを属性化(PII 漏れと容量爆発を防ぐ)
"tool.input.preview" : toolInput. slice ( 0 , 200 ),
},
},
parentCtx
);
// PostToolUse で復元できるよう、ツール呼び出し用の span を別ファイルで保持
const toolStateFile = join (
tmpdir (),
`claude-otel-tool-${ process . env . CLAUDE_TOOL_INVOCATION_ID }.json`
);
writeFileSync (
toolStateFile,
JSON . stringify ({
traceId: span. spanContext ().traceId,
spanId: span. spanContext ().spanId,
startedAt: Date. now (),
})
);
// hooks/post-tool-use.mjs — ツール実行直後
import { trace, context, SpanStatusCode } from "@opentelemetry/api" ;
import { readFileSync, unlinkSync } from "node:fs" ;
import { tmpdir } from "node:os" ;
import { join } from "node:path" ;
import "./otel-init.mjs" ;
const stateFile = join (
tmpdir (),
`claude-otel-tool-${ process . env . CLAUDE_TOOL_INVOCATION_ID }.json`
);
const state = JSON . parse ( readFileSync (stateFile, "utf8" ));
// SDK の active context に積めない場合は、span の参照を直接保持して end する設計が安全
const tracer = trace. getTracer ( "claude-code-tool" );
const span = tracer. startSpan ( `tool.end.${ process . env . CLAUDE_TOOL_NAME }` , {
startTime: state.startedAt,
links: [
{
context: {
traceId: state.traceId,
spanId: state.spanId,
traceFlags: 1 ,
isRemote: true ,
},
},
],
});
const exitCode = Number (process.env. CLAUDE_TOOL_EXIT_CODE ?? "0" );
const duration = Date. now () - state.startedAt;
span. setAttributes ({
"tool.exit_code" : exitCode,
"tool.duration_ms" : duration,
});
if (exitCode !== 0 ) {
span. setStatus ({ code: SpanStatusCode. ERROR , message: `exit ${ exitCode }` });
}
span. end ();
unlinkSync (stateFile);
ここで気をつけたいのは、ツール入力の全文をスパン属性として記録しない ことです。プロンプトには PII(個人情報)やコード断片が含まれることがあり、観測基盤に流すと社内コンプライアンス的に厄介になりがちです。私は slice(0, 200) のプレビューと長さの数値だけを属性化し、必要な場合のみ別系統のセキュアな KV にハッシュ付きで保存する設計にしています。
MCP サーバーとのトレースを繋ぐ
Claude Code が MCP サーバーを呼ぶ際、現状の MCP プロトコル(2026年4月時点)では traceparent ヘッダーの自動伝搬は標準化されていません。そのため、自前のラッパーを噛ませて W3C Trace Context を伝搬するのが実務的な選択肢になります。MCP の制約事項全般についてはClaude Code MCP サーバー実践ガイド で関連トピックを扱っているので、合わせてご参照ください。
// mcp-tracing-proxy.mjs — MCP サーバーへのリクエストに traceparent を付与
import { trace, context, propagation } from "@opentelemetry/api" ;
export async function callMcpWithTrace ( serverUrl , method , params ) {
const tracer = trace. getTracer ( "mcp-client" );
const span = tracer. startSpan ( `mcp.${ method }` , {
attributes: {
"mcp.server.url" : serverUrl,
"mcp.method" : method,
"mcp.params.keys" : Object. keys (params ?? {}). join ( "," ),
},
});
const headers = { "content-type" : "application/json" };
// W3C Trace Context (traceparent) を inject
propagation. inject (trace. setSpan (context. active (), span), headers);
try {
const res = await fetch (serverUrl, {
method: "POST" ,
headers,
body: JSON . stringify ({ jsonrpc: "2.0" , method, params, id: 1 }),
});
if ( ! res.ok) {
throw new Error ( `MCP HTTP ${ res . status }` );
}
const json = await res. json ();
span. setAttribute ( "mcp.response.has_error" , Boolean (json.error));
return json;
} catch (err) {
span. recordException (err);
span. setStatus ({ code: 2 /* ERROR */ , message: String (err) });
throw err;
} finally {
span. end ();
}
}
サーバー側でも OpenTelemetry SDK が動いていれば、traceparent を受け取った瞬間に同じ trace の続きとしてスパンが繋がります。これで「Claude Code → MCP サーバー → 外部 DB」の3階層が1本のトレースで見渡せるようになります。本番でこれが動くと、調査効率が体感で5倍くらい違います。
トークン数・モデル・キャッシュヒットを属性として記録する
コスト分析のためには、ツール呼び出しごとにトークン数や使用モデル、キャッシュヒットの有無を属性化しておくと後の分析が一気に楽になります。
// hooks/post-tool-use.mjs に追記する属性例
span. setAttributes ({
"claude.model" : process.env. CLAUDE_MODEL ?? "unknown" ,
"claude.tokens.input" : Number (process.env. CLAUDE_INPUT_TOKENS ?? 0 ),
"claude.tokens.output" : Number (process.env. CLAUDE_OUTPUT_TOKENS ?? 0 ),
"claude.cache.read_tokens" : Number (process.env. CLAUDE_CACHE_READ_TOKENS ?? 0 ),
"claude.cache.creation_tokens" : Number (process.env. CLAUDE_CACHE_CREATION_TOKENS ?? 0 ),
// コスト計算は事後集計に任せ、生のトークン数だけ記録するのが拡張性が高い
});
ここで「コストそのものを計算して属性に入れる」のは避けることをお勧めします。理由は、価格テーブルがモデルアップデートやキャンペーンで変動するので、生のトークン数だけ記録 しておき、ダッシュボード側で最新価格を掛ければよいからです。一度入れたスパン属性は事後に書き換えられないため、この「生データ最小単位で記録する」原則は守る価値があります。
トークンとコストの関係を理解するためには、事前にClaude Code のコンテキスト予算最適化ガイド を読んでおくとイメージが掴みやすいです。
メトリクスで週次の傾向を可視化する
トレースは「この1回がなぜ遅かったのか」を追うには最高ですが、「今週このシステムは健康なのか」を答えるには向きません。そこを埋めるのがメトリクスです。MeterProvider をトレーサーと並べて立ち上げておくと、毎回スパンを総なめしなくても集計済みの時系列をダッシュボードに描けます。
// otel-metrics.mjs — otel-init.mjs の sdk.start() 後に読み込む
import { metrics } from "@opentelemetry/api" ;
import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics" ;
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http" ;
import { Resource } from "@opentelemetry/resources" ;
const meterProvider = new MeterProvider ({
resource: new Resource ({ "service.name" : "claude-code-agent" }),
readers: [
new PeriodicExportingMetricReader ({
exporter: new OTLPMetricExporter ({
url: process.env. OTEL_EXPORTER_OTLP_ENDPOINT ?. replace ( "/v1/traces" , "/v1/metrics" ),
}),
exportIntervalMillis: 30_000 ,
}),
],
});
metrics. setGlobalMeterProvider (meterProvider);
const meter = metrics. getMeter ( "claude-code-agent" );
export const toolDurationHistogram = meter. createHistogram ( "claude.tool.duration" , {
unit: "ms" ,
description: "1回のツール呼び出しにかかった時間" ,
});
export const tokensCounter = meter. createCounter ( "claude.tokens.total" , {
unit: "1" ,
description: "種類別の累積トークン消費量" ,
});
PostToolUse フックでは、スパンを閉じるのと同時にこのメトリクスへも記録します。
// hooks/post-tool-use.mjs に追記
import { toolDurationHistogram, tokensCounter } from "../otel-metrics.mjs" ;
toolDurationHistogram. record (duration, {
"tool.name" : process.env. CLAUDE_TOOL_NAME ?? "unknown" ,
"tool.exit_status" : exitCode === 0 ? "ok" : "error" ,
});
tokensCounter. add ( Number (process.env. CLAUDE_INPUT_TOKENS ?? 0 ), {
"claude.model" : process.env. CLAUDE_MODEL ?? "unknown" ,
"token.type" : "input" ,
});
tokensCounter. add ( Number (process.env. CLAUDE_OUTPUT_TOKENS ?? 0 ), {
"claude.model" : process.env. CLAUDE_MODEL ?? "unknown" ,
"token.type" : "output" ,
});
メトリクスの利点はコストです。30秒解像度のヒストグラムは何年保持してもほぼ無料で、その裏にあるトレース本体は数週間だけ保持すればよくなります。この役割分担があると、「直近四半期で P95 レイテンシがどう推移したか」を、数百万件のトレースをコールドストレージに置き続けることなく問えます。
サンプリング戦略 — 数千実行/日でも壊れない設計
観測性で最初にコスト問題に直面するのが、ストレージ料金の急増です。1日 5,000 実行 × 1実行 50 スパン = 25万スパン/日。生データを全部送ると Honeycomb / Datadog の料金が跳ね上がります。
実用的なサンプリング戦略は次の3パターンの組み合わせです。
TraceID 比率サンプリング(基本) : OTEL_TRACES_SAMPLER=traceidratio + OTEL_TRACES_SAMPLER_ARG=0.1 で全体の 10% を保持。トラフィックが多いプロジェクトの基本設定です
エラーは 100% 保持 : ParentBased + AlwaysOn を組み合わせて、エラー発生時はサンプリングを上書きします。Tail-Based Sampling を使えるなら導入推奨です
重要セッションは強制保持 : 開発者の対話セッションや CI 上のリリース検証など、特定タグが付いた trace は無条件保持
組み合わせの具体的な実装は OpenTelemetry Collector で書くのが現実的で、Node.js SDK 単体では Tail-Based ができないため、Collector を1段挟む構成が最終形になります。
# otel-collector-config.yaml — Tail-Based サンプリングの例
processors :
tail_sampling :
decision_wait : 10s
num_traces : 50000
expected_new_traces_per_sec : 1000
policies :
- name : errors-policy
type : status_code
status_code : { status_codes : [ ERROR ] }
- name : slow-traces
type : latency
latency : { threshold_ms : 5000 }
- name : probabilistic
type : probabilistic
probabilistic : { sampling_percentage : 10 }
exporters :
otlphttp :
endpoint : ${env:OTEL_EXPORTER_OTLP_ENDPOINT}
service :
pipelines :
traces :
receivers : [ otlp ]
processors : [ tail_sampling ]
exporters : [ otlphttp ]
「全エラー」「5秒超の遅延」「ランダム10%」の3条件のいずれかにヒットしたトレースだけを保持する構成です。これでストレージコストを 80〜90% 削減しつつ、本当に必要なトレースは取りこぼさなくなります。
Node 以外のプロジェクト向けの Python 実装
Claude Code のワークフローは Python でラップされていることも少なくありません。エージェントのオーケストレーターや CI スクリプト、データパイプラインなどです。OpenTelemetry の考え方はまったく同じで、変わるのは SDK の形だけです。
# otel_init.py — Python ラッパーの先頭で import する
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
import os
resource = Resource.create({
"service.name" : "claude-code-agent" ,
"service.version" : os.environ.get( "AGENT_VERSION" , "dev" ),
"deployment.environment" : os.environ.get( "ENV" , "development" ),
})
provider = TracerProvider( resource = resource)
exporter = OTLPSpanExporter(
endpoint = os.environ.get( "OTEL_EXPORTER_OTLP_ENDPOINT" , "http://localhost:4318/v1/traces" ),
headers = { "authorization" : os.environ[ "OTEL_AUTH_HEADER" ]} if "OTEL_AUTH_HEADER" in os.environ else None ,
)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer( "claude-code-agent" )
BatchSpanProcessor はキューに溜まったスパンを送り切るために明示的な shutdown が必要です。すぐ終了するフックスクリプトでは try/finally で締めます。
# hooks/post_tool_use.py
import os, json, time
from otel_init import tracer, provider
from opentelemetry.trace import Status, StatusCode
state_file = f "/tmp/claude-otel-tool- { os.environ[ 'CLAUDE_TOOL_INVOCATION_ID' ] } .json"
with open (state_file) as f:
state = json.load(f)
duration_ms = int ((time.time() * 1000 ) - state[ "startedAt" ])
exit_code = int (os.environ.get( "CLAUDE_TOOL_EXIT_CODE" , "0" ))
with tracer.start_as_current_span( f "tool.end. { os.environ[ 'CLAUDE_TOOL_NAME' ] } " ) as span:
span.set_attribute( "tool.duration_ms" , duration_ms)
span.set_attribute( "tool.exit_code" , exit_code)
if exit_code != 0 :
span.set_status(Status(StatusCode. ERROR , f "exit { exit_code } " ))
# スパンが送り切れるまで最大 1.5 秒待ってから終了する
try :
provider.force_flush( timeout_millis = 1500 )
finally :
provider.shutdown()
start_as_current_span をコンテキストマネージャとして使える分、Python のほうが少し書き味は良いです。チームの言語が分かれていても、ワイヤフォーマットは共通なので、1つの Collector が両方を等しく受け取れます。
どうしてもプロンプトを記録するときの正しいマスキング
200 文字のプレビューでは足りない場面もあります。妙なリトライループを追うには、実際のプロンプトが要ることが多いです。やってはいけないのは、全文をスパンに載せてベンダーへそのまま送ることです。正しいのは、ソースでマスキングしてから、アクセス制御された別ストアに書き出すことです。
// redact.mjs — プロジェクト固有ルールを足せる最小マスキング
const PATTERNS = [
{ rx: /sk- [A-Za-z0-9] {16,} / g , mask: "[REDACTED:api_key]" },
{ rx: /Bearer \s + [A-Za-z0-9._-] + / gi , mask: "[REDACTED:bearer]" },
{ rx: / [\w.+-] + @ [\w-] + \. [\w.-] + / g , mask: "[REDACTED:email]" },
{ rx: / \d {16} / g , mask: "[REDACTED:card]" },
];
export function redact ( input ) {
let out = input;
for ( const { rx , mask } of PATTERNS ) out = out. replace (rx, mask);
return out;
}
これをスパン属性やアーカイブストアへ書く前に必ず通します。あわせて「そもそもプロンプトをアーカイブしてよいプロジェクト」を厳格な許可リストで絞ります。PII に隣接するものは、デフォルト許可よりデフォルト拒否のほうが安全です。
ダッシュボードで見るべき5つのシグナル
蓄積したトレースから、毎日眺めるダッシュボードに載せるべき指標を絞ると次の5つになります。
セッション完了率(成功 / 失敗 / タイムアウト) : エージェント全体の健康状態の一次指標
ツール別 P95 レイテンシ : どのツールが詰まりやすいかの傾向。Bash・WebFetch・Edit を分けて見る
MCP サーバー別エラー率 : 個別 MCP の不安定さを早期に検知
モデル別トークン消費量 : コスト最適化の余地(モデル切替・キャッシュ活用)の発見
リトライ回数分布 : 「3回以上リトライしたツール呼び出しの割合」が無駄コストの先行指標
私の運用ではこの5つを Grafana ダッシュボードに並べ、毎朝5分眺める習慣にしています。異常があればトレースに直接ジャンプできる構成にすると、調査時間がかなり短くなります。
主要バックエンドごとのダッシュボードの形
先ほどの5シグナルのダッシュボードは、トレースの送り先によってクエリの形が少しずつ変わります。
Honeycomb では各シグナルが保存クエリになります。service.name = claude-code-agent で絞り、tool.name でグループ化し、レイテンシ用タイルには P95(duration_ms) を描きます。「なぜこの1本だけ30秒なのか」の調査では、BubbleUp とトレース検査が特に効きます。
Grafana Tempo + Prometheus では、先ほど出したメトリクスが Prometheus にそのまま流れます。
# ツール別 P95 レイテンシ(5分窓・ツール名でファセット)
histogram_quantile(0.95, sum(rate(claude_tool_duration_bucket[5m])) by (tool_name, le))
# 直近1時間のセッション失敗率
sum(rate(claude_session_failures_total[1h]))
/ sum(rate(claude_session_total[1h]))
# 直近24時間のモデル別トークン消費
sum by (claude_model) (increase(claude_tokens_total[24h]))
Tempo がトレース保存を担い、Prometheus のアラートから TraceQL で対応するトレースへ飛べます。
Datadog では、クエリよりもタグ設定に時間をかけることになります。service / env / tool.name を一貫して付けてしまえば、あとは APM がほとんどの仕事をします。課金がホスト単位 + スパン単位なので、前述の Tail-Based サンプリングがここでは特に重要になります。
実務的な選び方は単純で、チームがすでに払っているバックエンドを選ぶことです。ここまで作ってきた計装は、送り先がどれでも変わりません。
一気に入れず、段階的にロールアウトする
エージェント観測性に初めて取り組むチームで、私が実際に機能したと感じる導入ペースは次の4週間です。
第1週 : ローカル Jaeger に、開発エージェント1台だけ計装する。トレースが描画され、スパン属性が正しいことを確認する
第2週 : 本番1環境に出し、ヘッドサンプリング 100%・Tail-Based なしで送る。流量を観察する
第3週 : Tail-Based Collector を足し、ヘッドサンプリングを 10% に下げ、エラートレースがちゃんと届くか検証する
第4週 : アラートとダッシュボードを配線する。過去のインシデントを最低1件トレースストアから引き、このアラートで実際に検知できたかを確認する
いきなり「全部・全環境でオン」にすると、たいていサンプリングが静かに壊れ、請求書が届くまで誰も気づきません。
よくある落とし穴
実装中に詰まりやすいポイントを、私が踏んだ順に並べておきます。
フック実行の非同期問題 : フックスクリプトは Claude Code から spawn されるため、エクスポーターが flush する前にプロセスが終了してスパンが届かないことがあります。SimpleSpanProcessor を使うか、process.on("beforeExit") で await sdk.shutdown() を必ず呼ぶ実装にします
trace ID の不整合 : 状態ファイル経由で traceId を渡す設計だと、並列セッションで上書きが起きやすいです。必ず CLAUDE_SESSION_ID をファイル名に含めて衝突を避けます
属性のサイズ制限 : スパン属性1つに長文プロンプトを丸ごと入れると、エクスポーターが reject します。OTEL の仕様上、1属性は最大 12KB 程度に抑えるのが安全です
MCP プロキシで stdin/stdout が壊れる : トレース注入のためのプロキシを stdio MCP サーバーの前に挟むと、JSON-RPC のフレーミングが崩れて MCP が動かなくなります。HTTP / SSE 系の MCP サーバーに対してのみ traceparent を注入する方針が安全です
観測性が本処理を遅延させる : 同期エクスポーター(SimpleSpanProcessor)を本番で使うと、エクスポーター先のレスポンスが遅いだけでエージェントが詰まります。BatchSpanProcessor を使い、フック終了前に forceFlush({ timeout: 1500 }) で時間制限付き flush するのが安全です
トレースが出てこないときのデバッグ手順
いちばん心が折れる失敗は、すべて配線したのにバックエンドに何も出ないことです。私が確認する順番を、痛みが増す順に並べておきます。
SDK は本当に起動しているか : stderr に [otel] SDK started が出ているか。出ていなければ、ラッパースクリプトが otel-init.mjs を十分早く読み込めていません。多くは動的 import の順序が原因なので、import をファイル先頭へ移します
エンドポイントに到達できるか : 同じマシンから curl -X POST $OTEL_EXPORTER_OTLP_ENDPOINT -d '{}'。4xx なら問題なし(受信は生きています)。connection refused や DNS エラーなら、エージェントは黙ってスパンをバッファし、やがて捨てます
終了前に flush されているか : 各フックの末尾に console.error("[otel] forcing flush") と await sdk.shutdown() を足します。ログは出るのにスパンが届かないなら、問題は Collector 側です
サンプリングで落ちていないか : デバッグ中は OTEL_TRACES_SAMPLER=always_on にします。always-on で出て本番サンプラーで出ないなら、サンプラー設定が誤っています
Collector が握りつぶしていないか : Collector 自身のログを見ます。tail_sampling の設定ミスは全件を静かに落とします。--config-source=file:./otel-collector.yaml で起動し、警告を確認します
1〜5 が通っても何も見えないときは、docker run jaegertracing/all-in-one でローカル Jaeger に切り替え、SDK の向きをそこへ変えます。ローカル Jaeger は即座に描画するので、問題が SDK 側かホスト型バックエンド側かを切り分けられます。
本番運用チェックリスト
最後に、デプロイ前に確認すべき項目を箇条書きでまとめます。
SDK 初期化は try/catch で囲み、観測性失敗で本処理を止めない設計になっているか
全フック(SessionStart / PreToolUse / PostToolUse / SessionEnd)でスパン発行の整合性が取れているか
MCP サーバーへの traceparent 注入が HTTP/SSE 系のみに限定されているか
スパン属性に PII / フルプロンプトが入っていないか(プレビュー長を 200 文字に制限)
Tail-Based サンプリングで「エラー全量・遅延全量・10%ランダム」が取れているか
1日のスパン数の予測値が観測基盤の料金プラン内に収まっているか
ダッシュボードに5つのコア指標(完了率・P95レイテンシ・MCPエラー率・トークン消費・リトライ分布)が並んでいるか
アラート条件として「セッション失敗率 > 5%」「P95 レイテンシ > 30秒」「MCP エラー率 > 10%」が設定されているか
観測性はモデル選定の仕方を変える
個人開発で自分の Claude 利用料を払いながらエージェントを運用していると、計装を入れて実際にトレースを眺め始めて、いちばん意外だったのは、モデル選定がドグマからデータ駆動に変わった ことでした。計装前は「念のため」最も高性能なモデルを既定にしていました。計装後、ヒストグラムが教えてくれたのは、ツール呼び出しのおよそ7割は十分に軽く、より速く安価なモデルが半分のレイテンシ・5分の1のコストで終えていた、という事実でした。
これはログだけではほぼ判断できません。モデル別にツール処理時間の分布を見て、同じ軸でエラー率も見る必要があります。OpenTelemetry の属性が付いていれば、どのバックエンドでも1行のクエリになります。
# モデル別 P95 レイテンシ — モデル選定を駆動する比較
histogram_quantile(0.95,
sum(rate(claude_tool_duration_bucket[7d])) by (claude_model, le)
)
私はこのクエリを毎週回しています。あるエージェントが、本来不要なタスクに対して重量級モデルへ静かに固定され続けていた回帰を、これで捕まえました。超過コストはそのプロジェクトの月額 Claude 利用料の約40%、しかも3週間続いていました。観測性があったおかげで、翌週の月曜には気づけたのです。
全体を振り返って
エージェント観測性の組み込みは、最初の SDK 初期化に1日、フック連携に1日、サンプリング調整に2〜3日というペースで進められます。週末に試すなら、まずは SessionStart と PreToolUse / PostToolUse だけスパン化して、ローカルの Jaeger に流してみてください。トレースが繋がった瞬間、エージェントの中で何が起きているかが文字通り「見える」ようになります。
その先のサンプリング設計や Tail-Based の運用は、データ量が見えてから調整するのが現実的です。最初から完璧を狙わず、まずは「小さく始めて見える化を達成する」ことを目標にしていただければと思います。
エージェント運用が大規模化したときに痛感するのは、観測性は「あったら便利」ではなく「ないと回らない」インフラだということです。今この瞬間から始めれば、3か月後の運用負荷が確実に違ってきます。