---
格式版本: 2
标题: "Tracing DeepSeek Harness with OpenTelemetry"
原文链接: "https://www.alibabacloud.com/blog/tracing-deepseek-harness-with-opentelemetry_603485"
发布日期: "2026-08-20"
发布时间校准状态: "found"
发布时间需复核: "否"
发布时间来源: "rule:configured_publication_date_rule"
发布时间证据: "alibaba-cloud-news-publication-date html:original: Alibaba Cloud Native Community August 20, 2026"
发布时间校准原因: "信源发布日期识别规则直接确认发布时间"
发布时间校准置信度: "high"
发布时间候选数量: 2
发布时间严格候选数量: 2
发布时间原页读取状态: "source template page reused from URL open"
发布时间未找到原因: ""
发布时间校准时间: "2026-08-21T13:16:55+08:00"
发布时间仲裁状态: "skipped"
发布时间仲裁尝试次数: 0
发布时间仲裁耗时毫秒: 0
发现时间: "2026-08-21T13:16:18+08:00"
入库时间: "2026-08-21T05:16:55.868Z"
来源平台: "固定入口"
搜索渠道: "fixed_url"
搜索词: "https://www.alibabacloud.com/blog"
匹配关键词:
  - "AI"
相关厂家:
  - "阿里"
相关专家:
  []
内容类型: "网页"
抓取工具: "Free Fetch + Defuddle"
清洗工具: "Defuddle Markdown + Defuddle/Readability 正文提取"
原始附件:
  []
AI优质: "否"
AI打分: 10
AI分档: "非优质"
AI质检状态: "不通过"
AI打分理由: "内容为DeepSeek Harness的OpenTelemetry追踪教程，属于软件可观测性工具链，与超节点/AI Rack/机柜级AI基础设施、供电、液冷、高速互连等主题完全无关，也未涉及商业部署信号。"
AI质检模型: "ali-deepseek-v4-flash"
AI质检时间: "2026-08-21T13:17:08+08:00"
AI主题相关性: 0
AI来源权威性: 5
AI新颖性: 0
AI技术细节: 0
AI商业部署信号: 0
AI完整性: 5
AI摘要: "本文介绍如何用OpenTelemetry 插件追踪 DeepSeek Harness（DSH）智能体运行过程，一条命令即可生成带 Agent、ReAct 步骤、LLM 与工具调用的瀑布式 trace。"
AI摘要模型: "ali-deepseek-v4-flash"
AI摘要时间: "2026-09-07T02:12:53.769Z"
采集批次: "2026年8月21日13点00分04秒"
采集批次ID: "20260821-130004-200"
去重键: "https://www.alibabacloud.com/blog/tracing-deepseek-harness-with-opentelemetry_603485"
---

I asked my coding agent to refactor a module last week. It thought for 17 seconds, called four tools, retried one of them, and got it right. Fine.

Then I wanted to know why it took 17 seconds. The session log had everything that happened, in order, with timestamps. So I started subtracting timestamps by hand — and gave up around the second tool call, because the log doesn't tell you which LLM call belonged to which step, or that one of those calls was a retry of a call that failed.

Logs answer what happened. They can't answer where the time went, because that's a question about a tree, and a log is a flat list.

That's what traces are for, and it turns out you can get proper ones out of DeepSeek Harness with one command.

## Quick Context: What DSH Is

DeepSeek Harness (DSH) is DeepSeek's open-source coding-agent runtime. The model generates, the *harness* is the layer that actually gets work done — it holds session state, drives the ReAct loop, executes tool calls, enforces permissions, and spawns sub-agents. It ships as an npm package (`@deepseek-ai/dsh`) with web, terminal, and headless front ends, and a plugin system built on Cordis whose user-level config file is watched and hot-reloaded on save.

If you're on Claude Code or Codex instead, skip to the last section — same data model, different install path.

## Three Commands to See a Trace

[@loongsuite/dsh-plugin](https://github.com/loongsuite/dsh-plugin) is a native DSH plugin that turns session, agent-loop, LLM, and tool lifecycle events into OpenTelemetry GenAI spans and pushes them over plain OTLP/HTTP. No collector, no sidecar — it runs inside the harness process.

The repo ships a [quickstart](https://github.com/loongsuite/dsh-plugin/tree/main/examples/quickstart) that spins up Jaeger v2, which speaks OTLP natively, so Jaeger is the only thing you need running:

```
docker compose -f examples/quickstart/docker-compose.yml up -d

dsh plugin --profile web add @loongsuite/dsh-plugin

OTEL_SERVICE_NAME=dsh-agent \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
OTEL_METRICS_EXPORTER=none \
  dsh --profile web
```

Now ask the agent for something that takes a few steps — "summarize this repository and list its dependencies" does the job — then open `http://localhost:16686`, pick the `dsh-agent` service, and open the newest trace.

Two notes on that last command. `OTEL_METRICS_EXPORTER=none` is there because Jaeger has no metrics endpoint; leave it out and the metric exporter retries `/v1/metrics` forever. Those failures are isolated and never touch agent execution, but they're noise in your terminal. And that's the standard OTel variable, not something we invented — the plugin also honours `OTEL_EXPORTER_OTLP_HEADERS`, the signal-specific endpoint variants, and `OTEL_RESOURCE_ATTRIBUTES`, so containers and CI never need a config file.

## Reading the Waterfall

One trace per turn. A two-step turn gives you eight spans:

```
ENTRY  enter_ai_application_system              605ms
└── AGENT  invoke_agent standard                605ms
    ├── STEP  react step                        255ms
    │   ├── LLM   chat deepseek-v4-pro           81ms
    │   └── TOOL  execute_tool read_file         40ms
    └── STEP  react step                        330ms
        ├── LLM   chat deepseek-v4-pro           81ms
        └── TOOL  execute_tool bash             120ms
```

Here's a real one, a 17-second turn in Langfuse:

![1](https://yqintl.alicdn.com/e1fb5baa4d37db0eb751ee87452fe1fb8f0eb316.png "1")

The 17.42 seconds resolve immediately: steps 3 and 4 ate five seconds each, while the slowest single model call was only 4.38s. So most of that turn was tool execution, not inference — the opposite of what I'd assumed.

A few other things the tree makes obvious that the log didn't:

- **Retries stop hiding.** Every real model call is its own span, so a `STEP` with three `LLM` children *is* a step that took three attempts.
- **Failures land where they happened.**`web_search` errored twice and the model finished the job with `bash` instead. The error status sits on the `TOOL` spans; you don't go grepping for it.
- **TTFT is right there.**`gen_ai.response.time_to_first_token` per call, next to the token counts.
- **Sessions stitch together.**`gen_ai.session.id` and `gen_ai.turn.id` are on the spans, so a backend like Langfuse can group many turns into one conversation.

## The Token Accounting Trap

This part cost me an afternoon, so it's worth spelling out.

Two invariants hold on every `LLM` span:

- `gen_ai.usage.cache_read.input_tokens` is **included in** `gen_ai.usage.input_tokens`, not a sibling of it.
- `input_tokens + output_tokens == total_tokens`. Reasoning tokens are reported separately but already counted inside `output_tokens`.

That first one bit us. An earlier build reported cached tokens *alongside* input tokens, so any dashboard computing `cache_read / input` produced cache hit rates above 100%. If you're building cost dashboards on GenAI spans from any source, check which convention it follows before trusting the ratio — providers disagree about this, and the attribute names look identical either way.

There's a second gotcha, and this one is by design. The `AGENT` span carries the aggregate usage for the whole turn, because you usually want one number per turn. But a backend that sums usage across *every* span in a trace will therefore count that aggregate twice, once on `AGENT` and once across the `LLM` spans. Langfuse works this way. So when you quote a token total, read it off the `AGENT` span, or add up the `LLM` spans yourself — don't use the trace-level figure.

## Pointing It at Langfuse

Jaeger is fine for "did this work", but Langfuse understands sessions and token costs, and it's straightforward to self-host. Its OTLP receiver lives at `/api/public/otel` and wants Basic auth:

```
export AUTH_STRING=$(echo -n "pk-lf-xxx:sk-lf-xxx" | base64)
```

Then edit the plugin's row in `$DSH_HOME/profiles/<profile>/cordis.patch.yml` (`~/.dsh` by default — and remember DSH hot-reloads this file, so no restart):

```
- id: loongsuite-observability
  config:
    endpoint: http://localhost:3000/api/public/otel   # or https://cloud.langfuse.com/api/public/otel
    serviceName: dsh-agent
    headers:
      Authorization: Basic <AUTH_STRING>
      x-langfuse-ingestion-version: "4"
    exportMetrics: false
```

The base URL is enough; the plugin appends `/v1/traces` itself. The `x-langfuse-ingestion-version: 4` header opts into Langfuse's v4 data model — without it your spans land on the old one. And `exportMetrics: false` (or `OTEL_METRICS_EXPORTER=none`) is required rather than optional here, because Langfuse's OTLP endpoint accepts traces only.

Anything else that speaks OTLP works the same way: Grafana Tempo, SigNoz, whatever you already run.

## One Collector for Every Agent on the Box

The plugin only knows about DSH. If you switch between several agents during the day — and most people do — there's a sibling project, [LoongSuite Pilot](https://github.com/alibaba/loongsuite-pilot), that covers around 19 of them (Claude Code, Codex, Cursor, Qwen Code, OpenCode, DSH, and friends) from a single local daemon.

It detects which agents are actually installed, deploys whatever integration each one needs (DSH gets a reversible YAML patch), and normalizes every native format into the same GenAI event schema. Usefully, it needs **no backend at all** to be worth running: by default it writes normalized JSONL locally and serves a dashboard showing per-agent token usage, sessions, models, and tools. Point it at Langfuse when you want to:

```
{
  "collectTrace": true,
  "serviceName": "my-agents",
  "otlpTrace": {
    "endpoint": "http://localhost:3000/api/public/otel",
    "headers": {
      "Authorization": "Basic <AUTH_STRING>",
      "x-langfuse-ingestion-version": "4"
    },
    "captureMessageContent": false
  }
}
```

Running both the plugin and Pilot on one machine is fine — they use different injection points and don't fight — but don't send both copies to the same backend unless you enjoy duplicate traces.

## Content Capture Is Off, and Stays off Unless You Say So

Prompts, responses, tool definitions, tool arguments, and tool results are **not collected by default**, in either project. Spans carry structural metadata and token counts, nothing you wrote.

Turning it on is `captureContent: true` in the plugin (or `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY`), and `otlpTrace.captureMessageContent` in Pilot. One deliberate asymmetry: an explicit `captureContent: false` in the profile always beats the environment variable, so you can pin a profile to "never capture content, no matter what the shell says".

Two more design choices worth knowing about, since instrumentation that misbehaves is worse than no instrumentation:

- The plugin keeps its own private `TracerProvider` and `MeterProvider`. It never replaces the global OTel providers, so it can't disturb instrumentation your own code already has.
- Collection and export failures are isolated. You can alarm on them, but they never change how the agent calls models or runs tools.

Before you enable content capture, think about where it ends up: source code, credentials, and personal data all ride along inside prompts. Check your backend's retention and access controls first. Pilot additionally supports per-agent content policies and secret masking before export.

## Wrapping Up

If you use a coding agent daily and have started wondering where the time and tokens actually go, this is a two-minute experiment: `dsh plugin add`, an endpoint, and a Jaeger container.

- **dsh-plugin** — [loongsuite/dsh-plugin](https://github.com/loongsuite/dsh-plugin), DSH only, in-process, direct OTLP
- **LoongSuite Pilot** — [alibaba/loongsuite-pilot](https://github.com/alibaba/loongsuite-pilot), one local collector for many agents

Both are Apache-2.0 and follow the [OpenTelemetry GenAI semantic conventions](https://github.com/open-telemetry/semantic-conventions), so your data isn't locked into anyone's UI.

It's early — the plugin is on a `0.1.x` line — and I'd particularly like to hear from anyone whose provider reports cached or reasoning tokens differently than described above. Issues and PRs welcome.
