From cf2e18411244f837999bcf934ba383a21ec8661d Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 23 Jul 2026 03:06:57 +0800 Subject: [PATCH] feat(telemetry): session-telemetry seam with mandatory redaction + OTel backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revive the reviewed session-telemetry packages from the closed session-telemetry-otlp-rfc branch (PR #222/#231) on current master, renamed to @deepseek-ai/dsh-session-telemetry{,-otel} (the SDK component-telemetry package holds the dsh-telemetry name). Delta over the branch version: every record now passes a telemetry/redact waterfall between projection and emit() — the innermost next() applies a non-configurable conservative credential-shape rule set, listeners stack stricter rules, a throwing rule withholds the record fail-closed, and the canonical log is never rewritten. This answers the export-side concern that closed PR #222; the boundary axiom (our aspect ends at emit(); delivery is the reporting SDK's) is unchanged, and the runtime-telemetry RFC's outbox / readCommitted lane is recorded as deferred in the Agent Note. Covered by seam/redact/OTel-wire unit tiers (100% per-file) and a keyless Loader-composition e2e that boots the examples fixture against a mock OTLP collector and pins redaction on the wire plus the untouched canonical log. --- ...3-session-telemetry-otel-revival.i18n.yaml | 6 + ...26-07-23-session-telemetry-otel-revival.md | 33 ++ ...07-23-session-telemetry-otel-revival.zh.md | 33 ++ docs/capability-seams.md | 6 + docs/config-catalog.md | 32 ++ docs/cordis-catalog/events.md | 27 ++ docs/cordis-catalog/services.md | 23 ++ docs/event-producer-consumer.md | 9 +- docs/module-graph.md | 13 + .../tests/fixtures/telemetry-otel-driver.ts | 43 +++ .../tests/fixtures/telemetry-otel.cordis.yml | 23 ++ examples/package.json | 1 + knip.json | 11 + packages/README.md | 7 +- .../cordis/tool-cordis/src/api-catalog.ts | 33 ++ packages/telemetry/README.md | 8 + .../session-telemetry-otel/README.md | 39 +++ .../session-telemetry-otel/package.json | 52 +++ .../session-telemetry-otel/src/index.ts | 168 ++++++++++ .../session-telemetry-otel/src/invariant.ts | 32 ++ .../tests/loader-composition.e2e.ts | 98 ++++++ .../session-telemetry-otel/tests/otel.e2e.ts | 25 ++ .../session-telemetry-otel/tests/otel.spec.ts | 166 ++++++++++ .../session-telemetry-otel/tsconfig.json | 33 ++ .../telemetry/session-telemetry/README.md | 40 +++ .../telemetry/session-telemetry/package.json | 41 +++ .../session-telemetry/src/coordinator.ts | 244 ++++++++++++++ .../telemetry/session-telemetry/src/index.ts | 145 ++++++++ .../session-telemetry/src/invariant.ts | 32 ++ .../telemetry/session-telemetry/src/redact.ts | 77 +++++ .../session-telemetry/tests/redact.spec.ts | 157 +++++++++ .../session-telemetry/tests/telemetry.spec.ts | 313 ++++++++++++++++++ .../telemetry/session-telemetry/tsconfig.json | 27 ++ pnpm-lock.yaml | 200 +++++++++++ scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 10 + .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 2 + tsconfig.host.json | 2 + 39 files changed, 2207 insertions(+), 7 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md create mode 100644 examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts create mode 100644 examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml create mode 100644 packages/telemetry/README.md create mode 100644 packages/telemetry/session-telemetry-otel/README.md create mode 100644 packages/telemetry/session-telemetry-otel/package.json create mode 100644 packages/telemetry/session-telemetry-otel/src/index.ts create mode 100644 packages/telemetry/session-telemetry-otel/src/invariant.ts create mode 100644 packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts create mode 100644 packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts create mode 100644 packages/telemetry/session-telemetry-otel/tests/otel.spec.ts create mode 100644 packages/telemetry/session-telemetry-otel/tsconfig.json create mode 100644 packages/telemetry/session-telemetry/README.md create mode 100644 packages/telemetry/session-telemetry/package.json create mode 100644 packages/telemetry/session-telemetry/src/coordinator.ts create mode 100644 packages/telemetry/session-telemetry/src/index.ts create mode 100644 packages/telemetry/session-telemetry/src/invariant.ts create mode 100644 packages/telemetry/session-telemetry/src/redact.ts create mode 100644 packages/telemetry/session-telemetry/tests/redact.spec.ts create mode 100644 packages/telemetry/session-telemetry/tests/telemetry.spec.ts create mode 100644 packages/telemetry/session-telemetry/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml new file mode 100644 index 0000000000..31a5914d0d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -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 +2026-07-23-session-telemetry-otel-revival.md: 1150d363e9db98a39e1188b86dc41fa291edba41 +2026-07-23-session-telemetry-otel-revival.zh.md: 76749a59c38ef2b8c5b3f09960ca500c25d8e1a8 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md new file mode 100644 index 0000000000..1150d363e9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -0,0 +1,33 @@ +# Agent Note: Session telemetry seam with mandatory redaction and the OTel backend + +Status: implemented + +English | [中文](2026-07-23-session-telemetry-otel-revival.zh.md) + +## Problem + +Every deployment that wants harness sessions in an observability stack must hand-roll a session-log consumer: subscription, lifecycle handoff, and — hardest — redaction, since the raw log carries file contents and command output that may embed credentials. A telemetry seam and OTel backend shipped once on the `session-telemetry-otlp-rfc` branch (PR #222/#231) but never reached master: the proposal exported raw session events verbatim, which legal review declined. The capture-side design (backend contract, coordinator, handoff cursor, chunk projection) was sound and reviewed; the export-side stance was the blocker. + +## Decision + +`packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go, and nothing crosses the seam unredacted: + +- **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: adoption with cursor read-back, the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), the fixed first-chunk-per-(turn, step) projection, the `agent/error` relay, and dispose-time `shutdown` records. +- **The `telemetry/redact` waterfall** — the delta over the branch version. Every record passes it before reaching any backend; the innermost `next()` applies a conservative built-in rule set (credential shapes: API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo), deployments stack stricter rules as listeners, and a throwing rule withholds the record fail-closed. The pattern list is a security invariant, deliberately not configurable. Redaction applies to the exported copy only; the canonical log is never rewritten. +- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `exporter.url` is required and validated at load; unmounted or unconfigured, nothing leaves the process. + +The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs — delivery is best-effort (at-most-once across a crash), which the READMEs state plainly. + +## Alternatives considered + +**Implement the runtime-telemetry RFC's outbox (durable spool, per-sink cursors, at-least-once, a `readCommitted` persistence-seam method).** Deferred, not rejected: the SDK stance makes delivery semantics the reporting SDK's territory, and the OTel SDK's own batch pipeline is the honest default. The outbox is a pure additive layer (the `emit()` contract does not move); revive it when a deployment states a crash-loss requirement telemetry must satisfy. + +**Export without built-in redaction, delegating to receiver-side collector processors.** Rejected — this is what legal declined. Receiver-side redaction ships the secret first and scrubs it second; the seam must scrub before bytes leave the process, and a waterfall makes the redaction point auditable and stackable. + +**A configurable pattern list for the default rules.** Rejected: deployment-varying tunables belong in config, but a security invariant does not — weakening the floor should require code, not YAML. Stricter rules stack as `telemetry/redact` listeners. + +**Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve. + +## Consequences + +A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. Credential-shaped substrings never leave the process even on a rule-free deployment, at the cost of a synchronous per-record scrub on the capture path (string-regex over lossless-JSON bodies — bounded by event size, no I/O). Exported bodies can differ from canonical log bytes wherever the placeholder landed, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md new file mode 100644 index 0000000000..76749a59c3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Session telemetry seam with mandatory redaction and the OTel backend + +Status: implemented + +[English](2026-07-23-session-telemetry-otel-revival.md) | 中文 + +## Problem + +每个想把 harness 会话接入可观测性体系的部署方都得手写一套会话日志消费端:订阅、生命周期交接、以及最难的脱敏——原始日志携带文件内容与命令输出,可能内嵌凭据。遥测 seam 和 OTel backend 曾在 `session-telemetry-otlp-rfc` 分支(PR #222/#231)上完成过一版,但从未进入 master:该提案将原始会话事件原样导出,法务评审未予通过。捕获侧设计(backend 契约、coordinator、handoff 游标、chunk 投影)本身合理且经过评审;导出侧的立场才是阻塞点。 + +## Decision + +`packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向,且任何数据未经脱敏不得跨越 seam: + +- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的收养、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每 (turn, step) 首 chunk 投影、`agent/error` 转发、以及 dispose 时的 `shutdown` 记录。 +- **`telemetry/redact` waterfall** —— 相对分支版本的增量。每条记录抵达任何 backend 前必经此处;最内层 `next()` 应用保守的内置规则集(凭据形状:API key、GitHub/Slack token、AWS/Google key、JWT、PEM 块、URL userinfo),部署方以监听器堆叠更严规则,抛异常的规则将该记录 fail-closed 扣下。模式列表是安全不变量,刻意不可配置。脱敏只作用于导出副本;canonical log 永不改写。 +- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。 + +边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。 + +## Alternatives considered + +**实现 runtime-telemetry RFC 的 outbox(落盘 spool、每 sink 游标、at-least-once、persistence seam 的 `readCommitted` 方法)。** 推迟而非否决:SDK 立场使投递语义归属 reporting SDK,OTel SDK 自身的批处理管线是诚实的默认。outbox 是纯增量层(`emit()` 契约不动);待某个部署提出遥测必须满足的崩溃丢失要求时再复活。 + +**不带内置脱敏直接导出,交给接收端 collector processor。** 否决——这正是法务否掉的方案。接收端脱敏是先把秘密发出去再擦除;seam 必须在字节离开进程前擦除,且 waterfall 使脱敏点可审计、可堆叠。 + +**默认规则的模式列表做成可配置。** 否决:随部署变化的调优项应进 config,但安全不变量不应——削弱底线应当需要改代码而非改 YAML。更严格的规则以 `telemetry/redact` 监听器堆叠。 + +**映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 + +## Consequences + +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。即使部署方未配置任何规则,凭据形状的子串也绝不离开进程,代价是捕获路径上每条记录一次同步擦除(对 lossless-JSON body 做字符串正则——受事件大小约束,无 I/O)。导出的 body 在占位符落点处可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a27d325e31..25406c8c9b 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -36,6 +36,9 @@ flowchart LR pkg_hooks_claude["hooks-claude"] pkg_hooks_codex["hooks-codex"] pkg_acp["acp"] + pkg_session_telemetry["session-telemetry"] + svc_telemetry["ctx.telemetry
Session telemetry seam"] + pkg_session_telemetry_otel["session-telemetry-otel"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] @@ -159,6 +162,8 @@ flowchart LR pkg_session_query --> svc_sessionQuery pkg_session_query_sqlite --> svc_sessionQuery pkg_session_reference --> svc_sessionReferences + pkg_session_telemetry --> svc_telemetry + pkg_session_telemetry_otel --> svc_telemetry pkg_session_title --> svc_sessionTitle pkg_session_title_all_messages_llm --> svc_sessionTitle pkg_session_title_first_message_llm --> svc_sessionTitle @@ -276,6 +281,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49a0a1510f..2f55f60bec 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1047,6 +1047,37 @@ export interface Config { Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) +## `@deepseek-ai/dsh-session-telemetry-otel` + +Requires: `sessions` + +```ts config-catalog +/** + * Plugin configuration: two verbatim SDK option shapes plus nothing else. + * `exporter.url` is the one field this package validates itself — required, + * no default, must parse as an `http(s)` URL — because a missing endpoint + * must fail at plugin load, not at first export. + */ +export interface Config { + /** Passed verbatim to the SDK's OTLP/HTTP log exporter. */ + exporter?: { + /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ + url?: string + /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */ + headers?: Record + } + /** + * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, + * which this plugin fills); the SDK owns and documents these knobs. + */ + processor?: Omit +} +``` + +Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) + +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:39`](../packages/telemetry/session-telemetry-otel/src/index.ts) + ## `@deepseek-ai/dsh-session-title` Requires: `sessions` @@ -1958,6 +1989,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) +- `@deepseek-ai/dsh-session-telemetry` ([`packages/telemetry/session-telemetry/src/index.ts`](../packages/telemetry/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session-title/session-title-llm/src/index.ts`](../packages/session-title/session-title-llm/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ed9bebdc92..4ed5de022b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -803,6 +803,33 @@ Emitted when any prompt provider changes. This registry notification is unfilter Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts) +## `telemetry/*` + +### `telemetry/redact` — waterfall + +Redact one outbound record before it reaches the backend. The innermost `next()` applies the seam's conservative default rule set (credential-shape scrubbing); listeners stack stricter rules by transforming its return value, and returning without `next()` replaces the default — the exported record is then only as clean as the replacing rule. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten. + +```ts cordis-catalog +/** + * Redact one outbound record before it reaches the backend. The innermost + * `next()` applies the seam's conservative default rule set + * (credential-shape scrubbing); listeners stack stricter rules by + * transforming its return value, and returning without `next()` replaces + * the default — the exported record is then only as clean as the + * replacing rule. Dispatched synchronously on the capture hot path inside + * the coordinator's containment: a throwing listener withholds that one + * record (fail-closed) and never reaches the agent loop. Redaction + * applies to the exported copy only; the canonical session log is never + * rewritten. + * @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/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord +``` + +Source: [`packages/telemetry/session-telemetry/src/index.ts:39`](../../packages/telemetry/session-telemetry/src/index.ts) + ## `tools/*` ### `tools/change` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0d6cc97086..22120b2f8e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1533,6 +1533,29 @@ Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-da Source: [`packages/tasks/tasks/src/index.ts:77`](../../packages/tasks/tasks/src/index.ts) +## `ctx.telemetry` — `Telemetry` (abstract seam) + +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 TelemetryCoordinator in its constructor to install the capture side. + +```ts cordis-catalog +/** + * 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 +``` + +Source: [`packages/telemetry/session-telemetry/src/index.ts:123`](../../packages/telemetry/session-telemetry/src/index.ts) + ## `ctx.tokenMeter` — `TokenMeterService` Replay owner for one service-wide estimator and isolated per-session folds. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b36d390f55..30380261fe 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -11,7 +11,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | @@ -33,16 +33,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `telemetry/redact` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:39`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:143`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 212fd723a7..a66c5edc0a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -199,6 +199,10 @@ flowchart TD pkg_tasks["tasks"] pkg_tool_tasks["tool-tasks"] end + subgraph group_telemetry["packages/telemetry"] + pkg_session_telemetry["session-telemetry"] + pkg_session_telemetry_otel["session-telemetry-otel"] + end subgraph group_workflow["packages/workflow"] pkg_tool_ralph["tool-ralph"] pkg_tool_workflow["tool-workflow"] @@ -408,6 +412,9 @@ flowchart TD pkg_tasks --> pkg_invariants pkg_tasks --> pkg_session pkg_tasks --> pkg_timeout + pkg_session_telemetry --> pkg_agent + pkg_session_telemetry --> pkg_invariants + pkg_session_telemetry --> pkg_session pkg_workflow --> pkg_agent pkg_workflow --> pkg_brand pkg_workflow --> pkg_invariants @@ -472,6 +479,10 @@ flowchart TD pkg_pty_local --> pkg_sandbox pkg_pty_local --> pkg_sandbox_policy pkg_pty_local --> pkg_session + pkg_session_telemetry_otel --> pkg_invariants + pkg_session_telemetry_otel --> pkg_llm + pkg_session_telemetry_otel --> pkg_session + pkg_session_telemetry_otel --> pkg_session_telemetry pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -858,6 +869,7 @@ flowchart TD | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | @@ -870,6 +882,7 @@ flowchart TD | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session) | +| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts new file mode 100644 index 0000000000..02be1a9011 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env node +/** + * Test driver: start a mock OTLP/HTTP collector, boot the telemetry Loader + * composition against it, run one turn whose prompt carries a fixture + * credential, then persist everything the collector captured to + * `./otlp-captures.json` for the e2e's inspect step. + */ + +import { writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { once } from 'node:events' +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('telemetry-otel driver requires a config path') + +const captures: unknown[] = [] +const server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', chunk => chunks.push(chunk as Buffer)) + request.on('end', () => { + captures.push(JSON.parse(Buffer.concat(chunks).toString())) + response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + }) +}) +server.listen(0, '127.0.0.1') +await once(server, 'listening') +const address = server.address() +if (address === null || typeof address === 'string') throw new Error('collector has no port') +process.env.DSH_TELEMETRY_E2E_URL = `http://127.0.0.1:${address.port}/v1/logs` + +const ctx = await boot('telemetry-otel-e2e', resolveConfigPath(configPath, undefined)) +try { + // The fixture credential rides the model-visible user message; the exported + // copy must scrub it while the canonical log keeps the original bytes. + await runOneShot(ctx, { task: 'prove telemetry with key sk-e2efixture1234567890' }) +} finally { + await ctx.fiber.dispose() +} +await writeFile('./otlp-captures.json', JSON.stringify(captures)) +server.close() +server.closeAllConnections() diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml new file mode 100644 index 0000000000..defde98ff5 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -0,0 +1,23 @@ +# Test-only composition: session-telemetry-otel through the real Loader/app +# path, exporting to the mock OTLP collector the driver starts (url via env). +- id: cli-mock-llm + name: './cli-mock-llm.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: telemetry-otel + name: '@deepseek-ai/dsh-session-telemetry-otel' + config: + exporter: + url: !!js process.env.DSH_TELEMETRY_E2E_URL + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: cli-mock + model: cli-mock + persona: 'Test the session-telemetry-otel plugin.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/package.json b/examples/package.json index bd87263340..133196aa96 100644 --- a/examples/package.json +++ b/examples/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", + "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-tui-demo": "workspace:*", diff --git a/knip.json b/knip.json index 59658e1df6..728ded39b7 100644 --- a/knip.json +++ b/knip.json @@ -31,6 +31,7 @@ "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", + "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", @@ -188,6 +189,16 @@ "tests/**/*.ts" ] }, + "packages/telemetry/session-telemetry-otel": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/util/brand": { "project": [ "src/**/*.ts" diff --git a/packages/README.md b/packages/README.md index 4e18d7b5f8..93e06631d2 100644 --- a/packages/README.md +++ b/packages/README.md @@ -18,7 +18,7 @@ Packages live at `packages///`; groups are containers, while names r | [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface | | [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface | | [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface | -| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | +| [`compact/`](compact/README.md) | Compaction capability family: abstract seam + basic backend (tool deferred) | Product — stable surface | | [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface | @@ -32,8 +32,9 @@ Packages live at `packages///`; groups are containers, while names r | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | -| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | +| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, SQLite full-text search | Product — stable surface | | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface | +| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface | | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra | @@ -46,6 +47,6 @@ Groups distinguish product API from support infrastructure. New packages join an The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI). -**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface / implementation / consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). +**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface/implementation/consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts). diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c3e4e1858b..aee1ac9ae8 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -724,6 +724,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'telemetry', + summary: '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.', + methods: [ + { + signature: 'abstract emit(record: TelemetryRecord): void', + jsDoc: '/**\n * See {@link TelemetryBackend.emit} — the seam declaration is the contract\'s one home.\n * @param record - the logical record to report; owned by the backend after the call.\n */', + }, + { + signature: 'flush?(): void', + jsDoc: '/** See {@link TelemetryBackend.flush}. */', + }, + { + signature: 'abstract shutdown(): Promise', + jsDoc: '/**\n * See {@link TelemetryBackend.shutdown}.\n * @returns resolves when the backend\'s pipeline has quiesced.\n */', + }, + ], + }, { key: 'tokenMeter', summary: 'Replay owner for one service-wide estimator and isolated per-session folds.', @@ -1102,6 +1120,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Emitted when any prompt provider changes. This registry notification is\n * unfiltered because a global change affects every scope.\n * @mode emit\n */', summary: 'Emitted when any prompt provider changes.', }, + { + name: 'telemetry/redact', + mode: 'waterfall', + signature: '\'telemetry/redact\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord', + jsDoc: '/**\n * Redact one outbound record before it reaches the backend. The innermost\n * `next()` applies the seam\'s conservative default rule set\n * (credential-shape scrubbing); listeners stack stricter rules by\n * transforming its return value, and returning without `next()` replaces\n * the default — the exported record is then only as clean as the\n * replacing rule. Dispatched synchronously on the capture hot path inside\n * the coordinator\'s containment: a throwing listener withholds that one\n * record (fail-closed) and never reaches the agent loop. Redaction\n * applies to the exported copy only; the canonical session log is never\n * rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */', + summary: 'Redact one outbound record before it reaches the backend.', + }, { name: 'tools/change', mode: 'emit', @@ -2087,6 +2112,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TaskStatus', declaration: 'export type TaskStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';', }, + { + name: 'TelemetryRecord', + declaration: 'export interface TelemetryRecord {\n channel: \'ledger\' | \'ops\';\n time: number;\n severity: TelemetrySeverity;\n attributes: Record;\n body: unknown;\n}', + }, + { + name: 'TelemetrySeverity', + declaration: 'export type TelemetrySeverity = \'info\' | \'warn\' | \'error\';', + }, { name: 'TerminalCallView', declaration: 'export interface TerminalCallView {\n card: \'terminal\';\n title: string;\n description?: string;\n cwd?: string;\n}', diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md new file mode 100644 index 0000000000..a5869077c2 --- /dev/null +++ b/packages/telemetry/README.md @@ -0,0 +1,8 @@ +# telemetry/ + +Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the mandatory `telemetry/redact` waterfall, the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). + +| Package | Role | +|---|---| +| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, handoff cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). | +| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | The backend a deployment loads: the OTel JS SDK's log pipeline (`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP exporter), configured verbatim through passthroughs. | diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md new file mode 100644 index 0000000000..26edc62645 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-session-telemetry-otel + +The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. It composes the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and maps each record the seam hands over onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use. + +## Config + +```yaml +- id: telemetry-otel + name: '@deepseek-ai/dsh-session-telemetry-otel' + config: + exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter + url: https://collector.example.com/v1/logs + headers: + authorization: !!js `Bearer ${process.env.OTLP_TOKEN}` + processor: {} # optional; passed verbatim to BatchLogRecordProcessor +``` + +`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load. Everything else is the SDK's option shape, owned and documented by the SDK; batching, retry, queue bounds, and loss policy under sustained failure are its documented behavior, tuned through the `processor` passthrough. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. + +## What leaves the machine + +Records carry the seam's REDACTED copy of `event.data` — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path) — after the seam's `telemetry/redact` waterfall has scrubbed credential-shaped substrings (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. A deployment with stricter requirements stacks `telemetry/redact` listeners or opts out structurally. + +## Field mapping + +Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record staleness (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). + +## Model Experience + +None, as the backend only forwards the seam's redacted records into the OTel SDK pipeline; 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 + +- **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. +- **Live-collector smoke is opt-in** — the e2e smoke (`tests/otel.e2e.ts`) self-skips without `$DSH_OTLP_E2E_ENDPOINT`; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape against a mock collector on every run. diff --git a/packages/telemetry/session-telemetry-otel/package.json b/packages/telemetry/session-telemetry-otel/package.json new file mode 100644 index 0000000000..706112d1fe --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/package.json @@ -0,0 +1,52 @@ +{ + "name": "@deepseek-ai/dsh-session-telemetry-otel", + "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", + "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", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.220.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-logs": "^0.220.0", + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-telemetry": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-telemetry": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts new file mode 100644 index 0000000000..4738cdd932 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -0,0 +1,168 @@ +/** + * OpenTelemetry backend for the DeepSeek Harness telemetry seam. + * + * Composes the OTel JS SDK as-is — a `LoggerProvider` with a + * `BatchLogRecordProcessor` and an OTLP/HTTP log exporter — and maps each + * record handed over by the seam onto `logger.emit()`. Per the seam's + * boundary axiom, everything downstream of that call (batching, retry, + * queueing, loss policy) is the SDK's documented behavior, configured + * verbatim through the `exporter`/`processor` passthroughs; this package + * adds no knobs of its own on top of them. + * + * @module @deepseek-ai/dsh-session-telemetry-otel + */ + +import { createRequire } from 'node:module' +import z from 'schemastery' +import type { Context } from 'cordis' +import { Telemetry, TelemetryCoordinator, type TelemetryRecord, type TelemetrySeverity } from '@deepseek-ai/dsh-session-telemetry' +import { APP_IDENTITY } from '@deepseek-ai/dsh-llm' +import { + BatchLogRecordProcessor, + LoggerProvider, + type BatchLogRecordProcessorOptions, +} from '@opentelemetry/sdk-logs' +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' +import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs' +import { resourceFromAttributes } from '@opentelemetry/resources' + +// The package's own manifest is the single source of the instrumentation-scope +// version (same pattern as dsh-llm's attribution identity). +const { version } = createRequire(import.meta.url)('../package.json') as { version: string } + +/** + * Plugin configuration: two verbatim SDK option shapes plus nothing else. + * `exporter.url` is the one field this package validates itself — required, + * no default, must parse as an `http(s)` URL — because a missing endpoint + * must fail at plugin load, not at first export. + */ +export interface Config { + /** Passed verbatim to the SDK's OTLP/HTTP log exporter. */ + exporter?: { + /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ + url?: string + /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */ + headers?: Record + } + /** + * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, + * which this plugin fills); the SDK owns and documents these knobs. + */ + processor?: Omit +} + +/** + * Schemastery validator for {@link Config}; cordis runs it before the plugin + * starts. Shape-level only — the load-bearing `exporter.url` check lives in + * the constructor so its error message names the field. + */ +export const Config: z = z.object({ + exporter: z.object({ + url: z.string(), + headers: z.dict(z.string()), + }), + // Opaque passthrough: the SDK owns this shape and validates its own + // options; re-declaring them here would violate the boundary axiom. + processor: z.any(), +}) + +/** Severity mapping from the seam's three-level vocabulary to OTel severity numbers. */ +const SEVERITY: Record = { + info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' }, + warn: { severityNumber: SeverityNumber.WARN, severityText: 'WARN' }, + error: { severityNumber: SeverityNumber.ERROR, severityText: 'ERROR' }, +} + +/** + * The backend plugin — the only entry a deployment loads. Constructing it + * wires the SDK pipeline, registers the `telemetry` service (duplicate load + * throws, cordis' standard duplicate-service behavior), and composes the + * seam's {@link TelemetryCoordinator}, which installs the capture side onto + * this fiber. + */ +export class TelemetryOtel extends Telemetry { + static inject = ['sessions'] + static Config = Config + + private readonly provider: LoggerProvider + private readonly ledger: Logger + private readonly ops: Logger + + constructor(ctx: Context, config: Config) { + super(ctx) + const url = config.exporter?.url + if (url === undefined || url.length === 0) { + throw new Error('session-telemetry-otel: exporter.url is required (the full OTLP logs endpoint)') + } + let parsed: URL + try { + parsed = new URL(url) + } catch { + // Re-thrown as a config error: the only way here is a malformed url string. + throw new Error(`session-telemetry-otel: exporter.url is not a valid URL: ${JSON.stringify(url)}`) + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`session-telemetry-otel: exporter.url must be http(s), got ${parsed.protocol}`) + } + this.provider = new LoggerProvider({ + resource: resourceFromAttributes({ + 'service.name': APP_IDENTITY.product, + 'service.version': APP_IDENTITY.version, + }), + processors: [ + new BatchLogRecordProcessor({ + ...config.processor, + exporter: new OTLPLogExporter({ + url, + // App identity travels in the Resource (service.name/version); + // the transport-level user-agent is the SDK's own, per the axiom. + // Schemastery fills `headers` with {} before cordis constructs the + // plugin, so the optional type exists for hand-authors only. + headers: config.exporter?.headers as Record, + }), + }), + ], + }) + this.ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version) + this.ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version) + new TelemetryCoordinator(ctx, this) + } + + /** + * Map one seam record onto the SDK logger for its channel — a synchronous + * enqueue into the batch processor's queue. + * @param record - the logical record handed over by the coordinator. + */ + emit(record: TelemetryRecord): void { + const logger = record.channel === 'ops' ? this.ops : this.ledger + logger.emit({ + timestamp: record.time, + observedTimestamp: record.time, + ...SEVERITY[record.severity], + // JSON-serializable by the seam's contract (validated at Session.append), + // which is exactly the AnyValue subset. + body: record.body as AnyValue, + attributes: record.attributes, + }) + } + + /** Forward the turn-boundary hint to the SDK's flush, fire-and-forget. */ + override flush(): void { + // Best-effort hint: the SDK resolves forceFlush even when exports fail + // (failures go to its own diagnostics), and the coordinator stops calling + // this once the fiber is disposed — a rejection would be SDK drift. + /* v8 ignore next -- unreachable guard: forceFlush does not reject while the provider is alive */ + void this.provider.forceFlush().catch(() => {}) + } + + /** + * Delegate disposal to the SDK's shutdown contract: flush the queue and + * quiesce. Awaited (and error-contained) by the coordinator's disposer. + * @returns resolves when the SDK pipeline has quiesced. + */ + shutdown(): Promise { + return this.provider.shutdown() + } +} + +export default TelemetryOtel diff --git a/packages/telemetry/session-telemetry-otel/src/invariant.ts b/packages/telemetry/session-telemetry-otel/src/invariant.ts new file mode 100644 index 0000000000..075e5cc193 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-telemetry-otel`. + * @module @deepseek-ai/dsh-session-telemetry-otel/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-otel' + +/** Cordis companion plugin name. */ +export const name = 'session-telemetry-otel-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the backend forwards seam records into the OTel SDK's + * in-process pipeline and appends nothing to any session; its only observable + * effects (batching, export) happen inside the SDK past the seam's boundary + * axiom, out of reach of an independent companion. + */ +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 */ diff --git a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..2c72fd6170 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts @@ -0,0 +1,98 @@ +/** + * REAL-composition tier: boot the examples-owned telemetry Loader fixture as + * a subprocess (per testing policy, through the same app/boot path a + * deployment uses), run one mocked-model turn with a real bash round trip, + * and assert against what the mock OTLP collector actually received on the + * wire: ledger mirroring, default redaction, ops markers, and the untouched + * canonical log. + */ + +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { REDACTION_PLACEHOLDER } from '@deepseek-ai/dsh-session-telemetry' + +const driver = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts', + import.meta.url, +)) +const configPath = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +const FIXTURE_SECRET = 'sk-e2efixture1234567890' + +interface OtlpLogRecord { + attributes?: { key: string; value: Record }[] + body?: unknown +} + +interface OtlpCapture { + resourceLogs: { + scopeLogs: { + scope: { name: string } + logRecords: OtlpLogRecord[] + }[] + }[] +} + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +describe('session-telemetry-otel through a real headless cordis.yml', () => { + it('exports redacted ledger records to the collector while the canonical log keeps the secret', async () => { + let captures: OtlpCapture[] = [] + let logContent = '' + const { stderr } = await runLoaderSmoke({ + label: 'session-telemetry-otel loader smoke', + tempDirPrefix: 'telemetry-otel-e2e-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + inspect: async (cwd) => { + captures = JSON.parse(await readFile(join(cwd, 'otlp-captures.json'), 'utf8')) as OtlpCapture[] + const logs = await jsonlFiles(join(cwd, '.sessions')) + expect(logs).toHaveLength(1) + logContent = await readFile(logs[0] as string, 'utf8') + }, + }) + expect(stderr).not.toContain('UNHANDLED') + + const records = captures.flatMap(capture => capture.resourceLogs.flatMap(resource => + resource.scopeLogs.flatMap(scoped => scoped.logRecords.map(record => ({ scope: scoped.scope.name, record }))))) + expect(records.length).toBeGreaterThan(0) + + const eventTypes = records.flatMap(({ record }) => + record.attributes?.flatMap(attribute => + attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string' + ? [attribute.value['stringValue']] + : []) ?? []) + for (const expected of ['turn/start', 'user/message', 'tool/call', 'tool/result', 'assistant/message', 'turn/end']) { + expect(eventTypes, expected).toContain(expected) + } + expect(records.some(({ scope }) => scope.endsWith('/ops'))).toBe(true) + + // Default redaction on the wire: the fixture credential never leaves the + // process, its surrounding prose does, and the placeholder marks the spot. + const wire = JSON.stringify(captures) + expect(wire).not.toContain(FIXTURE_SECRET) + expect(wire).toContain(REDACTION_PLACEHOLDER) + expect(wire).toContain('prove telemetry with key') + + // The canonical session log is never rewritten. + expect(logContent).toContain(FIXTURE_SECRET) + expect(logContent).not.toContain(REDACTION_PLACEHOLDER) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts new file mode 100644 index 0000000000..91c92b3486 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts @@ -0,0 +1,25 @@ +/** + * Keyless-self-skipping smoke: ship one real session's records to a live + * OTLP collector named by $DSH_OTLP_E2E_ENDPOINT and require the SDK's + * shutdown (flush-and-quiesce) to resolve. Skipped without the endpoint so + * secretless CI stays green — a CI accommodation, not a cost signal. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import TelemetryOtel from '../src/index.ts' + +describe.skipIf(!process.env.DSH_OTLP_E2E_ENDPOINT)('telemetry-otel e2e (live collector)', () => { + it('exports a session and quiesces cleanly', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(TelemetryOtel, { + exporter: { url: process.env.DSH_OTLP_E2E_ENDPOINT! }, + }) + const session = ctx.sessions.create(SessionId(`e2e-${Date.now()}`), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await expect(fiber.dispose()).resolves.not.toThrow() + }) +}) diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts new file mode 100644 index 0000000000..88a09a0f3b --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -0,0 +1,166 @@ +/** + * OTel backend unit tier: wire assertions against a scripted `node:http` + * mock collector through the SDK's REAL pipeline (BatchLogRecordProcessor → + * OTLP/HTTP JSON), config fail-loud cases, and the real-Loader-path guard + * for the default-exported Service class. + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { createServer, type Server } from 'node:http' +import { once } from 'node:events' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import TelemetryOtel, { Config } from '../src/index.ts' + +interface Capture { + headers: import('node:http').IncomingHttpHeaders + body: OtlpLogsRequest +} + +/** Just the slice of ExportLogsServiceRequest JSON these assertions touch. */ +interface OtlpLogsRequest { + resourceLogs: { + resource: { attributes: { key: string; value: { stringValue?: string } }[] } + scopeLogs: { + scope: { name: string } + logRecords: { + timeUnixNano: string + severityNumber: number + severityText: string + attributes?: { key: string; value: Record }[] + }[] + }[] + }[] +} + +const servers: Server[] = [] + +afterEach(async () => { + for (const server of servers.splice(0)) { + server.close() + server.closeAllConnections() + } +}) + +async function mockCollector(): Promise<{ url: string; captures: Capture[] }> { + const captures: Capture[] = [] + const server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', chunk => chunks.push(chunk as Buffer)) + request.on('end', () => { + captures.push({ + headers: request.headers, + body: JSON.parse(Buffer.concat(chunks).toString()) as OtlpLogsRequest, + }) + response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + }) + }) + servers.push(server) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { url: `http://127.0.0.1:${address.port}/v1/logs`, captures } +} + +async function boot(url: string) { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(TelemetryOtel, { + exporter: { url, headers: { authorization: 'Bearer test-token' } }, + }) + return { ctx, fiber } +} + +function allRecords(captures: Capture[]) { + return captures.flatMap(c => c.body.resourceLogs.flatMap(r => r.scopeLogs.flatMap(s => + s.logRecords.map(record => ({ scope: s.scope.name, record }))))) +} + +describe('TelemetryOtel wire', () => { + it('ships session records and the ops shutdown marker through the real SDK pipeline', async () => { + 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' } }) + await fiber.dispose() + + expect(captures.length).toBeGreaterThan(0) + const first = captures[0]! + const authorization: string | undefined = first.headers.authorization + expect(authorization).toBe('Bearer test-token') + + const resource = first.body.resourceLogs[0]!.resource.attributes + expect(resource).toContainEqual({ key: 'service.name', value: { stringValue: 'deepseek-harness' } }) + + const records = allRecords(captures) + const ledger = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel') + const ops = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops') + + const start = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start')) + expect(start).toBeDefined() + expect(start?.record.severityNumber).toBe(9) + expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.events[0]!.time) * 1_000_000n) + expect(start?.record.attributes).toContainEqual({ key: 'session.cwd', value: { stringValue: '/tmp/w' } }) + + const end = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/end')) + expect(end?.record.severityNumber).toBe(17) + expect(end?.record.severityText).toBe('ERROR') + + expect(ops).toHaveLength(1) + expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) + }) + + it('maps the warn severity and forwards the flush hint to the SDK', async () => { + const { url, captures } = await mockCollector() + const { ctx, fiber } = await boot(url) + const session = ctx.sessions.create(SessionId('warn'), { meta: {} }) + session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' }) + // The turn-boundary hint: safe, non-blocking, and enough to push the batch out. + expect(() => { + ctx.telemetry.flush!() + }).not.toThrow() + await fiber.dispose() + const blocked = allRecords(captures).find(r => + r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'prompt/blocked')) + expect(blocked?.record.severityNumber).toBe(13) + }) +}) + +describe('TelemetryOtel config fails loud', () => { + it.each([ + [{}, /exporter\.url is required/], + [{ exporter: { url: '' } }, /exporter\.url is required/], + [{ exporter: { url: 'not a url' } }, /not a valid URL/], + [{ exporter: { url: 'ftp://collector' } }, /must be http\(s\)/], + ])('rejects %j at plugin load', async (config, message) => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await expect(ctx.plugin(TelemetryOtel, config as Config)).rejects.toThrow(message) + }) +}) + +describe('dsh-session-telemetry-otel real-load-path guard', () => { + it('keeps the Service class with inject/Config through unwrapExports', async () => { + const module = await import('../src/index.ts') + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(module) as typeof TelemetryOtel + expect(unwrapped).toBe(TelemetryOtel) + expect(unwrapped.inject).toEqual(['sessions']) + expect(typeof unwrapped.Config).toBe('function') + }) + + it('boots through the unwrapped class and registers ctx.telemetry', async () => { + const { url } = await mockCollector() + const module = await import('../src/index.ts') + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(module) as Parameters[0] + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(unwrapped, { exporter: { url } }) + expect(ctx.telemetry).toBeInstanceOf(TelemetryOtel) + await fiber.dispose() + }) +}) diff --git a/packages/telemetry/session-telemetry-otel/tsconfig.json b/packages/telemetry/session-telemetry-otel/tsconfig.json new file mode 100644 index 0000000000..9512133cf7 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/session" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../session-telemetry" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md new file mode 100644 index 0000000000..bccc61fd06 --- /dev/null +++ b/packages/telemetry/session-telemetry/README.md @@ -0,0 +1,40 @@ +# @deepseek-ai/dsh-session-telemetry + +The telemetry seam: the CAPTURE side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). + +## The backend contract + +`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path), optional `flush()` (a turn-boundary hint, fire-and-forget), and `shutdown()` (the lifecycle forward: flush-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` in its constructor. + +## Capture points + +The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection — seed events from fork/resume never re-emit on the firehose), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (per adopted session emit its `shutdown` operational record, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). + +## The redact waterfall + +Every record passes the `telemetry/redact` waterfall between projection and `emit()` — nothing reaches a backend unredacted. The innermost `next()` applies the built-in conservative rule set (`applyDefaultRedaction`: credential shapes — API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo — replaced with `[REDACTED]` in body strings and string attribute values). Listeners stack stricter rules by transforming `next()`'s return value; returning without `next()` replaces the default rule set, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. The built-in pattern list is a security invariant, deliberately not configurable from cordis.yml. Redaction applies to the exported copy only; the canonical session log is never rewritten. + +## The handoff cursor + +A module-scope `WeakMap` marks the highest seq HANDED OFF (not delivered) per session, advanced at emit time. It survives reloads that do not re-evaluate this module — config re-applies and backend source reloads, which is where iteration happens; that asymmetry is why the cursor lives in the seam. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a full re-hand, absorbed by receiver-side dedupe on `(session.id, event.seq)`. 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, `compact/end` errors; WARN for `prompt/blocked`; INFO otherwise), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id` 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. 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). +- **Redaction is shape-based** — the default rules catch known credential shapes, not every secret; a deployment with stricter needs stacks `telemetry/redact` listeners, and exported data is only as clean as the mounted rules. diff --git a/packages/telemetry/session-telemetry/package.json b/packages/telemetry/session-telemetry/package.json new file mode 100644 index 0000000000..71646c130e --- /dev/null +++ b/packages/telemetry/session-telemetry/package.json @@ -0,0 +1,41 @@ +{ + "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", + "lib/types/**/*.d.ts.map", + "src" + ], + "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" + } +} diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts new file mode 100644 index 0000000000..08fbe498b1 --- /dev/null +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -0,0 +1,244 @@ +/** + * Capture coordinator: the seam's upstream half. Subscribes to the session + * firehose plus the one live-bus relay (`agent/error`), applies the fixed + * chunk projection, builds logical records, runs each through the + * `telemetry/redact` waterfall, and hands the redacted copy to the backend — + * synchronously, with every handler self-contained so a failing backend can + * never starve other subscribers (cordis `emit` is stop-on-throw) or touch + * the agent loop. Composed by a backend in its constructor. + * + * @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' +import { applyDefaultRedaction } from './redact.ts' + +/** + * 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() + +/** + * Install the telemetry capture side onto a context for one backend. + * + * Registers the persistence-coordinator listener set plus the `agent/error` + * relay, all through `ctx.effect()`/`ctx.on()` on the composing fiber, and + * sweeps already-live sessions (a hot reload does not replay + * `session/created`). Disposal emits each adopted session's `shutdown` + * operational record and 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, for dispose-time `shutdown` records and double-adoption protection. */ + private readonly adopted = new Set() + /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ + private readonly chunkSeen = new WeakMap>() + + /** + * @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. + */ + constructor( + private readonly ctx: Context, + private readonly backend: TelemetryBackend, + ) { + ctx.on('session/created', (session) => { + this.adopt(session) + }) + ctx.on('session/event', (session, event) => { + this.contain(() => { + this.capture(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) + }) + }) + ctx.effect(() => async () => { + for (const session of this.adopted) { + this.contain(() => { + this.handOff(shutdownRecord(session)) + }) + } + try { + await this.backend.shutdown() + } catch (error) { + this.ctx.logger.warn(`telemetry: backend shutdown failed: ${String(error)}`) + } + }, 'telemetry capture') + for (const session of ctx.sessions.list()) { + this.adopt(session) + } + } + + /** + * Adopt a session: replay its log THROUGH the projection from the handoff + * cursor (or from the start when no cursor survived), then rely on the + * firehose for everything after. Events at or below the cursor 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. + * @param session - the live session to adopt; a second adoption is a no-op. + */ + private adopt(session: Session): void { + this.contain(() => { + if (this.adopted.has(session)) return + this.adopted.add(session) + const cursor = handoffCursor.get(session) ?? -1 + for (const event of session.events) { + if (event.seq <= cursor) this.track(session, event) + else this.capture(session, event) + } + }) + } + + /** 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 one event and hand it to the backend, advancing the cursor on handoff. */ + private capture(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.handOff({ + channel: 'ledger', + time: event.time, + severity: severityOf(event), + attributes: identityOf(session, event), + // The live event object is mutable and the backend serializes later; + // append-time validation guarantees this clone cannot throw. + body: structuredClone(event.data), + }) + handoffCursor.set(session, event.seq) + } + + /** + * Run the `telemetry/redact` waterfall over one record and hand the result + * to the backend. The innermost `next` applies the seam's conservative + * default rules, so an unconfigured deployment still never exports raw + * credential shapes; callers run inside {@link contain}, so a throwing + * rule withholds the record instead of reaching the loop (fail-closed). + */ + private handOff(record: TelemetryRecord): void { + this.backend.emit(this.ctx.waterfall('telemetry/redact', record, () => applyDefaultRedaction(record))) + } + + /** 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: Error): void { + this.handOff({ + channel: 'ops', + time: Date.now(), + severity: 'error', + attributes: { + 'telemetry.op': 'agent-error', + 'session.id': String(agent.session.id), + 'agent.id': agent.id, + 'error.name': error.name, + turn, + step, + }, + body: { name: error.name, message: error.message }, + }) + } + + /** Lazily create the per-session first-chunk tracking set. */ + private seen(session: Session): Set { + 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 dispose, before the backend's `shutdown()`. */ +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.isError ? 'error' : 'info' + case 'turn/end': + return event.data.reason.kind === 'error' ? 'error' : 'info' + case 'prompt/blocked': + return 'warn' + default: { + // Merge-extensible fall-through (no assertNever): types this seam does + // not depend on still get their RFC-pinned severity via a widened + // probe — `compact/end` is declared by dsh-compact, which the seam + // deliberately does not import. + const type: string = event.type + if (type === 'compact/end' && (event.data as { error?: unknown }).error !== undefined) return 'error' + return 'info' + } + } +} + +/** Build the minimal identity attributes: envelope plus self-contained header facts. */ +function identityOf(session: Session, event: SessionEvent): Record { + const attributes: Record = { + 'session.id': String(session.id), + 'event.type': event.type, + 'event.seq': event.seq, + } + const { cwd, parentSession } = session.header + if (cwd !== undefined) attributes['session.cwd'] = cwd + if (parentSession !== undefined) attributes['session.parent_id'] = String(parentSession) + return attributes +} diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts new file mode 100644 index 0000000000..93e7e7b0f9 --- /dev/null +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -0,0 +1,145 @@ +/** + * 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 handed over (adoption, the per-append firehose, lifecycle + * forwarding), and the HMR handoff cursor. Everything downstream of + * {@link Telemetry.emit} — batching, retry, queueing, loss policy — is the + * reporting SDK's territory and is deliberately not modelled here. The + * design and its trade-offs are pinned in + * .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 { + /** + * Redact one outbound record before it reaches the backend. The innermost + * `next()` applies the seam's conservative default rule set + * (credential-shape scrubbing); listeners stack stricter rules by + * transforming its return value, and returning without `next()` replaces + * the default — the exported record is then only as clean as the + * replacing rule. Dispatched synchronously on the capture hot path inside + * the coordinator's containment: a throwing listener withholds that one + * record (fail-closed) and never reaches the agent loop. Redaction + * applies to the exported copy only; the canonical session log is never + * rewritten. + * @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/redact'(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 (`tool/result.isError`, `turn/end` error reasons, `compact/end` + * errors) and for `agent-error` operational records, `warn` for + * `prompt/blocked`, `info` for everything else. + */ +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 + /** + * 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, so anything slower than a queue push would tax + * the agent loop. Errors thrown here are contained by the coordinator and + * logged; they never reach the loop. + * @param record - the logical record to report; owned by the backend after the call. + */ + emit(record: TelemetryRecord): void + /** + * 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). + */ + flush?(): void + /** + * Forward the fiber's disposal to the SDK: flush whatever is queued and + * reach quiescence, per the SDK's own shutdown contract. Awaited by the + * coordinator's dispose; a rejection is logged as a warning and never + * fails application teardown. + * @returns resolves when the backend's pipeline has quiesced. + */ + shutdown(): Promise +} + +/** + * 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 +} + +export { TelemetryCoordinator } from './coordinator.ts' +export { applyDefaultRedaction, REDACTION_PLACEHOLDER } from './redact.ts' diff --git a/packages/telemetry/session-telemetry/src/invariant.ts b/packages/telemetry/session-telemetry/src/invariant.ts new file mode 100644 index 0000000000..ffc55b0107 --- /dev/null +++ b/packages/telemetry/session-telemetry/src/invariant.ts @@ -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 */ diff --git a/packages/telemetry/session-telemetry/src/redact.ts b/packages/telemetry/session-telemetry/src/redact.ts new file mode 100644 index 0000000000..d25452581a --- /dev/null +++ b/packages/telemetry/session-telemetry/src/redact.ts @@ -0,0 +1,77 @@ +/** + * Conservative default redaction for outbound telemetry records. + * + * Session-event bodies carry file contents and command output that may embed + * credentials; nothing may cross the seam to a backend unredacted. This module + * is the innermost rule set of the `telemetry/redact` waterfall — always + * applied unless an outer listener deliberately replaces the whole chain. It + * scrubs credential-SHAPED substrings from every string in the record body, + * leaving structure (keys, nesting, surrounding prose) intact. The pattern + * list is a security invariant, deliberately not configurable; deployments + * add stricter rules by stacking `telemetry/redact` listeners. + * + * @module @deepseek-ai/dsh-session-telemetry/redact + */ + +import type { TelemetryRecord } from './index.ts' + +/** Replacement text substituted for each detected credential-shaped span. */ +export const REDACTION_PLACEHOLDER = '[REDACTED]' + +/** + * Well-known credential shapes. A match anywhere inside a body string is + * replaced; low-signal values (package names, versions, git SHAs, plain URLs) + * deliberately stay untouched — they are the observability signal. + */ +const SECRET_PATTERNS: readonly RegExp[] = [ + /sk-(?:ant-)?[A-Za-z0-9_-]{10,}/g, // DeepSeek / OpenAI / Anthropic API keys + /gh[pousr]_[A-Za-z0-9]{16,}/g, // GitHub personal/oauth/server/refresh tokens + /github_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT + /xox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens + /AKIA[0-9A-Z]{16}/g, // AWS access key id + /AIza[0-9A-Za-z_-]{35}/g, // Google API key + /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, // JWT + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, // PEM blocks + /\b(?[a-z][a-z0-9+.-]*):\/\/[^/\s:@]+:[^/\s:@]+@/g, // URL userinfo credentials +] + +/** Replace every known credential shape inside one string. */ +function scrub(text: string): string { + let out = text + for (const pattern of SECRET_PATTERNS) { + out = out.replace(pattern, REDACTION_PLACEHOLDER) + } + return out +} + +/** + * Deep-scrub every string inside a lossless-JSON value, preserving structure. + * The record body is the coordinator's own `structuredClone` — mutation-free + * rebuilding keeps the exported copy independent of the canonical log either way. + */ +function scrubValue(value: unknown): unknown { + if (typeof value === 'string') return scrub(value) + if (Array.isArray(value)) return value.map(scrubValue) + if (value !== null && typeof value === 'object') { + const out: Record = {} + for (const [key, entry] of Object.entries(value)) out[key] = scrubValue(entry) + return out + } + return value +} + +/** + * Apply the conservative default rule set to one record — the innermost + * `next` of the `telemetry/redact` waterfall. Attribute VALUES are scrubbed + * alongside the body (identity attributes are seam-built and boring, but + * `session.cwd` is caller-supplied); attribute keys are seam-owned constants. + * @param record - the candidate record; not mutated. + * @returns a redacted copy safe to hand to a backend. + */ +export function applyDefaultRedaction(record: TelemetryRecord): TelemetryRecord { + const attributes: Record = {} + for (const [key, value] of Object.entries(record.attributes)) { + attributes[key] = typeof value === 'string' ? scrub(value) : value + } + return { ...record, attributes, body: scrubValue(record.body) } +} diff --git a/packages/telemetry/session-telemetry/tests/redact.spec.ts b/packages/telemetry/session-telemetry/tests/redact.spec.ts new file mode 100644 index 0000000000..cd23af6da2 --- /dev/null +++ b/packages/telemetry/session-telemetry/tests/redact.spec.ts @@ -0,0 +1,157 @@ +/** + * Default redaction rules and the `telemetry/redact` waterfall contract: + * credential shapes scrubbed from bodies and attribute values, structure + * preserved, canonical log untouched, listener stacking/replacement, 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 { + applyDefaultRedaction, + REDACTION_PLACEHOLDER, + TelemetryCoordinator, + type TelemetryBackend, + type TelemetryRecord, +} from '../src/index.ts' + +const SECRETS = { + deepseek: 'sk-abcdef1234567890abcdef', + anthropic: 'sk-ant-abcdef1234567890', + githubPat: 'ghp_ABCDEFGHIJKLMNOPqrstuv12345678', + finePat: 'github_pat_ABCDEFGHIJKLMNOPQRSTuvwx', + slack: 'xoxb-1234567890-abcdefghij', + aws: 'AKIAIOSFODNN7EXAMPLE', + google: 'AIzaSyA-1234567890abcdefghijklmnopqrstu', + jwt: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpM', + pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n-----END RSA PRIVATE KEY-----', + urlCreds: 'https://user:hunter2@internal.example.com/repo.git', +} as const + +function record(body: unknown, attributes: Record = {}): TelemetryRecord { + return { channel: 'ledger', time: 1, severity: 'info', attributes, body } +} + +describe('applyDefaultRedaction', () => { + it('scrubs every known credential shape while preserving surrounding text', () => { + for (const secret of Object.values(SECRETS)) { + const out = applyDefaultRedaction(record(`before ${secret} after`)) + expect(out.body, secret).not.toContain(secret.includes('\n') ? 'MIIEow' : secret) + expect(out.body).toContain('before ') + expect(out.body).toContain(' after') + expect(out.body).toContain(REDACTION_PLACEHOLDER) + } + }) + + it('scrubs URL userinfo credentials but leaves plain URLs alone', () => { + const out = applyDefaultRedaction(record(`${SECRETS.urlCreds} and https://example.com/path`)) + expect(out.body).not.toContain('hunter2') + expect(out.body).toContain('https://example.com/path') + }) + + it('recurses through arrays and objects, preserving structure and non-strings', () => { + const out = applyDefaultRedaction(record({ + list: [`key=${SECRETS.deepseek}`, 7, null, true], + nested: { text: SECRETS.githubPat, count: 3 }, + })) + expect(out.body).toEqual({ + list: [`key=${REDACTION_PLACEHOLDER}`, 7, null, true], + nested: { text: REDACTION_PLACEHOLDER, count: 3 }, + }) + }) + + it('leaves low-signal values untouched', () => { + const clean = { + pkg: '@deepseek-ai/dsh-session-telemetry@0.0.1', + sha: '342a4c3a9d3adf13cf4ad33b9f8d6e79170be5e2', + prose: 'ordinary sentence with kebab-case-identifier', + } + expect(applyDefaultRedaction(record(clean)).body).toEqual(clean) + }) + + it('scrubs string attribute values and keeps numeric ones', () => { + const out = applyDefaultRedaction(record(null, { + 'session.cwd': `/home/${SECRETS.aws}/proj`, + 'event.seq': 4, + })) + expect(out.attributes['session.cwd']).toBe(`/home/${REDACTION_PLACEHOLDER}/proj`) + expect(out.attributes['event.seq']).toBe(4) + }) + + it('never mutates its input', () => { + const input = record({ text: SECRETS.slack }, { 'session.cwd': SECRETS.aws }) + applyDefaultRedaction(input) + expect((input.body as { text: string }).text).toBe(SECRETS.slack) + expect(input.attributes['session.cwd']).toBe(SECRETS.aws) + }) +}) + +class CollectingBackend implements TelemetryBackend { + records: TelemetryRecord[] = [] + emit(record: TelemetryRecord): void { + this.records.push(record) + } + async shutdown(): Promise {} +} + +async function setup() { + const backend = new CollectingBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + return { ctx, backend } +} + +describe('telemetry/redact waterfall', () => { + it('applies the default rules when no listener is registered', async () => { + const { ctx, backend } = await setup() + const session = ctx.sessions.create(SessionId('w')) + session.append('user/message', { content: [{ type: 'text', text: `key ${SECRETS.deepseek}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const body = backend.records[0]!.body as { content: { text: string }[] } + expect(body.content[0]!.text).toBe(`key ${REDACTION_PLACEHOLDER}`) + }) + + it('keeps the canonical log unredacted', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create(SessionId('log')) + session.append('user/message', { content: [{ type: 'text', text: SECRETS.githubPat }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const logged = session.events[0]!.data as { content: { text: string }[] } + expect(logged.content[0]!.text).toBe(SECRETS.githubPat) + }) + + it('lets a listener stack a stricter rule on top of the defaults', async () => { + const { ctx, backend } = await setup() + ctx.on('telemetry/redact', (_record, next) => { + const defaulted = next() + return { ...defaulted, body: { shapeOnly: true } } + }) + const session = ctx.sessions.create(SessionId('stack')) + session.append('user/message', { content: [{ type: 'text', text: 'anything' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records[0]!.body).toEqual({ shapeOnly: true }) + }) + + it('a listener that skips next() replaces the default rules', async () => { + const { ctx, backend } = await setup() + ctx.on('telemetry/redact', record => record) + const session = ctx.sessions.create(SessionId('veto')) + session.append('user/message', { content: [{ type: 'text', text: SECRETS.slack }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const body = backend.records[0]!.body as { content: { text: string }[] } + expect(body.content[0]!.text).toBe(SECRETS.slack) + }) + + it('a throwing rule withholds the record fail-closed without disturbing the log', async () => { + const { ctx, backend } = await setup() + ctx.on('telemetry/redact', () => { + throw new Error('rule exploded') + }) + const session = ctx.sessions.create(SessionId('closed')) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records).toHaveLength(0) + expect(session.events).toHaveLength(1) + }) +}) diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts new file mode 100644 index 0000000000..9f694749ef --- /dev/null +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -0,0 +1,313 @@ +/** + * 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 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[] } } + /** + * Test-only stand-in for dsh-compact's merge, exercising the widened severity probe. + * @mode emit + * @param error - failure text when the compaction failed + */ + 'compact/end': { turn: number; error?: string } + } +} + +class FakeBackend implements TelemetryBackend { + records: TelemetryRecord[] = [] + calls: string[] = [] + emitError: Error | undefined + shutdownError: Error | undefined + shutdownResolved = false + + emit(record: TelemetryRecord): void { + if (this.emitError) throw this.emitError + this.records.push(record) + this.calls.push(`emit:${String(record.attributes['event.seq'] ?? record.attributes['telemetry.op'])}`) + } + + flush = vi.fn() + + async shutdown(): Promise { + 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()) { + 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 } +} + +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, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { 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, including the widened merge-extensible probe', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('tool/result', { turn: 1, step: 1, callId: 'c1' as never, content: [], isError: true }, { surfaceOp: 'append' }) + session.append('tool/result', { turn: 1, step: 1, callId: 'c2' as never, content: [], isError: false }, { surfaceOp: 'append' }) + session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' }) + session.append('compact/end', { turn: 1, error: 'summarizer died' }) + session.append('compact/end', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) + const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity]) + expect(severities).toEqual([ + ['turn/start', 'info'], + ['tool/result', 'error'], + ['tool/result', 'info'], + ['prompt/blocked', 'warn'], + ['compact/end', 'error'], + ['compact/end', '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 adoption', () => { + it('reads seeded events back at adoption (fork/resume seeds never re-emit)', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const parent = liveSession(ctx, 'seed-parent') + appendTurn(parent) + ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} }) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + 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], + ['seeded', 0], ['seeded', 1], + ])) + }) + + 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, trigger: { kind: 'message', source: { kind: 'user' } } }) + 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('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 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('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, trigger: { kind: 'message', source: { kind: 'user' } } })).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('relays agent/error as an ops record with identity and structured name', async () => { + 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, 3, 2, new TypeError('adapter exploded')) + 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': 'TypeError', + turn: 3, + step: 2, + }) + expect(record.body).toEqual({ name: 'TypeError', message: 'adapter exploded' }) + }) +}) diff --git a/packages/telemetry/session-telemetry/tsconfig.json b/packages/telemetry/session-telemetry/tsconfig.json new file mode 100644 index 0000000000..2c18a582e4 --- /dev/null +++ b/packages/telemetry/session-telemetry/tsconfig.json @@ -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" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd75466c5..0e96b9cf4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,6 +318,9 @@ importers: '@deepseek-ai/dsh-session-query-sqlite': specifier: workspace:* version: link:../packages/session-query/session-query-sqlite + '@deepseek-ai/dsh-session-telemetry-otel': + specifier: workspace:* + version: link:../packages/telemetry/session-telemetry-otel '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:* version: link:../packages/session-title/session-title-first-message-llm @@ -3538,6 +3541,61 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/telemetry/session-telemetry: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/telemetry/session-telemetry-otel: + dependencies: + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 + '@opentelemetry/api-logs': + specifier: ^0.220.0 + version: 0.220.0 + '@opentelemetry/exporter-logs-otlp-http': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: ^2.9.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-telemetry': + specifier: workspace:^ + version: link:../session-telemetry + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/timeout/timeout-policy: devDependencies: '@deepseek-ai/dsh-invariants': @@ -5803,10 +5861,78 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@opentelemetry/api-logs@0.220.0': + resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.10.0': + resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.9.0': + resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-http@0.220.0': + resolution: {integrity: sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.220.0': + resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.220.0': + resolution: {integrity: sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.10.0': + resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.9.0': + resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.220.0': + resolution: {integrity: sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.9.0': + resolution: {integrity: sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.9.0': + resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/semantic-conventions@1.43.0': resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} @@ -10711,8 +10837,82 @@ snapshots: '@nodable/entities@2.2.0': {} + '@opentelemetry/api-logs@0.220.0': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api@1.9.0': {} + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-logs-otlp-http@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/semantic-conventions@1.43.0': {} '@oxc-parser/binding-android-arm-eabi@0.133.0': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a3274a83c3..4912729ff4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -221,6 +221,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts', SubagentRunEndInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts', SubagentRunInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts', + TelemetryRecord: 'seam-local record contract is owned by packages/telemetry/session-telemetry/src/index.ts', WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1f54f7358f..075f394565 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -77,6 +77,7 @@ const GROUP_ORDER = [ 'session-persistence', 'session-query', 'session-title', + 'telemetry', 'support', 'ui', ] @@ -132,6 +133,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query', 'session-query-sqlite'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, + { + key: 'telemetry', + pkg: 'session-telemetry', + title: 'Session telemetry seam', + mode: 'seam', + implementations: ['session-telemetry-otel'], + consumers: [], + note: 'The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process.', + }, { key: 'sessionQuery', pkg: 'session-query', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index d5fa282a24..42792f14ca 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -80,6 +80,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, + 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, + 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, 'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 56ab3ffef3..e33ef26038 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -74,6 +74,7 @@ "./packages/hooks/*/src/invariant.ts", "./packages/session-persistence/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", + "./packages/telemetry/*/src/invariant.ts", "./packages/sdk/*/src/invariant.ts", "./packages/ui/*/src/invariant.ts", "./packages/examples/*/src/invariant.ts", @@ -142,6 +143,7 @@ "./packages/session-persistence/*/src", "./packages/session-query/*/src", "./packages/session-title/*/src", + "./packages/telemetry/*/src", "./packages/sdk/*/src", "./packages/ui/*/src", "./packages/examples/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index a13bcf35e3..465b38edf4 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -48,6 +48,8 @@ { "path": "./packages/session-title/session-title-llm" }, { "path": "./packages/session-title/session-title-first-message-llm" }, { "path": "./packages/session-title/session-title-all-messages-llm" }, + { "path": "./packages/telemetry/session-telemetry" }, + { "path": "./packages/telemetry/session-telemetry-otel" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/ui/commands" },