refactor(session): fold the session family into packages/session/
git mv the 12 packages from session-persistence/, session-projection/, session-title/, and telemetry/ into one session/ group per the regrouping RFC; merge the four group READMEs into one bilingual triplet; rewrite the group segment in tsconfig references (intra-group references shorten to ../<pkg>), tsconfig.base.json paths/globs, knip.json keys, vitest include, gate scripts, and authored doc/note citations; regenerate module graph, doc graphs, catalogs, and the lockfile importer keys. No npm names change. Full unit suite: 8779 passed; the 18 reported failures reproduce as env flakes (ambient-proxy IPv6 tunneling, watched-dir inotify timeouts under parallel load) — each passes in isolation with NO_PROXY set, matching their known pre-existing behavior on master.
This commit is contained in:
6
packages/session/session-telemetry/README.i18n.yaml
Normal file
6
packages/session/session-telemetry/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/telemetry/session-telemetry/README.md
|
||||
README.md: 67d95bcc62bbf6783f8dcd11f0236d8c926b557b
|
||||
README.zh.md: 1ee0e0eb14bb06c8ac669cd417f2ee2ce46ca430
|
||||
43
packages/session/session-telemetry/README.md
Normal file
43
packages/session/session-telemetry/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# @deepseek-ai/dsh-session-telemetry
|
||||
|
||||
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 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 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
|
||||
|
||||
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. 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. 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
|
||||
|
||||
Only the first `assistant/chunk` of each `(turn, step)` ships; the rest are dropped at capture and never advance the cursor. That one chunk is the stream-started signal: `step/start` + first-chunk presence + `assistant/message` presence + the `turn/end` reason distinguish "the request never started" from "the stream died midway" without chunk volume, and time-to-first-token stays computable. Chunk elision makes `seq` gaps routine on the wire — a gap is never a loss signal. Every other event type, including ones merged by plugins this package never heard of, passes through whole.
|
||||
|
||||
## The logical record
|
||||
|
||||
`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError`, `turn/end` error reasons, and `agent-error`; INFO for other captured records, while `telemetry/record` policies may assign WARN), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id`/`session.seed_length` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum; `agent-error` normalizes its arbitrary thrown value into a stable `{ name, message }` body. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the seam only observes the session stream and hands redacted copies to a reporting backend; it never contributes to a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Best-effort delivery** — the cursor marks handed-off, not delivered; a session torn down inside a reload window cannot be re-adopted; whatever sits in a backend queue at crash time is lost. A durable outbox (spool, per-sink cursors, at-least-once) is deferred until a deployment states a crash-loss requirement — see [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md).
|
||||
- **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.
|
||||
- **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.
|
||||
43
packages/session/session-telemetry/README.zh.md
Normal file
43
packages/session/session-telemetry/README.zh.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# @deepseek-ai/dsh-session-telemetry
|
||||
|
||||
[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)与[无缓冲反馈回放](../../../.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` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `captureSession(session, throughSeq?)`。
|
||||
|
||||
## 捕获点
|
||||
|
||||
在 `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;按需捕获则在回放权威日志时使用当时挂载的规则运行 waterfall。脱敏只作用于外发副本;权威会话日志永不改写。
|
||||
|
||||
## handoff 游标
|
||||
|
||||
一个模块作用域的 `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」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。
|
||||
|
||||
## 固定分片投影
|
||||
|
||||
每个 `(turn, step)` 只发出第一条 `assistant/chunk`;其余分片在捕获时丢弃,且绝不推进游标。这一条分片就是「流已开始」的信号:`step/start`、首分片是否存在、`assistant/message` 是否存在,加上 `turn/end` 的原因,无需分片流量即可区分「请求从未开始」与「流中途夭折」,首个 token 延迟(time-to-first-token)也仍然可以计算。分片省略使导出流中的 `seq` 缺口成为常态:缺口绝不是丢失信号。其余所有事件类型都会完整透传,包括本包从未听说过的插件所合并的事件类型。
|
||||
|
||||
## 逻辑记录
|
||||
|
||||
`TelemetryRecord` 包含:`channel`(`ledger` | `ops`)、`time`(epoch 毫秒)、`severity`(预先映射好的严重级别:`tool/result.isError`、`turn/end` 的错误原因与 `agent-error` 映射为 ERROR,其他已捕获记录映射为 INFO,而 `telemetry/record` 策略可以指定 WARN)、只含身份信息的 `attributes`(`session.id`、`event.type`、`event.seq`,header 中存在时再加 `session.cwd`/`session.parent_id`/`session.seed_length`),以及作为 `body` 的完整深拷贝 `event.data`,且以脱敏后的内容为准。运维记录携带 `telemetry.op`(`agent-error` | `shutdown`)和 `session.id`,并刻意不带 `event.seq`/`event.type`:它们是用来告警的信号,不是用来累加的条目;`agent-error` 会把任意抛出值规范化为稳定的 `{ name, message }` 记录主体。交接之后的投递由后端 SDK 负责;重复仍然可能出现(无游标的重新收养、SDK 重试),因此接收端基于 `(session.id, event.seq)` 去重。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该 seam 只观察会话流,并把脱敏后的副本交给上报后端;它绝不向模型请求贡献任何内容。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;本包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久化 outbox(spool、每 sink 游标、at-least-once)推迟到有部署方提出明确的崩溃丢失要求时再实现;见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。
|
||||
- **不内置脱敏规则**:未挂载 `telemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。
|
||||
- **按需脱敏使用当前状态**:未捕获的事件只存在于权威会话日志中。后续的 `captureSession()` 会使用当时挂载的策略,深拷贝并脱敏其当前值;不存在捕获时的遥测快照或持久化的捕获前 spool。
|
||||
39
packages/session/session-telemetry/package.json
Normal file
39
packages/session/session-telemetry/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-telemetry",
|
||||
"description": "Telemetry seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
319
packages/session/session-telemetry/src/coordinator.ts
Normal file
319
packages/session/session-telemetry/src/coordinator.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* 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. 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
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts'
|
||||
|
||||
/** Whether capture follows live events or reads the canonical log only when requested. */
|
||||
export type TelemetryCapture = 'live' | 'on-demand'
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* The handoff cursor: per session, the highest `seq` handed to a backend.
|
||||
* Deliberately MODULE-scope ambient state — a narrow, documented exception
|
||||
* to the registrations-are-effects discipline: cordis has no HMR
|
||||
* state-handover API, and keying by the `Session` object (which belongs to
|
||||
* the session store and outlives any telemetry fiber) is the only in-process
|
||||
* lifetime that lets a re-adopting fiber resume instead of re-handing
|
||||
* history. Entries die with their sessions; a missing entry safely means
|
||||
* "re-hand everything". Advanced only at emit time — the cursor marks
|
||||
* handed-off, not delivered.
|
||||
*/
|
||||
const handoffCursor = new WeakMap<Session, number>()
|
||||
|
||||
/**
|
||||
* Install the telemetry capture side onto a context for one backend.
|
||||
*
|
||||
* 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. 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 {
|
||||
/**
|
||||
* Sessions adopted by THIS fiber and still live, for double-adoption
|
||||
* protection and the teardown sweep of unmarked sessions;
|
||||
* `session/disposed` marks and retires entries.
|
||||
*/
|
||||
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>>()
|
||||
/**
|
||||
* @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 capture - follow live events, or wait for explicit canonical-log capture.
|
||||
*/
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly backend: TelemetryBackend,
|
||||
capture: TelemetryCapture = 'live',
|
||||
) {
|
||||
if (capture === 'live') {
|
||||
ctx.on('session/created', (session) => {
|
||||
this.adopt(session)
|
||||
})
|
||||
// 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)) })
|
||||
})
|
||||
})
|
||||
ctx.on('session/event', (session, event) => {
|
||||
this.contain(() => {
|
||||
this.captureEvent(session, event)
|
||||
})
|
||||
})
|
||||
// Parallel listeners are awaited by the loop at turn end; returning void
|
||||
// (not the SDK's flush promise) is the turn-latency contract.
|
||||
ctx.on('session/flush', (session) => {
|
||||
this.contain(() => {
|
||||
this.hintFlush(session)
|
||||
})
|
||||
})
|
||||
ctx.on('agent/error', ({ agent, turn, step, error }) => {
|
||||
this.contain(() => {
|
||||
this.relayAgentError(agent, turn, step, error)
|
||||
})
|
||||
})
|
||||
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.
|
||||
for (const session of this.adopted) {
|
||||
this.contain(() => {
|
||||
this.deliver(session, { record: this.redact(shutdownRecord(session)) })
|
||||
})
|
||||
}
|
||||
try {
|
||||
await this.backend.shutdown()
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn(`telemetry: backend shutdown failed: ${String(error)}`)
|
||||
}
|
||||
}, 'telemetry capture')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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(() => {
|
||||
if (event.seq <= cursor) this.track(session, event)
|
||||
else this.captureEvent(session, event)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a session: replay its log THROUGH the projection from the handoff
|
||||
* cursor, then rely on the firehose for everything after. When no cursor
|
||||
* survived, replay starts at the session's construction boundary
|
||||
* (`firstLiveSeq`), not seq 0: constructor seeds never publish on the
|
||||
* firehose, and their content already left the process under another
|
||||
* identity — the same id in a previous process (resume) or the parent's
|
||||
* stream (fork, stitched by receivers via `session.seed_length`). Events
|
||||
* at or below the start still feed the projection state (first-chunk
|
||||
* tracking) without being re-handed, so a resumed fiber drops mid-step
|
||||
* chunk continuations exactly like the fiber that saw the step begin. The
|
||||
* cost, accepted with the seam's at-most-once stance: a resume no longer
|
||||
* backfills records a previous process failed to deliver.
|
||||
* @param session - the live session to adopt; a second adoption is a no-op.
|
||||
*/
|
||||
private adopt(session: Session): void {
|
||||
if (this.adopted.has(session)) return
|
||||
this.adopted.add(session)
|
||||
this.captureSession(session)
|
||||
}
|
||||
|
||||
/** Feed the chunk projection without handing off — the ≤cursor half of re-adoption. */
|
||||
private track(session: Session, event: SessionEvent): void {
|
||||
if (event.type === 'assistant/chunk') {
|
||||
this.seen(session).add(`${event.data.turn}:${event.data.step}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Project, 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)
|
||||
// Fixed chunk projection: only the first chunk of each (turn, step)
|
||||
// ships — the stream-started signal; content is byte-complete in the
|
||||
// step's assembled assistant/message. Dropped chunks do not advance
|
||||
// the cursor, so re-adoption re-drops them deterministically.
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
}
|
||||
this.deliver(session, {
|
||||
record: this.redact({
|
||||
channel: 'ledger',
|
||||
time: event.time,
|
||||
severity: severityOf(event),
|
||||
attributes: identityOf(session, event),
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the `telemetry/record` waterfall at capture time. The innermost `next`
|
||||
* passes the record through unchanged — the seam ships no rules; exported
|
||||
* data is as clean as the listeners a deployment mounts. Callers run inside
|
||||
* {@link contain}, so a throwing rule withholds the record instead of
|
||||
* reaching the loop (fail-closed). 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)
|
||||
}
|
||||
|
||||
/** Hand one redacted record to the backend, then advance its ledger cursor. */
|
||||
private deliver(session: Session, pending: ProjectedRecord): void {
|
||||
this.backend.emit(pending.record)
|
||||
if (pending.seq !== undefined) handoffCursor.set(session, pending.seq)
|
||||
}
|
||||
|
||||
/** Forward the turn-end boundary to the backend's optional flush hint. */
|
||||
private hintFlush(session: Session): void {
|
||||
if (this.adopted.has(session)) this.backend.flush?.()
|
||||
}
|
||||
|
||||
/** Relay one `agent/error` bus emission as an `agent-error` operational record. */
|
||||
private relayAgentError(agent: Agent, turn: number, step: number, error: unknown): void {
|
||||
const detail = errorDetail(error)
|
||||
this.deliver(agent.session, {
|
||||
record: this.redact({
|
||||
channel: 'ops',
|
||||
time: Date.now(),
|
||||
severity: 'error',
|
||||
attributes: {
|
||||
'telemetry.op': 'agent-error',
|
||||
'session.id': String(agent.session.id),
|
||||
'agent.id': agent.id,
|
||||
'error.name': detail.name,
|
||||
turn,
|
||||
step,
|
||||
},
|
||||
body: detail,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/** Lazily create the per-session first-chunk tracking set. */
|
||||
private seen(session: Session): Set<string> {
|
||||
let set = this.chunkSeen.get(session)
|
||||
if (!set) this.chunkSeen.set(session, set = new Set())
|
||||
return set
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one capture-side step with its exception contained: cordis `emit`
|
||||
* is stop-on-throw, so a throwing listener would starve every subscriber
|
||||
* registered after this plugin — nothing from the backend may escape.
|
||||
*/
|
||||
private contain(step: () => void): void {
|
||||
try {
|
||||
step()
|
||||
} catch (error) {
|
||||
this.ctx.logger.warn(`telemetry: capture step failed: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-session clean-exit marker: emitted at the session's own
|
||||
* disposal edge, or at coordinator dispose for sessions still alive then.
|
||||
*/
|
||||
function shutdownRecord(session: Session): TelemetryRecord {
|
||||
return {
|
||||
channel: 'ops',
|
||||
time: Date.now(),
|
||||
severity: 'info',
|
||||
attributes: { 'telemetry.op': 'shutdown', 'session.id': String(session.id) },
|
||||
body: { op: 'shutdown' },
|
||||
}
|
||||
}
|
||||
|
||||
/** Map an event's own outcome flag to the pre-baked alerting severity. */
|
||||
function severityOf(event: SessionEvent): TelemetrySeverity {
|
||||
switch (event.type) {
|
||||
case 'tool/result':
|
||||
return event.data.message.content[0].isError === true ? 'error' : 'info'
|
||||
case 'turn/end':
|
||||
return event.data.reason.kind === 'error' ? 'error' : 'info'
|
||||
default:
|
||||
// Merge-extensible fall-through (no assertNever): event types this seam
|
||||
// does not depend on — including plugin-merged ones it never heard of —
|
||||
// pass through as info; their owners' outcome semantics stay theirs.
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize the live bus's arbitrary thrown value into the stable operational-record shape. */
|
||||
function errorDetail(error: unknown): { name: string; message: string } {
|
||||
const normalized = error instanceof Error ? error : new Error(String(error))
|
||||
return { name: normalized.name, message: normalized.message }
|
||||
}
|
||||
|
||||
/** Build the minimal identity attributes: envelope plus self-contained header facts. */
|
||||
function identityOf(session: Session, event: SessionEvent): Record<string, string | number> {
|
||||
const attributes: Record<string, string | number> = {
|
||||
'session.id': String(session.id),
|
||||
'event.type': event.type,
|
||||
'event.seq': event.seq,
|
||||
}
|
||||
const { cwd, parentSession, seedLength } = session.header
|
||||
if (cwd !== undefined) attributes['session.cwd'] = cwd
|
||||
if (parentSession !== undefined) attributes['session.parent_id'] = String(parentSession)
|
||||
// The durable fork boundary: a forked stream starts here, and its prefix
|
||||
// lives in the parent's stream — receivers stitch on (parent_id, seed_length).
|
||||
if (seedLength !== undefined) attributes['session.seed_length'] = seedLength
|
||||
return attributes
|
||||
}
|
||||
161
packages/session/session-telemetry/src/index.ts
Normal file
161
packages/session/session-telemetry/src/index.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Telemetry seam for the DeepSeek Harness.
|
||||
*
|
||||
* The seam owns the CAPTURE side of session-event reporting — which records
|
||||
* exist (the chunk projection), what they carry (the logical record), when
|
||||
* they are captured (adoption, the per-append firehose, lifecycle
|
||||
* forwarding), live versus on-demand canonical-log capture, and the HMR
|
||||
* cursor. Everything downstream of
|
||||
* {@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.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-telemetry
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
telemetry: Telemetry
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Transform one outbound record before it reaches the backend. This
|
||||
* waterfall is the seam's redaction extension point. It ships NO rules
|
||||
* of its own: the
|
||||
* innermost `next()` passes the record through unchanged, and with no
|
||||
* listener mounted records reach the backend as captured, so exported
|
||||
* data is exactly as clean as the rules a deployment mounts. Listeners
|
||||
* stack by transforming `next()`'s return value; returning without
|
||||
* `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. 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
|
||||
*/
|
||||
'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity of a telemetry record, pre-mapped at capture so a receiver can
|
||||
* alert with zero configuration: `error` for events whose own outcome flag
|
||||
* says so (the tool-result block's `isError`, `turn/end` error reasons) and for
|
||||
* `agent-error` operational records. Captured events otherwise default to
|
||||
* `info`; `warn` remains available to `telemetry/record` policies and
|
||||
* backends.
|
||||
*/
|
||||
export type TelemetrySeverity = 'info' | 'warn' | 'error'
|
||||
|
||||
/**
|
||||
* One logical record handed to a backend — the seam's whole outbound
|
||||
* vocabulary. Ledger records mirror session-log events one-to-one;
|
||||
* operational records (`channel: 'ops'`) carry the two signals with no log
|
||||
* home (`agent-error`, `shutdown`) and deliberately omit `event.seq`-style
|
||||
* identity so they can never be mistaken for ledger rows.
|
||||
*/
|
||||
export interface TelemetryRecord {
|
||||
/** Ledger (session-log mirror) or ops (operational signal) channel; backends keep the two under separate instrumentation scopes. */
|
||||
channel: 'ledger' | 'ops'
|
||||
/** Unix epoch milliseconds — the source event's append time for ledger records, the emission time for ops records. */
|
||||
time: number
|
||||
/** Pre-mapped alerting severity; see {@link TelemetrySeverity}. */
|
||||
severity: TelemetrySeverity
|
||||
/**
|
||||
* Identity attributes, deliberately minimal: ledger records carry
|
||||
* `session.id`, `event.type`, `event.seq`, plus `session.cwd` /
|
||||
* `session.parent_id` when the header has them; ops records carry
|
||||
* `telemetry.op`, `session.id`, and (for `agent-error`) `agent.id`,
|
||||
* `turn`, `step`, `error.name`. Anything recoverable from the body is
|
||||
* intentionally NOT duplicated here.
|
||||
*/
|
||||
attributes: Record<string, string | number>
|
||||
/**
|
||||
* The complete payload: a deep copy of the session event's `data` for
|
||||
* ledger records (JSON-serializable by `Session.append`'s own
|
||||
* validation), or the op payload for ops records. Never mutated after
|
||||
* handoff.
|
||||
*/
|
||||
body: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend contract the coordinator hands records to — the minimum any
|
||||
* reporting SDK satisfies with zero bending. {@link Telemetry} is its
|
||||
* service-registered form; tests compose the coordinator with a bare
|
||||
* implementation of this interface.
|
||||
*/
|
||||
export interface TelemetryBackend {
|
||||
/**
|
||||
* Hand one record to the backend's pipeline. MUST be a non-blocking
|
||||
* enqueue — the coordinator calls this synchronously from the
|
||||
* `session/event` hot path 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.
|
||||
*/
|
||||
emit(record: TelemetryRecord): void
|
||||
/**
|
||||
* Optional hint that a natural boundary (turn end) passed — a backend may
|
||||
* forward it to its SDK's flush so records land at turn boundaries. Called
|
||||
* fire-and-forget; implementations must not block and must not throw
|
||||
* meaningfully (the coordinator contains exceptions). Most backends should
|
||||
* leave this unimplemented and let their SDK's own batching cadence govern
|
||||
* export timing: a backend that does implement it owns the interaction
|
||||
* between its concurrent flushes and {@link shutdown}'s drain (the OTel
|
||||
* backend removed its implementation for exactly that hazard — see the
|
||||
* revival Agent Note).
|
||||
*/
|
||||
flush?(): void
|
||||
/**
|
||||
* Forward the fiber's disposal to the SDK: flush whatever is queued and
|
||||
* reach quiescence, per the SDK's own shutdown contract. Everything
|
||||
* emitted before this call must still be delivered — including records
|
||||
* enqueued while a {@link flush} hint is in flight, so a backend whose SDK
|
||||
* guards against concurrent flushes orders behind the outstanding one (the
|
||||
* 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 for live capture; on-demand capture creates no ops records.
|
||||
* @returns resolves when the backend's pipeline has quiesced.
|
||||
*/
|
||||
shutdown(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend contract in its loadable form: one implementation per context —
|
||||
* the cordis `Service` registration under the `telemetry` key throws on a
|
||||
* duplicate, cordis' standard behavior. A backend composes a
|
||||
* {@link TelemetryCoordinator} in its constructor to install the capture side.
|
||||
*/
|
||||
export abstract class Telemetry extends Service implements TelemetryBackend {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'telemetry')
|
||||
}
|
||||
|
||||
/**
|
||||
* See {@link TelemetryBackend.emit} — the seam declaration is the contract's one home.
|
||||
* @param record - the logical record to report; owned by the backend after the call.
|
||||
*/
|
||||
abstract emit(record: TelemetryRecord): void
|
||||
|
||||
/** See {@link TelemetryBackend.flush}. */
|
||||
flush?(): void
|
||||
|
||||
/**
|
||||
* See {@link TelemetryBackend.shutdown}.
|
||||
* @returns resolves when the backend's pipeline has quiesced.
|
||||
*/
|
||||
abstract shutdown(): Promise<void>
|
||||
}
|
||||
|
||||
export { TelemetryCoordinator, type TelemetryCapture } from './coordinator.ts'
|
||||
32
packages/session/session-telemetry/src/invariant.ts
Normal file
32
packages/session/session-telemetry/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-telemetry`.
|
||||
* @module @deepseek-ai/dsh-session-telemetry/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-telemetry'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-telemetry-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the seam's whole output is the backend handoff — a
|
||||
* synchronous `emit()` call outside every authoritative event stream — and its
|
||||
* capture side never appends session events, so no event/data relation exists
|
||||
* for an independent companion to observe.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
129
packages/session/session-telemetry/tests/redact.spec.ts
Normal file
129
packages/session/session-telemetry/tests/redact.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
/**
|
||||
* The `telemetry/record` waterfall contract: pass-through when no listener is
|
||||
* mounted, listener stacking and replacement, ops-record coverage, the
|
||||
* untouched canonical log, and the fail-closed containment of a throwing rule.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
TelemetryCoordinator,
|
||||
type TelemetryBackend,
|
||||
type TelemetryRecord,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const FIXTURE_SECRET = 'sk-fixture1234567890'
|
||||
|
||||
class CollectingBackend implements TelemetryBackend {
|
||||
records: TelemetryRecord[] = []
|
||||
emit(record: TelemetryRecord): void {
|
||||
this.records.push(record)
|
||||
}
|
||||
async shutdown(): Promise<void> {}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const backend = new CollectingBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
return { ctx, backend, fiber }
|
||||
}
|
||||
|
||||
describe('telemetry/record waterfall', () => {
|
||||
it('passes records through unchanged when no listener is mounted', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const session = ctx.sessions.create(SessionId('w'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `key ${FIXTURE_SECRET}` }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const body = backend.records[0]!.body as { content: { text: string }[] }
|
||||
expect(body.content[0]!.text).toBe(`key ${FIXTURE_SECRET}`)
|
||||
})
|
||||
|
||||
it('applies a mounted rule to every outbound record, ops records included', async () => {
|
||||
const { ctx, backend, fiber } = await setup()
|
||||
ctx.on('telemetry/record', (_record, next) => {
|
||||
const record = next()
|
||||
return { ...record, body: { scrubbed: true } }
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('rule'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(backend.records[0]!.body).toEqual({ scrubbed: true })
|
||||
// The dispose-time shutdown ops record passes through the same waterfall.
|
||||
await fiber.dispose()
|
||||
const ops = backend.records.filter(record => record.channel === 'ops')
|
||||
expect(ops).toHaveLength(1)
|
||||
expect(ops[0]!.body).toEqual({ scrubbed: true })
|
||||
})
|
||||
|
||||
it('keeps the canonical log untouched by a mounted rule', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.on('telemetry/record', (_record, next) => ({ ...next(), body: null }))
|
||||
const session = ctx.sessions.create(SessionId('log'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const logged = session.events[0]!.data as { content: { text: string }[] }
|
||||
expect(logged.content[0]!.text).toBe(FIXTURE_SECRET)
|
||||
})
|
||||
|
||||
it('stacks listeners outermost-first around next()', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const order: string[] = []
|
||||
ctx.on('telemetry/record', (_record, next) => {
|
||||
order.push('outer-before')
|
||||
const record = next()
|
||||
order.push('outer-after')
|
||||
return { ...record, attributes: { ...record.attributes, outer: 1 } }
|
||||
})
|
||||
ctx.on('telemetry/record', (_record, next) => {
|
||||
order.push('inner')
|
||||
const record = next()
|
||||
return { ...record, attributes: { ...record.attributes, inner: 1 } }
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('stack'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(order).toEqual(['outer-before', 'inner', 'outer-after'])
|
||||
expect(backend.records[0]!.attributes).toMatchObject({ outer: 1, inner: 1 })
|
||||
})
|
||||
|
||||
it('a listener that skips next() replaces everything beneath it', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const inner = { called: false }
|
||||
ctx.on('telemetry/record', () => ({ channel: 'ops', time: 0, severity: 'info', attributes: {}, body: 'replaced' } satisfies TelemetryRecord))
|
||||
ctx.on('telemetry/record', (_record, next) => {
|
||||
inner.called = true
|
||||
return next()
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('veto'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(backend.records[0]!.body).toBe('replaced')
|
||||
expect(inner.called).toBe(false)
|
||||
})
|
||||
|
||||
it('a throwing rule withholds the record fail-closed without disturbing the log', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
ctx.on('telemetry/record', () => {
|
||||
throw new Error('rule exploded')
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('closed'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(backend.records).toHaveLength(0)
|
||||
expect(session.events).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
552
packages/session/session-telemetry/tests/telemetry.spec.ts
Normal file
552
packages/session/session-telemetry/tests/telemetry.spec.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
import { createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
/**
|
||||
* Coordinator semantics against a bare fake backend — the RFC's named unit
|
||||
* tier for the seam: adoption (fresh, seeded, re-adoption via the handoff
|
||||
* cursor), the fixed chunk projection, deep-copy isolation, turn-latency and
|
||||
* dispose-ordering pins, failure containment, and the `agent/error` relay.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
TelemetryCoordinator,
|
||||
type TelemetryBackend,
|
||||
type TelemetryCapture,
|
||||
type TelemetryRecord,
|
||||
} from '../src/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* Test-only merged event proving unknown types flow through unchanged.
|
||||
* @mode emit
|
||||
* @param payload - opaque test payload
|
||||
*/
|
||||
'telemetry-test/opaque': { payload: { nested: string[] } }
|
||||
}
|
||||
}
|
||||
|
||||
class FakeBackend implements TelemetryBackend {
|
||||
records: TelemetryRecord[] = []
|
||||
calls: string[] = []
|
||||
emitError: Error | undefined
|
||||
rejectSeq: number | undefined
|
||||
shutdownError: Error | undefined
|
||||
shutdownResolved = false
|
||||
|
||||
emit(record: TelemetryRecord): void {
|
||||
if (this.emitError) throw this.emitError
|
||||
if (this.rejectSeq !== undefined && record.attributes['event.seq'] === this.rejectSeq) {
|
||||
throw new Error(`backend rejected seq ${this.rejectSeq}`)
|
||||
}
|
||||
this.records.push(record)
|
||||
this.calls.push(`emit:${String(record.attributes['event.seq'] ?? record.attributes['telemetry.op'])}`)
|
||||
}
|
||||
|
||||
flush = vi.fn()
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.calls.push('shutdown')
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
if (this.shutdownError) throw this.shutdownError
|
||||
this.shutdownResolved = true
|
||||
}
|
||||
|
||||
ledger(): TelemetryRecord[] {
|
||||
return this.records.filter(r => r.channel === 'ledger')
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(
|
||||
backend: FakeBackend = new FakeBackend(),
|
||||
capture: TelemetryCapture = 'live',
|
||||
) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
let coordinator!: TelemetryCoordinator
|
||||
const fiber = await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => {
|
||||
coordinator = new TelemetryCoordinator(inner, backend, capture)
|
||||
},
|
||||
})
|
||||
return { ctx, backend, coordinator, fiber }
|
||||
}
|
||||
|
||||
function liveSession(ctx: Context, id = `s-${Math.random().toString(36).slice(2)}`): Session {
|
||||
return ctx.sessions.create(SessionId(id), { meta: {} })
|
||||
}
|
||||
|
||||
function appendTurn(session: Session): void {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
describe('TelemetryCoordinator capture', () => {
|
||||
it('hands every appended event over with envelope identity and cloned body', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const session = liveSession(ctx, 'cap')
|
||||
appendTurn(session)
|
||||
|
||||
const start = backend.ledger()[0]!
|
||||
const message = backend.ledger()[1]!
|
||||
expect(start.attributes).toMatchObject({ 'session.id': 'cap', 'event.type': 'turn/start', 'event.seq': 0 })
|
||||
expect(start.time).toBe(session.events[0]!.time)
|
||||
expect(start.severity).toBe('info')
|
||||
expect(message.attributes['event.seq']).toBe(1)
|
||||
// Deep-copy isolation: mutating the handed-off body never reaches the log.
|
||||
;(message.body as { content: { text: string }[] }).content[0]!.text = 'tampered'
|
||||
const logged = session.events[1] as SessionEvent<'user/message'>
|
||||
expect(logged.data.content[0]).toMatchObject({ text: 'hello' })
|
||||
})
|
||||
|
||||
it('stamps header facts on every record when present', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const parent = SessionId('parent')
|
||||
const session = ctx.sessions.create(SessionId('child'), { meta: { cwd: '/tmp/proj', parentSession: parent } })
|
||||
appendTurn(session)
|
||||
for (const record of backend.ledger()) {
|
||||
expect(record.attributes['session.cwd']).toBe('/tmp/proj')
|
||||
expect(record.attributes['session.parent_id']).toBe('parent')
|
||||
}
|
||||
})
|
||||
|
||||
it('maps outcome flags to severity, unknown types falling through as info', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const session = liveSession(ctx)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: 'c1' as never,
|
||||
content: [],
|
||||
isError: true,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: 'c2' as never,
|
||||
content: [],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('telemetry-test/opaque', { payload: { nested: [] } })
|
||||
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'],
|
||||
['tool/result', 'error'],
|
||||
['tool/result', 'info'],
|
||||
['telemetry-test/opaque', 'info'],
|
||||
['turn/end', 'error'],
|
||||
])
|
||||
})
|
||||
|
||||
it('passes unknown merged event types through unchanged', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const session = liveSession(ctx)
|
||||
session.append('telemetry-test/opaque', { payload: { nested: ['a', 'b'] } })
|
||||
const record = backend.ledger()[0]!
|
||||
expect(record.attributes['event.type']).toBe('telemetry-test/opaque')
|
||||
expect(record.severity).toBe('info')
|
||||
expect(record.body).toEqual({ payload: { nested: ['a', 'b'] } })
|
||||
})
|
||||
|
||||
it('ships only the first chunk of each (turn, step), per session', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const a = liveSession(ctx, 'a')
|
||||
const b = liveSession(ctx, 'b')
|
||||
const chunk = (s: Session, turn: number, step: number, text: string) =>
|
||||
s.append('assistant/chunk', { turn, step, chunk: { type: 'text-delta', index: 0, text } })
|
||||
chunk(a, 1, 1, 'a11-first')
|
||||
chunk(a, 1, 1, 'a11-second')
|
||||
chunk(a, 1, 2, 'a12-first')
|
||||
chunk(b, 1, 1, 'b11-first')
|
||||
chunk(b, 1, 1, 'b11-second')
|
||||
const shipped = backend.ledger().map(r => [r.attributes['session.id'], (r.body as { chunk: { text: string } }).chunk.text])
|
||||
expect(shipped).toEqual([
|
||||
['a', 'a11-first'],
|
||||
['a', 'a12-first'],
|
||||
['b', 'b11-first'],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('TelemetryCoordinator 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.captureSession(session, firstBoundary)
|
||||
expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([
|
||||
'turn/start',
|
||||
'user/message',
|
||||
])
|
||||
|
||||
expect(backend.ledger()).toHaveLength(2)
|
||||
coordinator.captureSession(session)
|
||||
coordinator.captureSession(session)
|
||||
expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([
|
||||
'turn/start',
|
||||
'user/message',
|
||||
'turn/end',
|
||||
])
|
||||
})
|
||||
|
||||
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 })
|
||||
const disposeRule = ctx.on('telemetry/record', (_record, next) => ({
|
||||
...next(),
|
||||
body: { scrubbed: true },
|
||||
}))
|
||||
|
||||
coordinator.captureSession(session)
|
||||
expect(backend.ledger()[0]!.body).toEqual({ scrubbed: true })
|
||||
disposeRule()
|
||||
|
||||
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 replaying a prefix', async () => {
|
||||
const backend = new FakeBackend()
|
||||
backend.rejectSeq = 1
|
||||
const { ctx, coordinator } = await setup(backend, 'on-demand')
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const session = liveSession(ctx, 'on-demand-failure')
|
||||
appendTurn(session)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
coordinator.captureSession(session)
|
||||
expect(backend.ledger().map(record => record.attributes['event.seq'])).toEqual([0, 2])
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('captures a pending prefix after coordinator reload without retained records', async () => {
|
||||
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 })
|
||||
await fiber.dispose()
|
||||
expect(first.records).toEqual([])
|
||||
|
||||
const second = new FakeBackend()
|
||||
let coordinator!: TelemetryCoordinator
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry-after-on-demand-reload',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => {
|
||||
coordinator = new TelemetryCoordinator(inner, second, 'on-demand')
|
||||
},
|
||||
})
|
||||
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 })
|
||||
await ctx.parallel('session/flush', session)
|
||||
const agent = { id: 'agent-1', session } as Agent
|
||||
ctx.emit('agent/error', { agent, turn: 1, step: 1, error: 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', () => {
|
||||
it('exports an unpublished suffix without re-exporting constructor history', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const parent = liveSession(ctx, 'seed-parent')
|
||||
appendTurn(parent)
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.events], meta: {} })
|
||||
child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
ctx.sessions.enter(child)
|
||||
ctx.sessions.announce(child)
|
||||
|
||||
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]]))
|
||||
// 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 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 })
|
||||
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({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
const ofResumed = () => backend.ledger()
|
||||
.filter(r => r.attributes['session.id'] === 'resumed')
|
||||
.map(r => r.attributes['event.seq'])
|
||||
// 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([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([2, 4])
|
||||
})
|
||||
|
||||
it('stamps session.seed_length from the header so receivers can stitch fork streams', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const parent = liveSession(ctx, 'stitch-parent')
|
||||
appendTurn(parent)
|
||||
const child = ctx.sessions.create(SessionId('stitch-child'), {
|
||||
seed: [...parent.events],
|
||||
meta: { parentSession: SessionId('stitch-parent'), seedLength: 2 },
|
||||
})
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const record = backend.ledger().find(r => r.attributes['session.id'] === 'stitch-child')!
|
||||
expect(record.attributes['session.parent_id']).toBe('stitch-parent')
|
||||
expect(record.attributes['session.seed_length']).toBe(2)
|
||||
})
|
||||
|
||||
it('adopts exactly once when created fires after the sweep', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
// The enter/announce window: prepare+enter puts the session in the store
|
||||
// (visible to the constructor sweep) before `session/created` fires, so a
|
||||
// coordinator loaded inside that window sees the session twice — sweep
|
||||
// first, created second. The second adoption must be a no-op.
|
||||
const session = ctx.sessions.prepare(SessionId('overlap'))
|
||||
appendTurn(session)
|
||||
ctx.sessions.enter(session)
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
expect(backend.ledger()).toHaveLength(2)
|
||||
ctx.sessions.announce(session)
|
||||
expect(backend.ledger()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('resumes from the handoff cursor across a reload, re-dropping mid-step chunks', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const { ctx, fiber } = await setup(backend)
|
||||
const session = liveSession(ctx, 'hmr')
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } })
|
||||
expect(backend.ledger()).toHaveLength(2)
|
||||
|
||||
await fiber.dispose()
|
||||
// The reload window: appends while no telemetry listener is registered.
|
||||
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'mid-step continuation' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const second = new FakeBackend()
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry-2',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, second),
|
||||
})
|
||||
// Only the window events past the cursor are re-handed, and the mid-step
|
||||
// continuation is re-dropped because ≤cursor events rebuilt the projection.
|
||||
expect(second.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end'])
|
||||
})
|
||||
|
||||
it('replays past a record the backend rejects: one event withheld, the rest adopted', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const session = liveSession(ctx, 'partial')
|
||||
appendTurn(session)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// The backend rejects exactly the middle historical event: fail-closed
|
||||
// must withhold THAT record only — an adoption replay that dies on the
|
||||
// first contained failure would silently skip the rest of the log while
|
||||
// the session stays marked adopted.
|
||||
backend.rejectSeq = 1
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 2])
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-hands the full log when no cursor survived (fresh session object)', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = liveSession(ctx, 'fresh')
|
||||
appendTurn(session)
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('TelemetryCoordinator lifecycle and containment', () => {
|
||||
it('forwards session/flush as a hint without awaiting backend work', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const session = liveSession(ctx)
|
||||
let settled = false
|
||||
backend.flush.mockImplementation(() => {
|
||||
// The backend may kick off arbitrary async work; the loop's parallel must not wait for it.
|
||||
void new Promise(resolve => setTimeout(resolve, 50)).then(() => { settled = true })
|
||||
})
|
||||
await ctx.parallel('session/flush', session)
|
||||
expect(backend.flush).toHaveBeenCalledTimes(1)
|
||||
expect(settled).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores flush hints for sessions it never adopted', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const stranger = ctx.sessions.prepare(SessionId('stranger'), { meta: {} })
|
||||
await ctx.parallel('session/flush', stranger)
|
||||
expect(backend.flush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits no marker for a session whose announcement was vetoed before adoption', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
// A listener registered BEFORE the coordinator vetoes publication: the
|
||||
// store still emits the paired `session/disposed` for rollback, but the
|
||||
// coordinator never saw `session/created` — a marker for a session the
|
||||
// receiver saw no activity from would be noise, not signal.
|
||||
ctx.on('session/created', () => {
|
||||
throw new Error('vetoed by an earlier listener')
|
||||
})
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
expect(() => ctx.sessions.create(SessionId('vetoed'), { meta: {} })).toThrow('vetoed')
|
||||
expect(backend.records.filter(r => r.channel === 'ops')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('emits each adopted session’s shutdown record before awaiting backend shutdown', async () => {
|
||||
const { ctx, backend, fiber } = await setup()
|
||||
liveSession(ctx, 's1')
|
||||
liveSession(ctx, 's2')
|
||||
await fiber.dispose()
|
||||
expect(backend.calls).toEqual(['emit:shutdown', 'emit:shutdown', 'shutdown'])
|
||||
expect(backend.shutdownResolved).toBe(true)
|
||||
const ops = backend.records.filter(r => r.channel === 'ops')
|
||||
expect(ops.map(r => r.attributes['session.id']).sort()).toEqual(['s1', 's2'])
|
||||
expect(ops.every(r => r.attributes['telemetry.op'] === 'shutdown' && r.severity === 'info')).toBe(true)
|
||||
expect(ops.every(r => !('event.seq' in r.attributes) && !('event.type' in r.attributes))).toBe(true)
|
||||
})
|
||||
|
||||
it('emits the shutdown marker at the session’s own disposal edge, then retires it', async () => {
|
||||
const { ctx, backend, fiber } = await setup()
|
||||
liveSession(ctx, 'survivor')
|
||||
// A session owned by its own fiber: disposing the fiber detaches it from
|
||||
// the store and emits `session/disposed` — the authoritative termination
|
||||
// edge. The marker must ride THAT edge (receivers classify a session with
|
||||
// activity and no marker as crashed, so a normally closed session in a
|
||||
// long-running host must not look like a crash), and the session retires
|
||||
// from the adopted set so unload neither retains it nor re-marks it.
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.sessions.create(SessionId('ephemeral'), { meta: {} })
|
||||
}, { inject: ['sessions'] }))
|
||||
await owner.dispose()
|
||||
const atEdge = backend.records.filter(r => r.channel === 'ops')
|
||||
expect(atEdge.map(r => r.attributes['session.id'])).toEqual(['ephemeral'])
|
||||
expect(atEdge[0]!.attributes['telemetry.op']).toBe('shutdown')
|
||||
await fiber.dispose()
|
||||
const ops = backend.records.filter(r => r.channel === 'ops')
|
||||
expect(ops.map(r => r.attributes['session.id'])).toEqual(['ephemeral', 'survivor'])
|
||||
})
|
||||
|
||||
it('warns instead of throwing when backend shutdown fails', async () => {
|
||||
const backend = new FakeBackend()
|
||||
backend.shutdownError = new Error('exporter unreachable')
|
||||
const { ctx, fiber } = await setup(backend)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
liveSession(ctx)
|
||||
await expect(fiber.dispose()).resolves.not.toThrow()
|
||||
expect(warn.mock.calls.some(args => String(args[0]).includes('shutdown failed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('contains emit failures: the append succeeds and capture heals', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const session = liveSession(ctx)
|
||||
backend.emitError = new Error('backend broke')
|
||||
expect(() => session.append('turn/start', { turn: 1 })).not.toThrow()
|
||||
expect(warn).toHaveBeenCalled()
|
||||
backend.emitError = undefined
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(backend.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Error values', new TypeError('adapter exploded'), 'TypeError', 'adapter exploded'],
|
||||
['non-Error values', 'plain failure', 'Error', 'plain failure'],
|
||||
])('relays agent/error %s as an ops record with normalized identity', async (_label, error, name, message) => {
|
||||
const { ctx, backend } = await setup()
|
||||
const session = liveSession(ctx, 'erring')
|
||||
// Only the members the relay reads; the full Agent surface is irrelevant here.
|
||||
const agent = { id: 'agent-1', session } as Agent
|
||||
ctx.emit('agent/error', { agent, turn: 3, step: 2, error })
|
||||
const record = backend.records.find(r => r.channel === 'ops')!
|
||||
expect(record.severity).toBe('error')
|
||||
expect(record.attributes).toMatchObject({
|
||||
'telemetry.op': 'agent-error',
|
||||
'session.id': 'erring',
|
||||
'agent.id': 'agent-1',
|
||||
'error.name': name,
|
||||
turn: 3,
|
||||
step: 2,
|
||||
})
|
||||
expect(record.body).toEqual({ name, message })
|
||||
})
|
||||
})
|
||||
27
packages/session/session-telemetry/tsconfig.json
Normal file
27
packages/session/session-telemetry/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user