diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml
new file mode 100644
index 0000000000..31a5914d0d
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-07-23-session-telemetry-otel-revival.md: 1150d363e9db98a39e1188b86dc41fa291edba41
+2026-07-23-session-telemetry-otel-revival.zh.md: 76749a59c38ef2b8c5b3f09960ca500c25d8e1a8
diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md
new file mode 100644
index 0000000000..1150d363e9
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md
@@ -0,0 +1,33 @@
+# Agent Note: Session telemetry seam with mandatory redaction and the OTel backend
+
+Status: implemented
+
+English | [中文](2026-07-23-session-telemetry-otel-revival.zh.md)
+
+## Problem
+
+Every deployment that wants harness sessions in an observability stack must hand-roll a session-log consumer: subscription, lifecycle handoff, and — hardest — redaction, since the raw log carries file contents and command output that may embed credentials. A telemetry seam and OTel backend shipped once on the `session-telemetry-otlp-rfc` branch (PR #222/#231) but never reached master: the proposal exported raw session events verbatim, which legal review declined. The capture-side design (backend contract, coordinator, handoff cursor, chunk projection) was sound and reviewed; the export-side stance was the blocker.
+
+## Decision
+
+`packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go, and nothing crosses the seam unredacted:
+
+- **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: adoption with cursor read-back, the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), the fixed first-chunk-per-(turn, step) projection, the `agent/error` relay, and dispose-time `shutdown` records.
+- **The `telemetry/redact` waterfall** — the delta over the branch version. Every record passes it before reaching any backend; the innermost `next()` applies a conservative built-in rule set (credential shapes: API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo), deployments stack stricter rules as listeners, and a throwing rule withholds the record fail-closed. The pattern list is a security invariant, deliberately not configurable. Redaction applies to the exported copy only; the canonical log is never rewritten.
+- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `exporter.url` is required and validated at load; unmounted or unconfigured, nothing leaves the process.
+
+The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs — delivery is best-effort (at-most-once across a crash), which the READMEs state plainly.
+
+## Alternatives considered
+
+**Implement the runtime-telemetry RFC's outbox (durable spool, per-sink cursors, at-least-once, a `readCommitted` persistence-seam method).** Deferred, not rejected: the SDK stance makes delivery semantics the reporting SDK's territory, and the OTel SDK's own batch pipeline is the honest default. The outbox is a pure additive layer (the `emit()` contract does not move); revive it when a deployment states a crash-loss requirement telemetry must satisfy.
+
+**Export without built-in redaction, delegating to receiver-side collector processors.** Rejected — this is what legal declined. Receiver-side redaction ships the secret first and scrubs it second; the seam must scrub before bytes leave the process, and a waterfall makes the redaction point auditable and stackable.
+
+**A configurable pattern list for the default rules.** Rejected: deployment-varying tunables belong in config, but a security invariant does not — weakening the floor should require code, not YAML. Stricter rules stack as `telemetry/redact` listeners.
+
+**Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve.
+
+## Consequences
+
+A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. Credential-shaped substrings never leave the process even on a rule-free deployment, at the cost of a synchronous per-record scrub on the capture path (string-regex over lossless-JSON bodies — bounded by event size, no I/O). Exported bodies can differ from canonical log bytes wherever the placeholder landed, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited.
diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md
new file mode 100644
index 0000000000..76749a59c3
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: Session telemetry seam with mandatory redaction and the OTel backend
+
+Status: implemented
+
+[English](2026-07-23-session-telemetry-otel-revival.md) | 中文
+
+## Problem
+
+每个想把 harness 会话接入可观测性体系的部署方都得手写一套会话日志消费端:订阅、生命周期交接、以及最难的脱敏——原始日志携带文件内容与命令输出,可能内嵌凭据。遥测 seam 和 OTel backend 曾在 `session-telemetry-otlp-rfc` 分支(PR #222/#231)上完成过一版,但从未进入 master:该提案将原始会话事件原样导出,法务评审未予通过。捕获侧设计(backend 契约、coordinator、handoff 游标、chunk 投影)本身合理且经过评审;导出侧的立场才是阻塞点。
+
+## Decision
+
+`packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向,且任何数据未经脱敏不得跨越 seam:
+
+- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的收养、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每 (turn, step) 首 chunk 投影、`agent/error` 转发、以及 dispose 时的 `shutdown` 记录。
+- **`telemetry/redact` waterfall** —— 相对分支版本的增量。每条记录抵达任何 backend 前必经此处;最内层 `next()` 应用保守的内置规则集(凭据形状:API key、GitHub/Slack token、AWS/Google key、JWT、PEM 块、URL userinfo),部署方以监听器堆叠更严规则,抛异常的规则将该记录 fail-closed 扣下。模式列表是安全不变量,刻意不可配置。脱敏只作用于导出副本;canonical log 永不改写。
+- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。
+
+边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。
+
+## Alternatives considered
+
+**实现 runtime-telemetry RFC 的 outbox(落盘 spool、每 sink 游标、at-least-once、persistence seam 的 `readCommitted` 方法)。** 推迟而非否决:SDK 立场使投递语义归属 reporting SDK,OTel SDK 自身的批处理管线是诚实的默认。outbox 是纯增量层(`emit()` 契约不动);待某个部署提出遥测必须满足的崩溃丢失要求时再复活。
+
+**不带内置脱敏直接导出,交给接收端 collector processor。** 否决——这正是法务否掉的方案。接收端脱敏是先把秘密发出去再擦除;seam 必须在字节离开进程前擦除,且 waterfall 使脱敏点可审计、可堆叠。
+
+**默认规则的模式列表做成可配置。** 否决:随部署变化的调优项应进 config,但安全不变量不应——削弱底线应当需要改代码而非改 YAML。更严格的规则以 `telemetry/redact` 监听器堆叠。
+
+**映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。
+
+## Consequences
+
+部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。即使部署方未配置任何规则,凭据形状的子串也绝不离开进程,代价是捕获路径上每条记录一次同步擦除(对 lossless-JSON body 做字符串正则——受事件大小约束,无 I/O)。导出的 body 在占位符落点处可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index a27d325e31..25406c8c9b 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -36,6 +36,9 @@ flowchart LR
pkg_hooks_claude["hooks-claude"]
pkg_hooks_codex["hooks-codex"]
pkg_acp["acp"]
+ pkg_session_telemetry["session-telemetry"]
+ svc_telemetry["ctx.telemetry
Session telemetry seam"]
+ pkg_session_telemetry_otel["session-telemetry-otel"]
svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"]
pkg_session_reference["session-reference"]
svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"]
@@ -159,6 +162,8 @@ flowchart LR
pkg_session_query --> svc_sessionQuery
pkg_session_query_sqlite --> svc_sessionQuery
pkg_session_reference --> svc_sessionReferences
+ pkg_session_telemetry --> svc_telemetry
+ pkg_session_telemetry_otel --> svc_telemetry
pkg_session_title --> svc_sessionTitle
pkg_session_title_all_messages_llm --> svc_sessionTitle
pkg_session_title_first_message_llm --> svc_sessionTitle
@@ -276,6 +281,7 @@ flowchart LR
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
+| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service. |
| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. |
| `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 49a0a1510f..2f55f60bec 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1047,6 +1047,37 @@ export interface Config {
Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts)
+## `@deepseek-ai/dsh-session-telemetry-otel`
+
+Requires: `sessions`
+
+```ts config-catalog
+/**
+ * Plugin configuration: two verbatim SDK option shapes plus nothing else.
+ * `exporter.url` is the one field this package validates itself — required,
+ * no default, must parse as an `http(s)` URL — because a missing endpoint
+ * must fail at plugin load, not at first export.
+ */
+export interface Config {
+ /** Passed verbatim to the SDK's OTLP/HTTP log exporter. */
+ exporter?: {
+ /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */
+ url?: string
+ /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */
+ headers?: Record
+ }
+ /**
+ * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
+ * which this plugin fills); the SDK owns and documents these knobs.
+ */
+ processor?: Omit
+}
+```
+
+Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`)
+
+Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:39`](../packages/telemetry/session-telemetry-otel/src/index.ts)
+
## `@deepseek-ai/dsh-session-title`
Requires: `sessions`
@@ -1958,6 +1989,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts))
- `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
- `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts))
+- `@deepseek-ai/dsh-session-telemetry` ([`packages/telemetry/session-telemetry/src/index.ts`](../packages/telemetry/session-telemetry/src/index.ts))
- `@deepseek-ai/dsh-session-title-llm` ([`packages/session-title/session-title-llm/src/index.ts`](../packages/session-title/session-title-llm/src/index.ts))
- `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
- `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts))
diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md
index ed9bebdc92..4ed5de022b 100644
--- a/docs/cordis-catalog/events.md
+++ b/docs/cordis-catalog/events.md
@@ -803,6 +803,33 @@ Emitted when any prompt provider changes. This registry notification is unfilter
Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts)
+## `telemetry/*`
+
+### `telemetry/redact` — waterfall
+
+Redact one outbound record before it reaches the backend. The innermost `next()` applies the seam's conservative default rule set (credential-shape scrubbing); listeners stack stricter rules by transforming its return value, and returning without `next()` replaces the default — the exported record is then only as clean as the replacing rule. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten.
+
+```ts cordis-catalog
+/**
+ * Redact one outbound record before it reaches the backend. The innermost
+ * `next()` applies the seam's conservative default rule set
+ * (credential-shape scrubbing); listeners stack stricter rules by
+ * transforming its return value, and returning without `next()` replaces
+ * the default — the exported record is then only as clean as the
+ * replacing rule. Dispatched synchronously on the capture hot path inside
+ * the coordinator's containment: a throwing listener withholds that one
+ * record (fail-closed) and never reaches the agent loop. Redaction
+ * applies to the exported copy only; the canonical session log is never
+ * rewritten.
+ * @param record - the candidate record, already the coordinator's own deep
+ * copy; listeners return a (possibly new) record and must not mutate it.
+ * @mode waterfall
+ */
+'telemetry/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord
+```
+
+Source: [`packages/telemetry/session-telemetry/src/index.ts:39`](../../packages/telemetry/session-telemetry/src/index.ts)
+
## `tools/*`
### `tools/change` — emit
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 0d6cc97086..22120b2f8e 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -1533,6 +1533,29 @@ Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-da
Source: [`packages/tasks/tasks/src/index.ts:77`](../../packages/tasks/tasks/src/index.ts)
+## `ctx.telemetry` — `Telemetry` (abstract seam)
+
+The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side.
+
+```ts cordis-catalog
+/**
+ * See {@link TelemetryBackend.emit} — the seam declaration is the contract's one home.
+ * @param record - the logical record to report; owned by the backend after the call.
+ */
+abstract emit(record: TelemetryRecord): void
+
+/** See {@link TelemetryBackend.flush}. */
+flush?(): void
+
+/**
+ * See {@link TelemetryBackend.shutdown}.
+ * @returns resolves when the backend's pipeline has quiesced.
+ */
+abstract shutdown(): Promise
+```
+
+Source: [`packages/telemetry/session-telemetry/src/index.ts:123`](../../packages/telemetry/session-telemetry/src/index.ts)
+
## `ctx.tokenMeter` — `TokenMeterService`
Replay owner for one service-wide estimator and isolated per-session folds.
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index b36d390f55..30380261fe 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -11,7 +11,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
-| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
+| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) |
| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) |
| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) |
| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
@@ -33,16 +33,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`user-approval`](../packages/ui/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
-| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
+| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
+| `telemetry/redact` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:39`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:143`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 212fd723a7..a66c5edc0a 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -199,6 +199,10 @@ flowchart TD
pkg_tasks["tasks"]
pkg_tool_tasks["tool-tasks"]
end
+ subgraph group_telemetry["packages/telemetry"]
+ pkg_session_telemetry["session-telemetry"]
+ pkg_session_telemetry_otel["session-telemetry-otel"]
+ end
subgraph group_workflow["packages/workflow"]
pkg_tool_ralph["tool-ralph"]
pkg_tool_workflow["tool-workflow"]
@@ -408,6 +412,9 @@ flowchart TD
pkg_tasks --> pkg_invariants
pkg_tasks --> pkg_session
pkg_tasks --> pkg_timeout
+ pkg_session_telemetry --> pkg_agent
+ pkg_session_telemetry --> pkg_invariants
+ pkg_session_telemetry --> pkg_session
pkg_workflow --> pkg_agent
pkg_workflow --> pkg_brand
pkg_workflow --> pkg_invariants
@@ -472,6 +479,10 @@ flowchart TD
pkg_pty_local --> pkg_sandbox
pkg_pty_local --> pkg_sandbox_policy
pkg_pty_local --> pkg_session
+ pkg_session_telemetry_otel --> pkg_invariants
+ pkg_session_telemetry_otel --> pkg_llm
+ pkg_session_telemetry_otel --> pkg_session
+ pkg_session_telemetry_otel --> pkg_session_telemetry
pkg_agent_loop --> pkg_agent
pkg_agent_loop --> pkg_invariants
pkg_agent_loop --> pkg_llm
@@ -858,6 +869,7 @@ flowchart TD
| [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) |
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
+| [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
@@ -870,6 +882,7 @@ flowchart TD
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session) |
+| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts
new file mode 100644
index 0000000000..02be1a9011
--- /dev/null
+++ b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts
@@ -0,0 +1,43 @@
+#!/usr/bin/env node
+/**
+ * Test driver: start a mock OTLP/HTTP collector, boot the telemetry Loader
+ * composition against it, run one turn whose prompt carries a fixture
+ * credential, then persist everything the collector captured to
+ * `./otlp-captures.json` for the e2e's inspect step.
+ */
+
+import { writeFile } from 'node:fs/promises'
+import { createServer } from 'node:http'
+import { once } from 'node:events'
+import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
+import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts'
+
+const configPath = process.argv[2]
+if (configPath === undefined) throw new Error('telemetry-otel driver requires a config path')
+
+const captures: unknown[] = []
+const server = createServer((request, response) => {
+ const chunks: Buffer[] = []
+ request.on('data', chunk => chunks.push(chunk as Buffer))
+ request.on('end', () => {
+ captures.push(JSON.parse(Buffer.concat(chunks).toString()))
+ response.writeHead(200, { 'content-type': 'application/json' }).end('{}')
+ })
+})
+server.listen(0, '127.0.0.1')
+await once(server, 'listening')
+const address = server.address()
+if (address === null || typeof address === 'string') throw new Error('collector has no port')
+process.env.DSH_TELEMETRY_E2E_URL = `http://127.0.0.1:${address.port}/v1/logs`
+
+const ctx = await boot('telemetry-otel-e2e', resolveConfigPath(configPath, undefined))
+try {
+ // The fixture credential rides the model-visible user message; the exported
+ // copy must scrub it while the canonical log keeps the original bytes.
+ await runOneShot(ctx, { task: 'prove telemetry with key sk-e2efixture1234567890' })
+} finally {
+ await ctx.fiber.dispose()
+}
+await writeFile('./otlp-captures.json', JSON.stringify(captures))
+server.close()
+server.closeAllConnections()
diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml
new file mode 100644
index 0000000000..defde98ff5
--- /dev/null
+++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml
@@ -0,0 +1,23 @@
+# Test-only composition: session-telemetry-otel through the real Loader/app
+# path, exporting to the mock OTLP collector the driver starts (url via env).
+- id: cli-mock-llm
+ name: './cli-mock-llm.ts'
+
+- id: bash
+ name: '@deepseek-ai/dsh-bash-local'
+
+- id: telemetry-otel
+ name: '@deepseek-ai/dsh-session-telemetry-otel'
+ config:
+ exporter:
+ url: !!js process.env.DSH_TELEMETRY_E2E_URL
+
+- id: cli-agent
+ name: '@deepseek-ai/dsh-cli-demo'
+ config:
+ provider: cli-mock
+ model: cli-mock
+ persona: 'Test the session-telemetry-otel plugin.'
+ persistenceRoot: './.sessions'
+ persistenceCompression: 'none'
+ workspaceContext: false
diff --git a/examples/package.json b/examples/package.json
index bd87263340..133196aa96 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -41,6 +41,7 @@
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*",
"@deepseek-ai/dsh-session-query": "workspace:*",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:*",
+ "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*",
"@deepseek-ai/dsh-spill-local": "workspace:*",
"@deepseek-ai/dsh-spill-policy": "workspace:*",
"@deepseek-ai/dsh-tui-demo": "workspace:*",
diff --git a/knip.json b/knip.json
index 59658e1df6..728ded39b7 100644
--- a/knip.json
+++ b/knip.json
@@ -31,6 +31,7 @@
"headless-agent/tests/fixtures/goal-domain/seed-goal.ts",
"headless-agent/tests/fixtures/time-context-driver.ts",
"headless-agent/tests/fixtures/time-context-mock-llm.ts",
+ "headless-agent/tests/fixtures/telemetry-otel-driver.ts",
"acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts",
"tui-agent/tests/fixtures/tui-scripted-llm.ts",
"acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts",
@@ -188,6 +189,16 @@
"tests/**/*.ts"
]
},
+ "packages/telemetry/session-telemetry-otel": {
+ "entry": [
+ "tests/**/*.spec.ts",
+ "tests/**/*.e2e.ts"
+ ],
+ "project": [
+ "src/**/*.ts",
+ "tests/**/*.ts"
+ ]
+ },
"packages/util/brand": {
"project": [
"src/**/*.ts"
diff --git a/packages/README.md b/packages/README.md
index 4e18d7b5f8..93e06631d2 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -18,7 +18,7 @@ Packages live at `packages///`; groups are containers, while names r
| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface |
| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface |
| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface |
-| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
+| [`compact/`](compact/README.md) | Compaction capability family: abstract seam + basic backend (tool deferred) | Product — stable surface |
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
@@ -32,8 +32,9 @@ Packages live at `packages///`; groups are containers, while names r
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
-| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
+| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, SQLite full-text search | Product — stable surface |
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface |
+| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra |
@@ -46,6 +47,6 @@ Groups distinguish product API from support infrastructure. New packages join an
The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
-**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface / implementation / consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md).
+**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface/implementation/consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md).
Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts).
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index c3e4e1858b..aee1ac9ae8 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -724,6 +724,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
+ {
+ key: 'telemetry',
+ summary: 'The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.',
+ methods: [
+ {
+ signature: 'abstract emit(record: TelemetryRecord): void',
+ jsDoc: '/**\n * See {@link TelemetryBackend.emit} — the seam declaration is the contract\'s one home.\n * @param record - the logical record to report; owned by the backend after the call.\n */',
+ },
+ {
+ signature: 'flush?(): void',
+ jsDoc: '/** See {@link TelemetryBackend.flush}. */',
+ },
+ {
+ signature: 'abstract shutdown(): Promise',
+ jsDoc: '/**\n * See {@link TelemetryBackend.shutdown}.\n * @returns resolves when the backend\'s pipeline has quiesced.\n */',
+ },
+ ],
+ },
{
key: 'tokenMeter',
summary: 'Replay owner for one service-wide estimator and isolated per-session folds.',
@@ -1102,6 +1120,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * Emitted when any prompt provider changes. This registry notification is\n * unfiltered because a global change affects every scope.\n * @mode emit\n */',
summary: 'Emitted when any prompt provider changes.',
},
+ {
+ name: 'telemetry/redact',
+ mode: 'waterfall',
+ signature: '\'telemetry/redact\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord',
+ jsDoc: '/**\n * Redact one outbound record before it reaches the backend. The innermost\n * `next()` applies the seam\'s conservative default rule set\n * (credential-shape scrubbing); listeners stack stricter rules by\n * transforming its return value, and returning without `next()` replaces\n * the default — the exported record is then only as clean as the\n * replacing rule. Dispatched synchronously on the capture hot path inside\n * the coordinator\'s containment: a throwing listener withholds that one\n * record (fail-closed) and never reaches the agent loop. Redaction\n * applies to the exported copy only; the canonical session log is never\n * rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */',
+ summary: 'Redact one outbound record before it reaches the backend.',
+ },
{
name: 'tools/change',
mode: 'emit',
@@ -2087,6 +2112,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'TaskStatus',
declaration: 'export type TaskStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';',
},
+ {
+ name: 'TelemetryRecord',
+ declaration: 'export interface TelemetryRecord {\n channel: \'ledger\' | \'ops\';\n time: number;\n severity: TelemetrySeverity;\n attributes: Record;\n body: unknown;\n}',
+ },
+ {
+ name: 'TelemetrySeverity',
+ declaration: 'export type TelemetrySeverity = \'info\' | \'warn\' | \'error\';',
+ },
{
name: 'TerminalCallView',
declaration: 'export interface TerminalCallView {\n card: \'terminal\';\n title: string;\n description?: string;\n cwd?: string;\n}',
diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md
new file mode 100644
index 0000000000..a5869077c2
--- /dev/null
+++ b/packages/telemetry/README.md
@@ -0,0 +1,8 @@
+# telemetry/
+
+Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the mandatory `telemetry/redact` waterfall, the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md).
+
+| Package | Role |
+|---|---|
+| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, handoff cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). |
+| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | The backend a deployment loads: the OTel JS SDK's log pipeline (`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP exporter), configured verbatim through passthroughs. |
diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md
new file mode 100644
index 0000000000..26edc62645
--- /dev/null
+++ b/packages/telemetry/session-telemetry-otel/README.md
@@ -0,0 +1,39 @@
+# @deepseek-ai/dsh-session-telemetry-otel
+
+The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. It composes the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and maps each record the seam hands over onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use.
+
+## Config
+
+```yaml
+- id: telemetry-otel
+ name: '@deepseek-ai/dsh-session-telemetry-otel'
+ config:
+ exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
+ url: https://collector.example.com/v1/logs
+ headers:
+ authorization: !!js `Bearer ${process.env.OTLP_TOKEN}`
+ processor: {} # optional; passed verbatim to BatchLogRecordProcessor
+```
+
+`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load. Everything else is the SDK's option shape, owned and documented by the SDK; batching, retry, queue bounds, and loss policy under sustained failure are its documented behavior, tuned through the `processor` passthrough. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag.
+
+## What leaves the machine
+
+Records carry the seam's REDACTED copy of `event.data` — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path) — after the seam's `telemetry/redact` waterfall has scrubbed credential-shaped substrings (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. A deployment with stricter requirements stacks `telemetry/redact` listeners or opts out structurally.
+
+## Field mapping
+
+Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record staleness (a session with activity, no `shutdown` ops record, gone stale ended uncleanly).
+
+## Model Experience
+
+None, as the backend only forwards the seam's redacted records into the OTel SDK pipeline; it never contributes to a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **Upstream experimental tree** — `@opentelemetry/sdk-logs` is still published from the upstream experimental tree; SDK API churn lands here and only here — the seam contract does not move.
+- **Live-collector smoke is opt-in** — the e2e smoke (`tests/otel.e2e.ts`) self-skips without `$DSH_OTLP_E2E_ENDPOINT`; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape against a mock collector on every run.
diff --git a/packages/telemetry/session-telemetry-otel/package.json b/packages/telemetry/session-telemetry-otel/package.json
new file mode 100644
index 0000000000..706112d1fe
--- /dev/null
+++ b/packages/telemetry/session-telemetry-otel/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "@deepseek-ai/dsh-session-telemetry-otel",
+ "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/invariant.js",
+ "lib/types/**/*.d.ts",
+ "lib/types/**/*.d.ts.map",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@opentelemetry/api": "^1.9.1",
+ "@opentelemetry/api-logs": "^0.220.0",
+ "@opentelemetry/exporter-logs-otlp-http": "^0.220.0",
+ "@opentelemetry/resources": "^2.9.0",
+ "@opentelemetry/sdk-logs": "^0.220.0",
+ "schemastery": "^3.18.0"
+ },
+ "peerDependencies": {
+ "@deepseek-ai/dsh-invariants": "^0.0.1",
+ "@deepseek-ai/dsh-llm": "^0.0.1",
+ "@deepseek-ai/dsh-session": "^0.0.1",
+ "@deepseek-ai/dsh-session-telemetry": "^0.0.1",
+ "cordis": "^4.0.0-rc.7"
+ },
+ "devDependencies": {
+ "@cordisjs/plugin-loader": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-telemetry": "workspace:^",
+ "cordis": "^4.0.0-rc.7"
+ }
+}
diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts
new file mode 100644
index 0000000000..4738cdd932
--- /dev/null
+++ b/packages/telemetry/session-telemetry-otel/src/index.ts
@@ -0,0 +1,168 @@
+/**
+ * OpenTelemetry backend for the DeepSeek Harness telemetry seam.
+ *
+ * Composes the OTel JS SDK as-is — a `LoggerProvider` with a
+ * `BatchLogRecordProcessor` and an OTLP/HTTP log exporter — and maps each
+ * record handed over by the seam onto `logger.emit()`. Per the seam's
+ * boundary axiom, everything downstream of that call (batching, retry,
+ * queueing, loss policy) is the SDK's documented behavior, configured
+ * verbatim through the `exporter`/`processor` passthroughs; this package
+ * adds no knobs of its own on top of them.
+ *
+ * @module @deepseek-ai/dsh-session-telemetry-otel
+ */
+
+import { createRequire } from 'node:module'
+import z from 'schemastery'
+import type { Context } from 'cordis'
+import { Telemetry, TelemetryCoordinator, type TelemetryRecord, type TelemetrySeverity } from '@deepseek-ai/dsh-session-telemetry'
+import { APP_IDENTITY } from '@deepseek-ai/dsh-llm'
+import {
+ BatchLogRecordProcessor,
+ LoggerProvider,
+ type BatchLogRecordProcessorOptions,
+} from '@opentelemetry/sdk-logs'
+import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
+import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs'
+import { resourceFromAttributes } from '@opentelemetry/resources'
+
+// The package's own manifest is the single source of the instrumentation-scope
+// version (same pattern as dsh-llm's attribution identity).
+const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
+
+/**
+ * Plugin configuration: two verbatim SDK option shapes plus nothing else.
+ * `exporter.url` is the one field this package validates itself — required,
+ * no default, must parse as an `http(s)` URL — because a missing endpoint
+ * must fail at plugin load, not at first export.
+ */
+export interface Config {
+ /** Passed verbatim to the SDK's OTLP/HTTP log exporter. */
+ exporter?: {
+ /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */
+ url?: string
+ /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */
+ headers?: Record
+ }
+ /**
+ * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
+ * which this plugin fills); the SDK owns and documents these knobs.
+ */
+ processor?: Omit
+}
+
+/**
+ * Schemastery validator for {@link Config}; cordis runs it before the plugin
+ * starts. Shape-level only — the load-bearing `exporter.url` check lives in
+ * the constructor so its error message names the field.
+ */
+export const Config: z = z.object({
+ exporter: z.object({
+ url: z.string(),
+ headers: z.dict(z.string()),
+ }),
+ // Opaque passthrough: the SDK owns this shape and validates its own
+ // options; re-declaring them here would violate the boundary axiom.
+ processor: z.any(),
+})
+
+/** Severity mapping from the seam's three-level vocabulary to OTel severity numbers. */
+const SEVERITY: Record = {
+ info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' },
+ warn: { severityNumber: SeverityNumber.WARN, severityText: 'WARN' },
+ error: { severityNumber: SeverityNumber.ERROR, severityText: 'ERROR' },
+}
+
+/**
+ * The backend plugin — the only entry a deployment loads. Constructing it
+ * wires the SDK pipeline, registers the `telemetry` service (duplicate load
+ * throws, cordis' standard duplicate-service behavior), and composes the
+ * seam's {@link TelemetryCoordinator}, which installs the capture side onto
+ * this fiber.
+ */
+export class TelemetryOtel extends Telemetry {
+ static inject = ['sessions']
+ static Config = Config
+
+ private readonly provider: LoggerProvider
+ private readonly ledger: Logger
+ private readonly ops: Logger
+
+ constructor(ctx: Context, config: Config) {
+ super(ctx)
+ const url = config.exporter?.url
+ if (url === undefined || url.length === 0) {
+ throw new Error('session-telemetry-otel: exporter.url is required (the full OTLP logs endpoint)')
+ }
+ let parsed: URL
+ try {
+ parsed = new URL(url)
+ } catch {
+ // Re-thrown as a config error: the only way here is a malformed url string.
+ throw new Error(`session-telemetry-otel: exporter.url is not a valid URL: ${JSON.stringify(url)}`)
+ }
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ throw new Error(`session-telemetry-otel: exporter.url must be http(s), got ${parsed.protocol}`)
+ }
+ this.provider = new LoggerProvider({
+ resource: resourceFromAttributes({
+ 'service.name': APP_IDENTITY.product,
+ 'service.version': APP_IDENTITY.version,
+ }),
+ processors: [
+ new BatchLogRecordProcessor({
+ ...config.processor,
+ exporter: new OTLPLogExporter({
+ url,
+ // App identity travels in the Resource (service.name/version);
+ // the transport-level user-agent is the SDK's own, per the axiom.
+ // Schemastery fills `headers` with {} before cordis constructs the
+ // plugin, so the optional type exists for hand-authors only.
+ headers: config.exporter?.headers as Record,
+ }),
+ }),
+ ],
+ })
+ this.ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version)
+ this.ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version)
+ new TelemetryCoordinator(ctx, this)
+ }
+
+ /**
+ * Map one seam record onto the SDK logger for its channel — a synchronous
+ * enqueue into the batch processor's queue.
+ * @param record - the logical record handed over by the coordinator.
+ */
+ emit(record: TelemetryRecord): void {
+ const logger = record.channel === 'ops' ? this.ops : this.ledger
+ logger.emit({
+ timestamp: record.time,
+ observedTimestamp: record.time,
+ ...SEVERITY[record.severity],
+ // JSON-serializable by the seam's contract (validated at Session.append),
+ // which is exactly the AnyValue subset.
+ body: record.body as AnyValue,
+ attributes: record.attributes,
+ })
+ }
+
+ /** Forward the turn-boundary hint to the SDK's flush, fire-and-forget. */
+ override flush(): void {
+ // Best-effort hint: the SDK resolves forceFlush even when exports fail
+ // (failures go to its own diagnostics), and the coordinator stops calling
+ // this once the fiber is disposed — a rejection would be SDK drift.
+ /* v8 ignore next -- unreachable guard: forceFlush does not reject while the provider is alive */
+ void this.provider.forceFlush().catch(() => {})
+ }
+
+ /**
+ * Delegate disposal to the SDK's shutdown contract: flush the queue and
+ * quiesce. Awaited (and error-contained) by the coordinator's disposer.
+ * @returns resolves when the SDK pipeline has quiesced.
+ */
+ shutdown(): Promise {
+ return this.provider.shutdown()
+ }
+}
+
+export default TelemetryOtel
diff --git a/packages/telemetry/session-telemetry-otel/src/invariant.ts b/packages/telemetry/session-telemetry-otel/src/invariant.ts
new file mode 100644
index 0000000000..075e5cc193
--- /dev/null
+++ b/packages/telemetry/session-telemetry-otel/src/invariant.ts
@@ -0,0 +1,32 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-session-telemetry-otel`.
+ * @module @deepseek-ai/dsh-session-telemetry-otel/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-session-telemetry-otel'
+
+/** Cordis companion plugin name. */
+export const name = 'session-telemetry-otel-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the backend forwards seam records into the OTel SDK's
+ * in-process pipeline and appends nothing to any session; its only observable
+ * effects (batching, export) happen inside the SDK past the seam's boundary
+ * axiom, out of reach of an independent companion.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */
diff --git a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts
new file mode 100644
index 0000000000..2c72fd6170
--- /dev/null
+++ b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts
@@ -0,0 +1,98 @@
+/**
+ * REAL-composition tier: boot the examples-owned telemetry Loader fixture as
+ * a subprocess (per testing policy, through the same app/boot path a
+ * deployment uses), run one mocked-model turn with a real bash round trip,
+ * and assert against what the mock OTLP collector actually received on the
+ * wire: ledger mirroring, default redaction, ops markers, and the untouched
+ * canonical log.
+ */
+
+import { readFile, readdir } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
+import { REDACTION_PLACEHOLDER } from '@deepseek-ai/dsh-session-telemetry'
+
+const driver = fileURLToPath(new URL(
+ '../../../../examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts',
+ import.meta.url,
+))
+const configPath = fileURLToPath(new URL(
+ '../../../../examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml',
+ import.meta.url,
+))
+const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
+
+const FIXTURE_SECRET = 'sk-e2efixture1234567890'
+
+interface OtlpLogRecord {
+ attributes?: { key: string; value: Record }[]
+ body?: unknown
+}
+
+interface OtlpCapture {
+ resourceLogs: {
+ scopeLogs: {
+ scope: { name: string }
+ logRecords: OtlpLogRecord[]
+ }[]
+ }[]
+}
+
+async function jsonlFiles(dir: string): Promise {
+ const entries = await readdir(dir, { withFileTypes: true })
+ const paths = await Promise.all(entries.map(async (entry) => {
+ const path = join(dir, entry.name)
+ if (entry.isDirectory()) return jsonlFiles(path)
+ return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
+ }))
+ return paths.flat()
+}
+
+describe('session-telemetry-otel through a real headless cordis.yml', () => {
+ it('exports redacted ledger records to the collector while the canonical log keeps the secret', async () => {
+ let captures: OtlpCapture[] = []
+ let logContent = ''
+ const { stderr } = await runLoaderSmoke({
+ label: 'session-telemetry-otel loader smoke',
+ tempDirPrefix: 'telemetry-otel-e2e-',
+ binScript: driver,
+ libBinScript: driver,
+ configPath,
+ tsconfigPath: repoTsconfig,
+ inspect: async (cwd) => {
+ captures = JSON.parse(await readFile(join(cwd, 'otlp-captures.json'), 'utf8')) as OtlpCapture[]
+ const logs = await jsonlFiles(join(cwd, '.sessions'))
+ expect(logs).toHaveLength(1)
+ logContent = await readFile(logs[0] as string, 'utf8')
+ },
+ })
+ expect(stderr).not.toContain('UNHANDLED')
+
+ const records = captures.flatMap(capture => capture.resourceLogs.flatMap(resource =>
+ resource.scopeLogs.flatMap(scoped => scoped.logRecords.map(record => ({ scope: scoped.scope.name, record })))))
+ expect(records.length).toBeGreaterThan(0)
+
+ const eventTypes = records.flatMap(({ record }) =>
+ record.attributes?.flatMap(attribute =>
+ attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string'
+ ? [attribute.value['stringValue']]
+ : []) ?? [])
+ for (const expected of ['turn/start', 'user/message', 'tool/call', 'tool/result', 'assistant/message', 'turn/end']) {
+ expect(eventTypes, expected).toContain(expected)
+ }
+ expect(records.some(({ scope }) => scope.endsWith('/ops'))).toBe(true)
+
+ // Default redaction on the wire: the fixture credential never leaves the
+ // process, its surrounding prose does, and the placeholder marks the spot.
+ const wire = JSON.stringify(captures)
+ expect(wire).not.toContain(FIXTURE_SECRET)
+ expect(wire).toContain(REDACTION_PLACEHOLDER)
+ expect(wire).toContain('prove telemetry with key')
+
+ // The canonical session log is never rewritten.
+ expect(logContent).toContain(FIXTURE_SECRET)
+ expect(logContent).not.toContain(REDACTION_PLACEHOLDER)
+ }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+})
diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts
new file mode 100644
index 0000000000..91c92b3486
--- /dev/null
+++ b/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts
@@ -0,0 +1,25 @@
+/**
+ * Keyless-self-skipping smoke: ship one real session's records to a live
+ * OTLP collector named by $DSH_OTLP_E2E_ENDPOINT and require the SDK's
+ * shutdown (flush-and-quiesce) to resolve. Skipped without the endpoint so
+ * secretless CI stays green — a CI accommodation, not a cost signal.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import TelemetryOtel from '../src/index.ts'
+
+describe.skipIf(!process.env.DSH_OTLP_E2E_ENDPOINT)('telemetry-otel e2e (live collector)', () => {
+ it('exports a session and quiesces cleanly', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ const fiber = await ctx.plugin(TelemetryOtel, {
+ exporter: { url: process.env.DSH_OTLP_E2E_ENDPOINT! },
+ })
+ const session = ctx.sessions.create(SessionId(`e2e-${Date.now()}`), { meta: {} })
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ await expect(fiber.dispose()).resolves.not.toThrow()
+ })
+})
diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts
new file mode 100644
index 0000000000..88a09a0f3b
--- /dev/null
+++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts
@@ -0,0 +1,166 @@
+/**
+ * OTel backend unit tier: wire assertions against a scripted `node:http`
+ * mock collector through the SDK's REAL pipeline (BatchLogRecordProcessor →
+ * OTLP/HTTP JSON), config fail-loud cases, and the real-Loader-path guard
+ * for the default-exported Service class.
+ */
+
+import { afterEach, describe, expect, it } from 'vitest'
+import { createServer, type Server } from 'node:http'
+import { once } from 'node:events'
+import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import TelemetryOtel, { Config } from '../src/index.ts'
+
+interface Capture {
+ headers: import('node:http').IncomingHttpHeaders
+ body: OtlpLogsRequest
+}
+
+/** Just the slice of ExportLogsServiceRequest JSON these assertions touch. */
+interface OtlpLogsRequest {
+ resourceLogs: {
+ resource: { attributes: { key: string; value: { stringValue?: string } }[] }
+ scopeLogs: {
+ scope: { name: string }
+ logRecords: {
+ timeUnixNano: string
+ severityNumber: number
+ severityText: string
+ attributes?: { key: string; value: Record }[]
+ }[]
+ }[]
+ }[]
+}
+
+const servers: Server[] = []
+
+afterEach(async () => {
+ for (const server of servers.splice(0)) {
+ server.close()
+ server.closeAllConnections()
+ }
+})
+
+async function mockCollector(): Promise<{ url: string; captures: Capture[] }> {
+ const captures: Capture[] = []
+ const server = createServer((request, response) => {
+ const chunks: Buffer[] = []
+ request.on('data', chunk => chunks.push(chunk as Buffer))
+ request.on('end', () => {
+ captures.push({
+ headers: request.headers,
+ body: JSON.parse(Buffer.concat(chunks).toString()) as OtlpLogsRequest,
+ })
+ response.writeHead(200, { 'content-type': 'application/json' }).end('{}')
+ })
+ })
+ servers.push(server)
+ server.listen(0, '127.0.0.1')
+ await once(server, 'listening')
+ const address = server.address()
+ if (address === null || typeof address === 'string') throw new Error('no port')
+ return { url: `http://127.0.0.1:${address.port}/v1/logs`, captures }
+}
+
+async function boot(url: string) {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ const fiber = await ctx.plugin(TelemetryOtel, {
+ exporter: { url, headers: { authorization: 'Bearer test-token' } },
+ })
+ return { ctx, fiber }
+}
+
+function allRecords(captures: Capture[]) {
+ return captures.flatMap(c => c.body.resourceLogs.flatMap(r => r.scopeLogs.flatMap(s =>
+ s.logRecords.map(record => ({ scope: s.scope.name, record })))))
+}
+
+describe('TelemetryOtel wire', () => {
+ it('ships session records and the ops shutdown marker through the real SDK pipeline', async () => {
+ const { url, captures } = await mockCollector()
+ const { ctx, fiber } = await boot(url)
+ const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } })
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
+ await fiber.dispose()
+
+ expect(captures.length).toBeGreaterThan(0)
+ const first = captures[0]!
+ const authorization: string | undefined = first.headers.authorization
+ expect(authorization).toBe('Bearer test-token')
+
+ const resource = first.body.resourceLogs[0]!.resource.attributes
+ expect(resource).toContainEqual({ key: 'service.name', value: { stringValue: 'deepseek-harness' } })
+
+ const records = allRecords(captures)
+ const ledger = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel')
+ const ops = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops')
+
+ const start = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start'))
+ expect(start).toBeDefined()
+ expect(start?.record.severityNumber).toBe(9)
+ expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.events[0]!.time) * 1_000_000n)
+ expect(start?.record.attributes).toContainEqual({ key: 'session.cwd', value: { stringValue: '/tmp/w' } })
+
+ const end = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/end'))
+ expect(end?.record.severityNumber).toBe(17)
+ expect(end?.record.severityText).toBe('ERROR')
+
+ expect(ops).toHaveLength(1)
+ expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } })
+ })
+
+ it('maps the warn severity and forwards the flush hint to the SDK', async () => {
+ const { url, captures } = await mockCollector()
+ const { ctx, fiber } = await boot(url)
+ const session = ctx.sessions.create(SessionId('warn'), { meta: {} })
+ session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' })
+ // The turn-boundary hint: safe, non-blocking, and enough to push the batch out.
+ expect(() => {
+ ctx.telemetry.flush!()
+ }).not.toThrow()
+ await fiber.dispose()
+ const blocked = allRecords(captures).find(r =>
+ r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'prompt/blocked'))
+ expect(blocked?.record.severityNumber).toBe(13)
+ })
+})
+
+describe('TelemetryOtel config fails loud', () => {
+ it.each([
+ [{}, /exporter\.url is required/],
+ [{ exporter: { url: '' } }, /exporter\.url is required/],
+ [{ exporter: { url: 'not a url' } }, /not a valid URL/],
+ [{ exporter: { url: 'ftp://collector' } }, /must be http\(s\)/],
+ ])('rejects %j at plugin load', async (config, message) => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await expect(ctx.plugin(TelemetryOtel, config as Config)).rejects.toThrow(message)
+ })
+})
+
+describe('dsh-session-telemetry-otel real-load-path guard', () => {
+ it('keeps the Service class with inject/Config through unwrapExports', async () => {
+ const module = await import('../src/index.ts')
+ const loader = Object.create(Loader.prototype) as Loader
+ const unwrapped = loader.unwrapExports(module) as typeof TelemetryOtel
+ expect(unwrapped).toBe(TelemetryOtel)
+ expect(unwrapped.inject).toEqual(['sessions'])
+ expect(typeof unwrapped.Config).toBe('function')
+ })
+
+ it('boots through the unwrapped class and registers ctx.telemetry', async () => {
+ const { url } = await mockCollector()
+ const module = await import('../src/index.ts')
+ const loader = Object.create(Loader.prototype) as Loader
+ const unwrapped = loader.unwrapExports(module) as Parameters[0]
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ const fiber = await ctx.plugin(unwrapped, { exporter: { url } })
+ expect(ctx.telemetry).toBeInstanceOf(TelemetryOtel)
+ await fiber.dispose()
+ })
+})
diff --git a/packages/telemetry/session-telemetry-otel/tsconfig.json b/packages/telemetry/session-telemetry-otel/tsconfig.json
new file mode 100644
index 0000000000..9512133cf7
--- /dev/null
+++ b/packages/telemetry/session-telemetry-otel/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../../vendor/schemastery"
+ },
+ {
+ "path": "../../core/session"
+ },
+ {
+ "path": "../../llm/llm"
+ },
+ {
+ "path": "../session-telemetry"
+ },
+ {
+ "path": "../../support/invariants"
+ }
+ ]
+}
diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md
new file mode 100644
index 0000000000..bccc61fd06
--- /dev/null
+++ b/packages/telemetry/session-telemetry/README.md
@@ -0,0 +1,40 @@
+# @deepseek-ai/dsh-session-telemetry
+
+The telemetry seam: the CAPTURE side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md).
+
+## The backend contract
+
+`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path), optional `flush()` (a turn-boundary hint, fire-and-forget), and `shutdown()` (the lifecycle forward: flush-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` in its constructor.
+
+## Capture points
+
+The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection — seed events from fork/resume never re-emit on the firehose), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (per adopted session emit its `shutdown` operational record, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`).
+
+## The redact waterfall
+
+Every record passes the `telemetry/redact` waterfall between projection and `emit()` — nothing reaches a backend unredacted. The innermost `next()` applies the built-in conservative rule set (`applyDefaultRedaction`: credential shapes — API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo — replaced with `[REDACTED]` in body strings and string attribute values). Listeners stack stricter rules by transforming `next()`'s return value; returning without `next()` replaces the default rule set, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. The built-in pattern list is a security invariant, deliberately not configurable from cordis.yml. Redaction applies to the exported copy only; the canonical session log is never rewritten.
+
+## The handoff cursor
+
+A module-scope `WeakMap` marks the highest seq HANDED OFF (not delivered) per session, advanced at emit time. It survives reloads that do not re-evaluate this module — config re-applies and backend source reloads, which is where iteration happens; that asymmetry is why the cursor lives in the seam. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a full re-hand, absorbed by receiver-side dedupe on `(session.id, event.seq)`. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error.
+
+## The fixed chunk projection
+
+Only the first `assistant/chunk` of each `(turn, step)` ships; the rest are dropped at capture and never advance the cursor. That one chunk is the stream-started signal: `step/start` + first-chunk presence + `assistant/message` presence + the `turn/end` reason distinguish "the request never started" from "the stream died midway" without chunk volume, and time-to-first-token stays computable. Chunk elision makes `seq` gaps routine on the wire — a gap is never a loss signal. Every other event type, including ones merged by plugins this package never heard of, passes through whole.
+
+## The logical record
+
+`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError`, `turn/end` error reasons, `compact/end` errors; WARN for `prompt/blocked`; INFO otherwise), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`.
+
+## Model Experience
+
+None, as the seam only observes the session stream and hands redacted copies to a reporting backend; it never contributes to a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **Best-effort delivery** — the cursor marks handed-off, not delivered; a session torn down inside a reload window cannot be re-adopted; whatever sits in a backend queue at crash time is lost. A durable outbox (spool, per-sink cursors, at-least-once) is deferred until a deployment states a crash-loss requirement — see [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md).
+- **Redaction is shape-based** — the default rules catch known credential shapes, not every secret; a deployment with stricter needs stacks `telemetry/redact` listeners, and exported data is only as clean as the mounted rules.
diff --git a/packages/telemetry/session-telemetry/package.json b/packages/telemetry/session-telemetry/package.json
new file mode 100644
index 0000000000..71646c130e
--- /dev/null
+++ b/packages/telemetry/session-telemetry/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@deepseek-ai/dsh-session-telemetry",
+ "description": "Telemetry seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/invariant.js",
+ "lib/types/**/*.d.ts",
+ "lib/types/**/*.d.ts.map",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "peerDependencies": {
+ "@deepseek-ai/dsh-agent": "^0.0.1",
+ "@deepseek-ai/dsh-invariants": "^0.0.1",
+ "@deepseek-ai/dsh-session": "^0.0.1",
+ "cordis": "^4.0.0-rc.7"
+ },
+ "devDependencies": {
+ "@deepseek-ai/dsh-agent": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "cordis": "^4.0.0-rc.7"
+ }
+}
diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts
new file mode 100644
index 0000000000..08fbe498b1
--- /dev/null
+++ b/packages/telemetry/session-telemetry/src/coordinator.ts
@@ -0,0 +1,244 @@
+/**
+ * Capture coordinator: the seam's upstream half. Subscribes to the session
+ * firehose plus the one live-bus relay (`agent/error`), applies the fixed
+ * chunk projection, builds logical records, runs each through the
+ * `telemetry/redact` waterfall, and hands the redacted copy to the backend —
+ * synchronously, with every handler self-contained so a failing backend can
+ * never starve other subscribers (cordis `emit` is stop-on-throw) or touch
+ * the agent loop. Composed by a backend in its constructor.
+ *
+ * @module @deepseek-ai/dsh-session-telemetry/coordinator
+ */
+
+import type { Context } from 'cordis'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts'
+import { applyDefaultRedaction } from './redact.ts'
+
+/**
+ * The handoff cursor: per session, the highest `seq` handed to a backend.
+ * Deliberately MODULE-scope ambient state — a narrow, documented exception
+ * to the registrations-are-effects discipline: cordis has no HMR
+ * state-handover API, and keying by the `Session` object (which belongs to
+ * the session store and outlives any telemetry fiber) is the only in-process
+ * lifetime that lets a re-adopting fiber resume instead of re-handing
+ * history. Entries die with their sessions; a missing entry safely means
+ * "re-hand everything". Advanced only at emit time — the cursor marks
+ * handed-off, not delivered.
+ */
+const handoffCursor = new WeakMap()
+
+/**
+ * Install the telemetry capture side onto a context for one backend.
+ *
+ * Registers the persistence-coordinator listener set plus the `agent/error`
+ * relay, all through `ctx.effect()`/`ctx.on()` on the composing fiber, and
+ * sweeps already-live sessions (a hot reload does not replay
+ * `session/created`). Disposal emits each adopted session's `shutdown`
+ * operational record and then awaits the backend's `shutdown()`; a failure
+ * there warns instead of throwing — best-effort reporting must not fail
+ * application teardown.
+ */
+export class TelemetryCoordinator {
+ /** Sessions adopted by THIS fiber, for dispose-time `shutdown` records and double-adoption protection. */
+ private readonly adopted = new Set()
+ /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */
+ private readonly chunkSeen = new WeakMap>()
+
+ /**
+ * @param ctx - the composing backend's context; listeners bind to its fiber.
+ * @param backend - the backend receiving records; owned elsewhere, never disposed here beyond `shutdown()` forwarding.
+ */
+ constructor(
+ private readonly ctx: Context,
+ private readonly backend: TelemetryBackend,
+ ) {
+ ctx.on('session/created', (session) => {
+ this.adopt(session)
+ })
+ ctx.on('session/event', (session, event) => {
+ this.contain(() => {
+ this.capture(session, event)
+ })
+ })
+ // Parallel listeners are awaited by the loop at turn end; returning void
+ // (not the SDK's flush promise) is the turn-latency contract.
+ ctx.on('session/flush', (session) => {
+ this.contain(() => {
+ this.hintFlush(session)
+ })
+ })
+ ctx.on('agent/error', (agent, turn, step, error) => {
+ this.contain(() => {
+ this.relayAgentError(agent, turn, step, error)
+ })
+ })
+ ctx.effect(() => async () => {
+ for (const session of this.adopted) {
+ this.contain(() => {
+ this.handOff(shutdownRecord(session))
+ })
+ }
+ try {
+ await this.backend.shutdown()
+ } catch (error) {
+ this.ctx.logger.warn(`telemetry: backend shutdown failed: ${String(error)}`)
+ }
+ }, 'telemetry capture')
+ for (const session of ctx.sessions.list()) {
+ this.adopt(session)
+ }
+ }
+
+ /**
+ * Adopt a session: replay its log THROUGH the projection from the handoff
+ * cursor (or from the start when no cursor survived), then rely on the
+ * firehose for everything after. Events at or below the cursor still feed
+ * the projection state (first-chunk tracking) without being re-handed, so
+ * a resumed fiber drops mid-step chunk continuations exactly like the
+ * fiber that saw the step begin.
+ * @param session - the live session to adopt; a second adoption is a no-op.
+ */
+ private adopt(session: Session): void {
+ this.contain(() => {
+ if (this.adopted.has(session)) return
+ this.adopted.add(session)
+ const cursor = handoffCursor.get(session) ?? -1
+ for (const event of session.events) {
+ if (event.seq <= cursor) this.track(session, event)
+ else this.capture(session, event)
+ }
+ })
+ }
+
+ /** Feed the chunk projection without handing off — the ≤cursor half of re-adoption. */
+ private track(session: Session, event: SessionEvent): void {
+ if (event.type === 'assistant/chunk') {
+ this.seen(session).add(`${event.data.turn}:${event.data.step}`)
+ }
+ }
+
+ /** Project one event and hand it to the backend, advancing the cursor on handoff. */
+ private capture(session: Session, event: SessionEvent): void {
+ if (event.type === 'assistant/chunk') {
+ const key = `${event.data.turn}:${event.data.step}`
+ const seen = this.seen(session)
+ // Fixed chunk projection: only the first chunk of each (turn, step)
+ // ships — the stream-started signal; content is byte-complete in the
+ // step's assembled assistant/message. Dropped chunks do not advance
+ // the cursor, so re-adoption re-drops them deterministically.
+ if (seen.has(key)) return
+ seen.add(key)
+ }
+ this.handOff({
+ channel: 'ledger',
+ time: event.time,
+ severity: severityOf(event),
+ attributes: identityOf(session, event),
+ // The live event object is mutable and the backend serializes later;
+ // append-time validation guarantees this clone cannot throw.
+ body: structuredClone(event.data),
+ })
+ handoffCursor.set(session, event.seq)
+ }
+
+ /**
+ * Run the `telemetry/redact` waterfall over one record and hand the result
+ * to the backend. The innermost `next` applies the seam's conservative
+ * default rules, so an unconfigured deployment still never exports raw
+ * credential shapes; callers run inside {@link contain}, so a throwing
+ * rule withholds the record instead of reaching the loop (fail-closed).
+ */
+ private handOff(record: TelemetryRecord): void {
+ this.backend.emit(this.ctx.waterfall('telemetry/redact', record, () => applyDefaultRedaction(record)))
+ }
+
+ /** Forward the turn-end boundary to the backend's optional flush hint. */
+ private hintFlush(session: Session): void {
+ if (this.adopted.has(session)) this.backend.flush?.()
+ }
+
+ /** Relay one `agent/error` bus emission as an `agent-error` operational record. */
+ private relayAgentError(agent: Agent, turn: number, step: number, error: Error): void {
+ this.handOff({
+ channel: 'ops',
+ time: Date.now(),
+ severity: 'error',
+ attributes: {
+ 'telemetry.op': 'agent-error',
+ 'session.id': String(agent.session.id),
+ 'agent.id': agent.id,
+ 'error.name': error.name,
+ turn,
+ step,
+ },
+ body: { name: error.name, message: error.message },
+ })
+ }
+
+ /** Lazily create the per-session first-chunk tracking set. */
+ private seen(session: Session): Set {
+ let set = this.chunkSeen.get(session)
+ if (!set) this.chunkSeen.set(session, set = new Set())
+ return set
+ }
+
+ /**
+ * Run one capture-side step with its exception contained: cordis `emit`
+ * is stop-on-throw, so a throwing listener would starve every subscriber
+ * registered after this plugin — nothing from the backend may escape.
+ */
+ private contain(step: () => void): void {
+ try {
+ step()
+ } catch (error) {
+ this.ctx.logger.warn(`telemetry: capture step failed: ${String(error)}`)
+ }
+ }
+}
+
+/** Build the per-session clean-exit marker emitted at dispose, before the backend's `shutdown()`. */
+function shutdownRecord(session: Session): TelemetryRecord {
+ return {
+ channel: 'ops',
+ time: Date.now(),
+ severity: 'info',
+ attributes: { 'telemetry.op': 'shutdown', 'session.id': String(session.id) },
+ body: { op: 'shutdown' },
+ }
+}
+
+/** Map an event's own outcome flag to the pre-baked alerting severity. */
+function severityOf(event: SessionEvent): TelemetrySeverity {
+ switch (event.type) {
+ case 'tool/result':
+ return event.data.isError ? 'error' : 'info'
+ case 'turn/end':
+ return event.data.reason.kind === 'error' ? 'error' : 'info'
+ case 'prompt/blocked':
+ return 'warn'
+ default: {
+ // Merge-extensible fall-through (no assertNever): types this seam does
+ // not depend on still get their RFC-pinned severity via a widened
+ // probe — `compact/end` is declared by dsh-compact, which the seam
+ // deliberately does not import.
+ const type: string = event.type
+ if (type === 'compact/end' && (event.data as { error?: unknown }).error !== undefined) return 'error'
+ return 'info'
+ }
+ }
+}
+
+/** Build the minimal identity attributes: envelope plus self-contained header facts. */
+function identityOf(session: Session, event: SessionEvent): Record {
+ const attributes: Record = {
+ 'session.id': String(session.id),
+ 'event.type': event.type,
+ 'event.seq': event.seq,
+ }
+ const { cwd, parentSession } = session.header
+ if (cwd !== undefined) attributes['session.cwd'] = cwd
+ if (parentSession !== undefined) attributes['session.parent_id'] = String(parentSession)
+ return attributes
+}
diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts
new file mode 100644
index 0000000000..93e7e7b0f9
--- /dev/null
+++ b/packages/telemetry/session-telemetry/src/index.ts
@@ -0,0 +1,145 @@
+/**
+ * Telemetry seam for the DeepSeek Harness.
+ *
+ * The seam owns the CAPTURE side of session-event reporting — which records
+ * exist (the chunk projection), what they carry (the logical record), when
+ * they are handed over (adoption, the per-append firehose, lifecycle
+ * forwarding), and the HMR handoff cursor. Everything downstream of
+ * {@link Telemetry.emit} — batching, retry, queueing, loss policy — is the
+ * reporting SDK's territory and is deliberately not modelled here. The
+ * design and its trade-offs are pinned in
+ * .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md.
+ *
+ * @module @deepseek-ai/dsh-session-telemetry
+ */
+
+import { Context, Service } from 'cordis'
+
+declare module 'cordis' {
+ interface Context {
+ telemetry: Telemetry
+ }
+
+ interface Events {
+ /**
+ * Redact one outbound record before it reaches the backend. The innermost
+ * `next()` applies the seam's conservative default rule set
+ * (credential-shape scrubbing); listeners stack stricter rules by
+ * transforming its return value, and returning without `next()` replaces
+ * the default — the exported record is then only as clean as the
+ * replacing rule. Dispatched synchronously on the capture hot path inside
+ * the coordinator's containment: a throwing listener withholds that one
+ * record (fail-closed) and never reaches the agent loop. Redaction
+ * applies to the exported copy only; the canonical session log is never
+ * rewritten.
+ * @param record - the candidate record, already the coordinator's own deep
+ * copy; listeners return a (possibly new) record and must not mutate it.
+ * @mode waterfall
+ */
+ 'telemetry/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord
+ }
+}
+
+/**
+ * Severity of a telemetry record, pre-mapped at capture so a receiver can
+ * alert with zero configuration: `error` for events whose own outcome flag
+ * says so (`tool/result.isError`, `turn/end` error reasons, `compact/end`
+ * errors) and for `agent-error` operational records, `warn` for
+ * `prompt/blocked`, `info` for everything else.
+ */
+export type TelemetrySeverity = 'info' | 'warn' | 'error'
+
+/**
+ * One logical record handed to a backend — the seam's whole outbound
+ * vocabulary. Ledger records mirror session-log events one-to-one;
+ * operational records (`channel: 'ops'`) carry the two signals with no log
+ * home (`agent-error`, `shutdown`) and deliberately omit `event.seq`-style
+ * identity so they can never be mistaken for ledger rows.
+ */
+export interface TelemetryRecord {
+ /** Ledger (session-log mirror) or ops (operational signal) channel; backends keep the two under separate instrumentation scopes. */
+ channel: 'ledger' | 'ops'
+ /** Unix epoch milliseconds — the source event's append time for ledger records, the emission time for ops records. */
+ time: number
+ /** Pre-mapped alerting severity; see {@link TelemetrySeverity}. */
+ severity: TelemetrySeverity
+ /**
+ * Identity attributes, deliberately minimal: ledger records carry
+ * `session.id`, `event.type`, `event.seq`, plus `session.cwd` /
+ * `session.parent_id` when the header has them; ops records carry
+ * `telemetry.op`, `session.id`, and (for `agent-error`) `agent.id`,
+ * `turn`, `step`, `error.name`. Anything recoverable from the body is
+ * intentionally NOT duplicated here.
+ */
+ attributes: Record
+ /**
+ * The complete payload: a deep copy of the session event's `data` for
+ * ledger records (JSON-serializable by `Session.append`'s own
+ * validation), or the op payload for ops records. Never mutated after
+ * handoff.
+ */
+ body: unknown
+}
+
+/**
+ * The backend contract the coordinator hands records to — the minimum any
+ * reporting SDK satisfies with zero bending. {@link Telemetry} is its
+ * service-registered form; tests compose the coordinator with a bare
+ * implementation of this interface.
+ */
+export interface TelemetryBackend {
+ /**
+ * Hand one record to the backend's pipeline. MUST be a non-blocking
+ * enqueue — the coordinator calls this synchronously from the
+ * `session/event` hot path, so anything slower than a queue push would tax
+ * the agent loop. Errors thrown here are contained by the coordinator and
+ * logged; they never reach the loop.
+ * @param record - the logical record to report; owned by the backend after the call.
+ */
+ emit(record: TelemetryRecord): void
+ /**
+ * Optional hint that a natural boundary (turn end) passed — a backend may
+ * forward it to its SDK's flush so records land at turn boundaries. Called
+ * fire-and-forget; implementations must not block and must not throw
+ * meaningfully (the coordinator contains exceptions).
+ */
+ flush?(): void
+ /**
+ * Forward the fiber's disposal to the SDK: flush whatever is queued and
+ * reach quiescence, per the SDK's own shutdown contract. Awaited by the
+ * coordinator's dispose; a rejection is logged as a warning and never
+ * fails application teardown.
+ * @returns resolves when the backend's pipeline has quiesced.
+ */
+ shutdown(): Promise
+}
+
+/**
+ * The backend contract in its loadable form: one implementation per context —
+ * the cordis `Service` registration under the `telemetry` key throws on a
+ * duplicate, cordis' standard behavior. A backend composes a
+ * {@link TelemetryCoordinator} in its constructor to install the capture side.
+ */
+export abstract class Telemetry extends Service implements TelemetryBackend {
+ constructor(ctx: Context) {
+ super(ctx, 'telemetry')
+ }
+
+ /**
+ * See {@link TelemetryBackend.emit} — the seam declaration is the contract's one home.
+ * @param record - the logical record to report; owned by the backend after the call.
+ */
+ abstract emit(record: TelemetryRecord): void
+
+ /** See {@link TelemetryBackend.flush}. */
+ flush?(): void
+
+ /**
+ * See {@link TelemetryBackend.shutdown}.
+ * @returns resolves when the backend's pipeline has quiesced.
+ */
+ abstract shutdown(): Promise
+}
+
+export { TelemetryCoordinator } from './coordinator.ts'
+export { applyDefaultRedaction, REDACTION_PLACEHOLDER } from './redact.ts'
diff --git a/packages/telemetry/session-telemetry/src/invariant.ts b/packages/telemetry/session-telemetry/src/invariant.ts
new file mode 100644
index 0000000000..ffc55b0107
--- /dev/null
+++ b/packages/telemetry/session-telemetry/src/invariant.ts
@@ -0,0 +1,32 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-session-telemetry`.
+ * @module @deepseek-ai/dsh-session-telemetry/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-session-telemetry'
+
+/** Cordis companion plugin name. */
+export const name = 'session-telemetry-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the seam's whole output is the backend handoff — a
+ * synchronous `emit()` call outside every authoritative event stream — and its
+ * capture side never appends session events, so no event/data relation exists
+ * for an independent companion to observe.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */
diff --git a/packages/telemetry/session-telemetry/src/redact.ts b/packages/telemetry/session-telemetry/src/redact.ts
new file mode 100644
index 0000000000..d25452581a
--- /dev/null
+++ b/packages/telemetry/session-telemetry/src/redact.ts
@@ -0,0 +1,77 @@
+/**
+ * Conservative default redaction for outbound telemetry records.
+ *
+ * Session-event bodies carry file contents and command output that may embed
+ * credentials; nothing may cross the seam to a backend unredacted. This module
+ * is the innermost rule set of the `telemetry/redact` waterfall — always
+ * applied unless an outer listener deliberately replaces the whole chain. It
+ * scrubs credential-SHAPED substrings from every string in the record body,
+ * leaving structure (keys, nesting, surrounding prose) intact. The pattern
+ * list is a security invariant, deliberately not configurable; deployments
+ * add stricter rules by stacking `telemetry/redact` listeners.
+ *
+ * @module @deepseek-ai/dsh-session-telemetry/redact
+ */
+
+import type { TelemetryRecord } from './index.ts'
+
+/** Replacement text substituted for each detected credential-shaped span. */
+export const REDACTION_PLACEHOLDER = '[REDACTED]'
+
+/**
+ * Well-known credential shapes. A match anywhere inside a body string is
+ * replaced; low-signal values (package names, versions, git SHAs, plain URLs)
+ * deliberately stay untouched — they are the observability signal.
+ */
+const SECRET_PATTERNS: readonly RegExp[] = [
+ /sk-(?:ant-)?[A-Za-z0-9_-]{10,}/g, // DeepSeek / OpenAI / Anthropic API keys
+ /gh[pousr]_[A-Za-z0-9]{16,}/g, // GitHub personal/oauth/server/refresh tokens
+ /github_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT
+ /xox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens
+ /AKIA[0-9A-Z]{16}/g, // AWS access key id
+ /AIza[0-9A-Za-z_-]{35}/g, // Google API key
+ /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, // JWT
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, // PEM blocks
+ /\b(?[a-z][a-z0-9+.-]*):\/\/[^/\s:@]+:[^/\s:@]+@/g, // URL userinfo credentials
+]
+
+/** Replace every known credential shape inside one string. */
+function scrub(text: string): string {
+ let out = text
+ for (const pattern of SECRET_PATTERNS) {
+ out = out.replace(pattern, REDACTION_PLACEHOLDER)
+ }
+ return out
+}
+
+/**
+ * Deep-scrub every string inside a lossless-JSON value, preserving structure.
+ * The record body is the coordinator's own `structuredClone` — mutation-free
+ * rebuilding keeps the exported copy independent of the canonical log either way.
+ */
+function scrubValue(value: unknown): unknown {
+ if (typeof value === 'string') return scrub(value)
+ if (Array.isArray(value)) return value.map(scrubValue)
+ if (value !== null && typeof value === 'object') {
+ const out: Record = {}
+ for (const [key, entry] of Object.entries(value)) out[key] = scrubValue(entry)
+ return out
+ }
+ return value
+}
+
+/**
+ * Apply the conservative default rule set to one record — the innermost
+ * `next` of the `telemetry/redact` waterfall. Attribute VALUES are scrubbed
+ * alongside the body (identity attributes are seam-built and boring, but
+ * `session.cwd` is caller-supplied); attribute keys are seam-owned constants.
+ * @param record - the candidate record; not mutated.
+ * @returns a redacted copy safe to hand to a backend.
+ */
+export function applyDefaultRedaction(record: TelemetryRecord): TelemetryRecord {
+ const attributes: Record = {}
+ for (const [key, value] of Object.entries(record.attributes)) {
+ attributes[key] = typeof value === 'string' ? scrub(value) : value
+ }
+ return { ...record, attributes, body: scrubValue(record.body) }
+}
diff --git a/packages/telemetry/session-telemetry/tests/redact.spec.ts b/packages/telemetry/session-telemetry/tests/redact.spec.ts
new file mode 100644
index 0000000000..cd23af6da2
--- /dev/null
+++ b/packages/telemetry/session-telemetry/tests/redact.spec.ts
@@ -0,0 +1,157 @@
+/**
+ * Default redaction rules and the `telemetry/redact` waterfall contract:
+ * credential shapes scrubbed from bodies and attribute values, structure
+ * preserved, canonical log untouched, listener stacking/replacement, and the
+ * fail-closed containment of a throwing rule.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import {
+ applyDefaultRedaction,
+ REDACTION_PLACEHOLDER,
+ TelemetryCoordinator,
+ type TelemetryBackend,
+ type TelemetryRecord,
+} from '../src/index.ts'
+
+const SECRETS = {
+ deepseek: 'sk-abcdef1234567890abcdef',
+ anthropic: 'sk-ant-abcdef1234567890',
+ githubPat: 'ghp_ABCDEFGHIJKLMNOPqrstuv12345678',
+ finePat: 'github_pat_ABCDEFGHIJKLMNOPQRSTuvwx',
+ slack: 'xoxb-1234567890-abcdefghij',
+ aws: 'AKIAIOSFODNN7EXAMPLE',
+ google: 'AIzaSyA-1234567890abcdefghijklmnopqrstu',
+ jwt: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpM',
+ pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n-----END RSA PRIVATE KEY-----',
+ urlCreds: 'https://user:hunter2@internal.example.com/repo.git',
+} as const
+
+function record(body: unknown, attributes: Record = {}): TelemetryRecord {
+ return { channel: 'ledger', time: 1, severity: 'info', attributes, body }
+}
+
+describe('applyDefaultRedaction', () => {
+ it('scrubs every known credential shape while preserving surrounding text', () => {
+ for (const secret of Object.values(SECRETS)) {
+ const out = applyDefaultRedaction(record(`before ${secret} after`))
+ expect(out.body, secret).not.toContain(secret.includes('\n') ? 'MIIEow' : secret)
+ expect(out.body).toContain('before ')
+ expect(out.body).toContain(' after')
+ expect(out.body).toContain(REDACTION_PLACEHOLDER)
+ }
+ })
+
+ it('scrubs URL userinfo credentials but leaves plain URLs alone', () => {
+ const out = applyDefaultRedaction(record(`${SECRETS.urlCreds} and https://example.com/path`))
+ expect(out.body).not.toContain('hunter2')
+ expect(out.body).toContain('https://example.com/path')
+ })
+
+ it('recurses through arrays and objects, preserving structure and non-strings', () => {
+ const out = applyDefaultRedaction(record({
+ list: [`key=${SECRETS.deepseek}`, 7, null, true],
+ nested: { text: SECRETS.githubPat, count: 3 },
+ }))
+ expect(out.body).toEqual({
+ list: [`key=${REDACTION_PLACEHOLDER}`, 7, null, true],
+ nested: { text: REDACTION_PLACEHOLDER, count: 3 },
+ })
+ })
+
+ it('leaves low-signal values untouched', () => {
+ const clean = {
+ pkg: '@deepseek-ai/dsh-session-telemetry@0.0.1',
+ sha: '342a4c3a9d3adf13cf4ad33b9f8d6e79170be5e2',
+ prose: 'ordinary sentence with kebab-case-identifier',
+ }
+ expect(applyDefaultRedaction(record(clean)).body).toEqual(clean)
+ })
+
+ it('scrubs string attribute values and keeps numeric ones', () => {
+ const out = applyDefaultRedaction(record(null, {
+ 'session.cwd': `/home/${SECRETS.aws}/proj`,
+ 'event.seq': 4,
+ }))
+ expect(out.attributes['session.cwd']).toBe(`/home/${REDACTION_PLACEHOLDER}/proj`)
+ expect(out.attributes['event.seq']).toBe(4)
+ })
+
+ it('never mutates its input', () => {
+ const input = record({ text: SECRETS.slack }, { 'session.cwd': SECRETS.aws })
+ applyDefaultRedaction(input)
+ expect((input.body as { text: string }).text).toBe(SECRETS.slack)
+ expect(input.attributes['session.cwd']).toBe(SECRETS.aws)
+ })
+})
+
+class CollectingBackend implements TelemetryBackend {
+ records: TelemetryRecord[] = []
+ emit(record: TelemetryRecord): void {
+ this.records.push(record)
+ }
+ async shutdown(): Promise {}
+}
+
+async function setup() {
+ const backend = new CollectingBackend()
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin({
+ name: 'fake-telemetry',
+ inject: ['sessions'],
+ apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
+ })
+ return { ctx, backend }
+}
+
+describe('telemetry/redact waterfall', () => {
+ it('applies the default rules when no listener is registered', async () => {
+ const { ctx, backend } = await setup()
+ const session = ctx.sessions.create(SessionId('w'))
+ session.append('user/message', { content: [{ type: 'text', text: `key ${SECRETS.deepseek}` }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+ const body = backend.records[0]!.body as { content: { text: string }[] }
+ expect(body.content[0]!.text).toBe(`key ${REDACTION_PLACEHOLDER}`)
+ })
+
+ it('keeps the canonical log unredacted', async () => {
+ const { ctx } = await setup()
+ const session = ctx.sessions.create(SessionId('log'))
+ session.append('user/message', { content: [{ type: 'text', text: SECRETS.githubPat }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+ const logged = session.events[0]!.data as { content: { text: string }[] }
+ expect(logged.content[0]!.text).toBe(SECRETS.githubPat)
+ })
+
+ it('lets a listener stack a stricter rule on top of the defaults', async () => {
+ const { ctx, backend } = await setup()
+ ctx.on('telemetry/redact', (_record, next) => {
+ const defaulted = next()
+ return { ...defaulted, body: { shapeOnly: true } }
+ })
+ const session = ctx.sessions.create(SessionId('stack'))
+ session.append('user/message', { content: [{ type: 'text', text: 'anything' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+ expect(backend.records[0]!.body).toEqual({ shapeOnly: true })
+ })
+
+ it('a listener that skips next() replaces the default rules', async () => {
+ const { ctx, backend } = await setup()
+ ctx.on('telemetry/redact', record => record)
+ const session = ctx.sessions.create(SessionId('veto'))
+ session.append('user/message', { content: [{ type: 'text', text: SECRETS.slack }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+ const body = backend.records[0]!.body as { content: { text: string }[] }
+ expect(body.content[0]!.text).toBe(SECRETS.slack)
+ })
+
+ it('a throwing rule withholds the record fail-closed without disturbing the log', async () => {
+ const { ctx, backend } = await setup()
+ ctx.on('telemetry/redact', () => {
+ throw new Error('rule exploded')
+ })
+ const session = ctx.sessions.create(SessionId('closed'))
+ session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+ expect(backend.records).toHaveLength(0)
+ expect(session.events).toHaveLength(1)
+ })
+})
diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts
new file mode 100644
index 0000000000..9f694749ef
--- /dev/null
+++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts
@@ -0,0 +1,313 @@
+/**
+ * Coordinator semantics against a bare fake backend — the RFC's named unit
+ * tier for the seam: adoption (fresh, seeded, re-adoption via the handoff
+ * cursor), the fixed chunk projection, deep-copy isolation, turn-latency and
+ * dispose-ordering pins, failure containment, and the `agent/error` relay.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import SessionStore, { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { TelemetryCoordinator, type TelemetryBackend, type TelemetryRecord } from '../src/index.ts'
+
+declare module '@deepseek-ai/dsh-session' {
+ interface SessionEventMap {
+ /**
+ * Test-only merged event proving unknown types flow through unchanged.
+ * @mode emit
+ * @param payload - opaque test payload
+ */
+ 'telemetry-test/opaque': { payload: { nested: string[] } }
+ /**
+ * Test-only stand-in for dsh-compact's merge, exercising the widened severity probe.
+ * @mode emit
+ * @param error - failure text when the compaction failed
+ */
+ 'compact/end': { turn: number; error?: string }
+ }
+}
+
+class FakeBackend implements TelemetryBackend {
+ records: TelemetryRecord[] = []
+ calls: string[] = []
+ emitError: Error | undefined
+ shutdownError: Error | undefined
+ shutdownResolved = false
+
+ emit(record: TelemetryRecord): void {
+ if (this.emitError) throw this.emitError
+ this.records.push(record)
+ this.calls.push(`emit:${String(record.attributes['event.seq'] ?? record.attributes['telemetry.op'])}`)
+ }
+
+ flush = vi.fn()
+
+ async shutdown(): Promise {
+ this.calls.push('shutdown')
+ await new Promise(resolve => setTimeout(resolve, 5))
+ if (this.shutdownError) throw this.shutdownError
+ this.shutdownResolved = true
+ }
+
+ ledger(): TelemetryRecord[] {
+ return this.records.filter(r => r.channel === 'ledger')
+ }
+}
+
+async function setup(backend: FakeBackend = new FakeBackend()) {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ const fiber = await ctx.plugin({
+ name: 'fake-telemetry',
+ inject: ['sessions'],
+ apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
+ })
+ return { ctx, backend, fiber }
+}
+
+function liveSession(ctx: Context, id = `s-${Math.random().toString(36).slice(2)}`): Session {
+ return ctx.sessions.create(SessionId(id), { meta: {} })
+}
+
+function appendTurn(session: Session): void {
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+}
+
+describe('TelemetryCoordinator capture', () => {
+ it('hands every appended event over with envelope identity and cloned body', async () => {
+ const { ctx, backend } = await setup()
+ const session = liveSession(ctx, 'cap')
+ appendTurn(session)
+
+ const start = backend.ledger()[0]!
+ const message = backend.ledger()[1]!
+ expect(start.attributes).toMatchObject({ 'session.id': 'cap', 'event.type': 'turn/start', 'event.seq': 0 })
+ expect(start.time).toBe(session.events[0]!.time)
+ expect(start.severity).toBe('info')
+ expect(message.attributes['event.seq']).toBe(1)
+ // Deep-copy isolation: mutating the handed-off body never reaches the log.
+ ;(message.body as { content: { text: string }[] }).content[0]!.text = 'tampered'
+ const logged = session.events[1] as SessionEvent<'user/message'>
+ expect(logged.data.content[0]).toMatchObject({ text: 'hello' })
+ })
+
+ it('stamps header facts on every record when present', async () => {
+ const { ctx, backend } = await setup()
+ const parent = SessionId('parent')
+ const session = ctx.sessions.create(SessionId('child'), { meta: { cwd: '/tmp/proj', parentSession: parent } })
+ appendTurn(session)
+ for (const record of backend.ledger()) {
+ expect(record.attributes['session.cwd']).toBe('/tmp/proj')
+ expect(record.attributes['session.parent_id']).toBe('parent')
+ }
+ })
+
+ it('maps outcome flags to severity, including the widened merge-extensible probe', async () => {
+ const { ctx, backend } = await setup()
+ const session = liveSession(ctx)
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('tool/result', { turn: 1, step: 1, callId: 'c1' as never, content: [], isError: true }, { surfaceOp: 'append' })
+ session.append('tool/result', { turn: 1, step: 1, callId: 'c2' as never, content: [], isError: false }, { surfaceOp: 'append' })
+ session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' })
+ session.append('compact/end', { turn: 1, error: 'summarizer died' })
+ session.append('compact/end', { turn: 1 })
+ session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
+ const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity])
+ expect(severities).toEqual([
+ ['turn/start', 'info'],
+ ['tool/result', 'error'],
+ ['tool/result', 'info'],
+ ['prompt/blocked', 'warn'],
+ ['compact/end', 'error'],
+ ['compact/end', 'info'],
+ ['turn/end', 'error'],
+ ])
+ })
+
+ it('passes unknown merged event types through unchanged', async () => {
+ const { ctx, backend } = await setup()
+ const session = liveSession(ctx)
+ session.append('telemetry-test/opaque', { payload: { nested: ['a', 'b'] } })
+ const record = backend.ledger()[0]!
+ expect(record.attributes['event.type']).toBe('telemetry-test/opaque')
+ expect(record.severity).toBe('info')
+ expect(record.body).toEqual({ payload: { nested: ['a', 'b'] } })
+ })
+
+ it('ships only the first chunk of each (turn, step), per session', async () => {
+ const { ctx, backend } = await setup()
+ const a = liveSession(ctx, 'a')
+ const b = liveSession(ctx, 'b')
+ const chunk = (s: Session, turn: number, step: number, text: string) =>
+ s.append('assistant/chunk', { turn, step, chunk: { type: 'text-delta', index: 0, text } })
+ chunk(a, 1, 1, 'a11-first')
+ chunk(a, 1, 1, 'a11-second')
+ chunk(a, 1, 2, 'a12-first')
+ chunk(b, 1, 1, 'b11-first')
+ chunk(b, 1, 1, 'b11-second')
+ const shipped = backend.ledger().map(r => [r.attributes['session.id'], (r.body as { chunk: { text: string } }).chunk.text])
+ expect(shipped).toEqual([
+ ['a', 'a11-first'],
+ ['a', 'a12-first'],
+ ['b', 'b11-first'],
+ ])
+ })
+})
+
+describe('TelemetryCoordinator adoption', () => {
+ it('reads seeded events back at adoption (fork/resume seeds never re-emit)', async () => {
+ const backend = new FakeBackend()
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ const parent = liveSession(ctx, 'seed-parent')
+ appendTurn(parent)
+ ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} })
+ await ctx.plugin({
+ name: 'fake-telemetry',
+ inject: ['sessions'],
+ apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
+ })
+ const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']])
+ expect(seqs).toEqual(expect.arrayContaining([
+ ['seed-parent', 0], ['seed-parent', 1],
+ ['seeded', 0], ['seeded', 1],
+ ]))
+ })
+
+ it('adopts exactly once when created fires after the sweep', async () => {
+ const backend = new FakeBackend()
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ // The enter/announce window: prepare+enter puts the session in the store
+ // (visible to the constructor sweep) before `session/created` fires, so a
+ // coordinator loaded inside that window sees the session twice — sweep
+ // first, created second. The second adoption must be a no-op.
+ const session = ctx.sessions.prepare(SessionId('overlap'))
+ appendTurn(session)
+ ctx.sessions.enter(session)
+ await ctx.plugin({
+ name: 'fake-telemetry',
+ inject: ['sessions'],
+ apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
+ })
+ expect(backend.ledger()).toHaveLength(2)
+ ctx.sessions.announce(session)
+ expect(backend.ledger()).toHaveLength(2)
+ })
+
+ it('resumes from the handoff cursor across a reload, re-dropping mid-step chunks', async () => {
+ const backend = new FakeBackend()
+ const { ctx, fiber } = await setup(backend)
+ const session = liveSession(ctx, 'hmr')
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } })
+ expect(backend.ledger()).toHaveLength(2)
+
+ await fiber.dispose()
+ // The reload window: appends while no telemetry listener is registered.
+ session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'mid-step continuation' } })
+ session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+
+ const second = new FakeBackend()
+ await ctx.plugin({
+ name: 'fake-telemetry-2',
+ inject: ['sessions'],
+ apply: (inner: Context) => void new TelemetryCoordinator(inner, second),
+ })
+ // Only the window events past the cursor are re-handed, and the mid-step
+ // continuation is re-dropped because ≤cursor events rebuilt the projection.
+ expect(second.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end'])
+ })
+
+ it('re-hands the full log when no cursor survived (fresh session object)', async () => {
+ const backend = new FakeBackend()
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ const session = liveSession(ctx, 'fresh')
+ appendTurn(session)
+ await ctx.plugin({
+ name: 'fake-telemetry',
+ inject: ['sessions'],
+ apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
+ })
+ expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 1])
+ })
+})
+
+describe('TelemetryCoordinator lifecycle and containment', () => {
+ it('forwards session/flush as a hint without awaiting backend work', async () => {
+ const { ctx, backend } = await setup()
+ const session = liveSession(ctx)
+ let settled = false
+ backend.flush.mockImplementation(() => {
+ // The backend may kick off arbitrary async work; the loop's parallel must not wait for it.
+ void new Promise(resolve => setTimeout(resolve, 50)).then(() => { settled = true })
+ })
+ await ctx.parallel('session/flush', session)
+ expect(backend.flush).toHaveBeenCalledTimes(1)
+ expect(settled).toBe(false)
+ })
+
+ it('ignores flush hints for sessions it never adopted', async () => {
+ const { ctx, backend } = await setup()
+ const stranger = ctx.sessions.prepare(SessionId('stranger'), { meta: {} })
+ await ctx.parallel('session/flush', stranger)
+ expect(backend.flush).not.toHaveBeenCalled()
+ })
+
+ it('emits each adopted session’s shutdown record before awaiting backend shutdown', async () => {
+ const { ctx, backend, fiber } = await setup()
+ liveSession(ctx, 's1')
+ liveSession(ctx, 's2')
+ await fiber.dispose()
+ expect(backend.calls).toEqual(['emit:shutdown', 'emit:shutdown', 'shutdown'])
+ expect(backend.shutdownResolved).toBe(true)
+ const ops = backend.records.filter(r => r.channel === 'ops')
+ expect(ops.map(r => r.attributes['session.id']).sort()).toEqual(['s1', 's2'])
+ expect(ops.every(r => r.attributes['telemetry.op'] === 'shutdown' && r.severity === 'info')).toBe(true)
+ expect(ops.every(r => !('event.seq' in r.attributes) && !('event.type' in r.attributes))).toBe(true)
+ })
+
+ it('warns instead of throwing when backend shutdown fails', async () => {
+ const backend = new FakeBackend()
+ backend.shutdownError = new Error('exporter unreachable')
+ const { ctx, fiber } = await setup(backend)
+ const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+ liveSession(ctx)
+ await expect(fiber.dispose()).resolves.not.toThrow()
+ expect(warn.mock.calls.some(args => String(args[0]).includes('shutdown failed'))).toBe(true)
+ })
+
+ it('contains emit failures: the append succeeds and capture heals', async () => {
+ const { ctx, backend } = await setup()
+ const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+ const session = liveSession(ctx)
+ backend.emitError = new Error('backend broke')
+ expect(() => session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow()
+ expect(warn).toHaveBeenCalled()
+ backend.emitError = undefined
+ session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ expect(backend.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end'])
+ })
+
+ it('relays agent/error as an ops record with identity and structured name', async () => {
+ const { ctx, backend } = await setup()
+ const session = liveSession(ctx, 'erring')
+ // Only the members the relay reads; the full Agent surface is irrelevant here.
+ const agent = { id: 'agent-1', session } as Agent
+ ctx.emit('agent/error', agent, 3, 2, new TypeError('adapter exploded'))
+ const record = backend.records.find(r => r.channel === 'ops')!
+ expect(record.severity).toBe('error')
+ expect(record.attributes).toMatchObject({
+ 'telemetry.op': 'agent-error',
+ 'session.id': 'erring',
+ 'agent.id': 'agent-1',
+ 'error.name': 'TypeError',
+ turn: 3,
+ step: 2,
+ })
+ expect(record.body).toEqual({ name: 'TypeError', message: 'adapter exploded' })
+ })
+})
diff --git a/packages/telemetry/session-telemetry/tsconfig.json b/packages/telemetry/session-telemetry/tsconfig.json
new file mode 100644
index 0000000000..2c18a582e4
--- /dev/null
+++ b/packages/telemetry/session-telemetry/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../core/session"
+ },
+ {
+ "path": "../../core/agent"
+ },
+ {
+ "path": "../../support/invariants"
+ }
+ ]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index edd75466c5..0e96b9cf4d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -318,6 +318,9 @@ importers:
'@deepseek-ai/dsh-session-query-sqlite':
specifier: workspace:*
version: link:../packages/session-query/session-query-sqlite
+ '@deepseek-ai/dsh-session-telemetry-otel':
+ specifier: workspace:*
+ version: link:../packages/telemetry/session-telemetry-otel
'@deepseek-ai/dsh-session-title-first-message-llm':
specifier: workspace:*
version: link:../packages/session-title/session-title-first-message-llm
@@ -3538,6 +3541,61 @@ importers:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+ packages/telemetry/session-telemetry:
+ devDependencies:
+ '@deepseek-ai/dsh-agent':
+ specifier: workspace:^
+ version: link:../../core/agent
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../support/invariants
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ cordis:
+ specifier: ^4.0.0-rc.7
+ version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
+ packages/telemetry/session-telemetry-otel:
+ dependencies:
+ '@opentelemetry/api':
+ specifier: ^1.9.1
+ version: 1.9.1
+ '@opentelemetry/api-logs':
+ specifier: ^0.220.0
+ version: 0.220.0
+ '@opentelemetry/exporter-logs-otlp-http':
+ specifier: ^0.220.0
+ version: 0.220.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources':
+ specifier: ^2.9.0
+ version: 2.10.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-logs':
+ specifier: ^0.220.0
+ version: 0.220.0(@opentelemetry/api@1.9.1)
+ schemastery:
+ specifier: ^3.18.0
+ version: 3.18.0
+ devDependencies:
+ '@cordisjs/plugin-loader':
+ specifier: workspace:^
+ version: link:../../../vendor/loader
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../support/invariants
+ '@deepseek-ai/dsh-llm':
+ specifier: workspace:^
+ version: link:../../llm/llm
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ '@deepseek-ai/dsh-session-telemetry':
+ specifier: workspace:^
+ version: link:../session-telemetry
+ cordis:
+ specifier: ^4.0.0-rc.7
+ version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
+
packages/timeout/timeout-policy:
devDependencies:
'@deepseek-ai/dsh-invariants':
@@ -5803,10 +5861,78 @@ packages:
'@nodable/entities@2.2.0':
resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==}
+ '@opentelemetry/api-logs@0.220.0':
+ resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==}
+ engines: {node: '>=8.0.0'}
+
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
+ '@opentelemetry/api@1.9.1':
+ resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
+ engines: {node: '>=8.0.0'}
+
+ '@opentelemetry/core@2.10.0':
+ resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.0.0 <1.10.0'
+
+ '@opentelemetry/core@2.9.0':
+ resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.0.0 <1.10.0'
+
+ '@opentelemetry/exporter-logs-otlp-http@0.220.0':
+ resolution: {integrity: sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+
+ '@opentelemetry/otlp-exporter-base@0.220.0':
+ resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+
+ '@opentelemetry/otlp-transformer@0.220.0':
+ resolution: {integrity: sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': ^1.3.0
+
+ '@opentelemetry/resources@2.10.0':
+ resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
+ '@opentelemetry/resources@2.9.0':
+ resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
+ '@opentelemetry/sdk-logs@0.220.0':
+ resolution: {integrity: sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.4.0 <1.10.0'
+
+ '@opentelemetry/sdk-metrics@2.9.0':
+ resolution: {integrity: sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.9.0 <1.10.0'
+
+ '@opentelemetry/sdk-trace@2.9.0':
+ resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==}
+ engines: {node: ^18.19.0 || >=20.6.0}
+ peerDependencies:
+ '@opentelemetry/api': '>=1.3.0 <1.10.0'
+
'@opentelemetry/semantic-conventions@1.43.0':
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
engines: {node: '>=14'}
@@ -10711,8 +10837,82 @@ snapshots:
'@nodable/entities@2.2.0': {}
+ '@opentelemetry/api-logs@0.220.0':
+ dependencies:
+ '@opentelemetry/api': 1.9.0
+
'@opentelemetry/api@1.9.0': {}
+ '@opentelemetry/api@1.9.1': {}
+
+ '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/semantic-conventions': 1.43.0
+
+ '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/semantic-conventions': 1.43.0
+
+ '@opentelemetry/exporter-logs-otlp-http@0.220.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/api-logs': 0.220.0
+ '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1)
+
+ '@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1)
+
+ '@opentelemetry/otlp-transformer@0.220.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/api-logs': 0.220.0
+ '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1)
+
+ '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.43.0
+
+ '@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.43.0
+
+ '@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/api-logs': 0.220.0
+ '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.43.0
+
+ '@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
+
+ '@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.1)':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/semantic-conventions': 1.43.0
+
'@opentelemetry/semantic-conventions@1.43.0': {}
'@oxc-parser/binding-android-arm-eabi@0.133.0':
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index a3274a83c3..4912729ff4 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -221,6 +221,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = {
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
SubagentRunEndInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
SubagentRunInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
+ TelemetryRecord: 'seam-local record contract is owned by packages/telemetry/session-telemetry/src/index.ts',
WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 1f54f7358f..075f394565 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -77,6 +77,7 @@ const GROUP_ORDER = [
'session-persistence',
'session-query',
'session-title',
+ 'telemetry',
'support',
'ui',
]
@@ -132,6 +133,15 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query', 'session-query-sqlite'],
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
},
+ {
+ key: 'telemetry',
+ pkg: 'session-telemetry',
+ title: 'Session telemetry seam',
+ mode: 'seam',
+ implementations: ['session-telemetry-otel'],
+ consumers: [],
+ note: 'The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process.',
+ },
{
key: 'sessionQuery',
pkg: 'session-query',
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index d5fa282a24..42792f14ca 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -80,6 +80,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' },
+ 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' },
+ 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' },
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 56ab3ffef3..e33ef26038 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -74,6 +74,7 @@
"./packages/hooks/*/src/invariant.ts",
"./packages/session-persistence/*/src/invariant.ts",
"./packages/session-query/*/src/invariant.ts",
+ "./packages/telemetry/*/src/invariant.ts",
"./packages/sdk/*/src/invariant.ts",
"./packages/ui/*/src/invariant.ts",
"./packages/examples/*/src/invariant.ts",
@@ -142,6 +143,7 @@
"./packages/session-persistence/*/src",
"./packages/session-query/*/src",
"./packages/session-title/*/src",
+ "./packages/telemetry/*/src",
"./packages/sdk/*/src",
"./packages/ui/*/src",
"./packages/examples/*/src",
diff --git a/tsconfig.host.json b/tsconfig.host.json
index a13bcf35e3..465b38edf4 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -48,6 +48,8 @@
{ "path": "./packages/session-title/session-title-llm" },
{ "path": "./packages/session-title/session-title-first-message-llm" },
{ "path": "./packages/session-title/session-title-all-messages-llm" },
+ { "path": "./packages/telemetry/session-telemetry" },
+ { "path": "./packages/telemetry/session-telemetry-otel" },
{ "path": "./packages/core/system-prompt" },
{ "path": "./packages/core/agent" },
{ "path": "./packages/ui/commands" },