feat(telemetry): add feedback-gated OTEL modes

This commit is contained in:
Turtle
2026-08-05 12:43:35 +08:00
parent b8d51704f3
commit c836fcd416
41 changed files with 635 additions and 182 deletions

View File

@@ -2,5 +2,5 @@
# 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 packages/feedback/README.md
README.md: 7962a16ee9bc7d8a969a466591d761829cd55d7f
README.zh.md: aad8f4d797ff16a5ef9be4c968fb28d708bad13e
README.md: d2a4a5a27e1c661d2f62b328578fd890a0c622ee
README.zh.md: 2fa42e3bb5f05dfc425356f302f44e497b100f24

View File

@@ -8,4 +8,4 @@ The feedback family lets a human record a remark about the session without actin
|---|---|---|
| `command-feedback/` | Trigger-independent `feedback/record` event plus the human-facing `/feedback` producer | — |
A recorded remark is log-only: it never enters the model surface or derived history, and no shipped plugin consumes it. A future consumer reads `feedback/record` events from the session log rather than changing how they are captured.
A recorded remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../telemetry/session-telemetry-otel/) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy.

View File

@@ -8,4 +8,4 @@ feedback 家族让人类记录对会话的评价,但不据此采取任何动
|---|---|---|
| `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 |
被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史,随附插件也不会消费它。未来的消费方从会话日志中读取 `feedback/record` 事件,而不是改变它们的采集方式
被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史。挂载后,[`dsh-session-telemetry-otel`](../telemetry/session-telemetry-otel/) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立

View File

@@ -2,5 +2,5 @@
# 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 packages/feedback/command-feedback/README.md
README.md: c9650d6a2c595550545b3dbf07f62e6aa65f39b9
README.zh.md: ba24276ba1bd71a4eb68c7fdb48a3760bdbec8fc
README.md: e3b0e58f1746c7bcd1c74ac0990a872a1f24d7d7
README.zh.md: 40ec871caff6f90b0b1c685e833c874e32a48d16

View File

@@ -15,7 +15,7 @@ Surrounding whitespace is discarded, but feedback is otherwise unparsed: no trun
## What this plugin does and does not do
`recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer, starts no model work, and no plugin in this repository reads the event.
`recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer and starts no model work. The optional [`dsh-session-telemetry-otel`](../../telemetry/session-telemetry-otel/) consumer observes the event without changing its capture contract.
The feedback text appears in exactly one durable payload: `feedback/record`. [`dsh-commands`](../../ui/commands/README.md) still appends its generic `command/run` / `command/done` pairing, but this definition sets `recordInput: false`, so `command/run` omits `args`; the paired `command/done` carries only the outcome. All three events are log-only and absent from the ordered surface, `deriveMessages()`, and model requests. These appends start persistence's ordinary eager drain, but neither producer forces `session/flush`, so acknowledgement means the feedback is in the log, not that it has reached disk. Rejected empty input leaves only the command pairing settled as `kind: 'error'`, with no `feedback/record`.
@@ -52,7 +52,7 @@ Independent of the model request path. Recording appends to the session log only
## Known Limitations and Deferred Work
- **Nothing consumes the recorded feedback** — capture is deliberately inert. There is no retrieval, aggregation, export, or reporting surface, and no model-facing tool reads `feedback/record`; a consumer is a separate package.
- **No feedback retrieval or management surface** — the optional OTel plugin uses the event only as a sharing trigger. There is no retrieval, aggregation, categorization, or model-facing tool for `feedback/record`.
- **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text.
- **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one.
- **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`.

View File

@@ -15,7 +15,7 @@
## 本插件做什么、不做什么
`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,不启动任何模型工作;本仓库中也没有任何插件读取该事件
`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,不启动任何模型工作。可选的 [`dsh-session-telemetry-otel`](../../telemetry/session-telemetry-otel/) 消费方会观察该事件,但不改变它的采集契约
反馈文本只出现在一个持久载荷中:`feedback/record`。[`dsh-commands`](../../ui/commands/README.md) 仍会追加通用的 `command/run` / `command/done` 配对,但此定义设置了 `recordInput: false`,因此 `command/run` 会省略 `args`;配对的 `command/done` 只携带结果。三个事件都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及模型请求中。这些追加会启动持久化的常规即时排空,但两个生产方都不会强制 `session/flush`,因此确认文本表示反馈已进入日志,而不表示它已经落盘。被拒绝的空输入只会留下以 `kind: 'error'` 结算的命令配对,不会产生 `feedback/record`
@@ -52,7 +52,7 @@ TUI 应用无条件挂载此命令;它没有配置,也不依赖持久 goal
## 已知限制与暂缓工作
- **没有任何消费方读取被记录的反馈**:采集刻意不产生任何后续动作。这里没有检索、聚合、导出或报告 surface也没有面向模型的工具读取 `feedback/record`;消费方是另一个独立包
- **没有反馈检索或管理 surface**:可选的 OTel 插件仅将该事件用作共享触发器。本包不为 `feedback/record` 提供检索、聚合、分类或面向模型的工具
- **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。
- **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone因此错误的条目会一直保留在记录中只能由后续条目取代。
- **没有显式持久化屏障**:确认文本紧随追加而非 flush因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`

View File

@@ -2,5 +2,5 @@
# 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 packages/telemetry/README.md
README.md: 944cb3f9bac6169feddf8b49bc481cfbe7c6fa9d
README.zh.md: 795b20abb47e1bf791730cc7f3ebb0522549a271
README.md: 0adf140a19bd6ab19c4d4139d4ebdae941c0d1b0
README.zh.md: 57988732e36d105ebcc48adcdab9344a6cccb525

View File

@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
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 `telemetry/record` waterfall (deployment-mounted redaction rules; the seam ships none), 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).
Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The boundary axiom, redaction waterfall, fixed chunk projection, handoff cursor, and operational-record channel are pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md); immediate, feedback-gated, and disabled delivery are owned by [the mode decision](../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.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. |
| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, immediate or held 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: `FULL`, `FEEDBACK_ONLY`, or `DISABLED` policy around the OTel JS SDK log pipeline. |

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
面向外部的会话上报遥测telemetryseam 及其 OpenTelemetry 后端。整套设计固定在[复活 Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)边界公理harness 的职责止于 `emit()`,投递由上报 SDK 负责)、`telemetry/record` waterfall瀑布式事件脱敏规则由部署方挂载seam 自身不带任何规则、固定分片投影、handoff 游标,以及运维记录通道
面向外部的会话上报遥测telemetryseam 及其 OpenTelemetry 后端。边界公理、脱敏 waterfall瀑布式事件、固定分片投影、handoff 游标及运维记录通道的决定见[复活 Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md);即时、反馈门控及禁用投递由[模式决策](../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)统一规定
| 包package | 职责 |
|---|---|
| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | seam 本体:捕获点、投影、脱敏、handoff 游标、运维信号,以及最小后端契约(`emit`/`flush?`/`shutdown`)。 |
| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | 部署方要加载的后端OTel JS SDK 日志流水线`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP 导出器经透传passthrough原样配置。 |
| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | seam 本体:捕获点、投影、脱敏、即时或暂存交接、游标、运维信号,以及最小后端契约(`emit`/`flush?`/`shutdown`)。 |
| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | 部署方要加载的后端:围绕 OTel JS SDK 日志流水线实施 `FULL``FEEDBACK_ONLY``DISABLED` 策略。 |

View File

@@ -2,5 +2,5 @@
# 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 packages/telemetry/session-telemetry-otel/README.md
README.md: 9b208e291e77bee50d9d4fd14808268dca75f2db
README.zh.md: 76de1bf1ad58a0239907f3b63c672177874c7966
README.md: fab2461477b2174bded42ed6f05ae55c7c5f697c
README.zh.md: ab0191188836e03434adbce527d31b62ead848a3

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
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.
The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. Its `mode` decides whether the seam hands records over immediately, releases them only at recorded feedback, or keeps telemetry local. Uploading modes compose the OTel JS SDK as-is (`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP log exporter) and map each handed-over record 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
@@ -10,6 +10,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED
exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
url: https://collector.example.com/v1/logs
headers:
@@ -17,15 +18,21 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
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 (as does a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown). Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag.
| `mode` | Behavior |
|---|---|
| `FULL` | Default. Each projected record, including lifecycle ops records, is handed to the OTel SDK immediately. |
| `FEEDBACK_ONLY` | Each `feedback/record` releases the redacted, projected session prefix through that event. Later records wait for another feedback event and remain local if none arrives. |
| `DISABLED` | No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. |
`exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. Uploading modes also reject a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown. Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete.
## What leaves the machine
Records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — 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). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (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.
In uploading modes, records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — 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`, feedback text, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (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. `DISABLED` does not construct the SDK pipeline or hand any capture to a backend.
## 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 absence (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). The marker means telemetry stopped observing the session cleanly — emitted at the session's own disposal, or at application teardown for sessions still running then; a marker followed by more of that session's events is a telemetry reload, not a session restart. Streams are not self-contained across lineage: a resumed session continues its own id's stream from where the previous process left off, and a forked session's stream starts at its inherited boundary — its prefix lives in the parent's stream, stitched via `session.parent_id` + `session.seed_length`. One consequence of continuing rather than replaying: a turn left open mid-stream and never closed marks the previous process dying inside it. The local log is repaired with synthetic closers at resume, but those repairs are never exported the wire stream stays faithful to what the crashed process actually shipped, and a later clean `shutdown` marker attests only to the resumed process's own exit.
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)` and alert on severity. In `FULL`, they may also detect crashes by `shutdown`-record absence: the marker is emitted at the session's own disposal or application teardown, and a marker followed by more events is a telemetry reload. In `FEEDBACK_ONLY`, a released prefix normally has no later `shutdown` marker, so its absence is not a crash signal. Streams are not self-contained across lineage: a resumed session continues its own id's stream from where the previous process left off, and a forked session's stream starts at its inherited boundary — its prefix lives in the parent's stream, stitched via `session.parent_id` + `session.seed_length`. A resumed local log may contain synthetic closers that were never exported; the wire stream stays faithful to records actually handed to the SDK.
## Model Experience
@@ -39,3 +46,4 @@ None; this package neither assembles nor sends a provider request.
- **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.
- **No live-collector coverage** — every test exports to a local mock collector; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape on every run, and behavior against a real OTLP deployment (auth, TLS, throttling) is the SDK exporter's documented territory.
- **Feedback-only memory** — each session retains deep-copied, redacted projected records in memory until feedback releases them or the session becomes unreachable. There is no durable pre-feedback spool; a crash before feedback uploads nothing.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
[遥测telemetryseam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。原样组合 OTel JS SDK`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把 seam 交接过来的每条记录映射到 `logger.emit()`并使用两个插桩作用域instrumentation scopeledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm``APP_IDENTITY`,与归因标头同源。
[遥测telemetryseam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。`mode` 决定 seam 是立即交接记录、仅在记录反馈时释放记录,还是将遥测留在本地。上传模式会原样组合 OTel JS SDK`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把每条已交接记录映射到 `logger.emit()`并使用两个插桩作用域instrumentation scopeledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm``APP_IDENTITY`,与归因标头同源。
## 配置
@@ -10,6 +10,7 @@
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED
exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
url: https://collector.example.com/v1/logs
headers:
@@ -17,15 +18,21 @@
processor: {} # optional; passed verbatim to BatchLogRecordProcessor
```
`exporter.url` 是本包package唯一自行校验的字段必填、无默认值、必须能解析为 `http(s)`,因此缺失端点会在插件加载时失败(`processor.maxExportBatchSize` 不是正整数时同样如此SDK 会接受该值,随后却在关闭时因它挂起)。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明两个配置块都整体透传passthrough`OTLPExporterNodeConfigBase` 的每个字段(`headers``timeoutMillis``compression``keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。
| `mode` | 行为 |
|---|---|
| `FULL` | 默认值。每条已投影记录都立即交给 OTel SDK包括生命周期运维记录。 |
| `FEEDBACK_ONLY` | 每个 `feedback/record` 都会释放截至该事件的已脱敏、已投影会话前缀。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 |
| `DISABLED` | 不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 |
`exporter.url``FULL``FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。上传模式也会拒绝不是正整数的 `processor.maxExportBatchSize`SDK 虽会接受该值,但随后会在关闭时挂起。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明两个配置块都整体透传passthrough`OTLPExporterNodeConfigBase` 的每个字段(`headers``timeoutMillis``compression``keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。
## 哪些数据会离开本机
记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall瀑布式事件返回的结果为准用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema`request/header`、todo 文本、压缩compaction摘要、钩子的 `stderrSummary`,以及会话 `cwd`一个本地路径。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。
在上传模式中,记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall瀑布式事件返回的结果为准用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema`request/header`、todo 文本、压缩compaction摘要、钩子的 `stderrSummary`、反馈文本,以及会话 `cwd`一个本地路径。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。
## 字段映射
seam 记录 → SDK 日志记录:`time``timestamp`/`observedTimestamp``severity``severityNumber`/`severityText`INFO 9 / WARN 13 / ERROR 17`body` → 结构化日志 body`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重按严重级别告警,并通过 `shutdown` 记录的缺失检测崩溃(一个曾有活动、没有 `shutdown` 运维记录、且已然陈旧的会话,就是未干净结束的会话)。该标记的含义是遥测干净地停止了对该会话的观察:它在会话自身 dispose资源释放时发出,对于届时仍在运行的会话,则在应用关闭时发出;标记之后出现该会话的更多事件,说明发生的是遥测重载,而不是会话重启。跨谱系lineage的流并不自足恢复的会话在其自身 id 的流上从上一个进程停止之处继续fork 出的会话,其流从继承边界开始,前缀位于父会话的流中,由接收端基于 `session.parent_id` + `session.seed_length` 拼接。继续而非回放的一个后果:流中一个开启后再未关闭的轮次,标志着上一个进程死在了该轮次之内。恢复时本地日志会以合成关闭事件修复,但这些修复绝不导出:导出的流忠实于崩溃进程实际发出的内容,其后干净的 `shutdown` 标记也只证明恢复后进程自身的退出
seam 记录 → SDK 日志记录:`time``timestamp`/`observedTimestamp``severity``severityNumber`/`severityText`INFO 9 / WARN 13 / ERROR 17`body` → 结构化日志 body`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重,并按严重级别告警。在 `FULL` 中,接收端还可通过缺少 `shutdown` 记录检测崩溃:该标记在会话自身 dispose资源释放应用关闭时发出;标记之后出现更多事件,说明遥测发生了重载。在 `FEEDBACK_ONLY` 中,已释放的前缀通常不包含随后的 `shutdown` 标记,因此缺少该标记不是崩溃信号。跨谱系lineage的流并不自足恢复的会话在其自身 id 的流上从上一个进程停止之处继续fork 出的会话流从继承边界开始,前缀位于父会话的流中,由接收端基于 `session.parent_id` + `session.seed_length` 拼接。恢复后的本地日志可能包含从未导出的合成关闭事件;协议流忠实于实际交给 SDK 的记录
## 模型体验
@@ -39,3 +46,4 @@ seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`
- **上游实验性源码树**`@opentelemetry/sdk-logs` 仍从上游实验性experimental源码树发布SDK API 的变动只会落在本包也仅落在本包seam 契约不动。
- **无真实 collector 覆盖**:所有测试都导出到本地 mock collector无密钥的 Loader 组合 e2e`tests/loader-composition.e2e.ts`在每次运行中都覆盖协议格式wire format形态而面对真实 OTLP 部署的行为认证、TLS、限流属于 SDK 导出器文档的职责范围。
- **仅反馈模式的内存占用**:每个会话都会在内存中保留已深拷贝、已脱敏的投影记录,直到反馈将其释放或会话变得不可达。反馈前不存在持久化 spool如果在反馈前崩溃则什么都不上传。

View File

@@ -36,6 +36,7 @@
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-command-feedback": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -44,6 +45,7 @@
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-command-feedback": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -6,8 +6,8 @@
* 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.
* verbatim through the `exporter`/`processor` passthroughs. This package owns
* only whether capture is immediate, feedback-released, or disabled.
*
* @module @deepseek-ai/dsh-session-telemetry-otel
*/
@@ -15,7 +15,14 @@
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 type {} from '@deepseek-ai/dsh-command-feedback'
import {
Telemetry,
TelemetryCoordinator,
type TelemetryDelivery,
type TelemetryRecord,
type TelemetrySeverity,
} from '@deepseek-ai/dsh-session-telemetry'
import { APP_IDENTITY } from '@deepseek-ai/dsh-llm'
import {
BatchLogRecordProcessor,
@@ -31,13 +38,22 @@ import { resourceFromAttributes } from '@opentelemetry/resources'
// version (same pattern as dsh-llm's attribution identity).
const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
/** Supported session-sharing policies for the OTel backend. */
export const TELEMETRY_MODES = ['FULL', 'FEEDBACK_ONLY', 'DISABLED'] as const
/** Session-sharing policy selected by {@link Config.mode}. */
export type TelemetryMode = typeof TELEMETRY_MODES[number]
const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local'
/**
* 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.
* Plugin configuration: one sharing policy plus two verbatim SDK option
* shapes. `exporter.url` is required for modes that upload and unused for
* `DISABLED`.
*/
export interface Config {
/** Sharing policy; defaults to immediate `FULL` delivery. */
mode?: TelemetryMode
/**
* Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
* `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
@@ -45,7 +61,7 @@ export interface Config {
* is the one field this package requires and validates itself.
*/
exporter?: OTLPExporterNodeConfigBase & {
/** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */
/** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
url?: string
}
/**
@@ -57,13 +73,14 @@ export interface Config {
/**
* 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. Both slots are opaque
* passthroughs: the SDK owns their shapes and validates its own options;
* re-declaring them field-by-field here would violate the boundary axiom
* (and silently drop every field not re-declared).
* starts. Shape-level only — the mode-dependent `exporter.url` check lives in
* the constructor so its error message names the field. Both SDK slots are
* opaque passthroughs: the SDK owns their shapes and validates its own
* options; re-declaring them field-by-field here would violate the boundary
* axiom (and silently drop every field not re-declared).
*/
export const Config: z<Config> = z.object({
mode: z.union(TELEMETRY_MODES).default('FULL'),
exporter: z.any(),
processor: z.any(),
})
@@ -76,22 +93,32 @@ const SEVERITY: Record<TelemetrySeverity, { severityNumber: SeverityNumber; seve
}
/**
* 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.
* The backend plugin — the only entry a deployment loads. It always registers
* the `telemetry` service (duplicate load throws). Uploading modes wire the SDK
* pipeline and compose {@link TelemetryCoordinator}; `DISABLED` constructs no
* SDK state and listens only to warn when recorded feedback stays local.
*/
export class TelemetryOtel extends Telemetry {
static inject = ['sessions']
static Config = Config
private readonly provider: LoggerProvider
private readonly ledger: Logger
private readonly ops: Logger
private readonly provider: LoggerProvider | undefined
private readonly ledger: Logger | undefined
private readonly ops: Logger | undefined
constructor(ctx: Context, config: Config) {
super(ctx)
const mode = config.mode ?? 'FULL'
if (mode === 'DISABLED') {
this.provider = undefined
this.ledger = undefined
this.ops = undefined
ctx.on('session/event', (_session, event) => {
if (event.type === 'feedback/record') ctx.logger.warn(DISABLED_FEEDBACK_WARNING)
})
return
}
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)')
@@ -134,16 +161,26 @@ export class TelemetryOtel extends Telemetry {
})
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)
const delivery: TelemetryDelivery = mode === 'FULL' ? 'immediate' : 'held'
const coordinator = new TelemetryCoordinator(ctx, this, delivery)
if (mode === 'FEEDBACK_ONLY') {
// The coordinator listener is registered first, so a feedback event
// enters the held prefix before this listener releases that exact prefix.
ctx.on('session/event', (session, event) => {
if (event.type === 'feedback/record') coordinator.release(session)
})
}
}
/**
* Map one seam record onto the SDK logger for its channel — a synchronous
* enqueue into the batch processor's queue.
* enqueue into the batch processor's queue. Direct calls are no-ops in
* `DISABLED`, where no coordinator or SDK pipeline exists.
* @param record - the logical record handed over by the coordinator.
*/
emit(record: TelemetryRecord): void {
const logger = record.channel === 'ops' ? this.ops : this.ledger
if (logger === undefined) return
logger.emit({
timestamp: record.time,
observedTimestamp: record.time,
@@ -167,14 +204,15 @@ export class TelemetryOtel extends Telemetry {
/**
* Delegate disposal to the SDK's shutdown contract: drain the queue and
* quiesce. With no concurrent `forceFlush()` in the process (see above),
* shutdown's internal drain is complete — everything emitted before this
* call, including the coordinator's dispose-time `shutdown` markers, is
* exported before the exporter closes. Awaited (and error-contained) by
* the coordinator's disposer.
* shutdown's internal drain is complete — everything handed to the SDK
* before this call is exported before the exporter closes. In `FULL`, that
* includes dispose-time `shutdown` markers; held suffixes never reach the
* SDK. Awaited (and error-contained) by the coordinator's disposer. A
* disabled backend resolves immediately.
* @returns resolves when the SDK pipeline has quiesced.
*/
shutdown(): Promise<void> {
return this.provider.shutdown()
return this.provider === undefined ? Promise.resolve() : this.provider.shutdown()
}
}

View File

@@ -15,10 +15,9 @@ export const name = 'session-telemetry-otel-invariant'
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.
* No runtime invariant: mode selection changes capture handoff, SDK setup, and
* local diagnostics without mutating session or service state an independent
* companion can compare. Export remains inside the SDK past the seam boundary.
*/
const install: InvariantInstaller = () => {}

View File

@@ -40,6 +40,11 @@ interface OtlpCapture {
}[]
}
interface FixtureOutput {
captures: OtlpCapture[]
logContent: string
}
async function jsonlFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
const paths = await Promise.all(entries.map(async (entry) => {
@@ -50,10 +55,29 @@ async function jsonlFiles(dir: string): Promise<string[]> {
return paths.flat()
}
async function readFixtureOutput(cwd: string): Promise<FixtureOutput> {
const captures = JSON.parse(await readFile(join(cwd, 'otlp-captures.json'), 'utf8')) as OtlpCapture[]
const logs = await jsonlFiles(join(cwd, '.sessions'))
expect(logs).toHaveLength(1)
return { captures, logContent: await readFile(logs[0] as string, 'utf8') }
}
function allRecords(captures: OtlpCapture[]) {
return captures.flatMap(capture => capture.resourceLogs.flatMap(resource =>
resource.scopeLogs.flatMap(scoped => scoped.logRecords.map(record => ({ scope: scoped.scope.name, record })))))
}
function eventTypes(captures: OtlpCapture[]): string[] {
return allRecords(captures).flatMap(({ record }) =>
record.attributes?.flatMap(attribute =>
attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string'
? [attribute.value['stringValue']]
: []) ?? [])
}
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 = ''
let output!: FixtureOutput
const { stderr } = await runLoaderSmoke({
label: 'session-telemetry-otel loader smoke',
tempDirPrefix: 'telemetry-otel-e2e-',
@@ -61,39 +85,70 @@ describe('session-telemetry-otel through a real headless cordis.yml', () => {
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')
},
inspect: async (cwd) => { output = await readFixtureOutput(cwd) },
})
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 })))))
const records = allRecords(output.captures)
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']]
: []) ?? [])
const types = eventTypes(output.captures)
for (const expected of ['turn/start', 'user/message', 'tool/call', 'tool/result', 'assistant/message', 'turn/end']) {
expect(eventTypes, expected).toContain(expected)
expect(types, expected).toContain(expected)
}
expect(records.some(({ scope }) => scope.endsWith('/ops'))).toBe(true)
// The deployment-mounted rule on the wire: the fixture credential never
// leaves the process, its surrounding prose does, and the placeholder
// marks the spot — the seam itself ships no rules.
const wire = JSON.stringify(captures)
const wire = JSON.stringify(output.captures)
expect(wire).not.toContain(FIXTURE_SECRET)
expect(wire).toContain(FIXTURE_PLACEHOLDER)
expect(wire).toContain('prove telemetry with key')
// The canonical session log is never rewritten.
expect(logContent).toContain(FIXTURE_SECRET)
expect(logContent).not.toContain(FIXTURE_PLACEHOLDER)
expect(output.logContent).toContain(FIXTURE_SECRET)
expect(output.logContent).not.toContain(FIXTURE_PLACEHOLDER)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('exports only prefixes ending in feedback under feedback-only mode', async () => {
let output!: FixtureOutput
const { stderr } = await runLoaderSmoke({
label: 'session-telemetry-otel feedback-only loader smoke',
tempDirPrefix: 'telemetry-otel-feedback-e2e-',
binScript: driver,
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
env: { DSH_TELEMETRY_E2E_MODE: 'FEEDBACK_ONLY' },
inspect: async (cwd) => { output = await readFixtureOutput(cwd) },
})
expect(stderr).not.toContain('UNHANDLED')
const wire = JSON.stringify(output.captures)
expect(eventTypes(output.captures)).toContain('feedback/record')
expect(wire).toContain('fixture feedback')
expect(wire).toContain('prove telemetry with key')
expect(wire).not.toContain('post-feedback private suffix')
expect(output.logContent).toContain('post-feedback private suffix')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('keeps disabled feedback local and prints the stable warning', async () => {
let output!: FixtureOutput
const { stdout } = await runLoaderSmoke({
label: 'session-telemetry-otel disabled loader smoke',
tempDirPrefix: 'telemetry-otel-disabled-e2e-',
binScript: driver,
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
env: { DSH_TELEMETRY_E2E_MODE: 'DISABLED' },
inspect: async (cwd) => { output = await readFixtureOutput(cwd) },
})
expect(output.captures).toEqual([])
expect(output.logContent).toContain('fixture feedback')
expect(stdout.match(/session telemetry is DISABLED; nothing will be shared and this feedback remains local/)?.[0])
.toMatchInlineSnapshot('"session telemetry is DISABLED; nothing will be shared and this feedback remains local"')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -5,12 +5,13 @@
* for the default-exported Service class.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createServer, type Server } from 'node:http'
import { once } from 'node:events'
import { gunzipSync } from 'node:zlib'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { recordFeedback } from '@deepseek-ai/dsh-command-feedback'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import TelemetryOtel, { Config } from '../src/index.ts'
@@ -30,6 +31,7 @@ interface OtlpLogsRequest {
severityNumber: number
severityText: string
attributes?: { key: string; value: Record<string, unknown> }[]
body?: unknown
}[]
}[]
}[]
@@ -88,6 +90,14 @@ function allRecords(captures: Capture[]) {
s.logRecords.map(record => ({ scope: s.scope.name, record })))))
}
function eventTypes(captures: Capture[]): string[] {
return allRecords(captures).flatMap(({ record }) =>
record.attributes?.flatMap(attribute =>
attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string'
? [attribute.value['stringValue']]
: []) ?? [])
}
describe('TelemetryOtel wire', () => {
it('ships session records and the ops shutdown marker through the real SDK pipeline', async () => {
const { url, captures } = await mockCollector()
@@ -195,6 +205,82 @@ describe('TelemetryOtel wire', () => {
r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start'))
expect(start?.record.severityNumber).toBe(13)
})
it('holds each session suffix until the next feedback event', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: 'FEEDBACK_ONLY',
exporter: { url },
})
const session = ctx.sessions.create(SessionId('feedback-only'), { meta: {} })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
recordFeedback(session, 'first report')
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
recordFeedback(session, 'second report')
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
await fiber.dispose()
const types = allRecords(captures).flatMap(({ record }) =>
record.attributes?.flatMap(attribute =>
attribute.key === 'event.type' ? [attribute.value.stringValue] : []) ?? [])
expect(types).toEqual(['turn/start', 'feedback/record', 'turn/end', 'feedback/record'])
expect(JSON.stringify(captures)).toContain('first report')
expect(JSON.stringify(captures)).toContain('second report')
expect(allRecords(captures).some(({ scope }) => scope.endsWith('/ops'))).toBe(false)
})
it('sends no request when feedback-only mode ends without feedback', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: 'FEEDBACK_ONLY',
exporter: { url },
})
const session = ctx.sessions.create(SessionId('no-feedback'), { meta: {} })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await fiber.dispose()
expect(captures).toEqual([])
})
it('boots disabled without exporter config and warns when feedback stays local', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const fiber = await ctx.plugin(TelemetryOtel, { mode: 'DISABLED' })
const session = ctx.sessions.create(SessionId('disabled'), { meta: {} })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
recordFeedback(session, 'local report')
expect(warn).toHaveBeenCalledWith(
'session telemetry is DISABLED; nothing will be shared and this feedback remains local',
)
ctx.telemetry.emit({
channel: 'ledger',
time: 0,
severity: 'info',
attributes: {},
body: null,
})
await ctx.telemetry.shutdown()
await fiber.dispose()
recordFeedback(session, 'after disposal')
expect(warn).toHaveBeenCalledTimes(1)
})
it('defaults direct construction to full delivery', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
await ctx.plugin(SessionStore)
new TelemetryOtel(ctx, { exporter: { url } })
const session = ctx.sessions.create(SessionId('direct-default'), { meta: {} })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await ctx.fiber.dispose()
expect(eventTypes(captures)).toContain('turn/start')
})
})
describe('TelemetryOtel config fails loud', () => {
@@ -203,6 +289,8 @@ describe('TelemetryOtel config fails loud', () => {
[{ exporter: { url: '' } }, /exporter\.url is required/],
[{ exporter: { url: 'not a url' } }, /not a valid URL/],
[{ exporter: { url: 'ftp://collector' } }, /must be http\(s\)/],
[{ mode: 'FEEDBACK_ONLY' }, /exporter\.url is required/],
[{ mode: 'INVALID' }, /INVALID/],
// The SDK accepts a non-positive batch size but its shutdown drain then
// splices empty batches forever — dispose would hang, so reject at load.
[{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],

View File

@@ -20,6 +20,9 @@
{
"path": "../../core/session"
},
{
"path": "../../feedback/command-feedback"
},
{
"path": "../../llm/llm"
},

View File

@@ -2,5 +2,5 @@
# 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 packages/telemetry/session-telemetry/README.md
README.md: 272c9abe78849be3d2bba2c54cd7e25bcbe2d4c2
README.zh.md: e6f077c1d12d00e746147908560d05381fde11c3
README.md: d38433a728c699c7fb3cc0512bb6a2d977dd4cc6
README.zh.md: 3a86b01321fc7dfd33d39530ee7fa38a6ee1f2dc

View File

@@ -2,23 +2,23 @@
English | [中文](README.zh.md)
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 telemetry seam: the capture side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. Capture can hand each redacted record over immediately or hold a per-session prefix for an explicit release. 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) and [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.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; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-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.
`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path, either at capture or held-prefix release), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-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` with `immediate` delivery or `held` delivery and calls `release(session)` at its owning trigger.
## 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 from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `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), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (mark each session still alive at teardown, 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 coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, then hand off or hold; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (capture the session's `shutdown` operational record at its termination edge, then retire it), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (capture shutdown for each still-live session, 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`). Immediate delivery hands lifecycle records over; held delivery leaves any suffix after the last release local, including its later shutdown marker.
## The redact waterfall
Every record passes the `telemetry/record` waterfall between projection and `emit()` — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Redaction applies to the exported copy only; the canonical session log is never rewritten.
Every record passes the `telemetry/record` waterfall immediately after projection — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Held delivery stores only the waterfall result, so later policy removal cannot expose the original capture. Redaction applies to the outbound copy only; the canonical session log is never rewritten.
## The handoff cursor
A module-scope `WeakMap<Session, seq>` 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 re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. 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.
A module-scope `WeakMap<Session, seq>` marks the highest seq HANDED OFF (not delivered) per session. Immediate delivery advances it at capture; held delivery advances it only when `release(session)` hands that record to the backend. An unreleased prefix therefore survives a coordinator reload through deterministic re-adoption instead of disappearing with its in-memory copy. 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 re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. 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
@@ -40,3 +40,4 @@ None; this package neither assembles nor sends a provider request.
- **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).
- **No built-in redaction rules** — with no `telemetry/record` listener mounted, records leave the process exactly as captured, including any credentials embedded in file contents or command output; a deployment exporting to a shared collector owns its rule set.
- **Held prefixes duplicate memory** — held delivery retains one deep-copied, redacted record per projected event until release or session collection. It adds no durable outbox and intentionally trades memory for a simple no-upload-before-trigger boundary.

View File

@@ -2,23 +2,23 @@
[English](README.md) | 中文
遥测telemetryseam会话事件上报的捕获侧隔在一个后端契约之后任何上报 SDK 都无需变形即可满足该契约。塑造本包package一切设计的边界公理**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK本包既不为其立规也不做包装。设计依据与被否决的替代方案见[复活 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。
遥测telemetryseam会话事件上报的捕获侧隔在一个后端契约之后任何上报 SDK 都无需变形即可满足该契约。捕获侧可立即交接每条已脱敏记录,也可按会话暂存一个前缀,等待显式释放。塑造本包package一切设计的边界公理**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK本包既不为其立规也不做包装。设计依据与被否决的替代方案见[复活 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)与[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)
## 后端契约
`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose资源释放时被等待`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端在其构造函数中组合 `TelemetryCoordinator`
`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它会在捕获或暂存前缀释放时,于 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose资源释放时被等待`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端`immediate``held` 投递模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `release(session)`
## 捕获点
协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header并经投影从构造边界起回读日志来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏、交接;零 I/O`session/flush`(转发可选的 `flush()` 提示并返回 void循环所等待的并行任务绝不能等待遥测`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect拆卸时先标记每个仍存活会话,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。
协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header并经投影从构造边界起回读日志来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏,再交接或暂存;零 I/O`session/flush`(转发可选的 `flush()` 提示并返回 void循环所等待的并行任务绝不能等待遥测`session/disposed`(在会话自身的终止边缘捕获该会话的 `shutdown` 运维记录,然后将其退役)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect捕获每个仍存活会话的 shutdown,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。即时投递会交接生命周期记录;暂存投递会将上次释放后的任何后缀留在本地,包括随后的 shutdown 标记。
## 脱敏 waterfall瀑布式事件
每条记录在投影`emit()` 之间都要经过 `telemetry/record` waterfall这是该 seam 的脱敏扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。脱敏只作用于导出副本;权威会话日志永不改写。
每条记录在投影后立即经过 `telemetry/record` waterfall这是该 seam 的脱敏扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。暂存投递只保留 waterfall 的结果,因此后续移除策略也无法暴露捕获时的原始内容。脱敏只作用于外发副本;权威会话日志永不改写。
## handoff 游标
一个模块作用域的 `WeakMap<Session, seq>` 记录每个会话已交接(而非已投递)的最高 seq,在 emit 时推进游标在不重新求值本模块的重载(配置重新应用、后端源码重载)中存活,而迭代恰恰发生在这类重载中;这种不对称正是游标放在 seam 一侧的原因。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接。由此接受的代价与至多一次at-most-once投递一致恢复不会回填上一个进程未能投递的记录有回填要求的部署需要的是已推迟的 outbox而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外条目随其会话消亡值是单调水位线丢失它绝不是错误。
一个模块作用域的 `WeakMap<Session, seq>` 记录每个会话已交接(而非已投递)的最高 seq。即时投递在捕获时推进游标;暂存投递只有在 `release(session)` 将记录交给后端时才推进游标。因此,重建协调器后会通过确定性重新收养恢复未释放的前缀,而不会随其内存副本一同消失。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接。由此接受的代价与至多一次at-most-once投递一致恢复不会回填上一个进程未能投递的记录有回填要求的部署需要的是已推迟的 outbox而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外条目随其会话消亡值是单调水位线丢失它绝不是错误。
## 固定分片投影
@@ -40,3 +40,4 @@
- **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久化 outboxspool、每 sink 游标、at-least-once推迟到有部署方提出明确的崩溃丢失要求时再实现见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。
- **不内置脱敏规则**:未挂载 `telemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。
- **暂存前缀会重复占用内存**:暂存投递会为每个已投影事件保留一份深拷贝且已脱敏的记录,直到释放或回收会话。它不增加持久化 outbox而是有意以内存换取简单的「触发前不上传」边界。

View File

@@ -3,10 +3,11 @@
* firehose plus the one live-bus relay (`agent/error`), applies the fixed
* chunk projection, builds logical records, runs each through the
* `telemetry/record` waterfall (deployment-mounted redaction rules;
* pass-through when none), and hands the result 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.
* pass-through when none), then hands the result to the backend immediately
* or holds it for explicit release. Every synchronous handler is
* 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
*/
@@ -16,6 +17,16 @@ 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'
/** Whether capture hands records over immediately or holds them for an explicit release. */
export type TelemetryDelivery = 'immediate' | 'held'
/** One redacted record waiting at the capture boundary. */
interface PendingRecord {
readonly record: TelemetryRecord
/** Ledger cursor advanced only after the backend accepts this record. */
readonly seq?: number
}
/**
* The handoff cursor: per session, the highest `seq` handed to a backend.
* Deliberately MODULE-scope ambient state — a narrow, documented exception
@@ -35,14 +46,13 @@ const handoffCursor = new WeakMap<Session, number>()
* 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`). A `session/disposed` emits the session's `shutdown`
* operational record — the marker rides the session's own termination edge,
* where receivers key crash detection — and retires it from the adopted set,
* so a long-lived backend neither retains closed sessions (and their frozen
* event logs) nor re-marks them at unload. Disposal marks the sessions still
* alive at teardown (their own edge would fire unobserved) and then awaits
* the backend's `shutdown()`; a failure there warns instead of throwing —
* best-effort reporting must not fail application teardown.
* `session/created`). A `session/disposed` captures the session's `shutdown`
* operational record at its own termination edge and retires it from the
* adopted set. Immediate delivery hands that marker over; held delivery keeps
* it local without another explicit release. Disposal captures the same
* marker for sessions still alive, then awaits the backend's `shutdown()`; a
* failure there warns instead of throwing — best-effort reporting must not
* fail application teardown.
*/
export class TelemetryCoordinator {
/**
@@ -53,28 +63,30 @@ export class TelemetryCoordinator {
private readonly adopted = new Set<Session>()
/** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */
private readonly chunkSeen = new WeakMap<Session, Set<string>>()
/** Redacted records retained until {@link release}; weak keys do not extend session lifetime. */
private readonly held = new WeakMap<Session, PendingRecord[]>()
/**
* @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.
* @param delivery - immediate handoff, or held delivery released explicitly per session.
*/
constructor(
private readonly ctx: Context,
private readonly backend: TelemetryBackend,
private readonly delivery: TelemetryDelivery = 'immediate',
) {
ctx.on('session/created', (session) => {
this.adopt(session)
})
// The session's own termination edge: emit the shutdown marker HERE —
// receivers classify a session with activity and no marker as crashed,
// so a normally closed session in a long-running host must get its
// marker at disposal, not never. Then retire: the projection/cursor
// WeakMaps die with the Session object; only the strong adopted set
// needs the explicit release.
// Capture the shutdown marker at the session's own termination edge.
// Immediate delivery preserves crash classification; held delivery does
// not let a later lifecycle edge extend a user-released prefix. Then
// retire the only strong reference owned by this coordinator.
ctx.on('session/disposed', (session) => {
this.contain(() => {
if (!this.adopted.delete(session)) return
this.handOff(shutdownRecord(session))
this.submit(session, { record: this.redact(shutdownRecord(session)) })
})
})
ctx.on('session/event', (session, event) => {
@@ -95,13 +107,12 @@ export class TelemetryCoordinator {
})
})
ctx.effect(() => async () => {
// Sessions still adopted here are alive through a whole-application
// teardown (their own disposal edge will fire after telemetry is gone,
// unobserved) — mark them now so the receiver sees a clean stop of
// observation rather than a crash-shaped silence.
// Sessions still adopted here are alive through whole-application
// teardown, so capture the marker before the backend quiesces. Held
// delivery intentionally leaves it local without another release.
for (const session of this.adopted) {
this.contain(() => {
this.handOff(shutdownRecord(session))
this.submit(session, { record: this.redact(shutdownRecord(session)) })
})
}
try {
@@ -115,6 +126,23 @@ export class TelemetryCoordinator {
}
}
/**
* Hand the records currently held for one session to the backend in capture order.
* Records captured after this call form a new held prefix. Backend failures remain
* contained per record and do not starve later records in the same release.
* @param session - session whose pending capture prefix may leave the process.
*/
release(session: Session): void {
const pending = this.held.get(session)
if (pending === undefined) return
this.held.delete(session)
for (const record of pending) {
this.contain(() => {
this.deliver(session, record)
})
}
}
/**
* Adopt a session: replay its log THROUGH the projection from the handoff
* cursor, then rely on the firehose for everything after. When no cursor
@@ -153,7 +181,7 @@ export class TelemetryCoordinator {
}
}
/** Project one event and hand it to the backend, advancing the cursor on handoff. */
/** Project and redact one event, then submit it under the delivery policy. */
private capture(session: Session, event: SessionEvent): void {
if (event.type === 'assistant/chunk') {
const key = `${event.data.turn}:${event.data.step}`
@@ -165,27 +193,47 @@ export class TelemetryCoordinator {
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),
this.submit(session, {
record: this.redact({
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),
}),
seq: event.seq,
})
handoffCursor.set(session, event.seq)
}
/**
* Run the `telemetry/record` waterfall over one record and hand the result
* to the backend. The innermost `next` passes the record through unchanged
* — the seam ships no rules; exported data is as clean as the listeners a
* deployment mounts. Callers run inside {@link contain}, so a throwing
* rule withholds the record instead of reaching the loop (fail-closed).
* Run the `telemetry/record` waterfall at capture time. The innermost `next`
* passes the record through unchanged — the seam ships no rules; exported
* data is as clean as the listeners a deployment mounts. Callers run inside
* {@link contain}, so a throwing rule withholds the record instead of
* reaching the loop (fail-closed). Held delivery stores only this result, so
* a later policy reload cannot expose the pre-redaction capture.
*/
private handOff(record: TelemetryRecord): void {
this.backend.emit(this.ctx.waterfall('telemetry/record', record, () => record))
private redact(record: TelemetryRecord): TelemetryRecord {
return this.ctx.waterfall('telemetry/record', record, () => record)
}
/** Hold one redacted record or deliver it immediately under the configured policy. */
private submit(session: Session, pending: PendingRecord): void {
if (this.delivery === 'held') {
let records = this.held.get(session)
if (records === undefined) this.held.set(session, records = [])
records.push(pending)
return
}
this.deliver(session, pending)
}
/** Hand one redacted record to the backend, then advance its ledger cursor. */
private deliver(session: Session, pending: PendingRecord): void {
this.backend.emit(pending.record)
if (pending.seq !== undefined) handoffCursor.set(session, pending.seq)
}
/** Forward the turn-end boundary to the backend's optional flush hint. */
@@ -196,19 +244,21 @@ export class TelemetryCoordinator {
/** Relay one `agent/error` bus emission as an `agent-error` operational record. */
private relayAgentError(agent: Agent, turn: number, step: number, error: unknown): void {
const detail = errorDetail(error)
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': detail.name,
turn,
step,
},
body: detail,
this.submit(agent.session, {
record: this.redact({
channel: 'ops',
time: Date.now(),
severity: 'error',
attributes: {
'telemetry.op': 'agent-error',
'session.id': String(agent.session.id),
'agent.id': agent.id,
'error.name': detail.name,
turn,
step,
},
body: detail,
}),
})
}

View File

@@ -3,8 +3,9 @@
*
* 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
* they are captured (adoption, the per-append firehose, lifecycle
* forwarding), immediate versus explicitly released handoff, and the HMR
* 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
@@ -94,9 +95,10 @@ 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.
* `session/event` hot path, either at capture or while releasing a held
* prefix, 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
@@ -121,6 +123,9 @@ export interface TelemetryBackend {
* coordinator emits its dispose-time `shutdown` markers immediately before
* calling this). Awaited by the coordinator's dispose; a rejection is
* logged as a warning and never fails application teardown.
* The coordinator captures dispose-time shutdown markers immediately
* before this call; immediate delivery enqueues them, while held delivery
* leaves an unreleased suffix local.
* @returns resolves when the backend's pipeline has quiesced.
*/
shutdown(): Promise<void>
@@ -153,4 +158,4 @@ export abstract class Telemetry extends Service implements TelemetryBackend {
abstract shutdown(): Promise<void>
}
export { TelemetryCoordinator } from './coordinator.ts'
export { TelemetryCoordinator, type TelemetryDelivery } from './coordinator.ts'

View File

@@ -10,7 +10,12 @@ 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'
import {
TelemetryCoordinator,
type TelemetryBackend,
type TelemetryDelivery,
type TelemetryRecord,
} from '../src/index.ts'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
@@ -54,15 +59,21 @@ class FakeBackend implements TelemetryBackend {
}
}
async function setup(backend: FakeBackend = new FakeBackend()) {
async function setup(
backend: FakeBackend = new FakeBackend(),
delivery: TelemetryDelivery = 'immediate',
) {
const ctx = new Context()
await ctx.plugin(SessionStore)
let coordinator!: TelemetryCoordinator
const fiber = await ctx.plugin({
name: 'fake-telemetry',
inject: ['sessions'],
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
apply: (inner: Context) => {
coordinator = new TelemetryCoordinator(inner, backend, delivery)
},
})
return { ctx, backend, fiber }
return { ctx, backend, coordinator, fiber }
}
function liveSession(ctx: Context, id = `s-${Math.random().toString(36).slice(2)}`): Session {
@@ -167,6 +178,80 @@ describe('TelemetryCoordinator capture', () => {
})
})
describe('TelemetryCoordinator held delivery', () => {
it('releases one pending prefix at a time without handing later records over early', async () => {
const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'held')
const session = liveSession(ctx, 'held-prefix')
appendTurn(session)
expect(backend.records).toEqual([])
coordinator.release(session)
expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([
'turn/start',
'user/message',
])
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(backend.ledger()).toHaveLength(2)
coordinator.release(session)
coordinator.release(session)
expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([
'turn/start',
'user/message',
'turn/end',
])
})
it('stores the capture-time redacted copy rather than re-running policy at release', async () => {
const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'held')
const disposeRule = ctx.on('telemetry/record', (_record, next) => ({
...next(),
body: { scrubbed: true },
}))
const session = liveSession(ctx, 'held-redacted')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
disposeRule()
coordinator.release(session)
expect(backend.ledger()[0]!.body).toEqual({ scrubbed: true })
})
it('contains each backend failure independently while releasing a batch', async () => {
const backend = new FakeBackend()
backend.rejectSeq = 1
const { ctx, coordinator } = await setup(backend, 'held')
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const session = liveSession(ctx, 'held-failure')
appendTurn(session)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
coordinator.release(session)
expect(backend.ledger().map(record => record.attributes['event.seq'])).toEqual([0, 2])
expect(warn).toHaveBeenCalled()
})
it('rebuilds an unreleased prefix after coordinator reload', async () => {
const first = new FakeBackend()
const { ctx, fiber } = await setup(first, 'held')
const session = liveSession(ctx, 'held-reload')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await fiber.dispose()
expect(first.records).toEqual([])
const second = new FakeBackend()
let coordinator!: TelemetryCoordinator
await ctx.plugin({
name: 'fake-telemetry-after-held-reload',
inject: ['sessions'],
apply: (inner: Context) => {
coordinator = new TelemetryCoordinator(inner, second, 'held')
},
})
coordinator.release(session)
expect(second.ledger().map(record => record.attributes['event.seq'])).toEqual([0])
})
})
describe('TelemetryCoordinator adoption', () => {
it('exports an unpublished suffix without re-exporting constructor history', async () => {
const backend = new FakeBackend()