跳到主要内容
mcpd
无守护进程
源码

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 走同一条路。这正是共用这条流水线的意义: 下面这个顺序不可能在两道门之间发生偏移。

Ingest pipeline
  1. Decode

    OTLP or native, gzip or plain

  2. Sample

    Decided once, at the root

  3. Redact

    Before anything is written

  4. Price

    From the configured price book

  5. 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 标错,比不标 它更糟。

  1. mcpd.span.kind,当它给出的是一个 mcpd 认识的类型时。
  2. openinference.span.kind。
  3. gen_ai.operation.name。
  4. 某个只有一类 span 才会带的属性出现了。
属性的值 mcpd 的类型
openinference.span.kind=LLMmodel_generation
openinference.span.kind=CHAINworkflow_run
openinference.span.kind=TOOLtool_call
openinference.span.kind=AGENTagent_run
openinference.span.kind=RETRIEVERretrieval
openinference.span.kind=EMBEDDINGrag_embedding
openinference.span.kind=RERANKERrag_action
openinference.span.kind=GUARDRAILguardrail
openinference.span.kind=EVALUATORscorer_run
gen_ai.operation.name=chatmodel_generation
gen_ai.operation.name=text_completionmodel_generation
gen_ai.operation.name=generate_contentmodel_generation
gen_ai.operation.name=embeddingsrag_embedding
gen_ai.operation.name=execute_tooltool_call
gen_ai.operation.name=invoke_agentagent_run
gen_ai.operation.name=create_agentagent_run
gen_ai.system、llm.model_name、llm.provider 中出现任意一个model_generation
出现 mcp.tool.namemcp_tool_call
出现 http.request.method 或 http.methodhttp_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