From 70febffe1ad87f539cc50e0f5ea1d1cde5b0dc91 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 23 Jul 2026 11:58:47 +0800 Subject: [PATCH] refactor(telemetry): ship the redact waterfall without built-in rules The seam keeps the telemetry/redact scrubbing interface but ships no rules of its own: the innermost next() passes records through unchanged, and deployments mount their rules as waterfall listeners. As an SDK we cannot know which patterns are secrets in a given deployment; a shipped list invites false confidence while catching only known shapes, and false positives would corrupt exported bodies. Mechanism stays with the seam, policy moves to the deployment; both READMEs and the Agent Note state the raw-export default plainly. The loader-composition e2e now mounts a deployment-style rule fixture and pins the same wire behavior: secret absent, placeholder present, canonical log untouched. --- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 10 +- ...07-23-session-telemetry-otel-revival.zh.md | 10 +- docs/cordis-catalog/events.md | 25 +-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- .../tests/fixtures/telemetry-otel.cordis.yml | 5 + .../tests/fixtures/telemetry-redact-rule.ts | 29 ++++ knip.json | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/telemetry/README.md | 2 +- .../session-telemetry-otel/README.md | 2 +- .../tests/loader-composition.e2e.ts | 15 +- .../telemetry/session-telemetry/README.md | 4 +- .../session-telemetry/src/coordinator.ts | 18 +- .../telemetry/session-telemetry/src/index.ts | 22 +-- .../telemetry/session-telemetry/src/redact.ts | 77 --------- .../session-telemetry/tests/redact.spec.ts | 159 +++++++----------- 18 files changed, 155 insertions(+), 236 deletions(-) create mode 100644 examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts delete mode 100644 packages/telemetry/session-telemetry/src/redact.ts 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 index 31a5914d0d..e5c566662a 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-session-telemetry-otel-revival.md: 1150d363e9db98a39e1188b86dc41fa291edba41 -2026-07-23-session-telemetry-otel-revival.zh.md: 76749a59c38ef2b8c5b3f09960ca500c25d8e1a8 +2026-07-23-session-telemetry-otel-revival.md: 28a88218566dcfe34a3b99b682a4c21d22e00ce2 +2026-07-23-session-telemetry-otel-revival.zh.md: 79428c5fbb76bdd8f3c2d0b856edd712b6a650d0 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 index 1150d363e9..28a8821856 100644 --- 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 @@ -10,10 +10,10 @@ Every deployment that wants harness sessions in an observability stack must hand ## 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: +`packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go and owns what leaves in them: - **`@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. +- **The `telemetry/redact` waterfall** — the delta over the branch version. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. 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. @@ -22,12 +22,12 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry **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. +**No in-process redaction point, delegating to receiver-side collector processors.** Rejected — receiver-side redaction ships the secret first and scrubs it second. The waterfall puts an auditable, stackable scrubbing point before bytes leave the process; where the branch version (what PR #222 shipped) had no redaction point at all, every record now passes one. -**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. +**A built-in conservative rule set as the waterfall's innermost `next()`.** Rejected: as an SDK we cannot know which patterns are secrets in a given deployment, a shipped list invites false confidence ("redaction is on") while catching only known shapes, and false positives would corrupt exported bodies for consumers who never asked. The seam owns the mechanism; the deployment owns the policy — the innermost `next()` is a pass-through, and rules mount as 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. +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. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/redact` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, 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 index 76749a59c3..79428c5fbb 100644 --- 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 @@ -10,10 +10,10 @@ Status: implemented ## Decision -`packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向,且任何数据未经脱敏不得跨越 seam: +`packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向并对导出内容负责: - **`@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 永不改写。 +- **`telemetry/redact` waterfall** —— 相对分支版本的增量。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 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 对此如实陈述。 @@ -22,12 +22,12 @@ Status: implemented **实现 runtime-telemetry RFC 的 outbox(落盘 spool、每 sink 游标、at-least-once、persistence seam 的 `readCommitted` 方法)。** 推迟而非否决:SDK 立场使投递语义归属 reporting SDK,OTel SDK 自身的批处理管线是诚实的默认。outbox 是纯增量层(`emit()` 契约不动);待某个部署提出遥测必须满足的崩溃丢失要求时再复活。 -**不带内置脱敏直接导出,交给接收端 collector processor。** 否决——这正是法务否掉的方案。接收端脱敏是先把秘密发出去再擦除;seam 必须在字节离开进程前擦除,且 waterfall 使脱敏点可审计、可堆叠。 +**不设进程内脱敏点,交给接收端 collector processor。** 否决——接收端脱敏是先把秘密发出去再擦除。waterfall 在字节离开进程前提供一个可审计、可堆叠的擦除点;分支版本(PR #222 交付的形态)完全没有脱敏点,如今每条记录都必经其一。 -**默认规则的模式列表做成可配置。** 否决:随部署变化的调优项应进 config,但安全不变量不应——削弱底线应当需要改代码而非改 YAML。更严格的规则以 `telemetry/redact` 监听器堆叠。 +**在 waterfall 最内层 `next()` 内置一套保守规则集。** 否决:作为 SDK 我们无法预知某个部署里什么模式算秘密,内置列表只覆盖已知形状却会带来"脱敏已开启"的虚假信心,且误报会替从未要求过的消费者破坏导出 body。seam 拥有机制,部署方拥有策略——最内层 `next()` 原样透传,规则以监听器挂载。 **映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 ## Consequences -部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。即使部署方未配置任何规则,凭据形状的子串也绝不离开进程,代价是捕获路径上每条记录一次同步擦除(对 lossless-JSON body 做字符串正则——受事件大小约束,无 I/O)。导出的 body 在占位符落点处可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/redact` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4ed5de022b..5356f07a5b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -807,20 +807,21 @@ Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/syst ### `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. +Redact one outbound record before it reaches the backend — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. 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. + * Redact one outbound record before it reaches the backend — the seam's + * scrubbing extension point. The seam ships NO rules of its own: the + * innermost `next()` passes the record through unchanged, and with no + * listener mounted records reach the backend as captured, so exported + * data is exactly as clean as the rules a deployment mounts. Listeners + * stack by transforming `next()`'s return value; returning without + * `next()` replaces everything beneath. Dispatched synchronously on the + * capture hot path inside the coordinator's containment: a throwing + * listener withholds that one record (fail-closed) and never reaches the + * agent loop. 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 @@ -828,7 +829,7 @@ Redact one outbound record before it reaches the backend. The innermost `next()` 'telemetry/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:39`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:40`](../../packages/telemetry/session-telemetry/src/index.ts) ## `tools/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 22120b2f8e..3dec10f786 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1554,7 +1554,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:123`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:124`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 30380261fe..da367e81f2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -43,7 +43,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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`) | - | +| `telemetry/redact` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:40`](../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/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index defde98ff5..ff81d0116c 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -1,8 +1,13 @@ # Test-only composition: session-telemetry-otel through the real Loader/app # path, exporting to the mock OTLP collector the driver starts (url via env). +# The redact-rule entry models a deployment mounting its own scrub rule on the +# telemetry/redact waterfall — the seam itself ships no rules. - id: cli-mock-llm name: './cli-mock-llm.ts' +- id: telemetry-redact-rule + name: './telemetry-redact-rule.ts' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts new file mode 100644 index 0000000000..10b7081dd3 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts @@ -0,0 +1,29 @@ +import type { Context } from 'cordis' + +/** + * Deployment-style redaction rule for the telemetry e2e: scrubs the fixture + * credential from body strings, exactly as a real deployment would mount its + * own rules on the `telemetry/redact` waterfall. + */ + +const SECRET = /sk-e2efixture[0-9]+/g +const PLACEHOLDER = '[E2E-REDACTED]' + +function scrub(value: unknown): unknown { + if (typeof value === 'string') return value.replace(SECRET, PLACEHOLDER) + if (Array.isArray(value)) return value.map(scrub) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, scrub(entry)])) + } + return value +} + +export const name = 'telemetry-redact-rule' + +/** Mount the fixture scrub rule onto the redact waterfall. */ +export function apply(ctx: Context): void { + ctx.on('telemetry/redact', (_record, next) => { + const record = next() + return { ...record, body: scrub(record.body) } + }) +} diff --git a/knip.json b/knip.json index 728ded39b7..753b3dda34 100644 --- a/knip.json +++ b/knip.json @@ -32,6 +32,7 @@ "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", + "headless-agent/tests/fixtures/telemetry-redact-rule.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", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index aee1ac9ae8..f8377ffd54 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1124,8 +1124,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ 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.', + jsDoc: '/**\n * Redact one outbound record before it reaches the backend — the seam\'s\n * scrubbing extension point. The seam ships NO rules of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Redaction applies to the exported copy only; the canonical\n * session log is never rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */', + summary: 'Redact one outbound record before it reaches the backend — the seam\'s scrubbing extension point.', }, { name: 'tools/change', diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md index a5869077c2..ecb348d256 100644 --- a/packages/telemetry/README.md +++ b/packages/telemetry/README.md @@ -1,6 +1,6 @@ # 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). +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 `telemetry/redact` waterfall (deployment-mounted rules; the seam ships none), 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 | |---|---| diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 26edc62645..3fe9f34626 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -19,7 +19,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th ## 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. +Records carry the complete `event.data` as the seam's `telemetry/redact` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/redact` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. ## Field mapping diff --git a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts index 2c72fd6170..8f16662614 100644 --- a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts +++ b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts @@ -3,8 +3,8 @@ * 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. + * wire: ledger mirroring, the deployment-mounted redact rule applied to the + * exported copy, ops markers, and the untouched canonical log. */ import { readFile, readdir } from 'node:fs/promises' @@ -12,7 +12,6 @@ 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', @@ -25,6 +24,7 @@ const configPath = fileURLToPath(new URL( const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) const FIXTURE_SECRET = 'sk-e2efixture1234567890' +const FIXTURE_PLACEHOLDER = '[E2E-REDACTED]' interface OtlpLogRecord { attributes?: { key: string; value: Record }[] @@ -84,15 +84,16 @@ describe('session-telemetry-otel through a real headless cordis.yml', () => { } 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. + // The deployment-mounted rule on the wire: the fixture credential never + // leaves the process, its surrounding prose does, and the placeholder + // marks the spot — the seam itself ships no rules. const wire = JSON.stringify(captures) expect(wire).not.toContain(FIXTURE_SECRET) - expect(wire).toContain(REDACTION_PLACEHOLDER) + expect(wire).toContain(FIXTURE_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) + expect(logContent).not.toContain(FIXTURE_PLACEHOLDER) }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index bccc61fd06..0f3fe0ec28 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -12,7 +12,7 @@ The coordinator registers, all through the composing fiber's effects: `session/c ## 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. +Every record passes the `telemetry/redact` waterfall between projection and `emit()` — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Redaction applies to the exported copy only; the canonical session log is never rewritten. ## The handoff cursor @@ -37,4 +37,4 @@ 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. +- **No built-in redaction rules** — with no `telemetry/redact` listener mounted, records leave the process exactly as captured, including any credentials embedded in file contents or command output; a deployment exporting to a shared collector owns its rule set. diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 08fbe498b1..8e464c6add 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -2,10 +2,11 @@ * 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. + * `telemetry/redact` waterfall (deployment-mounted rules; pass-through when + * none), and hands the result 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 */ @@ -14,7 +15,6 @@ 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. @@ -145,13 +145,13 @@ export class TelemetryCoordinator { /** * 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 + * to the backend. The innermost `next` passes the record through unchanged + * — the seam ships no rules; exported data is as clean as the listeners a + * deployment mounts. Callers run inside {@link contain}, so a throwing * rule withholds the record instead of reaching the loop (fail-closed). */ private handOff(record: TelemetryRecord): void { - this.backend.emit(this.ctx.waterfall('telemetry/redact', record, () => applyDefaultRedaction(record))) + this.backend.emit(this.ctx.waterfall('telemetry/redact', record, () => record)) } /** Forward the turn-end boundary to the backend's optional flush hint. */ diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index 93e7e7b0f9..65f5868e04 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -22,16 +22,17 @@ declare module 'cordis' { 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. + * Redact one outbound record before it reaches the backend — the seam's + * scrubbing extension point. The seam ships NO rules of its own: the + * innermost `next()` passes the record through unchanged, and with no + * listener mounted records reach the backend as captured, so exported + * data is exactly as clean as the rules a deployment mounts. Listeners + * stack by transforming `next()`'s return value; returning without + * `next()` replaces everything beneath. Dispatched synchronously on the + * capture hot path inside the coordinator's containment: a throwing + * listener withholds that one record (fail-closed) and never reaches the + * agent loop. 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 @@ -142,4 +143,3 @@ export abstract class Telemetry extends Service implements TelemetryBackend { } export { TelemetryCoordinator } from './coordinator.ts' -export { applyDefaultRedaction, REDACTION_PLACEHOLDER } from './redact.ts' diff --git a/packages/telemetry/session-telemetry/src/redact.ts b/packages/telemetry/session-telemetry/src/redact.ts deleted file mode 100644 index d25452581a..0000000000 --- a/packages/telemetry/session-telemetry/src/redact.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * 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 index cd23af6da2..c4b9d4d5bb 100644 --- a/packages/telemetry/session-telemetry/tests/redact.spec.ts +++ b/packages/telemetry/session-telemetry/tests/redact.spec.ts @@ -1,91 +1,19 @@ /** - * 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. + * The `telemetry/redact` waterfall contract: pass-through when no listener is + * mounted, listener stacking and replacement, ops-record coverage, the + * untouched canonical log, and the fail-closed containment of a throwing rule. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { - 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) - }) -}) +const FIXTURE_SECRET = 'sk-fixture1234567890' class CollectingBackend implements TelemetryBackend { records: TelemetryRecord[] = [] @@ -99,49 +27,80 @@ async function setup() { const backend = new CollectingBackend() const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin({ + const fiber = await ctx.plugin({ name: 'fake-telemetry', inject: ['sessions'], apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), }) - return { ctx, backend } + return { ctx, backend, fiber } } describe('telemetry/redact waterfall', () => { - it('applies the default rules when no listener is registered', async () => { + it('passes records through unchanged when no listener is mounted', async () => { const { ctx, backend } = await setup() const session = ctx.sessions.create(SessionId('w')) - session.append('user/message', { content: [{ type: 'text', text: `key ${SECRETS.deepseek}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('user/message', { content: [{ type: 'text', text: `key ${FIXTURE_SECRET}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const body = backend.records[0]!.body as { content: { text: string }[] } - expect(body.content[0]!.text).toBe(`key ${REDACTION_PLACEHOLDER}`) + expect(body.content[0]!.text).toBe(`key ${FIXTURE_SECRET}`) }) - 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() + it('applies a mounted rule to every outbound record, ops records included', async () => { + const { ctx, backend, fiber } = await setup() ctx.on('telemetry/redact', (_record, next) => { - const defaulted = next() - return { ...defaulted, body: { shapeOnly: true } } + const record = next() + return { ...record, body: { scrubbed: true } } + }) + const session = ctx.sessions.create(SessionId('rule')) + session.append('user/message', { content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records[0]!.body).toEqual({ scrubbed: true }) + // The dispose-time shutdown ops record passes through the same waterfall. + await fiber.dispose() + const ops = backend.records.filter(record => record.channel === 'ops') + expect(ops).toHaveLength(1) + expect(ops[0]!.body).toEqual({ scrubbed: true }) + }) + + it('keeps the canonical log untouched by a mounted rule', async () => { + const { ctx } = await setup() + ctx.on('telemetry/redact', (_record, next) => ({ ...next(), body: null })) + const session = ctx.sessions.create(SessionId('log')) + session.append('user/message', { content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const logged = session.events[0]!.data as { content: { text: string }[] } + expect(logged.content[0]!.text).toBe(FIXTURE_SECRET) + }) + + it('stacks listeners outermost-first around next()', async () => { + const { ctx, backend } = await setup() + const order: string[] = [] + ctx.on('telemetry/redact', (_record, next) => { + order.push('outer-before') + const record = next() + order.push('outer-after') + return { ...record, attributes: { ...record.attributes, outer: 1 } } + }) + ctx.on('telemetry/redact', (_record, next) => { + order.push('inner') + const record = next() + return { ...record, attributes: { ...record.attributes, inner: 1 } } }) const session = ctx.sessions.create(SessionId('stack')) - session.append('user/message', { content: [{ type: 'text', text: 'anything' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(backend.records[0]!.body).toEqual({ shapeOnly: true }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(order).toEqual(['outer-before', 'inner', 'outer-after']) + expect(backend.records[0]!.attributes).toMatchObject({ outer: 1, inner: 1 }) }) - it('a listener that skips next() replaces the default rules', async () => { + it('a listener that skips next() replaces everything beneath it', async () => { const { ctx, backend } = await setup() - ctx.on('telemetry/redact', record => record) + const inner = { called: false } + ctx.on('telemetry/redact', () => ({ channel: 'ops', time: 0, severity: 'info', attributes: {}, body: 'replaced' } satisfies TelemetryRecord)) + ctx.on('telemetry/redact', (_record, next) => { + inner.called = true + return next() + }) 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) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records[0]!.body).toBe('replaced') + expect(inner.called).toBe(false) }) it('a throwing rule withholds the record fail-closed without disturbing the log', async () => {