tma1-ai/

DSH OTel

DeepSeek Harness 的遥测,就是标准 OpenTelemetry。

不需要 collector,不需要 sidecar,不用 fork DSH。它就是一个普通的 DeepSeek Harness 插件,装上之后每个 turn、每次模型调用、每次工具执行都变成 GreptimeDB 里一行可查的数据。

安装

dsh plugin --profile headless add @tma1-ai/dsh-plugin-greptimedb

包里自带 bundle patch,所以这一条命令就把它接进了 profile。dsh plugin 会转发给 PATH 上的 pnpm,而 dsh 的 profile 目录本身就是一个 pnpm workspace 根目录——pnpm 9 会拒绝在那里安装,并且忽略 dsh 写入的 linker 设置,所以请用 pnpm 10 及以上。默认配置已经指向本地的 GreptimeDB。

指向你自己的数据库,或者先起一个本地实例
$DSH_HOME/profiles/<name>/cordis.patch.yml
- id: greptimedb-otel
name: '@tma1-ai/dsh-plugin-greptimedb'
config:
endpoint: https://<host>/v1/otlp
database: <dbname>
username: <user>
password: <password>
本地 greptimedb
docker run -p 127.0.0.1:4000-4003:4000-4003 \
-v "$(pwd)/greptimedb_data:/greptimedb_data" \
--name greptime --rm greptime/greptimedb:v1.2.0-beta.2 standalone start \
--http-addr 0.0.0.0:4000 --rpc-bind-addr 0.0.0.0:4001 \
--mysql-addr 0.0.0.0:4002 --postgres-addr 0.0.0.0:4003
greptimedb · sql
-- 最慢的工具调用,以及是哪个模型发起的。
SELECT tool.span_name,
chat."span_attributes.gen_ai.request.model" AS model,
tool.duration_nano / 1000000 AS ms
FROM opentelemetry_traces AS tool
JOIN opentelemetry_traces AS chat
ON chat.trace_id = tool.trace_id
AND chat."span_attributes.dsh.step" = tool."span_attributes.dsh.step"
AND chat.span_name LIKE 'chat%'
WHERE tool.span_name LIKE 'execute_tool%'
ORDER BY tool.duration_nano DESC
LIMIT 10;

chat span 和 tool span 共享同一个 trace 和同一个 dsh.step,所以关联它们就是一次普通的 SQL join。每个时间戳都取自它对应的那条 session 事件,而不是处理事件时读的时钟。

三种信号,一个插件

traces、metrics、logs。signals 接受任意子集——关掉的信号根本不会构建 exporter。

默认什么内容都不出去

默认的 content: none 只导出结构和计数。没有 prompt,没有消息内容,没有工具参数,没有工具返回。

配置错了在加载时就报

配置有问题时插件加载阶段直接失败,并指出是哪个字段——而不是等到第一次导出才悄悄出错。

三种信号

每种信号里各有什么

traces 看结构,metrics 用于长期留存和不受采样影响的分位数,logs 保留原始 session 事件。

[01]

turn、chat、tool

turn span 是根节点。chat span 和 tool span 作为兄弟节点挂在它下面,通过 dsh.step 关联。

span 树
invoke_agent dsh turn/start → turn/end
├── chat deepseek-chat step/start → assistant/message
├── execute_tool bash tool/call → tool/result
└── chat deepseek-chat
[02]

每个 chat span 都有确定的结束时间

四条路径关闭一个 chat span,包括崩溃这种情况。没有一条会让 span 悬在一个随意的时间点上。

情况结束时间与状态
模型正常返回assistant/message · OK
流被打断assistant/message · OK,附带 dsh.response.interrupted
请求失败该 step 的 step/end · ERROR,带错误类型
没有结束事件(崩溃、进程退出)最后见到的事件 · UNSET,附带 dsh.span.unclosed
[03]

Token 口径

DSH 的计数是不相交的:inputTokens 只算未命中缓存的输入,缓存读和缓存写是独立字段。gen_ai.usage.input_tokens 是计费总量,所以插件把它们加起来。输出侧包含 reasoning token。

token 属性
gen_ai.usage.input_tokens = inputTokens
+ cacheReadTokens
+ cacheWriteTokens
gen_ai.usage.output_tokens = outputTokens
 
# 拆分口径仍然可查
dsh.usage.uncached_input_tokens
dsh.usage.cache_read_tokens
dsh.usage.cache_write_tokens
dsh.usage.reasoning_tokens
[04]

五个 Grafana 仪表盘

仪表盘放在 grafana/,配套一个把 GreptimeDB 和 Grafana 一起拉起来的 compose stack。每个面板的查询都由 node grafana/verify.mjs 对着真实数据库校验过。

localhost:3000 · overview
DSH OTel overview 仪表盘:turn 数、模型调用、计费 token、缓存命中率、延迟
[05]

一个 turn,逐个 span 看

每张表都能往下跳:trace id 打开那个 turn 的 waterfall,session id 在 trace 视图和日志视图之间切换。

localhost:3000 · trace explorer
DSH OTel trace explorer 仪表盘
[06]

一条 session 事件一行

四个属性通过 X-Greptime-Log-Extract-Keys 变成真正的列。assistant/chunk 永远不导出——拼装好的 assistant/message 已经带着同样的内容。

greptimedb · sql
SELECT session_id, event_type, turn, step, body
FROM dsh_logs
WHERE session_id = '...' AND event_type = 'tool/result'
ORDER BY timestamp;

Metrics

指标 instrument

和 traces 同一批活动,换成 PromQL 看——用于更长的留存,以及不受采样影响的分位数。

Instrument类型维度
gen_ai.client.token.usageHistogramgen_ai.token.type(只有 input/output)、model、provider
gen_ai.client.operation.durationHistogramgen_ai.operation.name、model、provider
gen_ai.invoke_agent.durationHistogramgen_ai.operation.name
gen_ai.execute_tool.durationHistogramgen_ai.operation.namegen_ai.tool.name
dsh.token.detailHistogramdsh.token.detail_kindcache_read/cache_write/reasoning
dsh.tool.invocationsCountergen_ai.tool.namedsh.tool.outcome
dsh.turns / dsh.stepsCounter

配置

实际会改的几个键

profile patch 会整个替换掉这一行的 config,而不是合并进去,所以想保留的字段都要重新写一遍。

默认值说明
endpoint必填OTLP 基础 URL。插件会自己拼上每种信号的 /v1/{traces,metrics,logs} 后缀;写成单信号路径会在加载时被拒绝。
databasepublic作为 X-Greptime-DB-Name 发送。
username / passwordBasic auth。要么都填,要么都不填。
signals三种全开tracesmetricslogs 的任意子集。
contentnone允许多少 payload 离开进程。
ttl180d插件创建的日志表和 trace 表的留存时间,通过 x-greptime-hints 发送,也接受 forever。已存在的表要 ALTER TABLE 才会变。

批量、超时、service name、目标表名都有合理默认值;完整表格见 README

哪些数据会离开这台机器

content 决定

默认不导出任何 payload。要放开就按 profile 显式放开。

模式导出内容
none (默认)结构和计数:事件类型、turn 与 step 编号、token 数、工具名、耗时、结果,以及错误的 namecode
full增加用户和助手的消息内容、工具参数、工具返回。
full+prompt再增加 request/header:完整的 system prompt 和每个工具的 schema。

有三样东西在任何模式下都不会出去:工具私有的 meta payload、失败 turn 的内部 error.message,以及失败请求的 message 和 stack。这个投影是白名单式的,所以插件不认识的事件类型——包括未来某个 DSH 插件新声明的——只会导出它的身份,别的什么都没有。

配合 TMA1

也可以直接指向 TMA1

TMA1 会把 OTLP 代理进它自己管理的 GreptimeDB。改一行配置,DSH 就出现在它的 OTel GenAI 视图里。

flow 表本来就对得上

TMA1 的 tma1_token_usage_1mcost_1mlatency_1mstatus_1m 这几张 flow 表都是从 span_attributes.gen_ai.* 推导出来的,而这个插件按约定就会填这些字段。除此之外不用配任何东西。

cordis.patch.yml
endpoint: http://localhost:14318/v1/otlp

已知限制

接进正式环境前先看这些

DSH 还没正式发布

在第一个 tag 之前它会随意改名和重新打包。peer 版本范围就是 CI 实际跑的那个版本(0.1.1-rc.2);DSH 发新版需要在这里做一次经过测试的 bump。

GenAI 语义约定还是实验性的

字段名来自 @opentelemetry/semantic-conventions/incubating,会跟着它变。span 上同时带 gen_ai.provider.name 和已废弃的 gen_ai.system

<code>ttl</code> 到不了指标表

指标落在 metric engine 上,那里的留存是物理表的属性。hint 只到逻辑表,逻辑表会存下来并显示,但不会执行(greptimedb#8951)。需要自己执行 ALTER TABLE greptime_physical_table SET 'ttl' = '180d'

导出是批量的,退出有时限

没有按 turn 的 flush——导出跟着 batch processor 的节奏走。shutdownTimeoutMillis 到点时还在途中的记录可能在退出时丢失。

子 agent 的 session 自成一条 trace

不会缝进父 trace 里。