Merge updated feedback base into telemetry stack

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md
#	.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml
#	docs/config-catalog.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	examples/package.json
#	packages/feedback/command-feedback/README.i18n.yaml
#	packages/telemetry/README.i18n.yaml
#	packages/telemetry/README.md
#	packages/telemetry/README.zh.md
#	packages/telemetry/session-telemetry-otel/README.i18n.yaml
#	packages/telemetry/session-telemetry-otel/README.md
#	packages/telemetry/session-telemetry-otel/README.zh.md
#	packages/telemetry/session-telemetry-otel/package.json
#	packages/telemetry/session-telemetry-otel/src/index.ts
#	packages/telemetry/session-telemetry-otel/tests/otel.spec.ts
#	packages/telemetry/session-telemetry/README.i18n.yaml
#	packages/telemetry/session-telemetry/README.zh.md
#	pnpm-lock.yaml
This commit is contained in:
Turtle
2026-08-06 18:08:45 +08:00
4133 changed files with 255372 additions and 59535 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/README.md
README.md: ddac6c6cdc5a7326190283fbe232b6985deb4927
README.zh.md: 6863ca56d4f63fccb60eb3c92acd1e70b25e27dd
README.md: d1910323176738d1ecab8fe8e6c07a2811f0f5cd
README.zh.md: 229cd3acc5ce3dc49a9c862efa824f4ee6a1fa7b

View File

@@ -1,10 +1,10 @@
# telemetry/
# telemetry/ — session telemetry capability family
English | [中文](README.zh.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).
This family projects session activity into outbound telemetry and delegates delivery to a configured reporting backend. The [telemetry decision](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) records the reporting boundary; the [mode decision](../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md) records immediate, feedback-gated, and disabled delivery.
| Package | Role |
|---|---|
| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, live or on-demand capture, 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. |
| [`session-telemetry/`](session-telemetry/README.md) | Defines capture, redaction, projection, and live or on-demand backend delivery. |
| [`session-telemetry-otel/`](session-telemetry-otel/README.md) | Delivers telemetry through OpenTelemetry logs in `FULL`, `FEEDBACK_ONLY`, or `DISABLED` mode. |

View File

@@ -1,10 +1,10 @@
# telemetry/
# telemetry/:会话遥测能力家族
[English](README.md) | 中文
面向外部的会话上报:遥测(telemetry)seam 及其 OpenTelemetry 后端。边界公理、脱敏 waterfall(瀑布式事件)、固定分片投影、handoff 游标及运维记录通道的决定见[复活 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/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 本体:捕获点、投影、脱敏、实时或按需捕获、游标、运维信号,以及最小后端契约(`emit`/`flush?`/`shutdown`)。 |
| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | 部署方要加载的后端:围绕 OTel JS SDK 日志流水线实施 `FULL`、`FEEDBACK_ONLY` 或 `DISABLED` 策略。 |
| [`session-telemetry/`](session-telemetry/README.md) | 定义捕获、脱敏、投影,以及实时或按需后端投递。 |
| [`session-telemetry-otel/`](session-telemetry-otel/README.md) | 通过 OpenTelemetry 日志以 `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: af177dc86bc30a7b17e34e3c8c3592326b9026f2
README.zh.md: 9a3ad628bb7d480a1c4cd8346669ad8ebbd6258f
README.md: 585995ce409255df9608bc33b76625374bc67669
README.zh.md: 6d2cfa4d492cee7f90d557c83f5c1ab3c730c6e5

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 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.
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 contains `service.name`/`service.version` from `dsh-llm`'s `APP_IDENTITY` plus this package's anonymous `user.id` (`$DSH_HOME/.userid`, a random UUID created on first use and reset by deleting the file), carried once per export batch rather than per record.
## Config
@@ -11,6 +11,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED
shutdownTimeoutMillis: 3000 # optional; defaults to 3000
exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
url: https://collector.example.com/v1/logs
headers:
@@ -28,7 +29,7 @@ Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`T
Upload authorization is positive and fail-closed. An unknown direct-construction mode fails before transport configuration is read. Only `FULL` accepts direct `ctx.telemetry.emit()` calls. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability and treats only the exact `feedback/record` object already stored at `session.events[event.seq]` as consent; an independently emitted bus value is ignored. `DISABLED` never constructs the SDK pipeline, even when exporter options are present.
`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.
`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`. In uploading modes, `shutdownTimeoutMillis` is a positive finite DSH-owned outer deadline that defaults to 3000 ms, and a non-positive-integer `processor.maxExportBatchSize` also fails at plugin load because the SDK accepts it but then hangs on shutdown. Both SDK 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 SDK behavior tuned through `processor`. The backend implements no `flush()`: the batch processor owns ordinary flushing. During shutdown, OTel awaits `exporter.forceFlush()` before the processor's `exportTimeoutMillis`-bounded completion promise; if that transport promise never settles, this package abandons the wait at `shutdownTimeoutMillis`, logs the contained shutdown failure through the coordinator, and lets application teardown continue. The deadline cannot cancel the SDK transport, so records still pending then may be lost at process exit.
## What leaves the machine
@@ -49,5 +50,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Upstream experimental tree** — `@opentelemetry/sdk-logs` is still published from the upstream experimental tree; SDK API churn lands here and only here — the seam contract does not move.
- **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.
- **Live-collector behavior belongs to the SDK exporter** — authentication, TLS, throttling, and other real OTLP deployment behavior follow the upstream SDK rather than a package-owned compatibility layer.
- **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`),以及本包的匿名 `user.id`(`$DSH_HOME/.userid`;首次使用时创建的随机 UUID,删除该文件可重置);这些身份随每个导出批次携带一次,而非逐条记录携带。
## 配置
@@ -11,6 +11,7 @@
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED
shutdownTimeoutMillis: 3000 # optional; defaults to 3000
exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
url: https://collector.example.com/v1/logs
headers:
@@ -28,7 +29,7 @@
上传授权采用显式许可,且为 fail-closed。通过直接构造传入未知模式时,会在读取传输配置前失败。只有 `FULL` 接受对 `ctx.telemetry.emit()` 的直接调用。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力,并且仅在 `feedback/record` 对象已经存储于 `session.events[event.seq]` 且对象身份完全相同时,才将其视为同意;独立发出的总线值会被忽略。即使存在导出器选项,`DISABLED` 也绝不会构造 SDK 流水线。
`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()` 的排空正因如此才是完整的。
`exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。在上传模式中,`shutdownTimeoutMillis` 是由 DSH 管理的有限正数外层截止时间,默认值为 3000 ms;`processor.maxExportBatchSize` 不是正整数时也会在插件加载时失败,因为 SDK 会接受该值,随后却在关闭时挂起。两个 SDK 配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是通过 `processor` 调节的 SDK 行为。该后端不实现 `flush()`:常规 flush 由批处理器负责。关闭期间,OTel 会先等待 `exporter.forceFlush()`,再进入受处理器 `exportTimeoutMillis` 限制的完成 promise;如果该传输 promise 始终不结算,本包会在 `shutdownTimeoutMillis` 到期时放弃等待,通过协调器记录已隔离的关闭失败,并让应用继续拆卸。该截止时间无法取消 SDK 传输,因此届时仍待处理的记录可能在进程退出时丢失。
## 哪些数据会离开本机
@@ -49,5 +50,5 @@ 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 导出器文档的职责范围。
- **真实 collector 行为属于 SDK 导出器**:身份验证、TLS、限流及其他真实 OTLP 部署行为遵循上游 SDK,不由本包自有兼容层处理。
- **反馈时快照**:`FEEDBACK_ONLY` 在反馈前不保留遥测自有副本。记录反馈时,它读取并脱敏当前的权威日志;反馈前发生崩溃时什么都不上传,而反馈前的策略变更会影响该次回放的导出内容。

View File

@@ -21,9 +21,7 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"dependencies": {
@@ -37,8 +35,10 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-command-feedback": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-telemetry": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -46,8 +46,10 @@
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-command-feedback": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-telemetry": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -7,8 +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 live, feedback-triggered from the canonical log, or
* disabled.
* capture mode and an outer shutdown deadline: the SDK's export timeout does
* not bound its preceding `forceFlush()` wait.
*
* @module @deepseek-ai/dsh-session-telemetry-otel
*/
@@ -25,6 +25,7 @@ import {
type TelemetrySeverity,
} from '@deepseek-ai/dsh-session-telemetry'
import { APP_IDENTITY } from '@deepseek-ai/dsh-llm'
import { getOrCreateAnonymousUserId } from './user-id.ts'
import {
BatchLogRecordProcessor,
LoggerProvider,
@@ -72,9 +73,9 @@ function assertNever(value: never): never {
}
/**
* Plugin configuration: one sharing policy plus two verbatim SDK option
* shapes. `exporter.url` is required for modes that upload and unused for
* `DISABLED`.
* Plugin configuration: one sharing policy, two verbatim SDK option shapes,
* and one DSH-owned shutdown bound. Uploading modes validate their endpoint
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
export interface Config {
/** Sharing policy; defaults to immediate `FULL` delivery. */
@@ -94,22 +95,32 @@ export interface Config {
* which this plugin fills); the SDK owns and documents these knobs.
*/
processor?: Omit<BatchLogRecordProcessorOptions, 'exporter'>
/** Maximum time spent awaiting the SDK provider's complete shutdown path. */
shutdownTimeoutMillis?: number
}
/**
* Schemastery validator for {@link Config}; cordis runs it before the plugin
* 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).
* starts. Shape-level only — load-bearing value checks live in the constructor
* so their errors name the fields. 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(Object.values(TelemetryMode)).default(DEFAULT_TELEMETRY_MODE),
exporter: z.any(),
processor: z.any(),
shutdownTimeoutMillis: z.number(),
})
/** Default outer allowance for the SDK's complete shutdown sequence. */
export const DEFAULT_SHUTDOWN_TIMEOUT_MILLIS = 3_000
// Node clamps larger timer delays to one millisecond. This is a runtime
// protocol limit, not a deployment default.
const MAX_TIMER_DELAY_MILLIS = 2_147_483_647
/** Severity mapping from the seam's three-level vocabulary to OTel severity numbers. */
const SEVERITY: Record<TelemetrySeverity, { severityNumber: SeverityNumber; severityText: string }> = {
info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' },
@@ -129,6 +140,7 @@ export class TelemetryOtel extends Telemetry {
private readonly directEmit: TelemetryBackend['emit']
private readonly provider: LoggerProvider | undefined
private readonly shutdownTimeoutMillis: number
constructor(ctx: Context, config: Config) {
const mode = resolveMode(config.mode)
@@ -136,6 +148,7 @@ export class TelemetryOtel extends Telemetry {
if (mode === TelemetryMode.DISABLED) {
this.directEmit = DROP_RECORD
this.provider = undefined
this.shutdownTimeoutMillis = DEFAULT_SHUTDOWN_TIMEOUT_MILLIS
ctx.on('session/event', (_session, event) => {
if (event.type === 'feedback/record') ctx.logger.warn(DISABLED_FEEDBACK_WARNING)
})
@@ -164,10 +177,19 @@ export class TelemetryOtel extends Telemetry {
if (batchSize !== undefined && (!Number.isInteger(batchSize) || batchSize < 1)) {
throw new Error(`session-telemetry-otel: processor.maxExportBatchSize must be a positive integer, got ${String(batchSize)}`)
}
const shutdownTimeoutMillis = config.shutdownTimeoutMillis ?? DEFAULT_SHUTDOWN_TIMEOUT_MILLIS
if (!Number.isFinite(shutdownTimeoutMillis) || shutdownTimeoutMillis <= 0 || shutdownTimeoutMillis > MAX_TIMER_DELAY_MILLIS) {
throw new Error(`session-telemetry-otel: shutdownTimeoutMillis must be a positive finite number no greater than ${MAX_TIMER_DELAY_MILLIS}, got ${String(shutdownTimeoutMillis)}`)
}
this.shutdownTimeoutMillis = shutdownTimeoutMillis
this.provider = new LoggerProvider({
resource: resourceFromAttributes({
'service.name': APP_IDENTITY.product,
'service.version': APP_IDENTITY.version,
// OTel semconv's standard user attribute, carried once per export
// batch on the Resource rather than per record: the collector
// aggregates by Resource, and the id is process-stable anyway.
'user.id': getOrCreateAnonymousUserId(),
}),
processors: [
new BatchLogRecordProcessor({
@@ -238,17 +260,29 @@ export class TelemetryOtel extends Telemetry {
// the revival Agent Note.
/**
* 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 handed to the SDK
* before this call is exported before the exporter closes. In `FULL`, that
* 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.
* Ask the SDK to drain and quiesce, but reject after the backend-owned
* deadline. OTel's processor export timeout wraps `exportCompleted` only;
* shutdown awaits `exporter.forceFlush()` first, which can remain pending
* when the transport never obtains a socket. The provider promise remains
* observed after the deadline so a later rejection cannot become unhandled.
* `DISABLED` has no provider and resolves immediately.
* @returns resolves when the SDK pipeline quiesces or is disabled, or rejects at the configured deadline.
*/
shutdown(): Promise<void> {
return this.provider === undefined ? Promise.resolve() : this.provider.shutdown()
async shutdown(): Promise<void> {
if (this.provider === undefined) return
const providerShutdown = this.provider.shutdown()
let timer: ReturnType<typeof setTimeout> | undefined
const deadline = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
reject(new Error(`session-telemetry-otel: provider shutdown exceeded ${this.shutdownTimeoutMillis}ms`))
}, this.shutdownTimeoutMillis)
})
try {
await Promise.race([providerShutdown, deadline])
} finally {
/* v8 ignore else -- the Promise executor assigns timer synchronously before this race starts. */
if (timer !== undefined) clearTimeout(timer)
}
}
}

View File

@@ -0,0 +1,103 @@
/**
* Per-harness-home anonymous user id for the OTel Resource.
*
* The id is a random UUID persisted as a bare line in `.userid` inside the
* harness home resolved by {@link resolveDshHome} (`$DSH_HOME` > `~/.dsh`),
* and never derived from the hostname, network address, git remote, or any
* other identifying source — a derived id would make "anonymous" a fiction.
* The id is scoped to the harness home, not the machine: every process
* sharing one `$DSH_HOME` reports the same id, and deleting the file simply
* mints a fresh identity on the next launch (loss is accepted by design).
* This identity belongs to the OTel feed alone; the dsh-sdk launcher
* telemetry keeps its own separate store.
*
* Reads and writes are synchronous so the backend constructor can call this
* on its boot path, and the result is memoized per resolved file path: one
* process touches the disk once, and a file deleted mid-run keeps the
* process's id until the next launch.
*
* @module @deepseek-ai/dsh-session-telemetry-otel/user-id
*/
import { randomUUID } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import type { Branded } from '@deepseek-ai/dsh-brand'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
/** A harness-home-scoped anonymous user id (random UUID v4). */
export type AnonymousUserId = Branded<'AnonymousUserId'>
/** File inside the harness home storing the id: a bare UUID line, no wrapper format. */
export const USER_ID_FILE_NAME = '.userid'
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** Ambient seams for locating and generating the id; every field has a default. */
export interface AnonymousUserIdOptions {
/** Environment consulted for `DSH_HOME`; defaults to `process.env`. */
env?: NodeJS.ProcessEnv
/** UUID generator; defaults to `crypto.randomUUID` (test seam). */
randomUUID?: () => string
}
/** Process-lifetime memo keyed by resolved file path, so distinct test homes never share an id. */
const memo = new Map<string, AnonymousUserId>()
/** Read a valid persisted id from the file, or `undefined` when absent/corrupt. */
function readPersistedId(file: string): AnonymousUserId | undefined {
let text: string
try {
text = readFileSync(file, 'utf8')
} catch {
// Absent or unreadable: the caller mints and persists a fresh id.
return undefined
}
const value = text.trim()
return UUID_PATTERN.test(value) ? (value as AnonymousUserId) : undefined
}
/**
* Return the harness home's anonymous user id, creating and persisting one on
* first use. A concurrent first launch is settled by an exclusive-create
* write: the loser rereads the winner's id. (A reread landing in the winner's
* narrow create-to-write window can still yield two per-process ids for that
* run; the next launch converges on the persisted one.) Persistence is
* best-effort — a write failure (read-only home) still returns a usable id
* for the current run so telemetry is never blocked.
* @param options - home-location and UUID-generation seams.
* @returns the stable per-harness-home anonymous user id.
*/
export function getOrCreateAnonymousUserId(options: AnonymousUserIdOptions = {}): AnonymousUserId {
const file = join(resolveDshHome(undefined, options.env ?? process.env), USER_ID_FILE_NAME)
const cached = memo.get(file)
if (cached !== undefined) return cached
let id = readPersistedId(file)
if (id === undefined) {
const generate = options.randomUUID ?? randomUUID
const created = generate() as AnonymousUserId
try {
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, `${created}\n`, { encoding: 'utf8', flag: 'wx' })
id = created
} catch {
// A wx refusal (EEXIST) covers both a concurrent winner and a
// pre-existing corrupt file: the reread adopts a valid winner, and an
// invalid reread falls through to the overwrite path. Non-EEXIST
// failures (read-only home) land there too, accepted best-effort below.
id = readPersistedId(file)
if (id === undefined) {
try {
writeFileSync(file, `${created}\n`, 'utf8')
} catch {
// Best-effort persistence: keep the fresh id in memory even when the
// home is unwritable, so this run still reports a consistent id.
}
id = created
}
}
}
memo.set(file, id)
return id
}

View File

@@ -5,11 +5,15 @@
* for the default-exported Service class.
*/
import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
import { afterAll, afterEach, beforeAll, describe, expect, expectTypeOf, it, vi } from 'vitest'
import { createServer, type Server } from 'node:http'
import { once } from 'node:events'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { gunzipSync } from 'node:zlib'
import { Context } from 'cordis'
import { getOrCreateAnonymousUserId } from '../src/user-id.ts'
import Loader from '@cordisjs/plugin-loader'
import { recordFeedback } from '@deepseek-ai/dsh-command-feedback'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -39,6 +43,21 @@ interface OtlpLogsRequest {
const servers: Server[] = []
// The backend resolves the harness home's anonymous user id at construction;
// pin DSH_HOME to a temp dir so the suite never touches the ambient ~/.dsh.
let tempHome: string
let previousDshHome: string | undefined
beforeAll(() => {
tempHome = mkdtempSync(join(tmpdir(), 'dsh-otel-home-'))
previousDshHome = process.env.DSH_HOME
process.env.DSH_HOME = tempHome
})
afterAll(() => {
if (previousDshHome === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previousDshHome
rmSync(tempHome, { recursive: true, force: true })
})
afterEach(async () => {
for (const server of servers.splice(0)) {
server.close()
@@ -103,8 +122,8 @@ describe('TelemetryOtel wire', () => {
const { url, captures } = await mockCollector()
const { ctx, fiber } = await boot(url)
const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
ctx.telemetry.emit({
channel: 'ledger',
time: Date.now(),
@@ -121,6 +140,7 @@ describe('TelemetryOtel wire', () => {
const resource = first.body.resourceLogs[0]!.resource.attributes
expect(resource).toContainEqual({ key: 'service.name', value: { stringValue: 'deepseek-harness' } })
expect(resource).toContainEqual({ key: 'user.id', value: { stringValue: getOrCreateAnonymousUserId() } })
const records = allRecords(captures)
const ledger = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel')
@@ -164,7 +184,7 @@ describe('TelemetryOtel wire', () => {
processor: { scheduledDelayMillis: 10 },
})
const session = ctx.sessions.create(SessionId('drain'), { meta: {} })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
await arrived.promise
const disposal = fiber.dispose()
@@ -178,6 +198,38 @@ describe('TelemetryOtel wire', () => {
expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } })
})
it('bounds the SDK forceFlush wait when an in-flight transport never settles', async () => {
const gate = Promise.withResolvers<boolean>()
const arrived = Promise.withResolvers<boolean>()
const { url, captures } = await mockCollector(async (index) => {
if (index === 0) {
arrived.resolve(true)
await gate.promise
}
})
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
exporter: { url, timeoutMillis: 60_000 },
processor: { scheduledDelayMillis: 10, exportTimeoutMillis: 60_000 },
shutdownTimeoutMillis: 50,
})
const session = ctx.sessions.create(SessionId('bounded-shutdown'), { meta: {} })
session.append('turn/start', { turn: 1 })
await arrived.promise
const started = performance.now()
await fiber.dispose()
expect(performance.now() - started).toBeLessThan(1_000)
expect(captures).toHaveLength(0)
// The outer deadline cannot cancel the SDK transport. Let it finish so
// the real provider promise remains clean after the test has proved the
// Cordis disposer no longer waits for it.
gate.resolve(true)
await expect.poll(() => captures.length).toBeGreaterThanOrEqual(2)
})
it('passes exporter options beyond url and headers through to the SDK exporter', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
@@ -189,7 +241,7 @@ describe('TelemetryOtel wire', () => {
exporter: { url, compression: 'gzip' },
} as Config)
const session = ctx.sessions.create(SessionId('gzip'), { meta: {} })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
await fiber.dispose()
expect(captures.length).toBeGreaterThan(0)
@@ -204,7 +256,7 @@ describe('TelemetryOtel wire', () => {
const { ctx, fiber } = await boot(url)
ctx.on('telemetry/record', (_record, next) => ({ ...next(), severity: 'warn' }))
const session = ctx.sessions.create(SessionId('warn'), { meta: {} })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
// No flush(): the coordinator's optional-call forwarding no-ops, and the
// batch processor owns export cadence end to end (see the backend note).
expect('flush' in ctx.telemetry && ctx.telemetry.flush !== undefined).toBe(false)
@@ -233,11 +285,11 @@ describe('TelemetryOtel wire', () => {
return next()
})
const session = ctx.sessions.create(SessionId('feedback-only'), { meta: {} })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
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' } } })
session.append('turn/start', { turn: 2 })
await fiber.dispose()
const types = allRecords(captures).flatMap(({ record }) =>
@@ -259,7 +311,7 @@ describe('TelemetryOtel wire', () => {
exporter: { url },
})
const session = ctx.sessions.create(SessionId('no-feedback'), { meta: {} })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
ctx.telemetry.emit({
channel: 'ledger',
time: Date.now(),
@@ -292,7 +344,7 @@ describe('TelemetryOtel wire', () => {
processor: { maxExportBatchSize: 0 },
})
const session = ctx.sessions.create(SessionId('disabled'), { meta: {} })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
recordFeedback(session, 'local report')
expect(warn).toHaveBeenCalledWith(
@@ -318,7 +370,7 @@ describe('TelemetryOtel wire', () => {
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' } } })
session.append('turn/start', { turn: 1 })
await ctx.fiber.dispose()
expect(eventTypes(captures)).toContain('turn/start')
@@ -345,6 +397,8 @@ describe('TelemetryOtel config fails loud', () => {
// splices empty batches forever — dispose would hang, so reject at load.
[{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
[{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
[{ exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/],
[{ exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/],
])('rejects %j at plugin load', async (config, message) => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -366,6 +420,30 @@ describe('TelemetryOtel config fails loud', () => {
expect(() => new TelemetryOtel(ctx, config)).toThrow(/unsupported mode "INVALID"/)
expect(exporterRead).toBe(false)
})
it('does not read any transport setting in disabled mode', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const transportRead = vi.fn(() => {
throw new Error('transport config was read')
})
const config = {
mode: TelemetryMode.DISABLED,
get exporter() {
return transportRead()
},
get processor() {
return transportRead()
},
get shutdownTimeoutMillis() {
return transportRead()
},
} as unknown as Config
new TelemetryOtel(ctx, config)
expect(transportRead).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
})
describe('dsh-session-telemetry-otel real-load-path guard', () => {

View File

@@ -0,0 +1,107 @@
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
USER_ID_FILE_NAME,
getOrCreateAnonymousUserId,
} from '../src/user-id.ts'
const dirs: string[] = []
function tempHome(): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-userid-'))
dirs.push(dir)
return dir
}
afterEach(() => {
for (const dir of dirs.splice(0)) {
chmodSync(dir, 0o700)
rmSync(dir, { recursive: true, force: true })
}
})
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
describe('getOrCreateAnonymousUserId', () => {
it('creates, persists, and returns a bare UUID line on first use', () => {
const home = tempHome()
const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })
expect(id).toMatch(UUID)
expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`)
})
it('creates the home directory when missing', () => {
const home = join(tempHome(), 'nested', 'home')
const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })
expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`)
})
it('returns the persisted id on subsequent calls, tolerating surrounding whitespace', () => {
const home = tempHome()
const existing = '01234567-89ab-4cde-8f01-23456789abcd'
writeFileSync(join(home, USER_ID_FILE_NAME), ` ${existing}\n\n`, 'utf8')
expect(getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })).toBe(existing)
})
it('overwrites a corrupt file with a fresh id', () => {
const home = tempHome()
writeFileSync(join(home, USER_ID_FILE_NAME), 'not-a-uuid\n', 'utf8')
const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })
expect(id).toMatch(UUID)
expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`)
})
it('adopts a concurrent winner: exclusive create loses to an id written after the initial read', () => {
const home = tempHome()
const winner = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
const file = join(home, USER_ID_FILE_NAME)
// The generator seam runs between the initial read (absent) and the wx
// write, so planting the winner here simulates the concurrent first launch.
const id = getOrCreateAnonymousUserId({
env: { DSH_HOME: home },
randomUUID: () => {
writeFileSync(file, `${winner}\n`, 'utf8')
return 'ffffffff-0000-4000-8000-000000000000'
},
})
expect(id).toBe(winner)
})
it('returns a usable id when the home is unwritable, without persisting', () => {
const home = tempHome()
const blocked = join(home, 'blocked')
mkdirSync(blocked)
chmodSync(blocked, 0o500)
const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: blocked } })
expect(id).toMatch(UUID)
expect(existsSync(join(blocked, USER_ID_FILE_NAME))).toBe(false)
})
it('memoizes per resolved home for the process lifetime: one read, deletion-proof', () => {
const home = tempHome()
const first = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })
rmSync(join(home, USER_ID_FILE_NAME))
expect(getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })).toBe(first)
})
it('keeps distinct homes on distinct ids', () => {
const a = getOrCreateAnonymousUserId({ env: { DSH_HOME: tempHome() } })
const b = getOrCreateAnonymousUserId({ env: { DSH_HOME: tempHome() } })
expect(a).not.toBe(b)
})
it('reads process.env by default', () => {
const home = tempHome()
const previous = process.env.DSH_HOME
process.env.DSH_HOME = home
try {
const id = getOrCreateAnonymousUserId()
expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`)
} finally {
if (previous === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previous
}
})
})

View File

@@ -29,6 +29,12 @@
{
"path": "../session-telemetry"
},
{
"path": "../../util/brand"
},
{
"path": "../../util/paths"
},
{
"path": "../../support/invariants"
}

View File

@@ -21,9 +21,7 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {

View File

@@ -81,7 +81,7 @@ function liveSession(ctx: Context, id = `s-${Math.random().toString(36).slice(2)
}
function appendTurn(session: Session): void {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -119,7 +119,7 @@ describe('TelemetryCoordinator capture', () => {
it('maps outcome flags to severity, unknown types falling through as info', async () => {
const { ctx, backend } = await setup()
const session = liveSession(ctx)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
@@ -137,7 +137,7 @@ describe('TelemetryCoordinator capture', () => {
}),
}, { surfaceOp: 'append' })
session.append('telemetry-test/opaque', { payload: { nested: [] } })
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity])
expect(severities).toEqual([
['turn/start', 'info'],
@@ -206,7 +206,7 @@ describe('TelemetryCoordinator on-demand capture', () => {
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' } } })
session.append('turn/start', { turn: 1 })
const disposeRule = ctx.on('telemetry/record', (_record, next) => ({
...next(),
body: { scrubbed: true },
@@ -239,7 +239,7 @@ describe('TelemetryCoordinator on-demand capture', () => {
const first = new FakeBackend()
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' } } })
session.append('turn/start', { turn: 1 })
await fiber.dispose()
expect(first.records).toEqual([])
@@ -261,7 +261,7 @@ describe('TelemetryCoordinator on-demand capture', () => {
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' } } })
session.append('turn/start', { turn: 1 })
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'))
@@ -295,15 +295,17 @@ describe('TelemetryCoordinator adoption', () => {
const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']])
expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]]))
expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2]])
// 2 end-seed, 3 turn/end: both this lifecycle's own writes, while
// inherited 0-1 stay with the parent stream.
expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2], ['seeded', 3]])
})
it('resume shape: a full-log seed exports nothing yet still rebuilds the chunk projection', async () => {
it('resume shape: a full-log seed exports only its own end-seed and rebuilds the chunk projection', async () => {
const backend = new FakeBackend()
const ctx = new Context()
await ctx.plugin(SessionStore)
const donor = ctx.sessions.create(SessionId('donor'), { meta: {} })
donor.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
donor.append('turn/start', { turn: 1 })
donor.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } })
const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.events], meta: {} })
await ctx.plugin({
@@ -314,14 +316,16 @@ describe('TelemetryCoordinator adoption', () => {
const ofResumed = () => backend.ledger()
.filter(r => r.attributes['session.id'] === 'resumed')
.map(r => r.attributes['event.seq'])
expect(ofResumed()).toEqual([])
// Nothing inherited is re-exported; seq 2 is this session's own first
// write — the end-seed event its constructor appended after the seed.
expect(ofResumed()).toEqual([2])
// The seed fed the projection: the (turn 1, step 1) first chunk already
// shipped from the original process, so its continuation is re-dropped…
resumed.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'continuation' } })
expect(ofResumed()).toEqual([])
expect(ofResumed()).toEqual([2])
// …while a new step's first chunk exports normally.
resumed.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'next step' } })
expect(ofResumed()).toEqual([3])
expect(ofResumed()).toEqual([2, 4])
})
it('stamps session.seed_length from the header so receivers can stitch fork streams', async () => {
@@ -370,7 +374,7 @@ describe('TelemetryCoordinator adoption', () => {
const backend = new FakeBackend()
const { ctx, fiber } = await setup(backend)
const session = liveSession(ctx, 'hmr')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } })
expect(backend.ledger()).toHaveLength(2)
@@ -517,7 +521,7 @@ describe('TelemetryCoordinator lifecycle and containment', () => {
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const session = liveSession(ctx)
backend.emitError = new Error('backend broke')
expect(() => session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow()
expect(() => session.append('turn/start', { turn: 1 })).not.toThrow()
expect(warn).toHaveBeenCalled()
backend.emitError = undefined
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })