OpenTelemetry
如果你的智能体已经在发 OpenTelemetry,那么接上 mcpd 只是换一个端点。这一页讲完整条 OTLP 路径:三个端点、编码方式,以及 mcpd 读的每一个属性。
三个端点
mcpd 把 OTLP 的 HTTP 端点放在和它的查询 API、它的界面同一个端口上。前面不用 再跑一个 collector,不用第二个容器,也没有单独的接收端口。
| 端点 | 接受 | 答复里带 |
|---|---|---|
POST /v1/traces | ExportTraceServiceRequest | partialSuccess.rejectedSpans |
POST /v1/logs | ExportLogsServiceRequest | partialSuccess.rejectedLogRecords |
POST /v1/metrics | ExportMetricsServiceRequest | partialSuccess.rejectedDataPoints |
每一次成功的导出,答复都用 OTLP 自己的 partialSuccess 形状,
里面带一个计数。collector 就是这样告诉发送方它丢掉了东西的,而导出器会读它。
返回一个空的响应体,会让发送方报出一次它其实没有得到的成功。
把一个导出器指过来
两个环境变量就够了。把它们设在跑你智能体的那个进程的环境里,别的什么都不用改。
导出器
# The base URL. Your exporter appends /v1/traces itself.
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
# Optional. Send gzip if your payloads are large.
export OTEL_EXPORTER_OTLP_COMPRESSION=gzip
# Only when mcpd runs with --ingest-key.
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY"
在你开始排查“怎么什么都没来”之前,有两件事值得先知道。第一,
OTEL_EXPORTER_OTLP_ENDPOINT 是一个基础 URL,导出器会自己把信号
的路径接在后面。如果你想给出包含路径的完整 URL,那就改设
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT。第二,在 OpenTelemetry 的
惯例里 4317 是 gRPC 端口,4318 才是 HTTP 端口。mcpd 在 4317 上服务 HTTP,
因为它把所有东西都放在一个端口上,所以一个还停在默认端口上的导出器会发往
4318,而你什么也收不到。把端口明确写出来。
trace 的两种编码都收
/v1/traces 会读 content-type 头,两种编码都接受:
头里含 protobuf 时按 OTLP/protobuf 读,否则按 OTLP/JSON 读。
protobuf 是 OpenTelemetry 的默认值,所以一个没改过的 SDK 指向 mcpd 时,
发出的 trace 不用任何配置就能落下来。
/v1/logs 和 /v1/metrics 只接受 JSON。在这两个地方
发一个 protobuf 的请求体,会被以 body is not valid JSON 拒掉。
如果三种信号你都要发,那就一次性把协议全局设好:
所有信号都用 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。响应按内容协商压缩,所以一条很大的 trace 在慢链路
上被读回来时也是压缩过的。
一个请求上有两条限制。请求体的上限是 32 MiB,按到达时的字节数量,所以
gzip 能给你换出余地。批次的上限是 10,000 个信号,在每一道摄取入口后按解码后
的数量算:/v1/traces 上是 span,/v1/logs 上是记录,
/v1/metrics 上是数据点,/v1/ingest 上是请求里的
全部东西。更大的批次会被 413 payload_too_large 拒掉,并把两个
数字都说出来。两条限制都是可设置的:--max-body-bytes 和
--max-batch-signals,见
配置。
从到达到存下之间发生了什么
一个 OTLP span 和一个原生 span 走同一条路。这正是共用这条流水线的意义: 下面这个顺序不可能在两道门之间发生偏移。
-
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 span 会有成本数字的原因。mcpd 用这台服务器 当前跑着的那份价目表,在它到达的那一刻给它定价,所以你之后改了自己的费率, 也不会悄悄改写上个月花了多少。参见 成本核算。
两套语义约定都读
AI 的 span 上有两套互相竞争的属性命名在用:OpenTelemetry 自己的
gen_ai.*,以及 OpenInference 的 llm.* 加上
openinference.span.kind。mcpd 两套都读,在这之上还读它自己的
mcpd.*。你不用改任何名字。
下面每一行都按顺序读,先匹配上的胜出。没有被任何一行点名的属性也仍然会留着: 整份拍平的属性表都存在这个 span 上,所以你发的东西不会因为没有被映射而丢失。
模型、载荷与用量
| 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 |
一个三种 token 计数一个都没有的 span,根本不带 usage 对象,而不是带一个填满 零的。输入 token 为零,和输入 token 未知,是两件不同的事实,一份把后者当成 前者算出来的成本报告是错的,而且错得没人会注意到。
关联
关联字段就是让你能要一个用户的运行、或者一次会话的运行的那些字段。先读 span 上的属性,再读资源属性。
| mcpd 字段 | 按顺序从这些里读 |
|---|---|
project | span 上的 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。一个没有设状态但有结束时间的 span 记为 ok,
因为一个没喊过疼就跑完的 span 确实是 ok。一个没有设状态也没有结束时间的
span 保持 unset:它还在跑,mcpd 就照这样存下来,而不是替它编出
一个结局。
在一个出错的 span 上,消息先取 status.message,再取
exception.message,两个都没有时取字面字符串
unspecified error。错误的种类取自 exception.type,
调用栈取自 exception.stacktrace。一个出错的 span 永远带着一条
消息,因为一句没有正文的“出错了”,你拿它没有任何办法。
Span 类型
类型决定一个 span 在瀑布图里怎么画。mcpd 按从具体到笼统的顺序推断它,最后
退回到 generic,而这一个永远是安全的。把一个 span 标错,比不标
它更糟。
mcpd.span.kind,当它给出的是一个 mcpd 认识的类型时。openinference.span.kind。gen_ai.operation.name。- 某个只有一类 span 才会带的属性出现了。
| 属性的值 | 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 不认识的 span 类型时,
它落到 generic,而不是被拒掉。全部类型的清单在
数据模型一页上。
日志与指标
日志记录按严重级别数字映射,就照规范规定的那样:1 到 4 是 trace,5 到 8 是
debug,9 到 12 是 info,13 到 16 是 warn,17 到 20 是 error,再往上都是
fatal。没有严重级别数字时,一条记录按 info 处理。消息取自
body.stringValue,logger 名取自 scope 名,而
traceId 和 spanId 在合法时会被保留,正是它们把一行
日志放到了发出它的那个 span 旁边。
指标这边,sum 变成计数器,gauge 还是 gauge。直方图按它的 sum 和 count 记录, 并保留桶的边界:mcpd 不会假装自己能从一个预聚合的直方图里重新推出一个精确的 分位数。exemplar 会被保留,正是它让图上的一个尖峰可以打开造成它的那次运行。 多数工具会把它们丢掉。
id,以及 W3C traceparent
mcpd 完全照用 OpenTelemetry 的标识符形状:一个 trace id 是 128 位,写成 32 个小写十六进制字符;一个 span id 是 64 位,写成 16 个。让线上格式保持一致, 正是省掉那层翻译的办法。全零的 id 被保留用来表示“没有 trace”,无论它出现在 哪里都会被拒掉,包括 mcpd 自己生成的 id。
OTLP/JSON 规定 id 字段用十六进制,但有几个 SDK 发的是 base64。mcpd 两种都 接受,存下来的是十六进制。一个 id 缺失、长度不对或者全零的 span 会被跳过, 这一批里其余的照样写入,因为一个为了一个坏 span 就拒掉一万个好 span 的 collector,是一个大家会关掉的 collector。
时间戳是自 Unix 纪元起的纳秒数。它们超过 253,所以 OTLP/JSON 把
它们当字符串发,mcpd 也把它们当字符串解析。用浮点去读会丢精度,表现出来就是
几个 span 看着像是相差几微秒开始的,其实并没有。endTimeUnixNano
为 0 或者不存在,表示这个 span 还没有结束。
一个 traceparent 头的形状是
version-traceid-parentid-flags。mcpd 按规范定下的规则解析它:
版本 ff 被拒掉,版本 00 必须正好有四段,更晚的版本
可以带更多段,各个 id 必须合法,而 flags 字节的第 0 位是采样位。一个格式
不对的头,永远不构成拒掉一个请求的理由。mcpd 会改为开始一条新的 trace。
这在哪里要紧:mcpd 的各个 SDK 会读进来的 traceparent,并在往外
调用时发出一个,所以一条 trace 能跨过服务边界活下来。OTLP 的那几个端点从
载荷里取 id,因为你的导出器已经把它们放进 span 里了。如果一条 trace 起点在
一个你没有用 mcpd 接入的服务里,把它的 traceparent 一路传下去,
两半就会落在同一个 trace id 下面。
一个完整的请求
上面这些没有一样需要 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 有 span、日志和指标的形状。它没有机器评分的形状,没有人的一次点踩的形状, 也没有方面注释的形状,更没有词汇去描述今天的模型厂商用来计费的那些 token 细分。发送这些有 两条路:
-
POST /v1/ingest,mcpd 自己的批量摄取端点,一个请求就能带上 完整的数据模型。 -
POST /v1/api/scores和POST /v1/api/feedback, 它们挂到你已经通过 OTLP 发过来的一条 trace 上。
两者混着用是正常的,也是预期之内的。用你已经在跑的 OpenTelemetry SDK 导出 span,从你的评估任务里发评分。它们靠 trace id 连在一起。参见 评分与反馈和 HTTP API。
在你把端口暴露出去之前
没有设 --ingest-key 时,任何能连上 4317 的人都能往里写;没有设
--read-key 时,他还能读走每一条存下来的提示词。当 mcpd 绑定一个
非回环地址、而这两个里有任何一个还开着时,它会在启动时打一条警告。用
配置把它们设上,然后
把密钥作为 Authorization: Bearer 发过来,上面的
OTEL_EXPORTER_OTLP_HEADERS 做的就是这件事。
带着密钥运行
mcpd --ingest-key $INGEST_KEY --read-key $READ_KEY