refactor(telemetry): replay feedback sessions without buffering
This commit is contained in:
@@ -1340,7 +1340,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
name: 'telemetry/record',
|
||||
mode: 'waterfall',
|
||||
signature: '\'telemetry/record\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord',
|
||||
jsDoc: '/**\n * Transform one outbound record before it reaches the backend. This\n * waterfall is the seam\'s redaction extension point. It ships NO rules\n * of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Redaction applies to the exported copy only; the canonical\n * session log is never 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 */',
|
||||
jsDoc: '/**\n * Transform one outbound record before it reaches the backend. This\n * waterfall is the seam\'s redaction extension point. It ships NO rules\n * of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Live capture dispatches at append time; on-demand capture\n * dispatches while reading the canonical log. Redaction applies to the\n * exported copy only; the canonical session log is never 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: 'Transform one outbound record before it reaches the backend.',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 在反馈前不保留遥测自有副本。记录反馈时,它读取并脱敏当前的权威日志;反馈前发生崩溃时什么都不上传,而反馈前的策略变更会影响该次回放的导出内容。
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: d38433a728c699c7fb3cc0512bb6a2d977dd4cc6
|
||||
README.zh.md: 3a86b01321fc7dfd33d39530ee7fa38a6ee1f2dc
|
||||
README.md: 67d95bcc62bbf6783f8dcd11f0236d8c926b557b
|
||||
README.zh.md: 1ee0e0eb14bb06c8ac669cd417f2ee2ce46ca430
|
||||
|
||||
@@ -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. 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 telemetry seam: the capture side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. Capture can follow live session events or replay a canonical session-log prefix on demand. 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), [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md), and [buffer-free feedback replay](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-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, 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.
|
||||
`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path or during an explicit canonical-log replay), 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 `live` capture or `on-demand` capture and calls `captureSession(session, throughSeq?)` 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, 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.
|
||||
In `live` mode 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; 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`). In `on-demand` mode it registers only the dispose effect: `captureSession()` reads the canonical log through an optional inclusive sequence boundary, while flush hints and operational events remain local.
|
||||
|
||||
## The redact waterfall
|
||||
|
||||
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.
|
||||
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. Live capture runs the waterfall at append time; on-demand capture runs it while replaying the canonical log, using the rules mounted at that time. 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. 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.
|
||||
A module-scope `WeakMap<Session, seq>` marks the highest seq HANDED OFF (not delivered) per session. Live capture advances it at append time; on-demand capture advances it only while `captureSession()` hands a requested prefix to the backend. An uncaptured prefix remains solely in the canonical log, so a coordinator reload adds no telemetry-owned recovery state. On replay 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,4 +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.
|
||||
- **On-demand redaction uses current state** — uncaptured events exist only in the canonical session log. A later `captureSession()` deep-copies and redacts their current values with the policy mounted at that time; there is no capture-time telemetry snapshot or durable pre-capture spool.
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
遥测(telemetry)seam:会话事件上报的捕获侧,隔在一个后端契约之后,任何上报 SDK 都无需变形即可满足该契约。捕获侧可立即交接每条已脱敏记录,也可按会话暂存一个前缀,等待显式释放。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)与[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)。
|
||||
遥测(telemetry)seam:会话事件上报的捕获侧,隔在一个后端契约之后,任何上报 SDK 都无需变形即可满足该契约。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)。
|
||||
|
||||
## 后端契约
|
||||
|
||||
`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它会在捕获或暂存前缀释放时,于 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `immediate` 或 `held` 投递模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `release(session)`。
|
||||
`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径或显式权威日志回放期间同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `captureSession(session, throughSeq?)`。
|
||||
|
||||
## 捕获点
|
||||
|
||||
协调器的全部注册都经由组合方 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 标记。
|
||||
在 `live` 模式中,协调器的全部注册都经由组合方 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`)。在 `on-demand` 模式中,协调器只注册 dispose effect:`captureSession()` 读取权威日志,直至可选的序列号边界(含边界);flush 提示与运维事件留在本地。
|
||||
|
||||
## 脱敏 waterfall(瀑布式事件)
|
||||
|
||||
每条记录在投影后立即经过 `telemetry/record` waterfall,这是该 seam 的脱敏扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。暂存投递只保留 waterfall 的结果,因此后续移除策略也无法暴露捕获时的原始内容。脱敏只作用于外发副本;权威会话日志永不改写。
|
||||
每条记录在投影后立即经过 `telemetry/record` waterfall,这是该 seam 的脱敏扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。实时捕获在追加时运行 waterfall;按需捕获则在回放权威日志时使用当时挂载的规则运行 waterfall。脱敏只作用于外发副本;权威会话日志永不改写。
|
||||
|
||||
## handoff 游标
|
||||
|
||||
一个模块作用域的 `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」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。
|
||||
一个模块作用域的 `WeakMap<Session, seq>` 记录每个会话已交接(而非已投递)的最高 seq。实时捕获在追加时推进游标;按需捕获只有在 `captureSession()` 将请求的前缀交给后端时才推进游标。未捕获的前缀只留在权威日志中,因此协调器重载不会增加遥测自有的恢复状态。回放时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0),由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接)。由此接受的代价与至多一次(at-most-once)投递一致:恢复不会回填上一个进程未能投递的记录;有回填要求的部署需要的是已推迟的 outbox,而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。
|
||||
|
||||
## 固定分片投影
|
||||
|
||||
@@ -40,4 +40,4 @@
|
||||
|
||||
- **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久化 outbox(spool、每 sink 游标、at-least-once)推迟到有部署方提出明确的崩溃丢失要求时再实现;见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。
|
||||
- **不内置脱敏规则**:未挂载 `telemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。
|
||||
- **暂存前缀会重复占用内存**:暂存投递会为每个已投影事件保留一份深拷贝且已脱敏的记录,直到释放或回收会话。它不增加持久化 outbox,而是有意以内存换取简单的「触发前不上传」边界。
|
||||
- **按需脱敏使用当前状态**:未捕获的事件只存在于权威会话日志中。后续的 `captureSession()` 会使用当时挂载的策略,深拷贝并脱敏其当前值;不存在捕获时的遥测快照或持久化的捕获前 spool。
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* 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
|
||||
* Capture coordinator: the seam's upstream half. Live capture subscribes to
|
||||
* the session firehose plus the one live-bus relay (`agent/error`). Both
|
||||
* capture paths apply the fixed chunk projection, build logical records, and
|
||||
* run each through the
|
||||
* `telemetry/record` waterfall (deployment-mounted redaction rules;
|
||||
* 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.
|
||||
* pass-through when none), then hands the result to the backend. Live capture
|
||||
* follows the session firehose; on-demand capture replays the canonical log
|
||||
* only when requested. 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
|
||||
*/
|
||||
@@ -17,11 +19,11 @@ 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'
|
||||
/** Whether capture follows live events or reads the canonical log only when requested. */
|
||||
export type TelemetryCapture = 'live' | 'on-demand'
|
||||
|
||||
/** One redacted record waiting at the capture boundary. */
|
||||
interface PendingRecord {
|
||||
/** One projected record ready for backend handoff. */
|
||||
interface ProjectedRecord {
|
||||
readonly record: TelemetryRecord
|
||||
/** Ledger cursor advanced only after the backend accepts this record. */
|
||||
readonly seq?: number
|
||||
@@ -43,16 +45,17 @@ const handoffCursor = new WeakMap<Session, number>()
|
||||
/**
|
||||
* 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
|
||||
* Live capture 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` 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.
|
||||
* adopted set. On-demand capture registers none of those continuous listeners;
|
||||
* {@link captureSession} reads the canonical log explicitly and never creates
|
||||
* operational records. Disposal captures shutdown markers for live-adopted
|
||||
* sessions, then awaits the backend's `shutdown()`; a failure there warns
|
||||
* instead of throwing — best-effort reporting must not fail application
|
||||
* teardown.
|
||||
*/
|
||||
export class TelemetryCoordinator {
|
||||
/**
|
||||
@@ -63,56 +66,55 @@ 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.
|
||||
* @param capture - follow live events, or wait for explicit canonical-log capture.
|
||||
*/
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly backend: TelemetryBackend,
|
||||
private readonly delivery: TelemetryDelivery = 'immediate',
|
||||
capture: TelemetryCapture = 'live',
|
||||
) {
|
||||
ctx.on('session/created', (session) => {
|
||||
this.adopt(session)
|
||||
})
|
||||
// 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.submit(session, { record: this.redact(shutdownRecord(session)) })
|
||||
if (capture === 'live') {
|
||||
ctx.on('session/created', (session) => {
|
||||
this.adopt(session)
|
||||
})
|
||||
})
|
||||
ctx.on('session/event', (session, event) => {
|
||||
this.contain(() => {
|
||||
this.capture(session, event)
|
||||
// Capture the shutdown marker at the session's own termination edge,
|
||||
// then retire the only strong reference owned by this coordinator.
|
||||
ctx.on('session/disposed', (session) => {
|
||||
this.contain(() => {
|
||||
if (!this.adopted.delete(session)) return
|
||||
this.deliver(session, { record: this.redact(shutdownRecord(session)) })
|
||||
})
|
||||
})
|
||||
})
|
||||
// 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('session/event', (session, event) => {
|
||||
this.contain(() => {
|
||||
this.captureEvent(session, event)
|
||||
})
|
||||
})
|
||||
})
|
||||
ctx.on('agent/error', (agent, turn, step, error) => {
|
||||
this.contain(() => {
|
||||
this.relayAgentError(agent, turn, step, error)
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
for (const session of ctx.sessions.list()) {
|
||||
this.adopt(session)
|
||||
}
|
||||
}
|
||||
ctx.effect(() => async () => {
|
||||
// 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.
|
||||
// teardown, so capture the marker before the backend quiesces.
|
||||
for (const session of this.adopted) {
|
||||
this.contain(() => {
|
||||
this.submit(session, { record: this.redact(shutdownRecord(session)) })
|
||||
this.deliver(session, { record: this.redact(shutdownRecord(session)) })
|
||||
})
|
||||
}
|
||||
try {
|
||||
@@ -121,24 +123,27 @@ export class TelemetryCoordinator {
|
||||
this.ctx.logger.warn(`telemetry: backend shutdown failed: ${String(error)}`)
|
||||
}
|
||||
}, 'telemetry capture')
|
||||
for (const session of ctx.sessions.list()) {
|
||||
this.adopt(session)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Project and hand over the canonical session-log suffix after the handoff
|
||||
* cursor, optionally stopping at an inclusive sequence boundary. Redaction
|
||||
* runs during this call, so an on-demand caller retains no copied records
|
||||
* before requesting capture and uses the policy mounted at that time.
|
||||
* Backend and policy failures remain contained per event and do not starve
|
||||
* later events in the same replay.
|
||||
* @param session - session whose current canonical-log prefix may be handed over.
|
||||
* @param throughSeq - optional last sequence included in this capture.
|
||||
*/
|
||||
release(session: Session): void {
|
||||
const pending = this.held.get(session)
|
||||
if (pending === undefined) return
|
||||
this.held.delete(session)
|
||||
for (const record of pending) {
|
||||
captureSession(session: Session, throughSeq?: number): void {
|
||||
const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1
|
||||
// Containment is PER EVENT: one rejected record is withheld fail-closed
|
||||
// while the rest of the historical replay proceeds.
|
||||
for (const event of session.events) {
|
||||
if (throughSeq !== undefined && event.seq > throughSeq) break
|
||||
this.contain(() => {
|
||||
this.deliver(session, record)
|
||||
if (event.seq <= cursor) this.track(session, event)
|
||||
else this.captureEvent(session, event)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -161,17 +166,7 @@ export class TelemetryCoordinator {
|
||||
private adopt(session: Session): void {
|
||||
if (this.adopted.has(session)) return
|
||||
this.adopted.add(session)
|
||||
const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1
|
||||
// Containment is PER EVENT, matching the firehose: one rejected record
|
||||
// is withheld fail-closed while the rest of the historical replay
|
||||
// proceeds — wrapping the whole loop would let a single failure silently
|
||||
// skip the remainder of the log on an already-adopted session.
|
||||
for (const event of session.events) {
|
||||
this.contain(() => {
|
||||
if (event.seq <= cursor) this.track(session, event)
|
||||
else this.capture(session, event)
|
||||
})
|
||||
}
|
||||
this.captureSession(session)
|
||||
}
|
||||
|
||||
/** Feed the chunk projection without handing off — the ≤cursor half of re-adoption. */
|
||||
@@ -181,8 +176,8 @@ export class TelemetryCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/** Project and redact one event, then submit it under the delivery policy. */
|
||||
private capture(session: Session, event: SessionEvent): void {
|
||||
/** Project, redact, and hand one event to the backend. */
|
||||
private captureEvent(session: Session, event: SessionEvent): void {
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const key = `${event.data.turn}:${event.data.step}`
|
||||
const seen = this.seen(session)
|
||||
@@ -193,14 +188,14 @@ export class TelemetryCoordinator {
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
}
|
||||
this.submit(session, {
|
||||
this.deliver(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.
|
||||
// The canonical event object is mutable and the backend serializes
|
||||
// later; append-time validation guarantees this clone cannot throw.
|
||||
body: structuredClone(event.data),
|
||||
}),
|
||||
seq: event.seq,
|
||||
@@ -212,26 +207,15 @@ export class TelemetryCoordinator {
|
||||
* 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.
|
||||
* reaching the loop (fail-closed). On-demand capture invokes this waterfall
|
||||
* while reading the canonical session log, not when the event was appended.
|
||||
*/
|
||||
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 {
|
||||
private deliver(session: Session, pending: ProjectedRecord): void {
|
||||
this.backend.emit(pending.record)
|
||||
if (pending.seq !== undefined) handoffCursor.set(session, pending.seq)
|
||||
}
|
||||
@@ -244,7 +228,7 @@ 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.submit(agent.session, {
|
||||
this.deliver(agent.session, {
|
||||
record: this.redact({
|
||||
channel: 'ops',
|
||||
time: Date.now(),
|
||||
|
||||
@@ -4,9 +4,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 captured (adoption, the per-append firehose, lifecycle
|
||||
* forwarding), immediate versus explicitly released handoff, and the HMR
|
||||
* forwarding), live versus on-demand canonical-log capture, and the HMR
|
||||
* cursor. Everything downstream of
|
||||
* {@link Telemetry.emit} — batching, retry, queueing, loss policy — is the
|
||||
* {@link Telemetry.emit} — batching, retry, queueing, and 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.
|
||||
@@ -33,8 +33,9 @@ declare module 'cordis' {
|
||||
* `next()` replaces everything beneath. 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.
|
||||
* agent loop. Live capture dispatches at append time; on-demand capture
|
||||
* dispatches while reading the canonical log. 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
|
||||
@@ -95,8 +96,8 @@ 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, either at capture or while releasing a held
|
||||
* prefix, so anything slower than a queue push would tax the agent loop.
|
||||
* `session/event` hot path or an explicit canonical-log capture, so anything
|
||||
* slower than a queue push would tax the agent loop or feedback handling.
|
||||
* 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.
|
||||
@@ -123,9 +124,8 @@ 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.
|
||||
* The coordinator captures dispose-time shutdown markers immediately before
|
||||
* this call for live capture; on-demand capture creates no ops records.
|
||||
* @returns resolves when the backend's pipeline has quiesced.
|
||||
*/
|
||||
shutdown(): Promise<void>
|
||||
@@ -158,4 +158,4 @@ export abstract class Telemetry extends Service implements TelemetryBackend {
|
||||
abstract shutdown(): Promise<void>
|
||||
}
|
||||
|
||||
export { TelemetryCoordinator, type TelemetryDelivery } from './coordinator.ts'
|
||||
export { TelemetryCoordinator, type TelemetryCapture } from './coordinator.ts'
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
TelemetryCoordinator,
|
||||
type TelemetryBackend,
|
||||
type TelemetryDelivery,
|
||||
type TelemetryCapture,
|
||||
type TelemetryRecord,
|
||||
} from '../src/index.ts'
|
||||
|
||||
@@ -61,7 +61,7 @@ class FakeBackend implements TelemetryBackend {
|
||||
|
||||
async function setup(
|
||||
backend: FakeBackend = new FakeBackend(),
|
||||
delivery: TelemetryDelivery = 'immediate',
|
||||
capture: TelemetryCapture = 'live',
|
||||
) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -70,7 +70,7 @@ async function setup(
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => {
|
||||
coordinator = new TelemetryCoordinator(inner, backend, delivery)
|
||||
coordinator = new TelemetryCoordinator(inner, backend, capture)
|
||||
},
|
||||
})
|
||||
return { ctx, backend, coordinator, fiber }
|
||||
@@ -178,23 +178,24 @@ 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')
|
||||
describe('TelemetryCoordinator on-demand capture', () => {
|
||||
it('captures one canonical-log prefix at a time without following later events', async () => {
|
||||
const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'on-demand')
|
||||
const session = liveSession(ctx, 'on-demand-prefix')
|
||||
appendTurn(session)
|
||||
const firstBoundary = session.events[1]!.seq
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(backend.records).toEqual([])
|
||||
|
||||
coordinator.release(session)
|
||||
coordinator.captureSession(session, firstBoundary)
|
||||
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)
|
||||
coordinator.captureSession(session)
|
||||
coordinator.captureSession(session)
|
||||
expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([
|
||||
'turn/start',
|
||||
'user/message',
|
||||
@@ -202,38 +203,42 @@ describe('TelemetryCoordinator held delivery', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('stores the capture-time redacted copy rather than re-running policy at release', async () => {
|
||||
const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'held')
|
||||
it('runs the currently mounted redaction policy during canonical-log capture', async () => {
|
||||
const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'on-demand')
|
||||
const session = liveSession(ctx, 'on-demand-redacted')
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
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' } } })
|
||||
|
||||
coordinator.captureSession(session)
|
||||
expect(backend.ledger()[0]!.body).toEqual({ scrubbed: true })
|
||||
disposeRule()
|
||||
|
||||
coordinator.release(session)
|
||||
expect(backend.ledger()[0]!.body).toEqual({ scrubbed: true })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
coordinator.captureSession(session)
|
||||
expect(backend.ledger()[1]!.body).toEqual({ turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
|
||||
it('contains each backend failure independently while releasing a batch', async () => {
|
||||
it('contains each backend failure independently while replaying a prefix', async () => {
|
||||
const backend = new FakeBackend()
|
||||
backend.rejectSeq = 1
|
||||
const { ctx, coordinator } = await setup(backend, 'held')
|
||||
const { ctx, coordinator } = await setup(backend, 'on-demand')
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const session = liveSession(ctx, 'held-failure')
|
||||
const session = liveSession(ctx, 'on-demand-failure')
|
||||
appendTurn(session)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
coordinator.release(session)
|
||||
coordinator.captureSession(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 () => {
|
||||
it('captures a pending prefix after coordinator reload without retained records', async () => {
|
||||
const first = new FakeBackend()
|
||||
const { ctx, fiber } = await setup(first, 'held')
|
||||
const session = liveSession(ctx, 'held-reload')
|
||||
const { ctx, fiber } = await setup(first, 'on-demand')
|
||||
const session = liveSession(ctx, 'on-demand-reload')
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
await fiber.dispose()
|
||||
expect(first.records).toEqual([])
|
||||
@@ -241,15 +246,34 @@ describe('TelemetryCoordinator held delivery', () => {
|
||||
const second = new FakeBackend()
|
||||
let coordinator!: TelemetryCoordinator
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry-after-held-reload',
|
||||
name: 'fake-telemetry-after-on-demand-reload',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => {
|
||||
coordinator = new TelemetryCoordinator(inner, second, 'held')
|
||||
coordinator = new TelemetryCoordinator(inner, second, 'on-demand')
|
||||
},
|
||||
})
|
||||
coordinator.release(session)
|
||||
coordinator.captureSession(session)
|
||||
expect(second.ledger().map(record => record.attributes['event.seq'])).toEqual([0])
|
||||
})
|
||||
|
||||
it('registers no continuous capture, flush, or ops listeners', async () => {
|
||||
const { ctx, backend, coordinator, fiber } = await setup(new FakeBackend(), 'on-demand')
|
||||
const redact = vi.fn((_record: TelemetryRecord, next: () => TelemetryRecord) => next())
|
||||
ctx.on('telemetry/record', redact)
|
||||
const session = liveSession(ctx, 'on-demand-ledger-only')
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
await ctx.parallel('session/flush', session)
|
||||
const agent = { id: 'agent-1', session } as Agent
|
||||
ctx.emit('agent/error', agent, 1, 1, new Error('local only'))
|
||||
expect(backend.flush).not.toHaveBeenCalled()
|
||||
expect(backend.records).toEqual([])
|
||||
expect(redact).not.toHaveBeenCalled()
|
||||
|
||||
coordinator.captureSession(session)
|
||||
expect(redact).toHaveBeenCalledTimes(1)
|
||||
await fiber.dispose()
|
||||
expect(backend.records.map(record => record.channel)).toEqual(['ledger'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('TelemetryCoordinator adoption', () => {
|
||||
|
||||
Reference in New Issue
Block a user