refactor(telemetry): replay feedback sessions without buffering

This commit is contained in:
Turtle
2026-08-06 14:57:30 +08:00
parent 9d9b547d55
commit 4e8067e8d5
24 changed files with 270 additions and 200 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/telemetry/session-telemetry-otel/README.md
README.md: fab2461477b2174bded42ed6f05ae55c7c5f697c
README.zh.md: ab0191188836e03434adbce527d31b62ead848a3
README.md: 7fc5572614a5bdba312ba97b52606032ef8f5394
README.zh.md: 3160b67c8225fb87d5e7be2e43453ef40496fba9

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. 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.
The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. Its `mode` decides whether the seam follows session events live, replays the canonical log 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
@@ -21,14 +21,14 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
| `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. |
| `FEEDBACK_ONLY` | Each `feedback/record` replays, projects, and redacts the canonical session-log suffix 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
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.
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)). `FULL` runs redaction at append time; `FEEDBACK_ONLY` retains no telemetry copy and runs the currently mounted rules when feedback triggers canonical-log replay. 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
@@ -46,4 +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.
- **Feedback-time snapshot** — `FEEDBACK_ONLY` retains no telemetry-owned copy before feedback. It reads and redacts the current canonical log when feedback is recorded; a crash before feedback uploads nothing, and policy changes before feedback affect what that replay exports.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。其 `mode` 决定 seam 是立即交接记录、仅在记录反馈时释放记录,还是将遥测留在本地。上传模式会原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把每条已交接记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源。
[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。其 `mode` 决定 seam 是实时跟随会话事件、仅在记录反馈时回放权威日志,还是将遥测留在本地。上传模式会原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把每条已交接记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源。
## 配置
@@ -21,14 +21,14 @@
| `mode` | 行为 |
|---|---|
| `FULL` | 默认值。每条已投影记录都立即交给 OTel SDK,包括生命周期运维记录。 |
| `FEEDBACK_ONLY` | 每个 `feedback/record` 都会释放截至该事件的已脱敏、已投影会话前缀。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 |
| `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 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。
在上传模式中,记录携带完整的 `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))。`FULL` 在追加时运行脱敏;`FEEDBACK_ONLY` 不保留遥测副本,而是在反馈触发权威日志回放时运行当时挂载的规则。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。
## 字段映射
@@ -46,4 +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;如果在反馈前崩溃,则什么都不上传。
- **反馈时快照**:`FEEDBACK_ONLY` 在反馈前不保留遥测自有副本。记录反馈时,它读取并脱敏当前的权威日志;反馈前发生崩溃时什么都不上传,而反馈前的策略变更会影响该次回放的导出内容。

View File

@@ -7,7 +7,8 @@
* 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 owns
* only whether capture is immediate, feedback-released, or disabled.
* only whether capture is live, feedback-triggered from the canonical log, or
* disabled.
*
* @module @deepseek-ai/dsh-session-telemetry-otel
*/
@@ -19,7 +20,7 @@ import type {} from '@deepseek-ai/dsh-command-feedback'
import {
Telemetry,
TelemetryCoordinator,
type TelemetryDelivery,
type TelemetryCapture,
type TelemetryRecord,
type TelemetrySeverity,
} from '@deepseek-ai/dsh-session-telemetry'
@@ -161,13 +162,13 @@ 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)
const delivery: TelemetryDelivery = mode === 'FULL' ? 'immediate' : 'held'
const coordinator = new TelemetryCoordinator(ctx, this, delivery)
const capture: TelemetryCapture = mode === 'FULL' ? 'live' : 'on-demand'
const coordinator = new TelemetryCoordinator(ctx, this, capture)
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.
// Session.append commits before publishing `session/event`, so the
// canonical log already includes this feedback record when replay begins.
ctx.on('session/event', (session, event) => {
if (event.type === 'feedback/record') coordinator.release(session)
if (event.type === 'feedback/record') coordinator.captureSession(session, event.seq)
})
}
}
@@ -206,8 +207,8 @@ export class TelemetryOtel extends Telemetry {
* quiesce. With no concurrent `forceFlush()` in the process (see above),
* 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
* includes dispose-time `shutdown` markers; `FEEDBACK_ONLY` creates no ops
* records. Awaited (and error-contained) by the coordinator's disposer. A
* disabled backend resolves immediately.
* @returns resolves when the SDK pipeline has quiesced.
*/

View File

@@ -206,7 +206,7 @@ describe('TelemetryOtel wire', () => {
expect(start?.record.severityNumber).toBe(13)
})
it('holds each session suffix until the next feedback event', async () => {
it('replays each session suffix only at the next feedback event', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
await ctx.plugin(SessionStore)