Back to home@jwilson411

dsh-otel

DeepSeek Harness plugin: emit OpenTelemetry spans from a session log (turn / step / tool execute). Export only.

Stars
0
Language
JavaScript
Created
Aug 30, 2026
Updated
Aug 30, 2026
GitHub repo

Introduction

dsh-otel

A DeepSeek Harness function plugin that exports a session as OpenTelemetry spans: one span per turn, per step, and per tool call, linked into one trace, sent to any OTLP/HTTP collector — Jaeger, Phoenix, Grafana Tempo, the OpenTelemetry Collector — or written to stdout.

The scope is one direction: the session log out, as spans. The harness already writes a durable record of what happened; Jaeger already knows how to draw a trace. What is missing is the translation, and that is all this is. Nothing here observes the harness at runtime, buffers, samples, or writes to the session log.

It is off by default, it takes no OpenTelemetry SDK as a runtime dependency — the OTLP/JSON document is written by hand — and it puts no prompts, tool arguments, or tool results on a span. A span carries names, ids, timings, and the token counts the log itself reported.

Install

dsh plugin --profile web add github:jwilson411/dsh-otel

dsh plugin forwards to pnpm inside $DSH_HOME/profiles/web, then reconciles the profile: because this package's manifest declares dsh.bundle.patch, it is appended to the profile manifest's ordered dsh.profile.bundles list and its cordis.patch.yml becomes a layer. Remove it the same way, with remove in place of add.

Note that @deepseek-ai/dsh-tools's npm latest tag still points at the older 0.0.1-rc.1; the 0.1.1-rc.2 line is published under next. Pin explicitly rather than relying on the tag.

Turn it on from the profile's own cordis.patch.yml — note that an id-targeted patch replaces the row's whole config, so restate every field you mean to keep:

- id: otel
  config:
    exporter: otlp-http
    endpoint: http://127.0.0.1:4318/v1/traces
    sessionLog: ~/.dsh/sessions/current.jsonl
    sessionId: sess-1
config keyenvironment fallbackdefault
exporterDSH_OTEL_EXPORTERoff — one of off, stdout, otlp-http
endpointDSH_OTEL_ENDPOINThttp://127.0.0.1:4318/v1/traces (used only by otlp-http)
sessionLogDSH_OTEL_SESSION_LOGunset — a call naming no log fails loudly
sessionIdDSH_OTEL_SESSION_IDunset — falls back to the log's header, else unknown

Off is the default on purpose. With no exporter configured the plugin still registers its tool and the tool still builds and counts spans — so a model can answer "how did that run go?" — but nothing leaves the process. Telemetry crossing a machine boundary should be a thing someone turned on.

The spans

spanopened byclosed byparent
dsh.sessionthe first timed eventthe last event— (root)
dsh.turnturn/startturn/enddsh.session
dsh.stepstep/startstep/endits turn
dsh.tool.executetool/callthe tool/result with the same callIdits step

Attributes:

keyonmeaning
session.idevery spanthe session the trace belongs to
dsh.turnturn, step, toolthe turn number
dsh.stepstep, toolthe step number within the turn
dsh.event.seq.start / dsh.event.seq.endevery spanthe log lines the span was opened and closed by
dsh.turn.reasonturndata.reason from turn/end, when the log reported one
tool.name, tool.call_idtoolthe tool and the call it answered
tool.errortoolwhether the result reported a failure
dsh.usage.*steptoken counts, copied verbatim from assistant/message's usage
dsh.span.unclosedanythe log ended before this bracket closed

Status is OK unless a tool result reported an error or a turn ended with a reason that reads like failure (error, failed, cancelled). The mapping is deliberately conservative: an unfamiliar reason is reported OK rather than guessed at.

dsh.usage.* is copied, never computed. No text is tokenised, nothing is summed, and a count the log did not report is simply absent.

Ids are derived, not generated

The trace id is sha256(sessionId) truncated to 32 hex characters; a span id is sha256("<sessionId>/<key>") truncated to 16, where the key is the bracket's own coordinates in the log:

turn:1                      → the turn span
turn:1:step:2               → its second step
turn:1:step:2:tool:call_7   → the tool call inside that step

So exporting the same log twice produces the same tree. If the collector was down the first time, re-run the export — you repair the gap rather than duplicating it. With no session id anywhere, ids derive from the literal string unknown.

What the reader tolerates

  • A line that is not JSON is counted, reported in malformed_lines, and skipped. A tail that caught a partial write does not cost you the trace.
  • An event type this plugin does not know is ignored.
  • A bracket the log never closed is ended at the last event's instant and marked dsh.span.unclosed, rather than dropped — an in-flight tool call at EOF is usually the one you came for.
  • A step/start with no turn/start above it synthesizes its turn, so the tree never has a dangling parent.

Try it against Jaeger

docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:latest
dsh-otel export --log ~/.dsh/sessions/current.jsonl --session sess-1 --exporter otlp-http --endpoint http://127.0.0.1:4318/v1/traces
# UI: http://127.0.0.1:16686

The otel_export tool

Cordis plugin idotel (the row id in cordis.patch.yml)
Injectstools — a hard dependency; the plugin waits rather than degrading
Toolotel_export
Argumentslog (string, optional), session_id (string, optional)

log defaults to the configured sessionLog and session_id to the configured sessionId, else the id the log's header names. A call with no log from either place fails loudly rather than exporting an empty trace.

It returns log, session_id, trace_id, exporter, endpoint, spans, exported, turns, steps, tools, malformed_lines, and status. exported is how many spans actually left the process, so it is 0 whenever the exporter is off while the counts still describe the run.

The tool reads a file and, at most, POSTs to the endpoint you configured. It needs no API key.

The CLI

The same export from a shell. It imports nothing outside node: and this package, so it runs against a checkout with no dependencies installed — useful when the trace is the thing you need and the harness is the thing that is broken.

dsh-otel export --log <path> [--session <id>] [--exporter off|stdout|otlp-http]
                [--endpoint <url>] [--service <name>] [--json]

stdout carries the OTLP document and nothing else, so a redirect is always a valid file; the run's report goes to stderr, as text or, with --json, as JSON. The CLI defaults to the stdout exporter — you asked for an export at a prompt, so printing it is the least surprising thing it can do, and it still sends nothing anywhere.

$ dsh-otel export --log ~/.dsh/sessions/current.jsonl > spans.json
session sess-1 → trace 4f8c…
14 span(s): 3 turn(s), 6 step(s), 4 tool call(s)
14 span(s) written to stdout
skipped unparseable log line(s): 92

Out of scope

  • A hosted product. This is a file reader and an HTTP POST.
  • A UI. Jaeger, Phoenix, and Tempo already draw traces.
  • Replacing or annotating the session log. The log is the source of truth and this package only reads it — no sidecar, no cursor file, no state.
  • A Langfuse clone. No prompt capture, no evals, no datasets, no scoring.
  • Runtime instrumentation. Spans come from the log after the fact, not from hooks inside a running turn.

Layout

package.json        manifest + `dsh.bundle.patch` — what makes this a bundle
cordis.patch.yml    the bundle's patch layer: one insert, one plugin row
src/session.js      reading the session log as JSONL, tolerating bad lines
src/spans.js        events → spans, with ids derived rather than generated
src/otlp.js         the OTLP/JSON document and the three exporters
src/export.js       one export: read, build, send, report
src/index.js        the plugin: `name`, `inject`, `apply(ctx, config)`
bin/                the CLI
test/               offline tests over a checked-in fixture log
package-lock.json   the pinned dependency tree `npm ci` installs in CI

Tests

npm install
npm test

Offline by construction. Every test reads the checked-in test/fixtures/session.jsonl; the stdout exporter writes to an injected sink and otlp-http posts through an injected fetch, so the whole export path is exercised without a collector, a socket, or a key.

Only test/plugin.test.js needs a dependency: it registers the plugin against a stub context and validates the tool's result with the real @deepseek-ai/dsh-tools, pinned to 0.1.1-rc.2 in devDependencies and in package-lock.json so the contract is tested against one known API. Everything else — the library, the CLI, and the other three test files — imports nothing outside node: and this package.

CI (.github/workflows/ci.yml) runs npm ci and npm test on Node 22 and 24 from the committed lockfile, against the public registry only. It needs no credentials and the suite reaches no network.

License

MIT — see LICENSE.