OpenTelemetry
エージェントがすでに OpenTelemetry を出しているなら、mcpd はエンドポイントの変更だけです。このページで OTLP の経路をすべて説明します。3 つのエンドポイント、エンコーディング、そして mcpd が読む属性のすべてです。
3 つのエンドポイント
mcpd は OTLP の HTTP エンドポイントを、クエリ API やインターフェースと同じポートで提供します。 手前に立てるコレクターも、2 つ目のコンテナも、受信専用の別ポートもありません。
| エンドポイント | 受け取るもの | 返すもの |
|---|---|---|
POST /v1/traces | ExportTraceServiceRequest | partialSuccess.rejectedSpans |
POST /v1/logs | ExportLogsServiceRequest | partialSuccess.rejectedLogRecords |
POST /v1/metrics | ExportMetricsServiceRequest | partialSuccess.rejectedDataPoints |
成功したエクスポートはいずれも、件数を持つ OTLP 自身の partialSuccess の形で答えます。
コレクターが何かを落としたことを送信側に伝える方法がこれであり、エクスポーターはこれを読みます。
空の本文を返せば、送信側は得ていない成功を報告できてしまいます。
エクスポーターの設定
必要なのは環境変数 2 つだけです。エージェントを動かすプロセスの環境に設定してください。 ほかに変えるものはありません。
エクスポーター
# ベース URL。/v1/traces はエクスポーターが自分で足します。
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
# 任意。ペイロードが大きいなら gzip で送ります。
export OTEL_EXPORTER_OTLP_COMPRESSION=gzip
# mcpd を --ingest-key 付きで動かしているときだけ。
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY"
何も届かない原因を追う前に、知っておく価値のある点が 2 つあります。まず、
OTEL_EXPORTER_OTLP_ENDPOINT はベース URL であり、
エクスポーターがそこにシグナルごとのパスを足します。パスまで含めた完全な URL を
与えたい場合は、代わりに OTEL_EXPORTER_OTLP_TRACES_ENDPOINT を設定してください。
次に、OpenTelemetry では慣例としてポート 4317 が gRPC、4318 が HTTP です。
mcpd はすべてを 1 つのポートで提供するため、HTTP を 4317 で提供します。
そのため既定のポートのままのエクスポーターは 4318 に送り、何も届きません。
ポートは明示してください。
トレースの 2 つのエンコーディング
/v1/traces は content-type ヘッダーを読み、
どちらのエンコーディングも受け取ります。protobuf を含むなら OTLP/protobuf、
それ以外なら OTLP/JSON です。protobuf は OpenTelemetry の既定なので、
手を加えていない SDK を mcpd に向けるだけで、設定を一切せずにトレースが届きます。
/v1/logs と /v1/metrics は JSON だけを受け取ります。
そこに protobuf の本文を送ると body is not valid JSON として拒否されます。
3 つのシグナルすべてを送るなら、プロトコルを全体に一度だけ設定してください。
すべてのシグナルを json で
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json content-type がまったく送られてこない場合、mcpd は
application/json と見なします。
圧縮とサイズ
リクエストの本文は content-encoding: gzip でも、identity でも、
エンコーディングのヘッダーなしでも届いてかまいません。それ以外は名前を挙げて拒否されます。
content-encoding "br" is not supported; use gzip or send the
body uncompressed のように返ります。レスポンスはコンテントネゴシエーションで
圧縮されるので、遅い回線越しに大きなトレースを読み出す場合も圧縮されます。
1 つのリクエストに 2 つの上限がかかります。本文の上限は 32 MiB で、届いたものを測るので、
gzip をかければその分の余地が生まれます。バッチの上限はシグナル 10,000 件で、
どの取り込み口でもデコード後に数えます。/v1/traces ではスパン、
/v1/logs ではレコード、/v1/metrics ではデータポイント、
/v1/ingest ではリクエストに入っているものすべてです。これを超えるバッチは
両方の数値を挙げて 413 payload_too_large で拒否されます。どちらの上限も設定項目で、
--max-body-bytes と --max-batch-signals として
設定で説明しています。
届いてから保存されるまでに起きること
OTLP のスパンとネイティブのスパンは同じ経路を通ります。パイプラインを共有している目的がそれです。 下の順序が、2 つの入り口の間でずれることはありません。
-
Decode
OTLP or native, gzip or plain
-
Sample
Decided once, at the root
-
Redact
Before anything is written
-
Price
From the configured price book
-
Store
One transaction per batch
Redaction is stage three, not a cleanup job. A value that a rule matches never reaches storage at all.
OTLP のスパンにそもそもコストの数値が付くのは、取り込み時に値付けするからです。mcpd は 届いたその時点で、このサーバーが動いている価格表を使って値を付けます。そのため後から料金を 変えても、先月のコストが黙って書き換わることはありません。 コスト集計を参照してください。
両方のセマンティック規約
AI のスパンには、競合する 2 組の属性名が使われています。OpenTelemetry 自身の
gen_ai.* と、OpenInference の llm.* および
openinference.span.kind です。mcpd はその両方を読み、さらに自身の
mcpd.* も読みます。名前を付け替える必要はありません。
下の各行は順に読まれ、最初に一致したものが採られます。どの行にも挙がっていない属性も残ります。 平坦化した属性のマップ全体がスパンに保存されるので、対応付けられなかったことで 送ったものが失われることはありません。
モデル、ペイロード、使用量
| mcpd のフィールド | 読む元、この順で |
|---|---|
model | gen_ai.request.model, gen_ai.response.model, llm.model_name, mcpd.model |
provider | gen_ai.system, llm.provider, mcpd.provider |
input | input.value, gen_ai.prompt, mcpd.input |
output | output.value, gen_ai.completion, mcpd.output |
entity_name | gen_ai.agent.name, mcpd.entity.name |
usage.input_tokens | gen_ai.usage.input_tokens, gen_ai.usage.prompt_tokens, llm.token_count.prompt, mcpd.usage.input_tokens |
usage.output_tokens | gen_ai.usage.output_tokens, gen_ai.usage.completion_tokens, llm.token_count.completion, mcpd.usage.output_tokens |
usage.total_tokens | llm.token_count.total, gen_ai.usage.total_tokens |
input_details.cache_read | gen_ai.usage.cache_read_input_tokens, llm.token_count.prompt_details.cache_read, mcpd.usage.cache_read_tokens |
input_details.cache_write | gen_ai.usage.cache_creation_input_tokens, llm.token_count.prompt_details.cache_write, mcpd.usage.cache_write_tokens |
input_details.audio | llm.token_count.prompt_details.audio |
output_details.reasoning | llm.token_count.completion_details.reasoning, gen_ai.usage.reasoning_tokens |
output_details.audio | llm.token_count.completion_details.audio |
3 つのトークン数のいずれも持たないスパンには、ゼロで埋めた使用量オブジェクトではなく、 使用量オブジェクトそのものが付きません。入力トークンがゼロであることと、 入力トークンが分からないことは別の事実であり、後者のつもりで前者を使って組んだコストの集計は、 誰も気づかない形で間違っています。
相関
あるユーザーの実行や、あるセッションの実行を問い合わせられるのは、相関のフィールドがあるからです。 まずスパンの属性を読み、次にリソースの属性を読みます。
| mcpd のフィールド | 読む元、この順で |
|---|---|
project | スパンの mcpd.project、次にリソースのもの、次にデーモンの --project |
environment | mcpd.environment、リソースの deployment.environment.name、リソースの deployment.environment |
service_name | リソースの service.name |
service_version | リソースの service.version |
session_id | gen_ai.conversation.id, session.id, mcpd.session_id |
thread_id | mcpd.thread_id, thread.id |
user_id | user.id, enduser.id, mcpd.user_id |
request_id | mcpd.request_id, http.request.id |
release | mcpd.release、リソースの service.version |
run_id, resource_id, experiment_id | mcpd.run_id, mcpd.resource_id, mcpd.experiment_id |
tags | mcpd.tags、文字列の配列として |
ステータスとエラー
OTLP のステータスコード 1 は ok に、2 は
error になります。ステータスが未設定でも終了時刻があるスパンは
ok として記録されます。文句を言わずに終わったスパンは、実際に問題なく終わったからです。
ステータスが未設定で終了時刻もない場合は unset のままです。
そのスパンはまだ動いており、mcpd は結末を勝手に決めずにそのまま保存します。
エラーのスパンでは、メッセージは status.message、次に
exception.message から取られ、どちらもなければ文字列
unspecified error がそのまま使われます。エラーの種類は
exception.type から、スタックは exception.stacktrace から取られます。
エラーのスパンは必ずメッセージを持ちます。本文のない「何かが失敗した」では、手の打ちようがないからです。
スパンの種別
種別は、ウォーターフォールでスパンがどう描かれるかを決めます。mcpd は具体的なものから順に推論し、
最後は常に安全な generic に落とします。スパンに誤った種別を付けるのは、
種別を付けないことより悪いことです。
mcpd.span.kind。mcpd が知っている種別を指している場合。openinference.span.kind。gen_ai.operation.name。- ある種別のスパンだけが持つ属性の有無。
| 属性の値 | mcpd の種別 |
|---|---|
openinference.span.kind=LLM | model_generation |
openinference.span.kind=CHAIN | workflow_run |
openinference.span.kind=TOOL | tool_call |
openinference.span.kind=AGENT | agent_run |
openinference.span.kind=RETRIEVER | retrieval |
openinference.span.kind=EMBEDDING | rag_embedding |
openinference.span.kind=RERANKER | rag_action |
openinference.span.kind=GUARDRAIL | guardrail |
openinference.span.kind=EVALUATOR | scorer_run |
gen_ai.operation.name=chat | model_generation |
gen_ai.operation.name=text_completion | model_generation |
gen_ai.operation.name=generate_content | model_generation |
gen_ai.operation.name=embeddings | rag_embedding |
gen_ai.operation.name=execute_tool | tool_call |
gen_ai.operation.name=invoke_agent | agent_run |
gen_ai.operation.name=create_agent | agent_run |
gen_ai.system, llm.model_name, llm.provider のいずれかがある | model_generation |
mcp.tool.name がある | mcp_tool_call |
http.request.method または http.method がある | http_request |
| それ以外 | generic |
openinference.span.kind や gen_ai.operation.name に
mcpd の知らない種別が入っていても、拒否はされず generic に落ちます。
種別の一覧はデータモデルにあります。
ログとメトリクス
ログのレコードは、仕様が定めるとおり深刻度の番号で対応付けられます。1 から 4 は trace、
5 から 8 は debug、9 から 12 は info、13 から 16 は warn、17 から 20 は error、
それより上はすべて fatal です。深刻度の番号がない場合、そのレコードは info として扱われます。
メッセージは body.stringValue から、ロガー名はスコープ名から取られ、
traceId と spanId は妥当であれば保持されます。
ログの行を、それを出したスパンの隣に置いているのがこれです。
メトリクスでは、sum はカウンターに、gauge はゲージになります。ヒストグラムは、 バケットの境界を保ったまま合計と件数で記録されます。mcpd は、事前に集計された ヒストグラムから正確な分位数を復元できるふりはしません。エグゼンプラーは保持されます。 チャート上のスパイクから、それを起こした実行を開けるのはこれのおかげです。 多くのツールはこれを捨てます。
id と W3C traceparent
mcpd は OpenTelemetry の識別子の形をそのまま使います。トレース id は 128 ビットで 小文字の 16 進 32 文字、スパン id は 64 ビットで 16 文字です。変換層が要らないのは、 ワイヤ上の形式を同一に保っているからです。すべてゼロの id は「トレースなし」を意味するものとして予約されており、 mcpd が自分で生成する id も含め、どこに現れても拒否されます。
OTLP/JSON は id のフィールドに 16 進を定めていますが、base64 を出す SDK がいくつかあります。 mcpd は両方を受け取り、16 進で保存します。id が欠けている、長さが違う、 あるいはすべてゼロであるスパンは飛ばされ、バッチの残りはそのまま書き込まれます。 1 つの不正なスパンのために 1 万件の正しいスパンを拒否するコレクターは、人が止めるコレクターだからです。
タイムスタンプは Unix エポックからのナノ秒です。253 を超えるため、
OTLP/JSON はこれを文字列として送り、mcpd も文字列として解釈します。浮動小数点として読むと
精度が落ち、実際には同時に始まったスパンがマイクロ秒ずれて始まったように見える形で表面化します。
endTimeUnixNano が 0 または無い場合、そのスパンは終わっていません。
traceparent ヘッダーは version-traceid-parentid-flags です。
mcpd は仕様が定める規則で解釈します。バージョン ff は拒否、バージョン
00 はちょうど 4 つのフィールドを持たなければならず、以降のバージョンは
それより多くを持ってもよく、id は妥当性を満たさなければならず、フラグのバイトのビット 0 が
サンプリング済みフラグです。ヘッダーの形が壊れていることは、リクエストを拒否する理由には決してなりません。
mcpd は代わりに新しいトレースを始めます。
これが効いてくる場面はこうです。mcpd の SDK は届いた traceparent を読み、
外向きの呼び出しにも付けるので、トレースはサービスの境界を越えて残ります。OTLP の
エンドポイントは id をペイロードから取ります。エクスポーターがすでにスパンに入れているからです。
mcpd で計装していないサービスからトレースが始まる場合は、その traceparent を
渡してください。両側が 1 つのトレース id の下に収まります。
リクエストを丸ごと 1 つ
ここまでのどれにも SDK は要りません。これはターミナルにそのまま貼り付けられる、完全で妥当なエクスポートです。
生の OTLP/JSON
curl -X POST http://localhost:4317/v1/traces \
-H 'content-type: application/json' \
-d '{
"resourceSpans": [{
"resource": { "attributes": [
{ "key": "service.name", "value": { "stringValue": "checkout-agent" } }
]},
"scopeSpans": [{
"spans": [{
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"spanId": "00f067aa0ba902b7",
"name": "chat gpt-4o",
"startTimeUnixNano": "1700000000000000000",
"endTimeUnixNano": "1700000001500000000",
"status": { "code": 1 },
"attributes": [
{ "key": "gen_ai.system", "value": { "stringValue": "openai" } },
{ "key": "gen_ai.request.model", "value": { "stringValue": "gpt-4o" } },
{ "key": "gen_ai.operation.name", "value": { "stringValue": "chat" } },
{ "key": "gen_ai.usage.input_tokens", "value": { "intValue": "1200" } },
{ "key": "gen_ai.usage.output_tokens", "value": { "intValue": "350" } }
]
}]
}]
}]
}'
# {"partialSuccess":{"rejectedSpans":0,"errorMessage":""}} OTLP が運べないもの
OTLP はスパン、ログ、メトリクスの形を持っています。機械のスコア、人の低評価、 観点ノートの形は持たず、最近のプロバイダーが課金の根拠にするトークンの 内訳を表す語彙もありません。 それらを送る方法は 2 つあります。
-
POST /v1/ingest。mcpd のネイティブなバッチ用エンドポイントで、 データモデル全体を 1 つのリクエストで運びます。 -
POST /v1/api/scoresとPOST /v1/api/feedback。 すでに OTLP で送ったトレースに紐づけます。
両方を混ぜて使うのは普通のことであり、想定どおりです。すでに動かしている OpenTelemetry の SDK でスパンを送り、評価のジョブからスコアを投げてください。両者はトレース id で結びつきます。 スコアとフィードバックと HTTP API を参照してください。
ポートを外に出す前に
--ingest-key を設定していなければ、4317 に届く相手は誰でも書き込めます。
--read-key がなければ、保存されたプロンプトをすべて読めます。mcpd は、
どちらかを開けたままループバック以外のアドレスにバインドしたとき、起動時に警告を記録します。
設定でこれらを指定し、
キーは Authorization: Bearer として送ってください。上の
OTEL_EXPORTER_OTLP_HEADERS がしているのがそれです。
キーを設定して起動
mcpd --ingest-key $INGEST_KEY --read-key $READ_KEY