tma1-ai/

DSH OTel

DeepSeek Harness telemetry, as plain OpenTelemetry.

No collector. No sidecar. No fork of DSH. It installs as an ordinary DeepSeek Harness plugin, and every turn, model call, and tool execution becomes a row in GreptimeDB you can query.

INSTALL

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

The package ships a bundle patch, so that one command wires it into the profile. dsh plugin forwards to whichever pnpm is on your PATH, and a dsh profile directory is its own pnpm workspace root — pnpm 9 refuses to install there and ignores the linker settings dsh writes, so use pnpm 10 or newer. The defaults already point at a local GreptimeDB.

Point it at your own database, or start a local one
$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>
local 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
-- Slowest tool calls, with the model that requested them.
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 spans and tool spans share a trace and a dsh.step, so correlating them is a plain SQL join. Every timestamp comes from the session event it belongs to, not from a clock read while the event is being handled.

Three signals, one plugin

Traces, metrics, and logs. signals takes any subset — a disabled signal builds no exporter at all.

Nothing leaves by default

The default content: none exports structure and accounting only. No prompts, no messages, no tool arguments, no tool results.

Fails at load, not at export

Bad configuration fails when the plugin loads, with the offending field named — not silently at the first export.

Signals

What lands in each signal

Traces for shape, metrics for long retention and sampling-proof percentiles, logs for the raw session events.

[01]

Turn, chat, tool

Turn spans are roots. Chat and tool spans hang off them as siblings, correlated by dsh.step.

span tree
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]

Every chat span has a defined end

Four paths close a chat span, including the crash case. None of them leaves a span dangling at an arbitrary time.

SituationEnd time and status
Model respondedassistant/message · OK
Stream interruptedassistant/message · OK, plus dsh.response.interrupted
Request failedthat step’s step/end · ERROR, with the error type
No end event (crash, teardown)last event seen · UNSET, plus dsh.span.unclosed
[03]

Token accounting

DSH’s counts are disjoint: inputTokens is uncached input alone, cache reads and writes are separate fields. gen_ai.usage.input_tokens is the billed total, so the plugin sums them. Output includes reasoning tokens.

token attributes
gen_ai.usage.input_tokens = inputTokens
+ cacheReadTokens
+ cacheWriteTokens
gen_ai.usage.output_tokens = outputTokens
 
# the breakdown stays queryable
dsh.usage.uncached_input_tokens
dsh.usage.cache_read_tokens
dsh.usage.cache_write_tokens
dsh.usage.reasoning_tokens
[04]

Five Grafana dashboards

They ship in grafana/ with a compose stack that brings up GreptimeDB and Grafana together. Every panel query is checked against a live database by node grafana/verify.mjs.

localhost:3000 · overview
DSH OTel overview dashboard — turns, model calls, billed tokens, cache share, latency
[05]

One turn, span by span

Every table links onward: a trace id opens that turn’s waterfall, a session id jumps between the trace and log views.

localhost:3000 · trace explorer
DSH OTel trace explorer dashboard
[06]

One row per session event

Four attributes become real columns through X-Greptime-Log-Extract-Keys. assistant/chunk is never exported — the assembled assistant/message carries the same content.

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

Instruments

The same activity as the traces, through PromQL — for longer retention and percentiles that survive sampling.

InstrumentTypeDimensions
gen_ai.client.token.usageHistogramgen_ai.token.type (input/output only), 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.name, gen_ai.tool.name
dsh.token.detailHistogramdsh.token.detail_kind (cache_read/cache_write/reasoning)
dsh.tool.invocationsCountergen_ai.tool.name, dsh.tool.outcome
dsh.turns / dsh.stepsCounter

Configuration

The keys you actually set

A profile patch replaces the row’s whole config instead of merging into it, so restate every field you want to keep.

KeyDefaultNotes
endpointrequiredOTLP base URL. The plugin appends each signal’s /v1/{traces,metrics,logs} suffix; a per-signal path is rejected at load.
databasepublicSent as X-Greptime-DB-Name.
username / passwordnoneBasic auth. Both or neither.
signalsall threeAny subset of traces, metrics, logs.
contentnoneHow much payload may leave the process.
ttl180dRetention for the log and trace tables this plugin creates, sent as x-greptime-hints. Also accepts forever. An existing table keeps its own until ALTER TABLE.

Batching, timeouts, service name, and table overrides have sensible defaults; the full table is in the README.

What leaves the machine

content decides this

The default withholds all payloads. Raise it deliberately, per profile.

ModeExported
none (default)Structure and accounting: event types, turn and step numbers, token counts, tool names, durations, outcomes, error name and code.
fullAdds user and assistant message content, tool arguments, tool results.
full+promptAdds request/header: the complete system prompt and every tool schema.

Three things never leave in any mode: a tool’s private meta payload, the internal error.message of a failed turn, and the message and stack of a failed request. The projection is a positive allowlist, so an event type the plugin does not know — including one a future DSH plugin declares — exports its identity and nothing else.

With TMA1

Point it at TMA1 instead

TMA1 proxies OTLP into a GreptimeDB it manages. Change one line and DSH shows up in its OTel GenAI view.

The flow tables already line up

TMA1’s tma1_token_usage_1m, cost_1m, latency_1m, and status_1m flow tables derive from span_attributes.gen_ai.*, which this plugin populates by convention. Nothing else to configure.

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

Known limitations

Read these first

DSH is pre-release

It renames and repackages freely before its first tagged release. The peer range is the exact version CI runs against (0.1.1-rc.2); a new DSH release needs a tested bump here.

The GenAI conventions are experimental

Names come from @opentelemetry/semantic-conventions/incubating and move with it. Spans carry both gen_ai.provider.name and the deprecated gen_ai.system.

<code>ttl</code> does not reach metric tables

Metrics land on the metric engine, where retention is a property of the physical table. The hint reaches the logical table, which stores and displays it but never enforces it (greptimedb#8951). Set it yourself with ALTER TABLE greptime_physical_table SET 'ttl' = '180d'.

Export is batched, shutdown is bounded

There is no per-turn flush — export follows the batch processors’ cadence. Records still in flight when shutdownTimeoutMillis expires may be lost at exit.

Subagent sessions get their own trace

They are not stitched into the parent’s.