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 web add @tma1-ai/dsh-plugin-greptimedb

The package ships a bundle patch, so that one command is the whole install. Requires 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.1 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. Timestamps come from the session events themselves, not from when the plugin handled them.

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. Every chat span gets a real end time, including the crash case.

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]

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.

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
[03]

Seven Grafana dashboards

Overview, Cost, Sessions, Agent loop, Trace explorer, Log explorer, Metrics. They ship in grafana/ with a compose stack that brings up GreptimeDB and Grafana together, and every panel query is checked against a live database in CI.

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

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
[05]

One row per session event

Session, event type, turn, and step are real columns, so filtering a session does not mean unpacking JSON.

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 the /v1/{traces,metrics,logs} suffix itself.
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 tables the plugin creates. Also accepts forever.

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.

Whatever the mode, a tool’s private meta payload and the message and stack of a failed request never leave. The projection is an allowlist, so an event type the plugin does not know 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

The full list is in the README.

DSH is pre-release

It renames and repackages freely before its first tagged release, so the peer range is pinned to the version CI runs against.

The GenAI conventions are experimental

Attribute names come from @opentelemetry/semantic-conventions/incubating and move with it.

ttl does not reach metric tables

On the metric engine, retention belongs to the physical table, so set it there yourself (greptimedb#8951).

Export is batched

There is no per-turn flush, and records still in flight at shutdown can be lost.