diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml
index 1fa95a5e2d..4b6ef324b9 100644
--- a/.agents/notes/README.i18n.yaml
+++ b/.agents/notes/README.i18n.yaml
@@ -12,8 +12,8 @@
en: 055a5e49f9af3dc2
zh: 9f7c78409cccfded
/agent-notes/archiving-and-deletion:
- en: 6c393c7058c787d7
- zh: 70ce9ca3bd9c2133
+ en: 57ccc5fec461ebb0
+ zh: 69e8f9e79abc53aa
/agent-notes/when-to-write-one:
en: 2beb1b94d8fb633c
zh: 889b5542bb771205
diff --git a/.agents/notes/README.md b/.agents/notes/README.md
index 6872de0a13..8cee939fcf 100644
--- a/.agents/notes/README.md
+++ b/.agents/notes/README.md
@@ -39,7 +39,7 @@ Delete an implemented Agent Note that only describes small UI adjustments or pur
The archive is path-encoded as `archived/{class}/yyyy-mm-dd-topic-title.md`; `implemented` is deliberately absent because only implemented notes can enter it. An archival change moves the complete English/Chinese/sidecar triplet, retains `Status: implemented`, inserts the same `Archived: YYYY-MM-DD` line immediately below that status in both language files, re-records the sidecar, and repairs or deletes inbound links. These are the only permitted content changes during archival. Existing title punctuation, blank-line layout, and language-switcher wording do not block archival; preserve them with the body.
-Once sealed, every archived triplet is permanently frozen. Do not edit, translate, reformat, update, move, or delete it, and do not treat it as authority for current behavior. Documentation gates skip archived sources, including their outbound links; active prose may still link into an archived note when it intentionally cites history. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) enforces the closed class tree, complete triplets, archive metadata, sidecar hashes, and the append-only frozen-content manifest. The [archive-policy Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) owns the rationale.
+Once sealed, every archived triplet is permanently frozen. Do not edit, translate, reformat, update, move, or delete it, and do not treat it as authority for current behavior. Documentation gates skip archived sources, including their outbound links; active prose may still link into an archived note when it intentionally cites history. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) enforces the closed class tree, complete triplets, archive metadata, sidecar hashes, and the append-only frozen-content manifest, except for the exact seal transitions documented in [archive instructions](archived/AGENTS.md). The [archive-policy Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) owns the rationale.
## When to write one
diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md
index ced81f77fa..6339f56f7c 100644
--- a/.agents/notes/README.zh.md
+++ b/.agents/notes/README.zh.md
@@ -41,7 +41,7 @@
归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 既有标题标点、空行布局和语言切换栏措辞不阻止归档;与正文一起保留。
-封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。
+封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单),但[归档说明](archived/AGENTS.md)中记录的精确封存哈希转换除外。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。
diff --git a/.agents/notes/archived/AGENTS.md b/.agents/notes/archived/AGENTS.md
index 2ac4518a6a..d013ed068e 100644
--- a/.agents/notes/archived/AGENTS.md
+++ b/.agents/notes/archived/AGENTS.md
@@ -5,3 +5,5 @@ Archived Agent Note triplets under the kind directories are frozen historical sn
The archival change may only relocate a complete English/Chinese/sidecar triplet, insert the identical `Archived: YYYY-MM-DD` line below both `Status: implemented` lines, re-record the sidecar, and repair or delete inbound links. Do not inspect, verify, or repair links out of archived notes.
Run the [`dsh-archive-agent-notes`](../../skills/dsh-archive-agent-notes/SKILL.md) workflow and append new artifact hashes with `pnpm run verify-archived-agent-notes --write`. The normal verifier rejects changed or missing sealed artifacts, incomplete triplets, unknown kind folders, and invalid archive metadata.
+
+The authorized Figma-link removal from `feature/2026-08-10-durable-workflow-runs-in-chat` has three exact old-to-new seal exceptions in `scripts/archived-agent-notes.ts` for its English, Chinese, and pairing files. These exceptions permit no other content change, reversal, or deletion.
diff --git a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml
index 284f43658a..871a8eefb8 100644
--- a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml
+++ b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.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 .agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md
-2026-08-10-durable-workflow-runs-in-chat.md: 01c59ef25996d6349ab2904f148bf4e72fdd5f09
-2026-08-10-durable-workflow-runs-in-chat.zh.md: 5f6b39b59beca5e2363261605ec58fbdcf71ea6f
+2026-08-10-durable-workflow-runs-in-chat.md: a49cb75e44bd65ef694679eeb295cfef564c7545
+2026-08-10-durable-workflow-runs-in-chat.zh.md: 753e02238cd8c00d0494cb673ba9896d61ff4cd8
diff --git a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md
index 01c59ef259..a49cb75e44 100644
--- a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md
+++ b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md
@@ -25,7 +25,7 @@ The renderer gives each level a distinct visual responsibility. The run uses a 3
Navigation is derived from two current authorities rather than persisted. A member row is interactive only while its durable member state is running and the current ordinary Session list contains the same id with `origin: 'subagent'`, `parentId` equal to the displayed parent, and `running: true`. Underlined member text is the only visible affordance; keyboard focus draws a two-pixel business-primary ring around the name area, and the fixed status label remains the lifecycle word rather than an action instruction. The renderer invokes only the injected ordinary `sessions.open(id)` callback. Addressed-only, remote, wrong-parent, and terminal members remain visible but static.
-The [seven-state Figma reference](https://www.figma.com/design/tguwzZRmHCjbq58mfsqT0M?node-id=5-2) fixes the information hierarchy for running expanded/collapsed, completed history/expanded, failed plus cancelled, interrupted recovery, and dark narrow presentation. Repository `DisclosureRow`, `StateDot`, icons, semantic tokens, and keyed-node behavior remain the implementation authority; the reference introduces no runtime field or state owner.
+The seven-state Figma reference fixes the information hierarchy for running expanded/collapsed, completed history/expanded, failed plus cancelled, interrupted recovery, and dark narrow presentation. Repository `DisclosureRow`, `StateDot`, icons, semantic tokens, and keyed-node behavior remain the implementation authority; the reference introduces no runtime field or state owner.
## Verification
diff --git a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md
index 5f6b39b59b..753e02238c 100644
--- a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md
+++ b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md
@@ -25,7 +25,7 @@ renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-pl
导航从两个当前权威派生,不写入持久记录。只有持久成员状态仍为运行中,且当前普通 Session 列表包含同一 id、`origin: 'subagent'`、`parentId` 等于当前父 Session、`running: true` 时,成员行才可交互。带下划线的成员文字是唯一可见提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,固定状态列继续只表达生命周期,而不写动作说明。renderer 只调用注入的普通 `sessions.open(id)` 回调。仅地址化、远程、父级不符或终态成员继续可见,但保持静态。
-[七状态 Figma 参考](https://www.figma.com/design/tguwzZRmHCjbq58mfsqT0M?node-id=5-2)固定运行展开/收起、完成历史/展开、失败与取消、恢复后中断以及暗色窄列的信息层级。仓库的 `DisclosureRow`、`StateDot`、图标、语义 token 和 keyed-node 行为仍是实现权威;参考稿不引入运行时字段或状态 owner。
+七状态 Figma 参考固定运行展开/收起、完成历史/展开、失败与取消、恢复后中断以及暗色窄列的信息层级。仓库的 `DisclosureRow`、`StateDot`、图标、语义 token 和 keyed-node 行为仍是实现权威;参考稿不引入运行时字段或状态 owner。
## 验证
diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json
index b4e085deb2..44178cc0ec 100644
--- a/.agents/notes/archived/manifest.json
+++ b/.agents/notes/archived/manifest.json
@@ -1267,9 +1267,9 @@
"feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml": "sha256:74f519839f0cf82c7304bdeae41ae1cab8bb930bfb94f79ab44708acd3b72128",
"feature/2026-08-10-creator-guidance-introduce-cue.md": "sha256:3e25409dda498de150de18943ee332e1760963e377a40a365902b66f667fdc9f",
"feature/2026-08-10-creator-guidance-introduce-cue.zh.md": "sha256:203847010cab9e9d13c3969f17921d3a5aa0d69377eccfef555c7ff96572f162",
- "feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml": "sha256:0db3c21a3c8785e56d5d7b90df990b158069cd1cea664e62fc4786c6d7588450",
- "feature/2026-08-10-durable-workflow-runs-in-chat.md": "sha256:f9f5290cd880908d17b1080ae5776f22e182f5253471f90cf8bead418b33b502",
- "feature/2026-08-10-durable-workflow-runs-in-chat.zh.md": "sha256:f4ffd2700bc9c2a0cc4b9d90f84e1d0a576939e2e6f0b130c5b142fd8cd518b0",
+ "feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml": "sha256:03eadb60c8ba4fde262cd61e3f911d8cd68ea4a58f57adeb8dd7bb9a97d08ca2",
+ "feature/2026-08-10-durable-workflow-runs-in-chat.md": "sha256:6018de4a89ca99d6cbfd618aff1c8136b23d8e4854a30f7b12a602f0417cbdc2",
+ "feature/2026-08-10-durable-workflow-runs-in-chat.zh.md": "sha256:0080859221773122022d5880efa3d52f29c0872b52d63e170250d94f214ff37a",
"feature/2026-08-10-minimal-read-image-tool.i18n.yaml": "sha256:2311b6453b90a1f81d4fcbdaa723ab233cc0998f34393956364c8b92bc4d1213",
"feature/2026-08-10-minimal-read-image-tool.md": "sha256:debe6b570d11634f57a80eaa5891e4fd1e7a5aac19f10fb7dd1683c316515150",
"feature/2026-08-10-minimal-read-image-tool.zh.md": "sha256:572d080340682b354cb1f8a0f74af153d6542bd3dffbe3b72b32591e5d5d3baf",
diff --git a/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.i18n.yaml
new file mode 100644
index 0000000000..15f69188be
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-25-session-log-otel-byte-limits.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.md
+/agent-note-session-log-otlp-strings-and-bounded-requests:
+ en: 041f7e2b55202ea3
+ zh: e01834ae6c9fc7bc
+/agent-note-session-log-otlp-strings-and-bounded-requests/problem:
+ en: 20f659a15fb55352
+ zh: 49d39c95da1b8aa5
+/agent-note-session-log-otlp-strings-and-bounded-requests/decision:
+ en: 0f0866232326658b
+ zh: aa608d17ba712d8a
+/agent-note-session-log-otlp-strings-and-bounded-requests/alternatives-considered:
+ en: 0dc933b18a1a3573
+ zh: 77655740322c4e45
+/agent-note-session-log-otlp-strings-and-bounded-requests/consequences:
+ en: 3859569c9cdea30c
+ zh: 517449b8c49807f6
diff --git a/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.md b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.md
new file mode 100644
index 0000000000..57fbe72dc5
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.md
@@ -0,0 +1,29 @@
+# Agent Note: Session-log OTLP strings and bounded requests
+
+Status: implemented
+
+English | [中文](2026-09-25-session-log-otel-byte-limits.zh.md)
+
+## Problem
+
+Session logs contain nested JSON and large tool results. The product collector accepts named records with string content and limits request size; count-only SDK batching cannot bound bytes after UTF-8 encoding and JSON escaping.
+
+## Decision
+
+The shared `otel` Cordis plugin owns ordinary-event and Session-log transport implementations. Business adapters inject it and create independent caller-owned channels; their existing UI/RPC entry points retain authorization, redaction, identity, configuration, and bounded shutdown. Mounting the shared service creates no unused queues. Each canonical event becomes `eventName: "session-log"`, with `sessionId` and JSON-stringified event values in `content`; serialization does not preserve JSONL key ordering. Redaction runs before serialization and the copied envelope excludes the payload.
+
+Session logs use a separate provider and byte/count queue. Each record is measured once with the SDK serializer including a full envelope; the sum conservatively bounds a combined request. Greedy packing avoids full-candidate serialization and recursive re-encoding. Single oversized records receive one diagnostic and do not block later events. One HTTP request owns the transport slot until settlement, even after its watchdog fires; shutdown expiry stops the remaining queue. Scope name/version and explicit transport options remain owned by the backend.
+
+The [telemetry revival](../feature/2026-07-23-session-telemetry-otel-revival.md) remains authoritative for redaction, capture, and best-effort handoff. This decision replaces its SDK-only queue/batching rule: SDK batch deadlines cannot own an operation split into multiple HTTP requests. The shared Session-log channel schedules complete requests; the SDK still owns transport/retry. Feedback authorization and the separate DeepSeek model-request contribution remain unchanged.
+
+## Alternatives considered
+
+**One string for the entire Session.** Rejected because one large Session would exceed the request ceiling even when each event fits. One event per record preserves event identity and permits batching.
+
+**Truncate oversized records or add client-only fragments.** Rejected because truncation loses content and fragments require a collector reassembly protocol. Explicit rejection retains the one-event-per-record meaning.
+
+**Count compressed bytes or characters.** Rejected because collector limits can apply after decompression, and characters omit UTF-8 and escaping overhead. Measuring full uncompressed requests gives a conservative bound.
+
+## Consequences
+
+Product and Session records never share a request or queue, and neither package imports the other. Conservative per-record envelope accounting can produce smaller batches than exact combined accounting. Queue overflow, oversize rejection, exit, and transport failure can lose data; no durable outbox or acknowledgement is added. Tests cover byte limits, linear measurement, slow-request serialization, metadata, redaction-copy isolation, and feedback-authorized replay.
diff --git a/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.zh.md b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.zh.md
new file mode 100644
index 0000000000..f6e8306978
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: Session 日志的 OTLP 字符串与有界请求
+
+Status: implemented
+
+[English](2026-09-25-session-log-otel-byte-limits.md) | 中文
+
+## 问题
+
+Session 日志包含嵌套 JSON 和大型工具结果。产品采集端接收带名称和字符串内容的记录,并限制请求大小;仅按数量分批的 SDK 无法约束 UTF-8 编码与 JSON 转义后的字节数。
+
+## 决策
+
+共享 `otel` Cordis 插件负责普通事件和 Session 日志的传输实现。业务适配器注入它并创建独立、由调用方持有的通道;现有 UI/RPC 入口保留授权、脱敏、身份、配置和有期限的关闭。只挂载共享服务不会创建无用队列。每条规范事件转换为 `eventName: "session-log"`,带有 `sessionId` 和在 `content` 中 JSON 字符串化的事件值;序列化不保留 JSONL 键顺序。脱敏先于序列化,复制的 envelope 不包含 payload。
+
+Session 日志使用独立 provider 和字节/条数队列。每条记录使用 SDK 序列化器计量一次,包含完整 envelope;其总和保守约束合并请求大小。顺序组包避免候选批次的完整序列化和递归重复编码。单条超限记录只产生一次诊断,不阻塞后续事件。每个 HTTP 请求在结束前始终占有传输槽位,即使其监测期限已经触发;关闭期限到期后停止剩余队列。scope 名称/版本和显式传输选项仍由后端拥有。
+
+[遥测恢复决策](../feature/2026-07-23-session-telemetry-otel-revival.zh.md) 继续约束脱敏、捕获和尽力交接。本决策替代其仅使用 SDK 队列/聚合的规则:SDK 批次期限无法拥有拆成多个 HTTP 请求的操作。共享 Session 日志通道调度完整请求,SDK 仍负责传输和重试。反馈授权和独立的 DeepSeek 模型请求附加路径不变。
+
+## 考虑过的替代方案
+
+**整个 Session 合为一个字符串。** 拒绝,因为即使每条事件都能放入请求,大型 Session 仍会超过请求上限。每条事件对应一条记录,既保留事件身份又允许分批。
+
+**截断超限记录或仅在客户端分片。** 拒绝,因为截断会丢失内容,而分片需要采集端重组协议。显式拒绝保留一条事件对应一条记录的含义。
+
+**计算压缩字节数或字符数。** 拒绝,因为采集端限制可能作用于解压后内容,而字符数不包含 UTF-8 和转义开销。计量完整未压缩请求提供保守上限。
+
+## 影响
+
+产品事件和 Session 记录不共享请求或队列,两个包也不相互导入。保守的逐记录 envelope 计量可能比精确合并计量产生更小批次。队列溢出、超限拒绝、退出和传输失败可能丢失数据;不新增持久 outbox 或确认机制。测试覆盖字节上限、线性计量、慢请求串行调度、元数据、脱敏复制隔离和反馈授权回放。
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 8f2fe2b211..a06d830891 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
@@ -9,8 +9,8 @@
en: d3383b495424d118
zh: 4e6ffc8ebfa7f741
/agent-note-session-telemetry-seam-with-mandatory-redaction-and-the-otel-backend/decision:
- en: 85b950a67315023c
- zh: cb4c08338c30b226
+ en: 739b871ae583dcbf
+ zh: d17053e3b0a35942
/agent-note-session-telemetry-seam-with-mandatory-redaction-and-the-otel-backend/alternatives-considered:
en: 01838eefca2bc117
zh: fc71f5449d095b27
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 dc52ee87ef..b3b50d4be5 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
@@ -16,6 +16,8 @@ Every deployment that wants harness sessions in an observability stack must hand
- **The `session-telemetry/record` waterfall** — the delta over the branch version and the seam's redaction extension point. 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. `DISABLED` is the plugin default and constructs no transport. `FEEDBACK_ONLY` requires `exporter.url`; the [explicit-feedback policy](../architecture/2026-09-05-nonofficial-feedback-otel.md) requires a new submission for every bounded capture, for all providers. [Buffer-free feedback replay](../../archived/simplification/2026-08-06-buffer-free-feedback-telemetry.md) avoids a second in-memory copy of the session prefix.
+The [Session-log request limits](../architecture/2026-09-25-session-log-otel-byte-limits.md) supersede the SDK-only batching rule below for the OTel Session sender; capture, redaction, and handoff rationale remain applicable.
+
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: a crash can lose queued records. Backend retry or losing the same live object's module-scope cursor can duplicate lifecycle-local rows, so receivers deduplicate on `(session.id, session.format_version, event.seq)`.
## Alternatives considered
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 f647ea2896..349cd47e59 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
@@ -16,6 +16,8 @@ Status: implemented
- **`session-telemetry/record` waterfall(瀑布式事件)** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何后端前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。
- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考后端:OTel JS SDK 日志流水线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`DISABLED` 是插件默认值,且不构造任何传输。`FEEDBACK_ONLY` 要求 `exporter.url`;[显式反馈策略](../architecture/2026-09-05-nonofficial-feedback-otel.zh.md)要求每次有界捕获都由新提交触发,适用于所有提供方。[无缓冲反馈回放](../../archived/simplification/2026-08-06-buffer-free-feedback-telemetry.md)避免在内存中创建会话前缀的第二份副本。
+[Session 日志请求上限](../architecture/2026-09-25-session-log-otel-byte-limits.zh.md) 在 OTel Session 发送器中替代下述仅由 SDK 分批的规则;采集、脱敏和交接的理由仍然适用。
+
边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,并经 passthrough 配置。投递是尽力而为:崩溃可能丢失已排队记录。后端重试或丢失同一 live 对象的模块作用域游标可能重复生命周期本地 row,因此接收端基于 `(session.id, session.format_version, event.seq)` 去重。
## 考虑过的替代方案
diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
index 770368862d..e811b85577 100644
--- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
@@ -12,8 +12,8 @@
en: 8892eea7f62d45ed
zh: 18f64966ff53e38b
/agent-note-web-subagent-catalog-and-human-continuation/design-context:
- en: 3c44fc5af90bc807
- zh: 478538d028a52858
+ en: da3858c86cf685ff
+ zh: b13ee4f45e3dff7d
/agent-note-web-subagent-catalog-and-human-continuation/product-contract:
en: 2060d607d1cd1ef3
zh: e7100887358625c4
diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
index 3798a5a2e5..ba72e31d88 100644
--- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
+++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
@@ -28,7 +28,7 @@ This decision covers Web discovery, transcript viewing, and parent-authorized hu
## Design context
-The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f), [hierarchical expansion](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f), and [child conversation](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f) frames are non-normative interaction and visual references. This note owns lifecycle, wire, and failure semantics.
+The Figma subagent list, hierarchical expansion, and child conversation frames are non-normative interaction and visual references. This note owns lifecycle, wire, and failure semantics.
| Design intent | Shipped contract |
| --- | --- |
diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
index 4531528dd2..a3841b05fb 100644
--- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
@@ -28,7 +28,7 @@ Web 产品通过页头的当前 title 谱系区域公开选中会话中由会话
## 设计上下文
-Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f)、[层级展开](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f)与 [child 对话](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f)画框是非规范性的交互与视觉参考。本记录负责生命周期、协议与失败语义。
+Figma 中的 subagent 列表、层级展开与 child 对话画框是非规范性的交互与视觉参考。本记录负责生命周期、协议与失败语义。
| 设计意图 | 已交付约定 |
| --- | --- |
diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml
index 1b44f40971..982839c935 100644
--- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml
@@ -9,8 +9,8 @@
en: cef0f05fd8dfd6a1
zh: 228107eb6c00a91a
/agent-note-freeze-low-future-value-agent-notes-outside-the-active-corpus/decision:
- en: 453cf66046816477
- zh: af5bb77e849416a1
+ en: f0d9c292c3b8dd3b
+ zh: fd928084dfd333e2
/agent-note-freeze-low-future-value-agent-notes-outside-the-active-corpus/alternatives-considered:
en: 7637deef67f3ec14
zh: 7f43606e487fab67
diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md
index e9ecfa78f5..8e4ae6beab 100644
--- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md
+++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md
@@ -18,7 +18,7 @@ The root `.rgignore` excludes the archive from searches that traverse a parent d
After archival, the triplet is permanently frozen and is historical context rather than current authority. It is not updated for renamed packages, changed behavior, translation standards, formatting rules, broken outbound links, or later documentation contracts. Active prose may intentionally link into an archived note, redirect that link to current authority, or delete it. Repository gates therefore validate links into archived files but never treat archived files as link sources.
-[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) owns the frozen boundary. It accepts only the closed set of Agent Note kinds, requires a complete triplet with implemented status and matching valid archive dates, verifies the sidecar against both current Git blob hashes, and seals every artifact by path and SHA-256 content hash in an append-only manifest. Its `--write` mode first proves every existing seal unchanged and then appends only newly archived artifacts. Pull-request CI supplies the trusted base SHA and checks out complete history before running the verifier, so a reused runner's shallow checkout cannot omit the baseline manifest. The ordinary Agent Note format, translation-pairing, wrapping, Markdown-link, package-path, Mermaid, documentation-TypeScript, and type-equivalence gates exclude archive sources; their evolving standards cannot create pressure to edit history.
+[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) owns the frozen boundary. It accepts only the closed set of Agent Note kinds, requires a complete triplet with implemented status and matching valid archive dates, verifies the sidecar against both current Git blob hashes, and seals every artifact by path and SHA-256 content hash in an append-only manifest, with only the exact seal-transition exceptions recorded in [archive instructions](../../archived/AGENTS.md). Its `--write` mode first proves every existing seal unchanged and then appends only newly archived artifacts. Pull-request CI supplies the trusted base SHA and checks out complete history before running the verifier, so a reused runner's shallow checkout cannot omit the baseline manifest. The ordinary Agent Note format, translation-pairing, wrapping, Markdown-link, package-path, Mermaid, documentation-TypeScript, and type-equivalence gates exclude archive sources; their evolving standards cannot create pressure to edit history.
The [`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md) workflow owns classification. It requires a semantic note-by-note audit, uses code and current documentation to identify present authority, treats word count only as triage, carries calibrated keep/archive/delete examples, and reports genuinely borderline outcomes for review.
diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md
index 9a3ce2e6e2..a41cf83cf5 100644
--- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md
+++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md
@@ -18,7 +18,7 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录
归档后,这三个文件永久冻结,只作为历史背景,不再是当前权威依据。不得因为包重命名、行为变化、翻译标准、格式规则、出站链接失效或后续文档约定而更新归档文件。活跃文档可以有意链接到归档 Agent Note,也可以把该链接重定向到当前权威依据,或直接删除。仓库门禁因此会校验指向归档文件的链接,但绝不把归档文件作为链接源来校验。
-[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) 负责维护冻结边界。它只接受封闭集合中的 Agent Note 类别,要求三个配对文件完整、状态为 implemented,且归档日期有效并互相匹配;它还会用双方当前的 Git blob hash 校验伴随记录,并在仅追加的 manifest(元数据清单)中按路径和 SHA-256 内容 hash 封存每项产物。其 `--write` 模式会先证明每条现有封存记录对应的内容都未改变,再仅追加新归档的产物。拉取请求 CI 会提供可信的基准 SHA,并在运行校验器前检出完整历史,因此复用运行器上的浅克隆检出无法漏掉基线 manifest。普通的 Agent Note 格式、翻译配对、换行、Markdown 链接、包路径、Mermaid、文档 TypeScript 和类型等价门禁都排除归档源文件,因此这些门禁持续演进的标准不会产生修改历史记录的压力。
+[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) 负责维护冻结边界。它只接受封闭集合中的 Agent Note 类别,要求三个配对文件完整、状态为 implemented,且归档日期有效并互相匹配;它还会用双方当前的 Git blob hash 校验伴随记录,并在仅追加的 manifest(元数据清单)中按路径和 SHA-256 内容 hash 封存每项产物,仅允许[归档说明](../../archived/AGENTS.md)中记录的精确封存哈希转换例外。其 `--write` 模式会先证明每条现有封存记录对应的内容都未改变,再仅追加新归档的产物。拉取请求 CI 会提供可信的基准 SHA,并在运行校验器前检出完整历史,因此复用运行器上的浅克隆检出无法漏掉基线 manifest。普通的 Agent Note 格式、翻译配对、换行、Markdown 链接、包路径、Mermaid、文档 TypeScript 和类型等价门禁都排除归档源文件,因此这些门禁持续演进的标准不会产生修改历史记录的压力。
[`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md) 工作流负责分类判断。它要求逐份 Agent Note 做语义审计,使用代码和当前文档识别现行权威依据,仅把字数作为初步筛选手段,收录经过校准的保留、归档和删除示例,并报告真正处于边界的结果,以供评审。
diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml
index 5d29f39a16..a87c0b5e8b 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml
@@ -9,8 +9,8 @@
en: b3ec2245644b46e6
zh: 83de1cb620f9ddcf
/agent-note-make-lefthook-installation-worktree-local/decision:
- en: 577d90ff7e03ebaf
- zh: dc680155c0c3a2a1
+ en: b53a2425f88a6031
+ zh: 41f459b00803b3f9
/agent-note-make-lefthook-installation-worktree-local/alternatives-considered:
en: d7100b0d2c95a3f9
zh: 070c38c4d0d1331a
diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
index 42adc1ed1d..66ae7a136e 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
@@ -14,7 +14,7 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal
Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer so `git config --show-scope` can report which scope supplied a value, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`.
-Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking.
+Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking. On Windows, acquiring or inspecting a lock retries EPERM within the existing 30-second acquisition deadline because deleting a lock can temporarily deny access while readers close their handles; other errors and expired deadlines still fail.
Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Git seeds a new linked worktree's `config.worktree` from the main worktree; when that seed contains the marker-backed reserved hook path of a registered worktree, the installer replaces only the new worktree's config with its own path. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation.
diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
index b4b9059678..641bc6d2c8 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
@@ -14,7 +14,7 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝
钩子安装以 worktree 为作用域。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,安装程序要求 Git 2.26 或更高版本,使 `git config --show-scope` 可以报告由哪个作用域提供配置值;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。
-升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空休眠 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须人工介入恢复,而不能自动强制解除。
+升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空休眠 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须人工介入恢复,而不能自动强制解除。在 Windows 上,获取或检查锁时遇到 EPERM 会在原有的 30 秒获取期限内重试,因为删除锁时,读取者关闭句柄前可能暂时无法访问该文件;其他错误及超时仍会导致失败。
每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Git 会以主 worktree 的配置为新链接 worktree 初始化 `config.worktree`;当该初始配置包含某个已注册 worktree 中由所有权标记佐证的保留钩子路径时,安装程序只会在新 worktree 的配置中将其替换为新 worktree 自有的路径。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 3f325012a0..e08afa0c1c 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -56,7 +56,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/core`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
-| [`@opentelemetry/exporter-logs-otlp-http`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/otlp-transformer`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
diff --git a/apps/cli/composition.md b/apps/cli/composition.md
index e72a1f0ce6..e627d9e47d 100644
--- a/apps/cli/composition.md
+++ b/apps/cli/composition.md
@@ -74,6 +74,8 @@ flowchart LR
cfg --> plugin_dsh_base_storage_domain
plugin_dsh_base_session_projection_cache["session-projection-cache
@deepseek-ai/dsh-session-projection-cache"]
cfg --> plugin_dsh_base_session_projection_cache
+ plugin_dsh_base_otel["otel
@deepseek-ai/dsh-otel"]
+ cfg --> plugin_dsh_base_otel
plugin_dsh_base_session_telemetry_otel["session-telemetry-otel
@deepseek-ai/dsh-session-telemetry-otel"]
cfg --> plugin_dsh_base_session_telemetry_otel
plugin_dsh_base_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"]
@@ -231,6 +233,7 @@ flowchart LR
| `storage-json` | `@deepseek-ai/dsh-storage-json` |
| `storage-domain` | `@deepseek-ai/dsh-storage-domain` |
| `session-projection-cache` | `@deepseek-ai/dsh-session-projection-cache` |
+| `otel` | `@deepseek-ai/dsh-otel` |
| `session-telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
| `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
| `sandbox` | `@deepseek-ai/dsh-sandbox-local` |
diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml
index 0b34a8ff2c..12ea49fc20 100644
--- a/apps/desktop/README.i18n.yaml
+++ b/apps/desktop/README.i18n.yaml
@@ -24,11 +24,11 @@
en: 9b9b362a9865e763
zh: c748901168e2db8c
/deepseek-harness-desktop/develop/startup-onboarding:
- en: 5e2d62207a603d81
- zh: 5fb863d4906f4a67
+ en: a51346b8d880ddba
+ zh: 7913214c1c66063c
/deepseek-harness-desktop/develop/welcome-window-appearance:
- en: 7220801ccc3b2e6b
- zh: 042dc97976aef979
+ en: ae319a496639b38d
+ zh: ef044103240ac9da
/deepseek-harness-desktop/package:
en: 4cd8a8da6977928d
zh: 9f624855a2edac37
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index b593fa2766..5075f86f1b 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -142,7 +142,7 @@ The API-key input starts empty and uses `autocomplete="new-password"` to ask Chr
Repeated launches and `dsh://open` keep the workspace hidden until the startup credential check or a welcome action permits entry. Entering from Welcome places keyboard focus on the document without selecting a sidebar control; Tab navigation remains available.
-Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the [API-key page](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2138-44626). Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice.
+Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the API-key page. Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice.
The welcome window reads the shared `locale.preference` before it appears. An explicit English or Chinese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector.
@@ -150,7 +150,7 @@ The browser-login waiting page offers a copy-link action for the current pending
### Welcome window appearance
-The welcome window follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 [entry layout](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334) and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and locally bundled Montserrat Light for non-button text. The window uses macOS menu vibrancy or Windows acrylic with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. The local React welcome entry bundles React and the shared `StateDot` loading indicator with its CSS; it uses the isolated preload without loading the main Web application. The entry, sign-in status and API-key pages share a fixed bottom action row; the back-to-sign-in link sits below it. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions.
+The welcome window follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 entry layout and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and locally bundled Montserrat Light for non-button text. The window uses macOS menu vibrancy or Windows acrylic with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. The local React welcome entry bundles React and the shared `StateDot` loading indicator with its CSS; it uses the isolated preload without loading the main Web application. The entry, sign-in status and API-key pages share a fixed bottom action row; the back-to-sign-in link sits below it. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions.
## Package
diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md
index 9ccead93ee..28aedba731 100644
--- a/apps/desktop/README.zh.md
+++ b/apps/desktop/README.zh.md
@@ -144,7 +144,7 @@ API Key 输入框初始为空,并通过 `autocomplete="new-password"` 请求 C
重复启动和 `dsh://open` 会保持工作区隐藏,直到启动凭据检查或欢迎页操作允许进入。从 Welcome 进入时,键盘焦点落在文档上,不选中侧边栏控件;Tab 导航仍可使用。
-Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 [API Key 页面](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2138-44626)。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。
+Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 API Key 页面。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。
欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文或中文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。
@@ -152,7 +152,7 @@ Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否
### 欢迎窗口外观
-欢迎窗口使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的[入口布局](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334)和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及非按钮文字使用的本地 Montserrat Light 字体。窗口使用 macOS menu vibrancy 或 Windows acrylic,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。本地 React 欢迎入口将 React、公共 `StateDot` 加载指示器及其 CSS 一起打包;它通过隔离 preload 工作,不加载主 Web 应用。入口、登录状态和 API Key 页面共用固定的底部操作行;“返回登录”链接位于操作行下方。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。
+欢迎窗口使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的入口布局和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及非按钮文字使用的本地 Montserrat Light 字体。窗口使用 macOS menu vibrancy 或 Windows acrylic,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。本地 React 欢迎入口将 React、公共 `StateDot` 加载指示器及其 CSS 一起打包;它通过隔离 preload 工作,不加载主 Web 应用。入口、登录状态和 API Key 页面共用固定的底部操作行;“返回登录”链接位于操作行下方。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。
## 打包
diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts
index 4643da9cf8..3de0ea2fca 100644
--- a/apps/web/tests/feedback-release.e2e.ts
+++ b/apps/web/tests/feedback-release.e2e.ts
@@ -27,6 +27,7 @@ const MODE = webSnapshotMode()
interface OtlpCapture {
resourceLogs: { scopeLogs: { logRecords: {
+ eventName: string
attributes: { key: string; value: { stringValue?: string; intValue?: number | string } }[]
}[] }[] }[]
}
@@ -53,8 +54,11 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
return capture.resourceLogs.flatMap(resource => resource.scopeLogs.flatMap(scope =>
scope.logRecords.map((record) => {
const attribute = (key: string) => record.attributes.find(value => value.key === key)?.value
- return [attribute('session.id')?.stringValue, Number(attribute('event.seq')?.intValue),
- attribute('event.type')?.stringValue] as [string | undefined, number, string | undefined]
+ expect(record.eventName).toBe('session-log')
+ const content = attribute('content')?.stringValue
+ expect(typeof content).toBe('string')
+ const event = JSON.parse(content!) as SessionEvent
+ return [attribute('sessionId')?.stringValue, event.seq, event.type] as [string | undefined, number, string | undefined]
})))
})
}
@@ -71,6 +75,10 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
const expected = authorized.map(event => [sessionId, event.seq, event.type])
// No teardown, flush hook, or subsequent interaction may cause this delivery.
await expect.poll(captured, { timeout: 10_000 }).toEqual(expected)
+ const uploadedEvents = uploads.flatMap(upload => (JSON.parse(upload) as OtlpCapture).resourceLogs
+ .flatMap(resource => resource.scopeLogs.flatMap(scope => scope.logRecords))
+ .map(record => JSON.parse(record.attributes.find(attribute => attribute.key === 'content')!.value.stringValue!) as SessionEvent))
+ expect(uploadedEvents).toEqual(authorized)
suffixes.push(authorized.slice(releasedCount).map(event => event.type))
releasedCount = authorized.length
expect(scaffold.ctx.agents.get(sessionId)).toBe(agent)
@@ -268,7 +276,9 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
}
})
await compareOrRefreshGolden(RELEASE_EXPECTED, JSON.stringify({
- mode: 'FEEDBACK_ONLY', feedback,
+ mode: 'FEEDBACK_ONLY',
+ eventName: (JSON.parse(uploads[0]!) as OtlpCapture).resourceLogs[0]!.scopeLogs[0]!.logRecords[0]!.eventName,
+ feedback,
// The prefix is compared with each provider's actual canonical log above.
laterSubmissionSuffixes: suffixes.slice(1),
}, null, 2), MODE)
diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml
index a20cbf9cde..9982152e55 100644
--- a/docs/capability-seams.i18n.yaml
+++ b/docs/capability-seams.i18n.yaml
@@ -6,5 +6,5 @@
en: 41d772192075e133
zh: 5ef3da69b4de1d8a
/capability-seams-and-core-services:
- en: 0d14d210d41c6770
- zh: 964e92962b7cfa88
+ en: 8d7ae3eb1265031d
+ zh: 970ce559f5f95a65
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 21b4a8205b..093c5852e5 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -112,11 +112,13 @@ flowchart LR
pkg_api_account_controller["api-account-controller"]
pkg_authorization["authorization"]
svc_authorization["ctx.authorization
Authorization flow registry"]
+ pkg_otel["otel"]
+ svc_otel["ctx.otel
Shared OTel reporting channels"]
pkg_host_product_telemetry_otel["host-product-telemetry-otel"]
+ pkg_session_telemetry_otel["session-telemetry-otel"]
svc_productTelemetry["ctx.productTelemetry
Product usage event sender"]
pkg_session_telemetry["session-telemetry"]
svc_sessionTelemetry["ctx.sessionTelemetry
Session telemetry seam"]
- pkg_session_telemetry_otel["session-telemetry-otel"]
pkg_storage["storage"]
svc_storage["ctx.storage
Non-session storage hub"]
pkg_storage_json["storage-json"]
@@ -348,6 +350,7 @@ flowchart LR
pkg_mcp_resources --> svc_mcpResources
pkg_message_feedback --> svc_messageFeedback
pkg_office_to_pdf --> svc_officeToPdf
+ pkg_otel --> svc_otel
pkg_permission_presets --> svc_permissionPresets
pkg_plan_mode --> svc_planMode
pkg_plugin_manager --> svc_pluginManager
@@ -472,6 +475,8 @@ flowchart LR
svc_lsp --> pkg_tool_lsp
svc_mcpResources --> pkg_mcp_resources
svc_officeToPdf --> pkg_client_ui_sidebar_documentpreview
+ svc_otel --> pkg_host_product_telemetry_otel
+ svc_otel --> pkg_session_telemetry_otel
svc_pluginManager --> pkg_plugin_manager
svc_pluginManager --> pkg_ui_settings_plugin_inventory
svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager
@@ -605,6 +610,7 @@ flowchart LR
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage. |
| `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The Host owns browser authorization and local credentials; UI consumers receive state without tokens. |
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. |
+| `ctx.otel` | `service` | [`otel`](../packages/telemetry/otel) | - | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel), [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | Product analytics and Session feedback adapters create independent reporting channels through one injected service. |
| `ctx.productTelemetry` | `service` | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | - | - | - | Exports explicitly submitted analytics events through OTLP/HTTP; mounting alone collects nothing. |
| `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/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.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md
index 996fc1b863..4e41705687 100644
--- a/docs/capability-seams.zh.md
+++ b/docs/capability-seams.zh.md
@@ -114,11 +114,13 @@ flowchart LR
pkg_api_account_controller["api-account-controller"]
pkg_authorization["authorization"]
svc_authorization["ctx.authorization
Authorization flow registry"]
+ pkg_otel["otel"]
+ svc_otel["ctx.otel
Shared OTel reporting channels"]
pkg_host_product_telemetry_otel["host-product-telemetry-otel"]
+ pkg_session_telemetry_otel["session-telemetry-otel"]
svc_productTelemetry["ctx.productTelemetry
Product usage event sender"]
pkg_session_telemetry["session-telemetry"]
svc_sessionTelemetry["ctx.sessionTelemetry
Session telemetry seam"]
- pkg_session_telemetry_otel["session-telemetry-otel"]
pkg_storage["storage"]
svc_storage["ctx.storage
Non-session storage hub"]
pkg_storage_json["storage-json"]
@@ -350,6 +352,7 @@ flowchart LR
pkg_mcp_resources --> svc_mcpResources
pkg_message_feedback --> svc_messageFeedback
pkg_office_to_pdf --> svc_officeToPdf
+ pkg_otel --> svc_otel
pkg_permission_presets --> svc_permissionPresets
pkg_plan_mode --> svc_planMode
pkg_plugin_manager --> svc_pluginManager
@@ -474,6 +477,8 @@ flowchart LR
svc_lsp --> pkg_tool_lsp
svc_mcpResources --> pkg_mcp_resources
svc_officeToPdf --> pkg_client_ui_sidebar_documentpreview
+ svc_otel --> pkg_host_product_telemetry_otel
+ svc_otel --> pkg_session_telemetry_otel
svc_pluginManager --> pkg_plugin_manager
svc_pluginManager --> pkg_ui_settings_plugin_inventory
svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager
@@ -607,6 +612,7 @@ flowchart LR
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 |
| `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Host 负责浏览器授权和本地凭证;UI 使用方只接收不含 token 的状态。 |
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 |
+| `ctx.otel` | `service` | [`otel`](../packages/telemetry/otel) | - | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel), [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | 产品埋点和 Session 反馈适配器通过同一个注入服务创建独立上报通道。 |
| `ctx.productTelemetry` | `service` | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | - | - | - | 通过 OTLP/HTTP 发送显式提交的分析事件;仅挂载插件不会采集信息。 |
| `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 7c19319159..5b48e842dd 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1435,7 +1435,8 @@ export interface Config {
## `@deepseek-ai/dsh-host-product-telemetry-otel`
-- `source`: [`packages/host/product-telemetry-otel/src/index.ts:37`](../packages/host/product-telemetry-otel/src/index.ts)
+- `inject`: `otel`
+- `source`: [`packages/host/product-telemetry-otel/src/index.ts:18`](../packages/host/product-telemetry-otel/src/index.ts)
```ts config-catalog
/** Collector routing, application identity, and bounded in-memory batch settings. */
@@ -2701,36 +2702,36 @@ export interface Config {
## `@deepseek-ai/dsh-session-telemetry-otel`
-- `inject`: `sessions`
+- `inject`: `sessions` · `otel`
- `refs`: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
-- `source`: [`packages/session/session-telemetry-otel/src/index.ts:100`](../packages/session/session-telemetry-otel/src/index.ts)
+- `source`: [`packages/session/session-telemetry-otel/src/index.ts:87`](../packages/session/session-telemetry-otel/src/index.ts)
```ts config-catalog
/**
- * Plugin configuration: one sharing policy, two verbatim SDK option objects,
- * and one DSH-owned shutdown bound. Uploading modes validate their endpoint
+ * Plugin configuration: sharing policy, SDK transport options, byte/count queue
+ * settings, and an overall shutdown bound. Uploading modes validate their endpoint
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
export interface Config {
/** Defaults to `FEEDBACK_ONLY`: capture session history only when feedback is explicitly submitted. */
mode?: SessionTelemetryMode
/**
- * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
- * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
- * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
- * is the one field this package requires and validates itself.
+ * Explicit SDK HTTP transport settings, including optional routing headers.
+ * Ambient credentials are not inherited. URL is required while uploading.
*/
exporter?: OTLPExporterNodeConfigBase & {
/** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
url?: string
}
/**
- * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
- * which this plugin fills); the SDK owns and documents these knobs.
+ * Count, queue, cadence, and per-request watchdog settings for the byte-bounded
+ * processor. A watchdog warning never releases an unsettled transport slot.
*/
processor?: Omit
/** Maximum time spent awaiting the SDK provider's complete shutdown path. */
shutdownTimeoutMillis?: number
+ /** Uncompressed OTLP request byte limit, at most 4,000,000. */
+ maxRequestBytes?: number
}
/** Session-sharing policy selected by {@link Config.mode}. */
@@ -4354,6 +4355,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
| `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) |
| `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) |
+| `@deepseek-ai/dsh-otel` | — | [`packages/telemetry/otel/src/index.ts`](../packages/telemetry/otel/src/index.ts) |
| `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) |
| `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) |
| `@deepseek-ai/dsh-session-checkpoint-policy` | `llm` · `sessionPersistence` · `sessions` · `tools` | [`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts) |
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index ef35c89a17..17168781aa 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -1437,7 +1437,8 @@ export interface Config {
## `@deepseek-ai/dsh-host-product-telemetry-otel`
-- `source`: [`packages/host/product-telemetry-otel/src/index.ts:37`](../packages/host/product-telemetry-otel/src/index.ts)
+- `inject`: `otel`
+- `source`: [`packages/host/product-telemetry-otel/src/index.ts:18`](../packages/host/product-telemetry-otel/src/index.ts)
```ts config-catalog
/** Collector routing, application identity, and bounded in-memory batch settings. */
@@ -2703,36 +2704,36 @@ export interface Config {
## `@deepseek-ai/dsh-session-telemetry-otel`
-- `inject`: `sessions`
+- `inject`: `sessions` · `otel`
- `refs`: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
-- `source`: [`packages/session/session-telemetry-otel/src/index.ts:100`](../packages/session/session-telemetry-otel/src/index.ts)
+- `source`: [`packages/session/session-telemetry-otel/src/index.ts:87`](../packages/session/session-telemetry-otel/src/index.ts)
```ts config-catalog
/**
- * Plugin configuration: one sharing policy, two verbatim SDK option objects,
- * and one DSH-owned shutdown bound. Uploading modes validate their endpoint
+ * Plugin configuration: sharing policy, SDK transport options, byte/count queue
+ * settings, and an overall shutdown bound. Uploading modes validate their endpoint
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
export interface Config {
/** Defaults to `FEEDBACK_ONLY`: capture session history only when feedback is explicitly submitted. */
mode?: SessionTelemetryMode
/**
- * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
- * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
- * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
- * is the one field this package requires and validates itself.
+ * Explicit SDK HTTP transport settings, including optional routing headers.
+ * Ambient credentials are not inherited. URL is required while uploading.
*/
exporter?: OTLPExporterNodeConfigBase & {
/** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
url?: string
}
/**
- * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
- * which this plugin fills); the SDK owns and documents these knobs.
+ * Count, queue, cadence, and per-request watchdog settings for the byte-bounded
+ * processor. A watchdog warning never releases an unsettled transport slot.
*/
processor?: Omit
/** Maximum time spent awaiting the SDK provider's complete shutdown path. */
shutdownTimeoutMillis?: number
+ /** Uncompressed OTLP request byte limit, at most 4,000,000. */
+ maxRequestBytes?: number
}
/** Session-sharing policy selected by {@link Config.mode}. */
@@ -4356,6 +4357,7 @@ export interface Config {
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
| `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) |
| `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) |
+| `@deepseek-ai/dsh-otel` | — | [`packages/telemetry/otel/src/index.ts`](../packages/telemetry/otel/src/index.ts) |
| `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) |
| `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) |
| `@deepseek-ai/dsh-session-checkpoint-policy` | `llm` · `sessionPersistence` · `sessions` · `tools` | [`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts) |
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 03ae64de94..ab11ba2f88 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -60,7 +60,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
-| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
+| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index db5790470c..5654d74b13 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -62,7 +62,7 @@
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
-| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
+| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index d443907498..30c9a21baa 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -396,6 +396,9 @@ flowchart TD
pkg_subprocess_local["subprocess-local"]
pkg_win32_process["win32-process"]
end
+ subgraph group_telemetry["packages/telemetry"]
+ pkg_otel["otel"]
+ end
subgraph group_terminal["packages/terminal"]
pkg_terminal["terminal"]
pkg_terminal_bash["terminal-bash"]
@@ -511,6 +514,7 @@ flowchart TD
pkg_settings --> pkg_brand
pkg_settings --> pkg_invariants
pkg_settings --> pkg_session
+ pkg_otel --> pkg_session
pkg_session_snapshot --> pkg_http_proxy
pkg_session_snapshot --> pkg_session
pkg_fs --> pkg_brand
@@ -523,6 +527,7 @@ flowchart TD
pkg_hmr --> pkg_app_boot
pkg_hmr --> pkg_cmdline
pkg_experimental_speech_to_text --> pkg_settings
+ pkg_host_product_telemetry_otel --> pkg_otel
pkg_ptc_runtime --> pkg_sandbox
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
@@ -1038,6 +1043,7 @@ flowchart TD
pkg_session_telemetry_otel --> pkg_command_feedback
pkg_session_telemetry_otel --> pkg_llm
pkg_session_telemetry_otel --> pkg_message_feedback
+ pkg_session_telemetry_otel --> pkg_otel
pkg_session_telemetry_otel --> pkg_session
pkg_session_telemetry_otel --> pkg_session_telemetry
pkg_tool_bash --> pkg_agent
@@ -1466,7 +1472,6 @@ flowchart TD
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | — |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — |
| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
-| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | — |
| [`host-webserver`](../packages/host/webserver) | `host` | — |
| [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
| [`session-format`](../packages/session/session-format) | `session` | — |
@@ -1526,12 +1531,14 @@ flowchart TD
| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) |
| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
+| [`otel`](../packages/telemetry/otel) | `telemetry` | [`session`](../packages/core/session) |
| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
| [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) |
+| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) |
| [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1641,7 +1648,7 @@ flowchart TD
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) |
-| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
+| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`otel`](../packages/telemetry/otel), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`fs-ssh`](../packages/ssh/fs-ssh) | `ssh` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`ssh`](../packages/ssh/ssh) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index 8f974e3169..07483708c7 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -398,6 +398,9 @@ flowchart TD
pkg_subprocess_local["subprocess-local"]
pkg_win32_process["win32-process"]
end
+ subgraph group_telemetry["packages/telemetry"]
+ pkg_otel["otel"]
+ end
subgraph group_terminal["packages/terminal"]
pkg_terminal["terminal"]
pkg_terminal_bash["terminal-bash"]
@@ -513,6 +516,7 @@ flowchart TD
pkg_settings --> pkg_brand
pkg_settings --> pkg_invariants
pkg_settings --> pkg_session
+ pkg_otel --> pkg_session
pkg_session_snapshot --> pkg_http_proxy
pkg_session_snapshot --> pkg_session
pkg_fs --> pkg_brand
@@ -525,6 +529,7 @@ flowchart TD
pkg_hmr --> pkg_app_boot
pkg_hmr --> pkg_cmdline
pkg_experimental_speech_to_text --> pkg_settings
+ pkg_host_product_telemetry_otel --> pkg_otel
pkg_ptc_runtime --> pkg_sandbox
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
@@ -1040,6 +1045,7 @@ flowchart TD
pkg_session_telemetry_otel --> pkg_command_feedback
pkg_session_telemetry_otel --> pkg_llm
pkg_session_telemetry_otel --> pkg_message_feedback
+ pkg_session_telemetry_otel --> pkg_otel
pkg_session_telemetry_otel --> pkg_session
pkg_session_telemetry_otel --> pkg_session_telemetry
pkg_tool_bash --> pkg_agent
@@ -1468,7 +1474,6 @@ flowchart TD
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | — |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — |
| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
-| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | — |
| [`host-webserver`](../packages/host/webserver) | `host` | — |
| [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
| [`session-format`](../packages/session/session-format) | `session` | — |
@@ -1528,12 +1533,14 @@ flowchart TD
| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) |
| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
+| [`otel`](../packages/telemetry/otel) | `telemetry` | [`session`](../packages/core/session) |
| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
| [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) |
+| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) |
| [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1643,7 +1650,7 @@ flowchart TD
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) |
-| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
+| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`otel`](../packages/telemetry/otel), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`fs-ssh`](../packages/ssh/fs-ssh) | `ssh` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`ssh`](../packages/ssh/ssh) |
diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml
index a7934d3e58..7051478b51 100644
--- a/docs/subsystems/README.i18n.yaml
+++ b/docs/subsystems/README.i18n.yaml
@@ -3,5 +3,5 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/README.md
/subsystems:
- en: 58466f6fd60755eb
- zh: ab46a374f5a3c3ed
+ en: ab6cd11804f6b644
+ zh: 7a82dc5b22c40703
diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md
index 479dbe7cb3..e2f8dc51e2 100644
--- a/docs/subsystems/README.md
+++ b/docs/subsystems/README.md
@@ -67,6 +67,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| [conversation.md](conversation.md) | target-neutral Session-event assembly: Context identity, Location data, replay paths, view builders, and target-owned render nodes |
| [session-projection.md](session-projection.md) | the projection seam: `SessionProjectionMap`, the pure `ProjectionDefinition` unit, `ProjectionSnapshot`'s consistent cut, the change feed |
| [session-telemetry.md](session-telemetry.md) | the outbound session-reporting capability seam: `SessionTelemetryRecord`/`SessionTelemetrySeverity`, the `SessionTelemetrySink` contract, and the `session-telemetry/record` redact waterfall |
+| [otel.md](otel.md) | Shared ordinary-event and Session-log OTLP channels |
| [product-telemetry.md](product-telemetry.md) | Explicit product analytics submission and OTLP/HTTP transport |
> Type declarations and their JSDoc on these pages are source-equivalent and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Ordinary blocks preserve complete declarations; `public-api` blocks preserve body-stripped public class declarations. Cordis services and events use each page's generated **Cordis API** section.
diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md
index a6b9226df6..31f4489d43 100644
--- a/docs/subsystems/README.zh.md
+++ b/docs/subsystems/README.zh.md
@@ -67,6 +67,7 @@
| [conversation.md](conversation.zh.md) | 目标无关的会话事件组装:上下文标识、位置数据、回放路径、视图构建器与目标自有的渲染节点 |
| [session-projection.md](session-projection.zh.md) | 投影 seam:`SessionProjectionMap`、纯函数 `ProjectionDefinition` 单元、`ProjectionSnapshot` 的一致切面、变更馈送 |
| [session-telemetry.md](session-telemetry.zh.md) | 对外会话上报能力 seam:`SessionTelemetryRecord`/`SessionTelemetrySeverity`、`SessionTelemetrySink` 约定和 `session-telemetry/record` 脱敏 waterfall(瀑布式事件) |
+| [otel.md](otel.zh.md) | 共享普通事件与 Session 日志 OTLP 通道 |
| [product-telemetry.md](product-telemetry.zh.md) | 显式产品分析事件提交与 OTLP/HTTP 传输 |
> 这些页面上的类型声明及其 JSDoc 与源码等价,并由 `pnpm run verify-type-equiv` 检查漂移(见 [development.md](../development.zh.md#documenting-types-verbatim-ts-type-equiv))。普通块保留完整声明;`public-api` 块保留去除实现体的公开 class 声明。Cordis 服务与事件使用每页生成的 **Cordis API** 小节。
diff --git a/docs/subsystems/otel.i18n.yaml b/docs/subsystems/otel.i18n.yaml
new file mode 100644
index 0000000000..7547678647
--- /dev/null
+++ b/docs/subsystems/otel.i18n.yaml
@@ -0,0 +1,7 @@
+# Bilingual-pair consistency record for otel.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/subsystems/otel.md
+/otel-reporting:
+ en: adf67758d37c46f2
+ zh: 9e41b63531fa875a
diff --git a/docs/subsystems/otel.md b/docs/subsystems/otel.md
new file mode 100644
index 0000000000..bcba92f51b
--- /dev/null
+++ b/docs/subsystems/otel.md
@@ -0,0 +1,44 @@
+# OTel reporting
+
+English | [中文](otel.zh.md)
+
+The [OTel plugin](../../packages/telemetry/otel/README.md) registers `ctx.otel`, a shared factory for independent ordinary-event and Session-log channels. It owns SDK transport and batching implementations. Business consumers inject the service, supply explicit transport/resource/scope options, and own each returned channel's shutdown.
+
+UI feedback reaches the Session feedback services through their existing Remote APIs. Feedback recording authorizes the Session adapter's canonical-prefix capture and redaction before it reports through an OTel channel. Ordinary product consumers submit selected fields through the product adapter. The shared service has no automatic identity or capture policy, and channels never share queues or requests.
+
+The [product adapter](../../packages/host/product-telemetry-otel/README.md) and [Session adapter](../../packages/session/session-telemetry-otel/README.md) retain their deployment configuration and disposal deadlines. Consumers must not reuse a channel after shutdown. Mounting only the OTel service allocates no provider or transport.
+
+
+
+
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+
+
+### `ctx.otel` — `OTel`
+
+Shared transport provider. Mounting creates no queue, identity, or network connection.
+
+```ts cordis-catalog
+/**
+ * Create an independent ordinary-event channel with count-based batching.
+ * The injected consumer must drain it during its fiber disposal.
+ * @param options - transport, scope, resource, queue, and diagnostic settings selected by the consumer.
+ * @returns the caller-owned channel; no state is shared with other channels.
+ */
+createEventReporter(options: EventLogOptions): EventLogReporter
+
+/**
+ * Create an independent byte-bounded Session-log channel.
+ * Authorization and redaction precede reporting; the consumer owns shutdown and its outer deadline.
+ * @param options - transport, scope, resource, queue, and diagnostic settings selected by the consumer.
+ * @returns the caller-owned channel, preserving complete accepted events within the request byte ceiling.
+ */
+createSessionLogReporter(options: SessionLogOptions): SessionLogReporter
+```
+
+Source: [`packages/telemetry/otel/src/index.ts`](../../packages/telemetry/otel/src/index.ts)
+
diff --git a/docs/subsystems/otel.zh.md b/docs/subsystems/otel.zh.md
new file mode 100644
index 0000000000..dbe7f3cb9e
--- /dev/null
+++ b/docs/subsystems/otel.zh.md
@@ -0,0 +1,44 @@
+# OTel 上报
+
+[English](otel.md) | 中文
+
+[OTel 插件](../../packages/telemetry/otel/README.zh.md) 注册 `ctx.otel`,作为独立普通事件通道和 Session 日志通道的共享工厂。它持有 SDK 传输与聚合实现。业务调用方注入服务,显式提供传输、resource 和 scope 选项,并负责返回通道的关闭。
+
+UI 反馈通过现有 Remote API 到达 Session 反馈服务。记录反馈后,Session 适配器才获得规范日志前缀的捕获授权,完成脱敏后通过 OTel 通道上报。普通产品调用方通过产品适配器提交选定字段。共享服务没有自动身份或捕获策略,通道之间不共享队列或请求。
+
+[产品适配器](../../packages/host/product-telemetry-otel/README.zh.md) 和 [Session 适配器](../../packages/session/session-telemetry-otel/README.zh.md) 保留各自部署配置及销毁期限。通道关闭后调用方不得复用。只挂载 OTel 服务不会分配 provider 或 transport。
+
+
+
+
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+
+
+### `ctx.otel` — `OTel`
+
+Shared transport provider. Mounting creates no queue, identity, or network connection.
+
+```ts cordis-catalog
+/**
+ * Create an independent ordinary-event channel with count-based batching.
+ * The injected consumer must drain it during its fiber disposal.
+ * @param options - transport, scope, resource, queue, and diagnostic settings selected by the consumer.
+ * @returns the caller-owned channel; no state is shared with other channels.
+ */
+createEventReporter(options: EventLogOptions): EventLogReporter
+
+/**
+ * Create an independent byte-bounded Session-log channel.
+ * Authorization and redaction precede reporting; the consumer owns shutdown and its outer deadline.
+ * @param options - transport, scope, resource, queue, and diagnostic settings selected by the consumer.
+ * @returns the caller-owned channel, preserving complete accepted events within the request byte ceiling.
+ */
+createSessionLogReporter(options: SessionLogOptions): SessionLogReporter
+```
+
+Source: [`packages/telemetry/otel/src/index.ts`](../../packages/telemetry/otel/src/index.ts)
+
diff --git a/docs/subsystems/product-telemetry.md b/docs/subsystems/product-telemetry.md
index d86540d5a2..10732ec45b 100644
--- a/docs/subsystems/product-telemetry.md
+++ b/docs/subsystems/product-telemetry.md
@@ -11,24 +11,13 @@ Enqueue is synchronous and does not acknowledge delivery. The SDK owns batching
## Record types
```ts type-equiv
-/** Scalar values accepted by the collector's Arrow attributes map. */
-type ProductTelemetryScalar = string | number | boolean
+/** Scalar values accepted in product attributes. */
+type ProductTelemetryScalar = OTelEventScalar
```
```ts type-equiv
-/** Explicitly selected analytics fields; object values may contain scalars only. */
-interface ProductTelemetryRecord {
- /** Product/DA-owned event name. */
- eventName: string
- /** Human-readable summary; never a prompt, response, credential, or file contents. */
- body: string
- /** Event occurrence time in Unix milliseconds. Observation time is assigned on enqueue. */
- timestamp: number
- /** OTel severity; omitted values use INFO. */
- severityNumber?: SeverityNumber
- /** Business fields selected by the caller; no automatic device or account identity. */
- attributes?: Record>
-}
+/** Caller-selected ordinary analytics record. */
+type ProductTelemetryRecord = OTelEventRecord
```
Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/host/product-telemetry-otel/src/index.ts)
diff --git a/docs/subsystems/product-telemetry.zh.md b/docs/subsystems/product-telemetry.zh.md
index 52ad119363..6747e31310 100644
--- a/docs/subsystems/product-telemetry.zh.md
+++ b/docs/subsystems/product-telemetry.zh.md
@@ -11,24 +11,13 @@
## 记录类型
```ts type-equiv
-/** Scalar values accepted by the collector's Arrow attributes map. */
-type ProductTelemetryScalar = string | number | boolean
+/** Scalar values accepted in product attributes. */
+type ProductTelemetryScalar = OTelEventScalar
```
```ts type-equiv
-/** Explicitly selected analytics fields; object values may contain scalars only. */
-interface ProductTelemetryRecord {
- /** Product/DA-owned event name. */
- eventName: string
- /** Human-readable summary; never a prompt, response, credential, or file contents. */
- body: string
- /** Event occurrence time in Unix milliseconds. Observation time is assigned on enqueue. */
- timestamp: number
- /** OTel severity; omitted values use INFO. */
- severityNumber?: SeverityNumber
- /** Business fields selected by the caller; no automatic device or account identity. */
- attributes?: Record>
-}
+/** Caller-selected ordinary analytics record. */
+type ProductTelemetryRecord = OTelEventRecord
```
Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/host/product-telemetry-otel/src/index.ts)
diff --git a/docs/subsystems/session-telemetry.i18n.yaml b/docs/subsystems/session-telemetry.i18n.yaml
index b2c2f42fb9..553e00308c 100644
--- a/docs/subsystems/session-telemetry.i18n.yaml
+++ b/docs/subsystems/session-telemetry.i18n.yaml
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-telemetry.md
/sessiontelemetrybackend:
- en: d400c8668cd76829
- zh: 67689676c19daa7f
+ en: b82e4db22a4d58aa
+ zh: 439181fa2c27542d
/sessiontelemetrybackend/the-logical-record:
en: 9695489076496a76
zh: 457c54698bc38c2f
diff --git a/docs/subsystems/session-telemetry.md b/docs/subsystems/session-telemetry.md
index 18fe935e5e..4b99fc8ff9 100644
--- a/docs/subsystems/session-telemetry.md
+++ b/docs/subsystems/session-telemetry.md
@@ -2,7 +2,7 @@
English | [中文](session-telemetry.zh.md)
-Outbound session reporting is split as a [capability seam](../capability-seams.md): the Service Definition and capture coordinator ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.sessionTelemetry`) own complete canonical-event capture, the `session-telemetry/record` redaction waterfall, the handoff cursor, and the minimal backend contract; the Service Provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) is the OpenTelemetry JS SDK's log pipeline configured verbatim. It is one optional capability, not part of the agent-loop spine, and nothing here reaches a model request. The boundary axiom — the harness's aspect ends at `emit()`; batching, retry, queueing, and loss policy belong to the reporting SDK — and the rejected alternatives are pinned in the [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md); the capture and cursor contracts live in the [Service Definition README](../../packages/session/session-telemetry/README.md).
+The [capture service](../../packages/session/session-telemetry/README.md) owns canonical-event capture, redaction, and handoff cursors. The [feedback backend](../../packages/session/session-telemetry-otel/README.md) authorizes Session prefixes and owns their independent queue, per-request scheduling, and 4,000,000-byte ceiling. No reporting data reaches model requests.
Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts)
@@ -29,6 +29,8 @@ type SessionTelemetrySeverity = 'info' | 'warn' | 'error'
* identity so they can never be mistaken for ledger rows.
*/
interface SessionTelemetryRecord {
+ /** Canonical envelope without data; body carries the separately redacted payload. Absent for operational records. */
+ sourceEvent?: { sessionId: SessionId; envelope: Omit }
/** 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. */
diff --git a/docs/subsystems/session-telemetry.zh.md b/docs/subsystems/session-telemetry.zh.md
index 1b3807d2e3..00480938d9 100644
--- a/docs/subsystems/session-telemetry.zh.md
+++ b/docs/subsystems/session-telemetry.zh.md
@@ -2,7 +2,7 @@
[English](session-telemetry.md) | 中文
-对外的会话上报拆分为一项[能力 seam](../capability-seams.zh.md):Service Definition 与捕获协调器([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.sessionTelemetry`)拥有完整的权威事件捕获、`session-telemetry/record` 脱敏 waterfall(瀑布式事件)、handoff 游标与最小后端约定;部署方加载的 Service Provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))则是原样配置的 OpenTelemetry JS SDK 日志流水线。它是一项可选能力,不属于 agent loop(智能体循环)主干,这里也没有任何内容会进入模型请求。边界公理(harness 的职责止于 `emit()`;批处理、重试、排队与丢失策略都属于上报 SDK)连同被否决的替代方案,均已在[复活 Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md)中定案;捕获与游标约定见 [Service Definition README](../../packages/session/session-telemetry/README.zh.md)。
+[捕获服务](../../packages/session/session-telemetry/README.zh.md) 负责规范事件捕获、脱敏和交接游标。[反馈后端](../../packages/session/session-telemetry-otel/README.zh.md) 授权 Session 前缀,并负责独立队列、逐请求调度及 4,000,000 字节上限。上报数据不会进入模型请求。
源码:[`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts)
@@ -29,6 +29,8 @@ type SessionTelemetrySeverity = 'info' | 'warn' | 'error'
* identity so they can never be mistaken for ledger rows.
*/
interface SessionTelemetryRecord {
+ /** Canonical envelope without data; body carries the separately redacted payload. Absent for operational records. */
+ sourceEvent?: { sessionId: SessionId; envelope: Omit }
/** 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. */
diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml
index d091e6d74c..f9879d6dcd 100644
--- a/packages/README.i18n.yaml
+++ b/packages/README.i18n.yaml
@@ -15,8 +15,8 @@
en: ffc55792d6e922e1
zh: 83bd759638857e2d
/packages/package-groups:
- en: 2818a70fa6bbb1b6
- zh: 800b34102e82c192
+ en: d865abbfa65d1d4f
+ zh: ea079b795b51dd55
/packages/release-expectations:
en: 1ab880da3642bde1
zh: 6e168322f026a9cf
diff --git a/packages/README.md b/packages/README.md
index 54c4cdc758..cfb5ea6c74 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -34,6 +34,7 @@ Every package lives in exactly one group; new packages join existing groups, and
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle |
| [`schedule/`](schedule/README.md) | Host-owned scheduled follow-ups |
| [`feedback/`](feedback/README.md) | Human feedback capture and command |
+| [`telemetry/`](telemetry/README.md) | Shared Cordis OTel reporting channels |
| [`identity/`](identity/README.md) | Shared anonymous identity |
| [`llm/`](llm/README.md) | LLM capability family: abstract service + provider adapters |
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider |
diff --git a/packages/README.zh.md b/packages/README.zh.md
index 9ea4426082..2edb62d8cd 100644
--- a/packages/README.zh.md
+++ b/packages/README.zh.md
@@ -34,6 +34,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会
| [`goal/`](goal/README.zh.md) | 同会话 goal 的持久化与生命周期 |
| [`schedule/`](schedule/README.zh.md) | Host 拥有的定时后续操作 |
| [`feedback/`](feedback/README.zh.md) | 人类反馈的采集与命令 |
+| [`telemetry/`](telemetry/README.zh.md) | 共享 Cordis OTel 上报通道 |
| [`identity/`](identity/README.zh.md) | 共享匿名身份 |
| [`llm/`](llm/README.zh.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 |
| [`subprocess/`](subprocess/README.zh.md) | 子进程能力系列:Service Definition + 本地进程树提供方 |
diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml
index db6701a9b6..9c35ebf5c2 100644
--- a/packages/bundle/base/cordis.patch.yml
+++ b/packages/bundle/base/cordis.patch.yml
@@ -185,6 +185,9 @@
writeEveryEvents: 200
writeIntervalMs: 5000
+ - id: otel
+ name: '@deepseek-ai/dsh-otel'
+
# OTel releases a Session-log prefix only after explicit user feedback,
# regardless of model provider. Ordinary activity never triggers capture.
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint. A non-empty
@@ -192,22 +195,20 @@
# process out (the launchers patch the row disabled; config cannot disable
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.anonymous-user-id,
# random UUID; delete the file to reset the identity) as the Resource's
- # user.id. The exporter/processor values normally bound the shutdown drain
- # to ~1s against an unreachable collector: exporter.timeoutMillis is both
- # the per-attempt socket timeout and the retry deadline (1s effectively
- # disables the SDK's 5-try backoff), while maxExportBatchSize == maxQueueSize
- # (both explicit) makes the drain a single batch. The SDK awaits
- # exporter.forceFlush() outside exportTimeoutMillis, so the backend's 3s
- # shutdownTimeoutMillis is the load-bearing outer bound when a transport
- # promise never settles. Every CLI exit path drains it by disposing the root
- # on SIGINT/SIGTERM.
+ # user.id. Byte-bounded batching may turn an authorized prefix into
+ # several HTTP requests. Requests run serially; each has its own 1s
+ # transport timeout and 1.5s watchdog. A watchdog does not release the
+ # active request slot. The outer 3s shutdown deadline stops queued sends;
+ # large prefixes or an unsettled transport may leave records unsent.
+ # Every CLI exit path drains by disposing the root on SIGINT/SIGTERM.
- id: session-telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: !!js process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY'
shutdownTimeoutMillis: 3000
+ maxRequestBytes: 4000000
exporter:
- url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
+ url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://dsh-otel-collector.deepseeksvc.com/v1/logs'
compression: gzip
timeoutMillis: 1000
processor:
diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json
index e5b0025eaa..51b37cf282 100644
--- a/packages/bundle/base/package.json
+++ b/packages/bundle/base/package.json
@@ -125,7 +125,8 @@
"@deepseek-ai/dsh-authorization": "workspace:*",
"@deepseek-ai/dsh-deepseek-account-platform": "workspace:*",
"@deepseek-ai/dsh-config-editor": "workspace:*",
- "@deepseek-ai/dsh-settings": "workspace:*"
+ "@deepseek-ai/dsh-settings": "workspace:*",
+ "@deepseek-ai/dsh-otel": "workspace:*"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 2cfa30c7fe..51c3835920 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -1541,6 +1541,25 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
+ {
+ key: 'otel',
+ summary: 'Shared transport provider.',
+ description: 'Shared transport provider. Mounting creates no queue, identity, or network connection.',
+ methods: [
+ {
+ signature: 'createEventReporter(options: EventLogOptions): EventLogReporter',
+ description: 'Create an independent ordinary-event channel with count-based batching. The injected consumer must drain it during its fiber disposal.',
+ parameters: [{ name: 'options', description: 'transport, scope, resource, queue, and diagnostic settings selected by the consumer.' }],
+ returns: 'the caller-owned channel; no state is shared with other channels.',
+ },
+ {
+ signature: 'createSessionLogReporter(options: SessionLogOptions): SessionLogReporter',
+ description: 'Create an independent byte-bounded Session-log channel. Authorization and redaction precede reporting; the consumer owns shutdown and its outer deadline.',
+ parameters: [{ name: 'options', description: 'transport, scope, resource, queue, and diagnostic settings selected by the consumer.' }],
+ returns: 'the caller-owned channel, preserving complete accepted events within the request byte ceiling.',
+ },
+ ],
+ },
{
key: 'permissionPresets',
summary: 'Owns the deployment\'s configured permission presets, the fixed Auto integration hook, and their write path.',
@@ -5081,6 +5100,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'EpochHeader',
declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n tools?: ToolSchema[];\n system?: never;\n}',
},
+ {
+ name: 'EventLogOptions',
+ declaration: 'export interface EventLogOptions {\n exporter: SessionLogOptions[\'exporter\'];\n resourceAttributes: Attributes;\n scope: {\n name: string;\n version?: string;\n };\n processor: Omit;\n onFailure: SessionLogOptions[\'onFailure\'];\n}',
+ },
+ {
+ name: 'EventLogReporter',
+ declaration: 'export class EventLogReporter {\n constructor(options: EventLogOptions);\n emit(record: OTelEventRecord): void;\n shutdown(): Promise;\n}',
+ },
{
name: 'EveryScheduleRecord',
declaration: 'export interface EveryScheduleRecord {\n readonly id: ScheduleId;\n readonly kind: \'every\';\n readonly title: string;\n readonly prompt: string;\n readonly everySeconds: number;\n readonly scheduledAt: string;\n}',
@@ -5801,6 +5828,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'OptionalSessionSeq',
declaration: 'export type OptionalSessionSeq = SessionSeq | null;',
},
+ {
+ name: 'OTelEventRecord',
+ declaration: 'export interface OTelEventRecord {\n eventName: string;\n body: string;\n timestamp: number;\n severityNumber?: SeverityNumber;\n attributes?: Record>;\n}',
+ },
+ {
+ name: 'OTelEventScalar',
+ declaration: 'export type OTelEventScalar = string | number | boolean;',
+ },
{
name: 'PackageResult',
declaration: 'export interface PackageResult {\n exitCode: number;\n output: string;\n truncated: boolean;\n logPath: string;\n kind?: PluginInstallFailureKind;\n timedOut?: boolean;\n incompatible?: IncompatiblePlugin[];\n}',
@@ -5931,11 +5966,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ProductTelemetryRecord',
- declaration: 'export interface ProductTelemetryRecord {\n eventName: string;\n body: string;\n timestamp: number;\n severityNumber?: SeverityNumber;\n attributes?: Record>;\n}',
- },
- {
- name: 'ProductTelemetryScalar',
- declaration: 'export type ProductTelemetryScalar = string | number | boolean;',
+ declaration: 'export type ProductTelemetryRecord = OTelEventRecord;',
},
{
name: 'ProfilePnpmInvocation',
@@ -6561,6 +6592,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionLogOffset',
declaration: 'export type SessionLogOffset = BrandedNumber<\'SessionLogOffset\'>;',
},
+ {
+ name: 'SessionLogOptions',
+ declaration: 'export interface SessionLogOptions {\n exporter: OTLPExporterNodeConfigBase & {\n url: string;\n };\n processor?: Omit;\n maxRequestBytes?: number;\n scope: {\n name: string;\n version?: string;\n };\n resourceAttributes: Attributes;\n onFailure: (message: string, error?: Error) => void;\n}',
+ },
+ {
+ name: 'SessionLogRecord',
+ declaration: 'export interface SessionLogRecord {\n sessionId: SessionId;\n event: Omit & {\n data: unknown;\n };\n attributes?: Attributes;\n severityNumber?: SeverityNumber;\n}',
+ },
+ {
+ name: 'SessionLogReporter',
+ declaration: 'export class SessionLogReporter {\n constructor(options: SessionLogOptions);\n reportSessionLog(record: SessionLogRecord): void;\n stopPending(): void;\n shutdown(): Promise;\n}',
+ },
{
name: 'SessionLogSnapshot',
declaration: 'export interface SessionLogSnapshot {\n session: SessionHeader;\n inheritedEventCount: SessionLogOffset;\n events: SessionEvent[];\n}',
@@ -6767,7 +6810,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionTelemetryRecord',
- declaration: 'export interface SessionTelemetryRecord {\n channel: \'ledger\' | \'ops\';\n time: number;\n severity: SessionTelemetrySeverity;\n attributes: Record;\n body: unknown;\n}',
+ declaration: 'export interface SessionTelemetryRecord {\n sourceEvent?: {\n sessionId: SessionId;\n envelope: Omit;\n };\n channel: \'ledger\' | \'ops\';\n time: number;\n severity: SessionTelemetrySeverity;\n attributes: Record;\n body: unknown;\n}',
},
{
name: 'SessionTelemetrySeverity',
diff --git a/packages/host/product-telemetry-otel/README.i18n.yaml b/packages/host/product-telemetry-otel/README.i18n.yaml
index e35c7672f8..5c840f876c 100644
--- a/packages/host/product-telemetry-otel/README.i18n.yaml
+++ b/packages/host/product-telemetry-otel/README.i18n.yaml
@@ -18,8 +18,8 @@
en: 08e0bcbc259d131b
zh: 1d7cbde2688a903f
/deepseek-ai-dsh-host-product-telemetry-otel/understand-the-implementation:
- en: 5d2c06a357a1c06a
- zh: 9a1a17535889f5e3
+ en: d7807324fdeb2b42
+ zh: 1bfe1e943e513500
/deepseek-ai-dsh-host-product-telemetry-otel/further-exploration:
en: 6bfb5e8a723ff0a9
zh: fffc6b02e2c1a3cb
diff --git a/packages/host/product-telemetry-otel/README.md b/packages/host/product-telemetry-otel/README.md
index af1f2c415f..f5b00d1496 100644
--- a/packages/host/product-telemetry-otel/README.md
+++ b/packages/host/product-telemetry-otel/README.md
@@ -28,6 +28,7 @@ Send selected product usage events to an OTLP/HTTP collector. Events carry a nam
Mount the plugin in a Cordis composition with the application identity; override the collector endpoint when needed. The shipped profiles do not mount it. Set `DSH_APP_VERSION` to the running application release version in the launcher environment; the schema rejects an absent version.
```yaml
+- name: '@deepseek-ai/dsh-otel'
- name: '@deepseek-ai/dsh-host-product-telemetry-otel'
config:
endpoint: https://dsh-otel-collector.deepseeksvc.com/v1/logs
@@ -65,7 +66,7 @@ The collector expects a string body and attributes containing strings, numbers,
Implementation internals — click to expand
-A private OTel logger feeds `BatchLogRecordProcessor` and the SDK HTTP delegate with its JSON log serializer. The delegate receives explicit headers and an HTTP agent; only shared timeout and compression settings use SDK environment resolution. The direct `@opentelemetry/core` dependency matches `sdk-logs` at 2.9.0 so exporter result enums share one TypeScript identity. The SDK owns queueing, transient-error retries, and compression; plugin disposal drains pending records with a bounded wait. Export completion is observed separately because SDK shutdown can resolve after a rejected export. No global OTel provider is installed.
+The adapter injects `otel` and creates an independent ordinary-event channel through `ctx.otel.createEventReporter()`. The [shared OTel plugin](../../telemetry/otel/README.md) owns SDK transport and batching; this adapter owns analytics configuration and its bounded shutdown wait. No global OTel provider is installed.
[`src/index.ts`](src/index.ts) owns configuration and submission. No runtime invariant companion is published: delivery has no independent local acknowledgement to compare with the SDK's queue.
diff --git a/packages/host/product-telemetry-otel/README.zh.md b/packages/host/product-telemetry-otel/README.zh.md
index ffdb682d9a..8d88254368 100644
--- a/packages/host/product-telemetry-otel/README.zh.md
+++ b/packages/host/product-telemetry-otel/README.zh.md
@@ -28,6 +28,7 @@ kind: "package-reference"
在 Cordis 组合中挂载插件并提供应用标识;需要时可覆盖接收地址。内置 profile 不挂载本插件。启动器环境须将 `DSH_APP_VERSION` 设为运行中应用的发布版本;缺少版本时 schema 会拒绝配置。
```yaml
+- name: '@deepseek-ai/dsh-otel'
- name: '@deepseek-ai/dsh-host-product-telemetry-otel'
config:
endpoint: https://dsh-otel-collector.deepseeksvc.com/v1/logs
@@ -65,7 +66,7 @@ kind: "package-reference"
实现细节——点击展开
-私有 OTel logger 将记录交给 `BatchLogRecordProcessor`,再由 SDK HTTP delegate 和 JSON 日志序列化器发送。delegate 使用显式请求头和 HTTP agent,只有共享的超时与压缩配置使用 SDK 环境变量解析。直接依赖的 `@opentelemetry/core` 与 `sdk-logs` 对齐为 2.9.0,使导出结果枚举共享同一个 TypeScript 类型身份。SDK 负责队列、临时错误重试与压缩;插件卸载时在限定时间内发送剩余记录。发送完成结果单独观测,因为 SDK 在发送被拒绝后仍可能正常完成退出。不安装全局 OTel provider。
+适配器注入 `otel`,通过 `ctx.otel.createEventReporter()` 创建独立的普通事件通道。[共享 OTel 插件](../../telemetry/otel/README.zh.md) 负责 SDK 传输和聚合;本适配器负责埋点配置及有期限的关闭等待。不安装全局 OTel provider。
[`src/index.ts`](src/index.ts) 负责配置与提交。不发布运行时不变量伴随模块:本地没有独立的送达确认可与 SDK 队列比较。
diff --git a/packages/host/product-telemetry-otel/package.json b/packages/host/product-telemetry-otel/package.json
index 29c291020d..6a5ba372cb 100644
--- a/packages/host/product-telemetry-otel/package.json
+++ b/packages/host/product-telemetry-otel/package.json
@@ -28,21 +28,19 @@
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~",
- "@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.220.0",
- "@opentelemetry/core": "2.9.0",
"@opentelemetry/otlp-exporter-base": "^0.220.0",
- "@opentelemetry/otlp-transformer": "^0.220.0",
- "@opentelemetry/resources": "^2.9.0",
"@opentelemetry/sdk-logs": "^0.220.0"
},
"peerDependencies": {
- "@deepseek-ai/cordis": "workspace:~"
+ "@deepseek-ai/cordis": "workspace:~",
+ "@deepseek-ai/dsh-otel": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
"@deepseek-ai/dsh-app-boot": "workspace:*",
- "@deepseek-ai/dsh-loader-smoke": "workspace:*"
+ "@deepseek-ai/dsh-loader-smoke": "workspace:*",
+ "@deepseek-ai/dsh-otel": "workspace:*"
}
}
diff --git a/packages/host/product-telemetry-otel/src/index.ts b/packages/host/product-telemetry-otel/src/index.ts
index eef6ec5dce..ec2eda239e 100644
--- a/packages/host/product-telemetry-otel/src/index.ts
+++ b/packages/host/product-telemetry-otel/src/index.ts
@@ -1,37 +1,18 @@
-/** Explicit product usage events over OTLP/HTTP; no automatic collection or Session access. */
+/** Product analytics policy adapter for the shared Cordis OTel service. */
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
-import { SeverityNumber, type Logger } from '@opentelemetry/api-logs'
import { validateHeaderValue } from 'node:http'
-import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer'
-import { createOtlpHttpExportDelegate, getSharedConfigurationFromEnvironment, httpAgentFactoryFromOptions } from '@opentelemetry/otlp-exporter-base/node-http'
-import { CompressionAlgorithm, getSharedConfigurationDefaults, mergeOtlpSharedConfigurationWithDefaults, OTLPExporterBase } from '@opentelemetry/otlp-exporter-base'
-import { ExportResultCode } from '@opentelemetry/core'
-import { resourceFromAttributes } from '@opentelemetry/resources'
-import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs'
+import { CompressionAlgorithm } from '@opentelemetry/otlp-exporter-base'
+import type { EventLogReporter, OTelEventRecord, OTelEventScalar } from '@deepseek-ai/dsh-otel'
declare module '@deepseek-ai/cordis' {
- interface Context {
- productTelemetry: ProductTelemetry
- }
+ interface Context { productTelemetry: ProductTelemetry }
}
-/** Scalar values accepted by the collector's Arrow attributes map. */
-export type ProductTelemetryScalar = string | number | boolean
-
-/** Explicitly selected analytics fields; object values may contain scalars only. */
-export interface ProductTelemetryRecord {
- /** Product/DA-owned event name. */
- eventName: string
- /** Human-readable summary; never a prompt, response, credential, or file contents. */
- body: string
- /** Event occurrence time in Unix milliseconds. Observation time is assigned on enqueue. */
- timestamp: number
- /** OTel severity; omitted values use INFO. */
- severityNumber?: SeverityNumber
- /** Business fields selected by the caller; no automatic device or account identity. */
- attributes?: Record>
-}
+/** Caller-selected ordinary analytics record. */
+export type ProductTelemetryRecord = OTelEventRecord
+/** Scalar values accepted in product attributes. */
+export type ProductTelemetryScalar = OTelEventScalar
/** Collector routing, application identity, and bounded in-memory batch settings. */
export interface Config {
@@ -78,8 +59,9 @@ export const Config: z, Config> = z.object({
/** Host analytics sender. Mounting alone sends nothing; the owning fiber drains it on unload. */
export default class ProductTelemetry extends Service {
+ static inject = ['otel']
static Config = Config
- private readonly logger: Logger
+ private readonly reporter: EventLogReporter
constructor(ctx: Context, config: Config) {
let endpoint: URL
@@ -100,44 +82,20 @@ export default class ProductTelemetry extends Service {
throw new Error('product-telemetry-otel: maxExportBatchSize must not exceed maxQueueSize')
}
super(ctx, 'productTelemetry')
- const shared = mergeOtlpSharedConfigurationWithDefaults({
- timeoutMillis: config.timeoutMillis,
- ...(config.compression === undefined ? {} : {
- compression: config.compression === 'gzip' ? CompressionAlgorithm.GZIP : CompressionAlgorithm.NONE,
- }),
- }, getSharedConfigurationFromEnvironment('LOGS'), getSharedConfigurationDefaults())
- const transport = {
- ...shared,
- url: config.endpoint,
- // This collector must not inherit another endpoint's headers or TLS client identity.
- headers: () => Promise.resolve({ 'Content-Type': 'application/json', 'x-channel': config.channel }),
- agentFactory: httpAgentFactoryFromOptions({ keepAlive: true }),
- }
- const exporter = new OTLPExporterBase(createOtlpHttpExportDelegate(transport, JsonLogsSerializer))
- const provider = new LoggerProvider({
- resource: resourceFromAttributes({
- 'service.name': config.serviceName,
- 'service.version': config.serviceVersion,
- }),
- processors: [new BatchLogRecordProcessor({
- maxExportBatchSize: config.maxExportBatchSize,
- maxQueueSize: config.maxQueueSize,
- scheduledDelayMillis: config.scheduledDelayMillis,
- exportTimeoutMillis: config.exportTimeoutMillis,
- exporter: {
- export: (records, callback) => {
- exporter.export(records, (result) => {
- // SDK flush/shutdown can resolve after export failure; observe the actual completion.
- if (result.code !== ExportResultCode.SUCCESS) ctx.logger.warn('Product telemetry export failed', result.error)
- callback(result)
- })
- },
- forceFlush: () => exporter.forceFlush(),
- shutdown: () => exporter.shutdown(),
- },
- })],
+ const reporter = ctx.otel.createEventReporter({
+ exporter: {
+ url: config.endpoint, headers: { 'x-channel': config.channel }, timeoutMillis: config.timeoutMillis,
+ ...(config.compression === undefined ? {} : { compression: config.compression === 'gzip' ? CompressionAlgorithm.GZIP : CompressionAlgorithm.NONE }),
+ },
+ resourceAttributes: { 'service.name': config.serviceName, 'service.version': config.serviceVersion },
+ scope: { name: '@deepseek-ai/dsh-host-product-telemetry-otel' },
+ processor: {
+ maxExportBatchSize: config.maxExportBatchSize, maxQueueSize: config.maxQueueSize,
+ scheduledDelayMillis: config.scheduledDelayMillis, exportTimeoutMillis: config.exportTimeoutMillis,
+ },
+ onFailure: (message, error) => { ctx.logger.warn(message, error) },
})
- this.logger = provider.getLogger('@deepseek-ai/dsh-host-product-telemetry-otel')
+ this.reporter = reporter
ctx.effect(() => async () => {
let timer!: ReturnType
const deadline = new Promise((resolve) => {
@@ -147,7 +105,7 @@ export default class ProductTelemetry extends Service {
}, config.shutdownTimeoutMillis)
})
try {
- await Promise.race([provider.shutdown(), deadline])
+ await Promise.race([reporter.shutdown(), deadline])
} finally {
clearTimeout(timer)
}
@@ -160,12 +118,6 @@ export default class ProductTelemetry extends Service {
* @param record - caller-owned event containing only approved analytics fields.
*/
emit(record: ProductTelemetryRecord): void {
- const severityNumber = record.severityNumber ?? SeverityNumber.INFO
- this.logger.emit({
- ...record,
- observedTimestamp: Date.now(),
- severityNumber,
- severityText: SeverityNumber[severityNumber],
- })
+ this.reporter.emit(record)
}
}
diff --git a/packages/host/product-telemetry-otel/tests/telemetry.spec.ts b/packages/host/product-telemetry-otel/tests/telemetry.spec.ts
index 22f7df5c30..eadaf49f12 100644
--- a/packages/host/product-telemetry-otel/tests/telemetry.spec.ts
+++ b/packages/host/product-telemetry-otel/tests/telemetry.spec.ts
@@ -1,3 +1,4 @@
+import OTel from '@deepseek-ai/dsh-otel'
import { createServer, type IncomingHttpHeaders } from 'node:http'
import { once } from 'node:events'
import { gunzipSync } from 'node:zlib'
@@ -58,7 +59,8 @@ function config(endpoint: string, overrides: Partial = {}): Config {
function context() {
const ctx = new Context()
- cleanup.push(() => ctx.fiber.dispose())
+ const ready = ctx.plugin(OTel)
+ cleanup.push(async () => { await ready; await ctx.fiber.dispose() })
return ctx
}
@@ -201,3 +203,16 @@ describe('explicit product telemetry', () => {
await vi.advanceTimersByTimeAsync(0)
})
})
+
+it('drains the dependent product channel when the shared OTel plugin unloads', async () => {
+ const target = await collector()
+ const ctx = new Context()
+ cleanup.push(() => ctx.fiber.dispose())
+ const service = await ctx.plugin(OTel)
+ await ctx.plugin(ProductTelemetry, config(target.endpoint))
+ ctx.productTelemetry.emit(event)
+ await service.dispose()
+ expect(ctx.get('otel')).toBeUndefined()
+ expect(ctx.get('productTelemetry')).toBeUndefined()
+ expect(target.captures).toHaveLength(1)
+})
diff --git a/packages/host/product-telemetry-otel/tsconfig.json b/packages/host/product-telemetry-otel/tsconfig.json
index f0063ae28d..2c542e3a1f 100644
--- a/packages/host/product-telemetry-otel/tsconfig.json
+++ b/packages/host/product-telemetry-otel/tsconfig.json
@@ -16,6 +16,9 @@
},
{
"path": "../../../vendor/schemastery"
+ },
+ {
+ "path": "../../telemetry/otel"
}
]
}
diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml
index 9bd72dd10a..04a7e52140 100644
--- a/packages/llm/llm-deepseek/README.i18n.yaml
+++ b/packages/llm/llm-deepseek/README.i18n.yaml
@@ -39,8 +39,8 @@
en: f863390198cc177c
zh: 8856c62ba3fb36ca
/deepseek-ai-dsh-llm-deepseek/use-this-package/failures-and-recovery:
- en: d31bd24c8c419f88
- zh: 91205cb3cd754f39
+ en: 7a09ab81593cd9c1
+ zh: 919dc0eb6347e9fd
/deepseek-ai-dsh-llm-deepseek/understand-the-implementation:
en: 1598ac8a8bb9b6bf
zh: fdd089c5ae7722cd
diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md
index 3dc3efacb3..651258fcbb 100644
--- a/packages/llm/llm-deepseek/README.md
+++ b/packages/llm/llm-deepseek/README.md
@@ -115,7 +115,7 @@ Configuration accepts Messages only and has no `protocol` field. If resolution r
Successful Files responses must contain valid JSON. JSON decoding failures from upload, list, retrieve, and delete throw `INVALID_RESPONSE` with the operation and HTTP status in the message, the status in `LlmError.failure`, and the original parser error as `cause`. Body-read transport and cancellation errors retain their identity.
-Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection invalidates the named mappings (or every mapping used by the attempt) and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request on the official route without an API key fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key.
+Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection removes every named mapping (or every mapping used by the attempt) in one upload-index update and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request on the official route without an API key fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key.
Provider plugins own catalog availability; only the account route requires a stored grant for discovery. Their catalogs are configured independently; the transport supplies shared default model metadata and capability resolution.
diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md
index 257765c0fd..0c4694bd8f 100644
--- a/packages/llm/llm-deepseek/README.zh.md
+++ b/packages/llm/llm-deepseek/README.zh.md
@@ -115,7 +115,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
成功的 Files 响应必须包含有效 JSON。上传、列举、获取和删除操作的 JSON 解码失败抛出 `INVALID_RESPONSE`,消息包含操作名称与 HTTP 状态,`LlmError.failure` 保留该状态,`cause` 保留原始解析错误。读取响应体时的传输和取消错误保留其原有身份。
-非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会使点名映射(或该次尝试使用的全部映射)失效,并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。官方路由缺少 API Key 的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。
+非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会在一次上传索引更新中删除全部点名映射(或该次尝试使用的全部映射),并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。官方路由缺少 API Key 的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。
提供方插件负责目录可用性;仅账号路由要求存有凭据才能发现模型。两者的目录独立配置;传输层提供共享的默认模型元数据和能力解析。
diff --git a/packages/llm/llm-deepseek/src/file-store.ts b/packages/llm/llm-deepseek/src/file-store.ts
index cebff0809b..da6f87654a 100644
--- a/packages/llm/llm-deepseek/src/file-store.ts
+++ b/packages/llm/llm-deepseek/src/file-store.ts
@@ -244,21 +244,15 @@ export class DeepSeekFileStore {
}
/**
- * Invalidate one exact local mapping after a model request rejects its remote id.
- * @param version - request-image version whose remote generation failed.
- * @param fileId - exact rejected file id.
+ * Invalidate exact local mappings in one index update after a model request rejects their remote ids.
+ * @param generations - request-image variants with the exact file id the request used for each.
* @param connection - endpoint and API-key snapshot.
*/
async invalidate(
- version: RequestImageAttachment,
- fileId: DeepSeekFileId,
+ generations: readonly Pick[],
connection: DeepSeekFileConnection,
): Promise {
- await this.index.remove(
- fileScope(connection),
- version.variantId,
- fileId,
- )
+ await this.index.remove(fileScope(connection), generations)
}
/**
@@ -284,7 +278,7 @@ export class DeepSeekFileStore {
)
if (record === undefined) return false
await this.client(connection).delete(record.fileId, signal)
- await this.index.remove(scope, version.variantId, record.fileId)
+ await this.index.remove(scope, [{ variantId: version.variantId, fileId: record.fileId }])
return true
}
diff --git a/packages/llm/llm-deepseek/src/request-files.ts b/packages/llm/llm-deepseek/src/request-files.ts
index 4b391420ab..85e1460903 100644
--- a/packages/llm/llm-deepseek/src/request-files.ts
+++ b/packages/llm/llm-deepseek/src/request-files.ts
@@ -128,13 +128,16 @@ export class RequestFiles {
}
/**
- * Invalidate rejected mappings; only the first stale-id response permits another request.
+ * Invalidate every rejected mapping in one index update; only the first stale-id response permits another request.
* @param detail - provider error fields used for stale-id classification.
* @returns whether the caller should serialize and dispatch again.
*/
async retry(detail: string): Promise {
if (this.used.length === 0 || !providerRejectedFileId(detail)) return false
- await Promise.all(staleMappings(this.used, detail).map(file => this.files.invalidate(file.version, file.fileId, this.connection)))
+ await this.files.invalidate(
+ staleMappings(this.used, detail).map(file => ({ variantId: file.version.variantId, fileId: file.fileId })),
+ this.connection,
+ )
if (this.retried) return false
this.retried = true
return true
diff --git a/packages/llm/llm-deepseek/src/upload-index.ts b/packages/llm/llm-deepseek/src/upload-index.ts
index 1467e7d93a..27a8ea89e7 100644
--- a/packages/llm/llm-deepseek/src/upload-index.ts
+++ b/packages/llm/llm-deepseek/src/upload-index.ts
@@ -190,21 +190,20 @@ export class DeepSeekUploadIndex {
}
/**
- * Remove one exact mapping without deleting a concurrently installed successor.
+ * Remove exact mappings in one locked rewrite without deleting concurrently installed successors.
* @param scope - endpoint/API-key namespace.
- * @param variantId - complete request-image transformation identity.
- * @param fileId - exact remote generation being invalidated.
+ * @param generations - exact remote generations being invalidated; pairs absent from the index are ignored.
*/
async remove(
scope: DeepSeekFileScopeType,
- variantId: ImageVariantIdType,
- fileId: DeepSeekFileIdType,
+ generations: readonly Pick[],
): Promise {
+ const invalidated = new Set(generations.map(generation => `${generation.variantId}\0${generation.fileId}`))
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 })
await withFileLock(this.path, async () => {
const index = await this.load()
const records = index.records.filter(record => !(
- record.scope === scope && record.variantId === variantId && record.fileId === fileId
+ record.scope === scope && invalidated.has(`${record.variantId}\0${record.fileId}`)
))
if (records.length !== index.records.length) await this.save({ formatVersion: 3, records })
})
diff --git a/packages/llm/llm-deepseek/tests/file-store.spec.ts b/packages/llm/llm-deepseek/tests/file-store.spec.ts
index b821304c02..a8cb1458a4 100644
--- a/packages/llm/llm-deepseek/tests/file-store.spec.ts
+++ b/packages/llm/llm-deepseek/tests/file-store.spec.ts
@@ -98,10 +98,10 @@ describe('DeepSeekFileStore', () => {
expect((await store.ensureUploaded(VERSION, { ...CONNECTION, baseURL: `${CONNECTION.baseURL}/v1/` }, POLICY)).record).toEqual(first.record)
const reopened = new DeepSeekFileStore({ index, fetch: remote.fetchImpl, now: () => now })
expect((await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)).record).toEqual(first.record)
- await reopened.invalidate(VERSION, other.record.fileId, CONNECTION)
+ await reopened.invalidate([{ variantId: VERSION.variantId, fileId: other.record.fileId }], CONNECTION)
expect((await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)).record).toEqual(first.record)
expect(remote.uploads()).toBe(2)
- await reopened.invalidate(VERSION, first.record.fileId, CONNECTION)
+ await reopened.invalidate([{ variantId: VERSION.variantId, fileId: first.record.fileId }], CONNECTION)
const replacement = await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)
expect(replacement.record.fileId).not.toBe(first.record.fileId)
expect((await reopened.ensureUploaded(VERSION, alternate, POLICY)).record).toEqual(other.record)
diff --git a/packages/llm/llm-deepseek/tests/files.spec.ts b/packages/llm/llm-deepseek/tests/files.spec.ts
index 7ec3129e1c..bb44326318 100644
--- a/packages/llm/llm-deepseek/tests/files.spec.ts
+++ b/packages/llm/llm-deepseek/tests/files.spec.ts
@@ -73,7 +73,7 @@ describe('Messages Files requests', () => {
vi.stubGlobal('fetch', fetchImpl)
const h = harness()
await chunks(h.adapter.stream(request([ref, second])))
- expect(h.invalidate).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ attachment: ref }), 'file-a', expect.objectContaining({}))
+ expect(h.invalidate).toHaveBeenCalledExactlyOnceWith([{ variantId: version(ref).variantId, fileId: 'file-a' }], expect.objectContaining({}))
expect(fetchImpl).toHaveBeenCalledTimes(2)
expect(h.prepare).toHaveBeenCalledTimes(2)
expect(h.ensureUploaded).toHaveBeenCalledTimes(4)
@@ -85,7 +85,7 @@ describe('Messages Files requests', () => {
const h = harness()
await expect(chunks(h.adapter.stream(request([ref, second])))).rejects.toMatchObject({ code: 'INVALID_REQUEST' })
expect(fetchImpl).toHaveBeenCalledTimes(2)
- expect(h.invalidate).toHaveBeenCalledTimes(4)
+ expect(h.invalidate.mock.calls.map(call => call[0].length)).toEqual([2, 2])
expect(fetchImpl.mock.calls.every(call => !body(call[1]).includes('base64'))).toBe(true)
})
diff --git a/packages/llm/llm-deepseek/tests/runtime.spec.ts b/packages/llm/llm-deepseek/tests/runtime.spec.ts
index 4c505e1fe2..e051f6f909 100644
--- a/packages/llm/llm-deepseek/tests/runtime.spec.ts
+++ b/packages/llm/llm-deepseek/tests/runtime.spec.ts
@@ -1017,6 +1017,57 @@ describe('DeepSeekAdapter against a mock server', () => {
])
})
+ it('recovers in one replacement request when the provider lists dozens of missing file ids', async () => {
+ const refs = Array.from({ length: 40 }, (_, index): ImageAttachmentRef => ({
+ ...imageRef,
+ attachmentId: AttachmentId(`sha256:${(index + 1).toString(16).padStart(64, '0')}`),
+ }))
+ const missing = Array.from({ length: 30 }, (_, index) => `file-api-${index + 1}`)
+ const server = await mockServer([
+ {
+ kind: 'http-error',
+ status: 400,
+ body: JSON.stringify({
+ error: {
+ type: 'invalid_request_error',
+ message: 'messages.0.content.1.tool_result: the following file_ids do not exist or are not created under your account: '
+ + `${missing.join(', ')} (request_id: 00000000-0000-4000-8000-000000000002)`,
+ },
+ }),
+ },
+ { kind: 'sse', events: textEvents },
+ ])
+ const attachments = attachmentStoreOf(ref => Promise.resolve({
+ ...requestImage(ref),
+ variantId: ImageVariantId(String(ref.attachmentId)),
+ })).store
+ const adapter = adapterOf({
+ baseURL: server.url,
+ models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }],
+ }, attachments)
+
+ await drain(adapter.stream({
+ provider: 'deepseek-official',
+ model: 'deepseek-v4-flash-vision-exp',
+ messages: [createUserMessage({
+ content: refs.map(ref => ({ type: 'image' as const, attachment: ref })),
+ source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+ })],
+ }))
+
+ expect(server.requests).toHaveLength(2)
+ expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(70)
+ const attempts = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; source?: { file_id?: string } }> }> }>
+ const fileIds = (attempt: number) => attempts[attempt]?.messages[0]?.content
+ .filter(block => block.type === 'image')
+ .map(block => block.source?.file_id)
+ expect(fileIds(0)).toEqual(refs.map((_, index) => `file-api-${index + 1}`))
+ expect(fileIds(1)).toEqual([
+ ...missing.map((_, index) => `file-api-${41 + index}`),
+ ...refs.slice(30).map((_, index) => `file-api-${31 + index}`),
+ ])
+ })
+
it('invalidates every used mapping when a stale-file response does not identify one file id', async () => {
const secondRef: ImageAttachmentRef = {
...imageRef,
diff --git a/packages/llm/llm-deepseek/tests/upload-index.spec.ts b/packages/llm/llm-deepseek/tests/upload-index.spec.ts
index 989a921e69..1a57409ed7 100644
--- a/packages/llm/llm-deepseek/tests/upload-index.spec.ts
+++ b/packages/llm/llm-deepseek/tests/upload-index.spec.ts
@@ -56,12 +56,36 @@ describe('DeepSeekUploadIndex', () => {
await index.commit(first, 1, 1)
await expect(index.commit(duplicate, 2, 1)).resolves.toEqual({ record: first, accepted: false })
- await index.remove(scope, VARIANT, duplicate.fileId)
+ await index.remove(scope, [{ variantId: VARIANT, fileId: duplicate.fileId }])
await expect(index.get(scope, VARIANT, 2, 1)).resolves.toEqual(first)
- await index.remove(scope, VARIANT, first.fileId)
+ await index.remove(scope, [{ variantId: VARIANT, fileId: first.fileId }])
await expect(index.get(scope, VARIANT, 2, 1)).resolves.toBeUndefined()
})
+ it('removes several exact generations in one update and keeps unlisted mappings', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+ roots.push(dir)
+ const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
+ const scope = deepSeekFileScope('https://api.deepseek.com', 'key')
+ const record = (digest: string) => ({
+ scope, attachmentId: ATTACHMENT, variantId: ImageVariantId(`sha256:${digest.repeat(64)}`),
+ fileId: DeepSeekFileId(`file-api-${digest}`), bytes: 3, createdAt: 1, expiresAt: 10_000,
+ })
+ const first = record('b')
+ const second = record('c')
+ const third = record('d')
+ for (const entry of [first, second, third]) await index.commit(entry, 1, 1)
+
+ await index.remove(scope, [
+ { variantId: first.variantId, fileId: first.fileId },
+ { variantId: second.variantId, fileId: second.fileId },
+ { variantId: third.variantId, fileId: DeepSeekFileId('file-api-superseded') },
+ ])
+ await expect(index.get(scope, first.variantId, 2, 1)).resolves.toBeUndefined()
+ await expect(index.get(scope, second.variantId, 2, 1)).resolves.toBeUndefined()
+ await expect(index.get(scope, third.variantId, 2, 1)).resolves.toEqual(third)
+ })
+
it('treats a corrupt upload cache as empty and repairs it on the next commit', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
roots.push(dir)
diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md
index 1abe901b99..0cdc1333f1 100644
--- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md
+++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md
@@ -356,7 +356,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo
| `@deepseek-ai/dsh-session-projection` | no | Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state |
| `@deepseek-ai/dsh-session-projection-cache` | yes | Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read |
| `@deepseek-ai/dsh-session-stats` | no | Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness |
-| `@deepseek-ai/dsh-session-telemetry-otel` | yes | OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline |
+| `@deepseek-ai/dsh-session-telemetry-otel` | yes | Feedback-authorized Session logs over byte-bounded OpenTelemetry HTTP requests |
| `@deepseek-ai/dsh-session-title` | yes | Log-backed session title service and provider registry for the DeepSeek Harness |
| `@deepseek-ai/dsh-session-title-all-prompts-llm` | yes | All-user-messages LLM provider plugin for DeepSeek Harness session titles |
| `@deepseek-ai/dsh-session-title-first-prompt-llm` | yes | First-message LLM provider plugin for DeepSeek Harness session titles |
@@ -446,6 +446,12 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo
|---|---|---|
| `@deepseek-ai/dsh-subprocess-local` | no | Local-subprocess implementation of the DeepSeek Harness subprocess seam |
+## telemetry
+
+| Package | Config | Description |
+|---|---|---|
+| `@deepseek-ai/dsh-otel` | no | Cordis service for independent ordinary-event and byte-bounded Session-log OTLP channels |
+
## terminal
| Package | Config | Description |
diff --git a/packages/session/session-telemetry-otel/README.i18n.yaml b/packages/session/session-telemetry-otel/README.i18n.yaml
index b89f0da8de..d26ad40f35 100644
--- a/packages/session/session-telemetry-otel/README.i18n.yaml
+++ b/packages/session/session-telemetry-otel/README.i18n.yaml
@@ -9,8 +9,8 @@
en: f8ea869792c8b207
zh: bc076172ce22ba9b
/deepseek-ai-dsh-session-telemetry-otel/summary:
- en: 1c5d2f15cb13e318
- zh: 5e061a16cd38c4c5
+ en: 828903209ef34983
+ zh: 8eac63f1e8df15d8
/deepseek-ai-dsh-session-telemetry-otel/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
@@ -21,29 +21,29 @@
en: bc7e598148a7f8c3
zh: 0209c6aa258ec134
/deepseek-ai-dsh-session-telemetry-otel/use-this-package/minimal-configuration:
- en: 3de0d5db3156a7e4
- zh: 71199133cd9998c4
+ en: 11845fd50dc0a494
+ zh: ee990bfca3621bd3
/deepseek-ai-dsh-session-telemetry-otel/use-this-package/what-leaves-the-machine:
- en: b94ca8299ad60c5d
- zh: 3791131f3440e3b3
+ en: 2a5ddd227e400455
+ zh: 74b7bd9b9161b054
/deepseek-ai-dsh-session-telemetry-otel/use-this-package/failures-and-shutdown:
- en: 20c6e78df8ad2c2a
- zh: fd6a00f98c4cc05c
+ en: a66831cb9de57e04
+ zh: 3ada28bb868e0932
/deepseek-ai-dsh-session-telemetry-otel/understand-the-implementation:
en: ee672a6a6f0fa02d
zh: c3bab8e6b60e768c
/deepseek-ai-dsh-session-telemetry-otel/understand-the-implementation/design-concept:
- en: d5e1f8249d3eea34
- zh: 7a6915e33390f4ae
+ en: 34f8a88224a1f6cf
+ zh: 40839dabaea39602
/deepseek-ai-dsh-session-telemetry-otel/understand-the-implementation/source-map:
en: 0b99d2e5c27213cd
zh: 3cbdaa7f6a45d81a
/deepseek-ai-dsh-session-telemetry-otel/understand-the-implementation/capture-wiring:
- en: 9bdc01d0fa130b3a
- zh: 63bf30c4cc983ca0
+ en: 5c85eb0992eb5b2b
+ zh: a19cdd5814c0e2b9
/deepseek-ai-dsh-session-telemetry-otel/understand-the-implementation/field-mapping:
- en: 38590f3afee40bb8
- zh: b2fc1f4341008d79
+ en: a42e9e081fb93bee
+ zh: b8e817c2455278b9
/deepseek-ai-dsh-session-telemetry-otel/further-exploration:
en: 155b75490ec65d86
zh: 07d37d6e4ac90c89
@@ -57,5 +57,5 @@
en: e43087aea7af4c9e
zh: f8b529e2012b4c63
/deepseek-ai-dsh-session-telemetry-otel/known-limitations-and-deferred-work/dev-note:
- en: a770daec456212ab
- zh: 1b861566d591837d
+ en: 0065df2648b09ac9
+ zh: b5bac41fc3aec70e
diff --git a/packages/session/session-telemetry-otel/README.md b/packages/session/session-telemetry-otel/README.md
index 35dc351bd5..f3fc3fa417 100644
--- a/packages/session/session-telemetry-otel/README.md
+++ b/packages/session/session-telemetry-otel/README.md
@@ -9,7 +9,9 @@ English | [中文](README.zh.md)
## Summary
-`dsh-session-telemetry-otel` exports session records through the OTel JS SDK only after new explicit feedback, for all users and providers, including `deepseek-official`. `FEEDBACK_ONLY` releases the canonical prefix through that feedback, including context; later records wait for the next explicit feedback. `DISABLED` constructs no transport. SDK batching can finish an authorized upload without another user interaction or model call. Deployments own their redaction rules.
+The adapter injects `otel`; the [shared OTel plugin](../../telemetry/otel/README.md) creates its independent Session-log channel. Authorization, redaction, identity, scope version, configuration, and the shutdown deadline remain owned here.
+
+`dsh-session-telemetry-otel` exports session records through the OTel JS SDK only after new explicit feedback, for all users and providers, including `deepseek-official`. `FEEDBACK_ONLY` releases the canonical prefix through that feedback, including context; later records wait for the next explicit feedback. `DISABLED` constructs no transport. Scheduled batching can finish an authorized upload without another user interaction or model call. Deployments own their redaction rules.
## Table of Contents
@@ -38,7 +40,7 @@ Programmatic TypeScript configuration uses the exported `SessionTelemetryMode` e
### Minimal configuration
-Uploading modes require an exporter URL and accept the SDK option blocks verbatim:
+Uploading modes require an exporter URL. Processor settings control the independent Session-log queue; routing headers are explicit `exporter.headers` values.
```yaml
- id: sessionTelemetry-otel
@@ -46,31 +48,34 @@ Uploading modes require an exporter URL and accept the SDK option blocks verbati
config:
mode: FEEDBACK_ONLY # optional; defaults to FEEDBACK_ONLY
shutdownTimeoutMillis: 3000 # optional; defaults to 3000
- exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
+ exporter: # explicit SDK transport settings
url: https://collector.example.com/v1/logs
headers:
authorization: !!js `Bearer ${process.env.OTLP_TOKEN}`
- processor: {} # optional; passed verbatim to BatchLogRecordProcessor
+ processor: {} # optional; byte/count batching and per-request watchdog
```
| Field | Default | Meaning |
|---|---|---|
| `mode` | `FEEDBACK_ONLY` | Sharing policy: `FEEDBACK_ONLY` or `DISABLED` |
| `exporter.url` | required in uploading modes | Full OTLP logs endpoint; must parse as `http(s)` |
-| `exporter`, `processor` | — | Passed verbatim to the SDK exporter and batch processor |
-| `shutdownTimeoutMillis` | `3,000` | Outer deadline for the SDK's complete shutdown sequence |
+| `exporter`, `processor` | — | SDK transport plus byte/count batching; headers/TLS identity are not inherited from the environment. Agent factories own their returned agent settings, including keepAlive |
+| `shutdownTimeoutMillis` | `3,000` | Outer deadline for all queued HTTP requests; remaining queued sends stop at expiry |
+| `maxRequestBytes` | `4,000,000` | Maximum complete OTLP JSON request bytes before gzip; may only be lowered |
Direct `ctx.sessionTelemetry.emit()` calls are no-ops in every mode and cannot bypass feedback authorization. Inherited parent feedback does not authorize a child export: the child needs new feedback of its own. Its authorized prefix then includes inherited context.
-Model requests, request headers, Session creation or adoption, restoration, and plugin mount or HMR do not authorize capture. Stored feedback alone triggers nothing. SDK scheduled flush and shutdown may finish batches authorized earlier, but never capture new records.
+Model requests, request headers, Session creation or adoption, restoration, and plugin mount or HMR do not authorize capture. Stored feedback alone triggers nothing. Scheduled flush and shutdown may finish batches authorized earlier, but never capture new records.
### What leaves the machine
-In uploading modes, records carry the complete `event.data` as the seam's `sessionTelemetry/record` waterfall returns it — message content, tool arguments and results, the system prompt and tool schemas, todo text, compaction summaries, feedback text, and the session `cwd`. Provider credentials never appear: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. `DISABLED` constructs no SDK pipeline and hands no capture to a backend.
+Each Session event becomes one `eventName: "session-log"` record. `attributes.sessionId` is the collector Session identity; `attributes.content` encodes the complete event envelope with redacted `event.data`. JSON values are preserved, not the original JSONL bytes or key ordering. Legacy `session.id`, `event.seq`, and `event.type` metadata remain for existing consumers. Resources carry application and anonymous-user identity; scope carries the backend package name and version. The base profile uses `https://dsh-otel-collector.deepseeksvc.com/v1/logs`; `DSH_TELEMETRY_OTLP_URL` overrides it. No channel header is added implicitly.
+
+The shared OTel channel measures each record once with the SDK OTLP JSON serializer, including its resource/scope envelope, then greedily packs requests using those conservative sizes. A single oversized event produces one rejection diagnostic without truncation. Session logs never mix with product analytics in a request. Capture handoff and shutdown are not collector acknowledgements.
### Failures and shutdown
-Misconfiguration fails at plugin load: a missing or non-`http(s)` `exporter.url`, a non-positive-integer `processor.maxExportBatchSize` (which the SDK accepts but then hangs on at shutdown), and an invalid `shutdownTimeoutMillis` all reject before any record is exported. During shutdown, OTel awaits `exporter.forceFlush()` before the processor's bounded completion promise; if that transport promise never settles, this package abandons the wait at `shutdownTimeoutMillis`, logs the contained failure, and lets application teardown continue — records still pending then may be lost at process exit.
+Invalid byte limits, non-positive queue/timer values, `maxExportBatchSize > maxQueueSize`, invalid endpoints, and invalid shutdown deadlines fail at load. Processor defaults are 2,048 queued records, 512 records per request, a 1,000 ms scheduling delay, and a 30,000 ms request watchdog. Byte limits may split a count batch into multiple serial HTTP requests. Each request waits for SDK export-queue cleanup after its callback before the next starts. `exportTimeoutMillis` warns per request but never frees an unsettled transport slot; the SDK transport owns network timeout/retry. Shutdown drains these requests until `shutdownTimeoutMillis`; expiry discards the remaining queue and prevents further sends, while an active request may still settle. Large prefixes can therefore remain partially unsent at CLI exit.
-----
@@ -84,7 +89,7 @@ This section explains the backend's composition; the observable behavior is full
### Design concept
-The backend is a thin adapter over the OTel JS SDK: it owns feedback authorization, resource identity, and an outer shutdown deadline. Canonical ledger records use the `@deepseek-ai/dsh-session-telemetry-otel` instrumentation scope; this backend captures no operational records. Resource identity carries `service.name`/`service.version` from `dsh-llm`'s `APP_IDENTITY` plus the anonymous `user.id` (from `$DSH_HOME/.anonymous-user-id`), once per export batch rather than per record.
+The backend owns feedback authorization, identity, and shutdown. The shared OTel service owns channel creation, byte/count scheduling, record construction, JSON transport, compression, and retries. Resource identity carries `service.name`/`service.version` from `APP_IDENTITY` and anonymous `user.id`; the scope retains this package’s name and version.
### Source map
@@ -94,11 +99,11 @@ The backend is a thin adapter over the OTel JS SDK: it owns feedback authorizati
### Capture wiring
-The backend uses on-demand capture with stored history included. Only new own `feedback/record`, `feedback/message-put`, or `feedback/message-delete` events trigger live capture, bounded by that event. A cold `feedback/committed` notification supplies its committed canonical snapshot without publishing a live Session or Agent. Same-object handoff cursors suppress repeated capture. The backend implements no `flush()`; the SDK owns batching and shutdown drain.
+The backend captures history on demand through new own feedback events or committed cold snapshots. Its private reporter serializes queued HTTP requests even after a watchdog fires; an outer shutdown deadline stops the remaining queue. It exposes no extra flush entry point.
### Field mapping
-Each telemetry record maps to one SDK log record with captured timestamp, severity, body, and attributes. Feedback authorizes the complete unhanded prefix, not only the feedback payload.
+Each capture record supplies a separately copied event envelope and redacted payload to `SessionLogReporter`; serialization retains optional surface metadata and the event sequence/time. Feedback authorizes the complete unhanded prefix, not only the feedback payload.
@@ -148,4 +153,4 @@ None.
-**Runtime invariant:** No companion is published. Mode selection changes capture handoff, SDK setup, and local diagnostics without mutating session or service state an independent companion can compare. Export remains inside the SDK past the backend boundary.
+**Runtime invariant:** No companion is published. Mode selection changes capture handoff, SDK setup, and local diagnostics without mutating session or service state an independent companion can compare. Collector delivery cannot be inferred from local queue state.
diff --git a/packages/session/session-telemetry-otel/README.zh.md b/packages/session/session-telemetry-otel/README.zh.md
index c414e6461a..2220cc4291 100644
--- a/packages/session/session-telemetry-otel/README.zh.md
+++ b/packages/session/session-telemetry-otel/README.zh.md
@@ -9,7 +9,9 @@ kind: "package-reference"
## 概述
-`dsh-session-telemetry-otel` 仅在新的显式反馈后通过 OTel JS SDK 导出会话记录,适用于所有用户和提供方,包括 `deepseek-official`。`FEEDBACK_ONLY` 释放截至该反馈的权威日志前缀,包含上下文;后续记录等待下一次显式反馈。`DISABLED` 不构造传输。SDK 批处理可完成已授权的上传,无需另一次用户交互或模型调用。部署方负责脱敏规则。
+适配器注入 `otel`;[共享 OTel 插件](../../telemetry/otel/README.zh.md) 创建其独立 Session 日志通道。授权、脱敏、身份、scope 版本、配置和关闭期限仍由本包负责。
+
+`dsh-session-telemetry-otel` 仅在新的显式反馈后通过 OTel JS SDK 导出会话记录,适用于所有用户和提供方,包括 `deepseek-official`。`FEEDBACK_ONLY` 释放截至该反馈的权威日志前缀,包含上下文;后续记录等待下一次显式反馈。`DISABLED` 不构造传输。定时批处理可完成已授权的上传,无需另一次用户交互或模型调用。部署方负责脱敏规则。
## 目录
@@ -38,7 +40,7 @@ kind: "package-reference"
### 最小配置
-上传模式需要导出器 URL,并原样接受 SDK 选项块:
+启用上传的模式必须提供 exporter URL。processor 设置控制独立的 Session 日志队列;路由头通过 `exporter.headers` 显式配置。
```yaml
- id: sessionTelemetry-otel
@@ -46,31 +48,34 @@ kind: "package-reference"
config:
mode: FEEDBACK_ONLY # optional; defaults to FEEDBACK_ONLY
shutdownTimeoutMillis: 3000 # optional; defaults to 3000
- exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
+ exporter: # explicit SDK transport settings
url: https://collector.example.com/v1/logs
headers:
authorization: !!js `Bearer ${process.env.OTLP_TOKEN}`
- processor: {} # optional; passed verbatim to BatchLogRecordProcessor
+ processor: {} # optional; byte/count batching and per-request watchdog
```
| 字段 | 默认值 | 含义 |
|---|---|---|
| `mode` | `FEEDBACK_ONLY` | 共享策略:`FEEDBACK_ONLY` 或 `DISABLED` |
| `exporter.url` | 上传模式必填 | 完整 OTLP 日志端点;必须能解析为 `http(s)` |
-| `exporter`、`processor` | — | 原样传给 SDK 导出器与批处理器 |
-| `shutdownTimeoutMillis` | `3,000` | SDK 完整关闭序列的外层截止时间 |
+| `exporter`, `processor` | — | SDK 传输及字节/条数聚合;不继承环境中的头部和 TLS 身份。agent 工厂负责返回实例的设置,包括 keepAlive |
+| `shutdownTimeoutMillis` | `3,000` | 所有排队 HTTP 请求的外层期限;到期后停止剩余排队发送 |
+| `maxRequestBytes` | `4,000,000` | gzip 前完整 OTLP JSON 请求的最大字节数;只能调低 |
直接调用 `ctx.sessionTelemetry.emit()` 在任何模式下都是空操作,不能绕过反馈授权。继承的父会话反馈不授权子会话导出:子会话需要新的自身反馈。授权后的前缀包含继承的上下文。
-模型请求、请求头、Session 创建或接纳、恢复,以及插件挂载或 HMR(热模块替换)均不授权捕获。仅凭已存储的反馈不会触发任何操作。SDK 定时刷新和关闭可以完成先前已授权的批次,但绝不捕获新记录。
+模型请求、请求头、Session 创建或接纳、恢复,以及插件挂载或 HMR(热模块替换)均不授权捕获。仅凭已存储的反馈不会触发任何操作。定时刷新和关闭可以完成先前已授权的批次,但绝不捕获新记录。
### 哪些数据会离开本机
-在上传模式中,记录携带 seam 的 `sessionTelemetry/record` waterfall(瀑布式事件)返回的完整 `event.data`——消息内容、工具参数与结果、系统提示词与工具 schema、todo 文本、压缩(compaction)摘要、反馈文本,以及会话 `cwd`。提供方凭据绝不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不构造 SDK 流水线,也不把任何捕获内容交给后端。
+每条 Session 事件对应一个 `eventName: "session-log"` 记录。`attributes.sessionId` 是 collector 使用的 Session 身份;`attributes.content` 编码完整事件 envelope 和脱敏后的 `event.data`。保留的是 JSON 值,不保证原 JSONL 字节或键顺序相同。为现有消费者保留 `session.id`、`event.seq` 和 `event.type` 元数据。Resource 携带应用和匿名用户身份;scope 携带后端包名和版本。基础配置使用 `https://dsh-otel-collector.deepseeksvc.com/v1/logs`,可用 `DSH_TELEMETRY_OTLP_URL` 覆盖。不隐式添加 channel 头。
+
+共享 OTel 通道使用 SDK 的 OTLP JSON 序列化器对每条记录计量一次,包含其 resource/scope envelope,再按保守大小顺序组包。单条超限事件产生一次拒绝诊断且不截断。Session 日志不会与产品埋点混在一个请求中。捕获交接和关闭完成不代表 collector 确认。
### 失败与关闭
-配置错误会在插件加载时失败:缺少或非 `http(s)` 的 `exporter.url`、非正整数的 `processor.maxExportBatchSize`(SDK 会接受该值,随后却在关闭时挂起)以及无效的 `shutdownTimeoutMillis` 都会在任何记录导出前被拒绝。关闭期间,OTel 会先等待 `exporter.forceFlush()`,再等待处理器有界完成 promise;如果该传输 promise 始终不结算,本包会在 `shutdownTimeoutMillis` 到期时放弃等待、记录已隔离的失败,并让应用继续拆卸——届时仍待处理的记录可能在进程退出时丢失。
+无效字节上限、非正队列/计时值、`maxExportBatchSize > maxQueueSize`、无效 endpoint 或关闭期限在加载时失败。processor 默认队列为 2,048 条、单请求最多 512 条、调度延迟 1,000 ms、请求监测期限 30,000 ms。字节上限可能将按条数划分的批次拆成多个串行 HTTP 请求。每个请求在回调后等待 SDK 导出队列清理完成,才启动下一个请求。`exportTimeoutMillis` 针对每个请求告警,但不会释放未结束的传输槽位;SDK transport 负责网络超时和重试。关闭时发送这些请求直到 `shutdownTimeoutMillis`,到期后丢弃剩余队列并禁止后续发送,在途请求仍可能结束。因此较大的授权前缀在 CLI 退出时可能只发送了一部分。
-----
@@ -84,7 +89,7 @@ kind: "package-reference"
### 设计理念
-后端是对 OTel JS SDK 的薄适配层:它拥有反馈授权、资源身份与外层关闭截止时间。权威 ledger 记录使用 `@deepseek-ai/dsh-session-telemetry-otel` 插桩作用域;此后端不捕获运维记录。资源身份携带 `service.name`/`service.version`(来自 `dsh-llm` 的 `APP_IDENTITY`)以及匿名 `user.id`(来自 `$DSH_HOME/.anonymous-user-id`),按导出批次携带一次,而非逐条记录。
+后端负责反馈授权、身份和关闭。共享 OTel 服务负责通道创建、字节和条数调度、记录构造、JSON 传输、压缩与重试。Resource 身份携带 `APP_IDENTITY` 中的 `service.name` / `service.version` 以及匿名 `user.id`;scope 保留本包的名称和版本。
### 源码地图
@@ -94,11 +99,11 @@ kind: "package-reference"
### 捕获接线
-后端使用包含存储历史的按需捕获。只有新的自身 `feedback/record`、`feedback/message-put` 或 `feedback/message-delete` 事件触发活跃会话捕获,并以该事件为上限。冷会话 `feedback/committed` 通知提供已提交的权威快照,不发布存活 Session 或 Agent。同对象交接游标抑制重复捕获。后端不实现 `flush()`;SDK 负责批处理和关闭排空。
+后端按需捕获历史,由新的自身反馈事件或已提交的冷会话快照触发。私有 reporter 串行发送排队 HTTP 请求,即使监测期限已触发也不会重叠;外层关闭期限停止剩余队列。不额外暴露 flush 入口。
### 字段映射
-每条遥测记录映射为一条 SDK 日志记录,携带捕获的时间戳、严重级别、正文和属性。反馈授权的是尚未交接的完整前缀,而非只有反馈载荷。
+每条采集记录向 `SessionLogReporter` 提供单独复制的事件信封和脱敏后的载荷;序列化保留可选的呈现元数据及事件序号和时间。反馈授权整个尚未交接的前缀,而不只是反馈载荷。
@@ -148,4 +153,4 @@ kind: "package-reference"
-**运行时不变式:** 不发布伴生入口。模式选择只改变 capture handoff、SDK setup 与本地 diagnostics,不改变可由独立 companion 对照的会话或服务状态。导出在越过后端边界后仍由 SDK 内部处理。
+**运行时不变式:** 不发布伴生入口。模式选择只改变 capture handoff、SDK setup 与本地 diagnostics,不改变可由独立 companion 对照的会话或服务状态。无法根据本地队列状态推断 collector 是否收到记录。
diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json
index c1ae23c564..7777001d47 100644
--- a/packages/session/session-telemetry-otel/package.json
+++ b/packages/session/session-telemetry-otel/package.json
@@ -1,6 +1,6 @@
{
"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",
+ "description": "Feedback-authorized Session logs over byte-bounded OpenTelemetry HTTP requests",
"version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
@@ -27,11 +27,8 @@
],
"license": "MIT",
"dependencies": {
- "@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.220.0",
- "@opentelemetry/exporter-logs-otlp-http": "^0.220.0",
"@opentelemetry/otlp-exporter-base": "^0.220.0",
- "@opentelemetry/resources": "^2.9.0",
"@opentelemetry/sdk-logs": "^0.220.0",
"@deepseek-ai/schemastery": "workspace:~"
},
@@ -42,7 +39,8 @@
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-telemetry": "workspace:*",
"@deepseek-ai/dsh-anonymous-user-id": "workspace:*",
- "@deepseek-ai/cordis": "workspace:~"
+ "@deepseek-ai/cordis": "workspace:~",
+ "@deepseek-ai/dsh-otel": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-logger-console": "workspace:~",
@@ -60,6 +58,7 @@
"@deepseek-ai/dsh-subprocess-local": "workspace:*",
"@deepseek-ai/dsh-anonymous-user-id": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
- "@deepseek-ai/dsh-http-proxy": "workspace:*"
+ "@deepseek-ai/dsh-http-proxy": "workspace:*",
+ "@deepseek-ai/dsh-otel": "workspace:*"
}
}
diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts
index 48792dbe67..6d351a585f 100644
--- a/packages/session/session-telemetry-otel/src/index.ts
+++ b/packages/session/session-telemetry-otel/src/index.ts
@@ -1,13 +1,9 @@
/**
* OpenTelemetry Service Provider for the DeepSeek Harness telemetry capability.
*
- * 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 capture coordinator onto `logger.emit()`. After that call,
- * batching, retry, queueing, and loss policy use the SDK's documented behavior, configured
- * verbatim through the `exporter`/`processor` passthroughs. This package owns
- * capture mode and an outer shutdown deadline: the SDK's export timeout does
- * not bound its preceding `forceFlush()` wait.
+ * Authorizes feedback-bounded capture and hands complete event strings to the
+ * Session-log reporter. This plugin owns resource identity and an outer
+ * shutdown deadline; the reporter owns byte-bounded SDK delivery.
*
* @module @deepseek-ai/dsh-session-telemetry-otel
*/
@@ -28,19 +24,10 @@ import {
} from '@deepseek-ai/dsh-session-telemetry'
import { APP_IDENTITY } from '@deepseek-ai/dsh-llm'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
-import {
- BatchLogRecordProcessor,
- LoggerProvider,
- type BatchLogRecordProcessorOptions,
-} from '@opentelemetry/sdk-logs'
-import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
+import type { SessionLogReporter } from '@deepseek-ai/dsh-otel'
+import type { BatchLogRecordProcessorOptions } from '@opentelemetry/sdk-logs'
import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'
-import { SeverityNumber, type AnyValue } 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 }
+import { SeverityNumber } from '@opentelemetry/api-logs'
/** Session-sharing policy selected by {@link Config.mode}. */
export enum SessionTelemetryMode {
@@ -93,44 +80,44 @@ function sharingStatusFor(mode: SessionTelemetryMode): SessionTelemetrySharingSt
}
/**
- * Plugin configuration: one sharing policy, two verbatim SDK option objects,
- * and one DSH-owned shutdown bound. Uploading modes validate their endpoint
+ * Plugin configuration: sharing policy, SDK transport options, byte/count queue
+ * settings, and an overall shutdown bound. Uploading modes validate their endpoint
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
export interface Config {
/** Defaults to `FEEDBACK_ONLY`: capture session history only when feedback is explicitly submitted. */
mode?: SessionTelemetryMode
/**
- * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
- * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
- * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
- * is the one field this package requires and validates itself.
+ * Explicit SDK HTTP transport settings, including optional routing headers.
+ * Ambient credentials are not inherited. URL is required while uploading.
*/
exporter?: OTLPExporterNodeConfigBase & {
/** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
url?: string
}
/**
- * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
- * which this plugin fills); the SDK owns and documents these knobs.
+ * Count, queue, cadence, and per-request watchdog settings for the byte-bounded
+ * processor. A watchdog warning never releases an unsettled transport slot.
*/
processor?: Omit
/** Maximum time spent awaiting the SDK provider's complete shutdown path. */
shutdownTimeoutMillis?: number
+ /** Uncompressed OTLP request byte limit, at most 4,000,000. */
+ maxRequestBytes?: number
}
/**
* Schemastery validator for {@link Config}; cordis runs it before the plugin
- * starts. It checks only the top-level fields; value checks live in the constructor
- * so their errors name the fields. Both SDK option objects pass through unchanged:
- * the SDK defines and validates their fields. Re-declaring them here would
- * silently drop every field this plugin did not repeat.
+ * starts. The constructor validates endpoint and shutdown requirements; the
+ * reporter validates Session byte and queue limits. SDK transport and
+ * processor settings retain their upstream types.
*/
export const Config: z = z.object({
mode: z.union(Object.values(SessionTelemetryMode)).default(DEFAULT_TELEMETRY_MODE),
exporter: z.any(),
processor: z.any(),
shutdownTimeoutMillis: z.number(),
+ maxRequestBytes: z.number().step(1).min(1).max(4_000_000),
})
/** Default outer allowance for the SDK's complete shutdown sequence. */
@@ -141,10 +128,10 @@ export const DEFAULT_SHUTDOWN_TIMEOUT_MILLIS = 3_000
const MAX_TIMER_DELAY_MILLIS = 2_147_483_647
/** Severity mapping from the Service Definition'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' },
+const SEVERITY: Record = {
+ info: SeverityNumber.INFO,
+ warn: SeverityNumber.WARN,
+ error: SeverityNumber.ERROR,
}
/**
@@ -154,10 +141,10 @@ const SEVERITY: Record { ctx.logger.warn(message, error) },
})
- const ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version)
+ this.provider = reporter
const enqueue: SessionTelemetrySink['emit'] = (record) => {
- ledger.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,
+ if (record.sourceEvent === undefined) {
+ ctx.logger.warn('Session log record withheld: redaction removed sourceEvent')
+ return
+ }
+ reporter.reportSessionLog({
+ sessionId: record.sourceEvent.sessionId,
+ event: { ...record.sourceEvent.envelope, data: record.body },
+ severityNumber: SEVERITY[record.severity],
attributes: record.attributes,
})
}
@@ -273,23 +249,11 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
*/
emit(_record: SessionTelemetryRecord): void {}
- // The Service Definition's optional flush() hint is deliberately NOT implemented. The
- // batch processor exports on its own cadence (`processor.scheduledDelayMillis`,
- // the SDK's documented knob), and this backend is the SDK pipeline's only
- // caller — forwarding the hint to `forceFlush()` would be the sole source of
- // concurrent flushes, whose undocumented interactions with shutdown's
- // internal drain (concurrent-flush guard, provider-level flush timeout)
- // silently drop tail records. Rationale and the revival trigger: the
- // revival Agent Note.
-
/**
- * Ask the SDK to drain and quiesce, but reject after the backend-owned
- * deadline. OTel's processor export timeout wraps `exportCompleted` only;
- * shutdown awaits `exporter.forceFlush()` first, which can remain pending
- * when the transport never obtains a socket. The provider promise remains
- * observed after the deadline so a later rejection cannot become unhandled.
- * `DISABLED` has no provider and resolves immediately.
- * @returns resolves when the SDK pipeline quiesces or is disabled, or rejects at the configured deadline.
+ * Drain queued HTTP requests until the deployment deadline. The watchdog
+ * never releases an unsettled transport slot. At the outer deadline,
+ * queued records are abandoned and no further requests may start.
+ * @returns completion after transport shutdown, or rejection at the configured deadline.
*/
async shutdown(): Promise {
if (this.provider === undefined) return
@@ -297,6 +261,7 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
let timer: ReturnType | undefined
const deadline = new Promise((_resolve, reject) => {
timer = setTimeout(() => {
+ this.provider?.stopPending()
reject(new Error(`session-telemetry-otel: provider shutdown exceeded ${this.shutdownTimeoutMillis}ms`))
}, this.shutdownTimeoutMillis)
})
diff --git a/packages/session/session-telemetry-otel/tests/egress.spec.ts b/packages/session/session-telemetry-otel/tests/egress.spec.ts
index 72f7c35617..5dea6f9473 100644
--- a/packages/session/session-telemetry-otel/tests/egress.spec.ts
+++ b/packages/session/session-telemetry-otel/tests/egress.spec.ts
@@ -1,3 +1,4 @@
+import OTel from '@deepseek-ai/dsh-otel'
import { createServer, type Server } from 'node:http'
import { once } from 'node:events'
import { mkdtempSync, rmSync } from 'node:fs'
@@ -80,6 +81,7 @@ describe('session-telemetry-otel egress', () => {
captures.length = 0
const disposeProxy = await installProxyFromEnvironment(proxyEnv(), () => undefined)
const ctx = new Context()
+ ctx.plugin(OTel)
try {
// The positive control proves a fetch-based exporter would reach the proxy.
const response = await fetch(collectorUrl)
diff --git a/packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts b/packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts
index af28e1e892..3d0d01e64d 100644
--- a/packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts
+++ b/packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts
@@ -4,6 +4,7 @@
* only the redacted authorized prefix; the canonical log keeps every event.
*/
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { readFile, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -24,6 +25,7 @@ const FIXTURE_SECRET = 'sk-e2efixture1234567890'
const FIXTURE_PLACEHOLDER = '[E2E-REDACTED]'
interface OtlpLogRecord {
+ eventName: string
attributes?: { key: string; value: Record }[]
body?: unknown
}
@@ -102,6 +104,17 @@ describe('session-telemetry-otel through the production headless profile', () =>
const records = allRecords(output.captures)
expect(records.length).toBeGreaterThan(0)
+ for (const { record } of records) {
+ expect(record.eventName).toBe('session-log')
+ expect(record.attributes?.find(attribute => attribute.key === 'sessionId')?.value.stringValue).toBeTypeOf('string')
+ const content = record.attributes?.find(attribute => attribute.key === 'content')?.value.stringValue
+ expect(typeof content).toBe('string')
+ const event = JSON.parse(content as string) as SessionEvent
+ expect(event.type).toBeTypeOf('string')
+ expect(event.seq).toBeTypeOf('number')
+ expect(event.time).toBeTypeOf('number')
+ expect(event).toHaveProperty('data')
+ }
const types = eventTypes(output.captures)
for (const expected of ['turn/start', 'user/message', 'tool/call', 'tool/result', 'assistant/message', 'turn/end']) {
diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts
index 72a8e5ef10..86082a026b 100644
--- a/packages/session/session-telemetry-otel/tests/otel.spec.ts
+++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts
@@ -1,3 +1,4 @@
+import OTel from '@deepseek-ai/dsh-otel'
/**
* OTel backend unit tier: wire assertions against a scripted `node:http`
* mock collector through the SDK's REAL pipeline (BatchLogRecordProcessor →
@@ -102,6 +103,7 @@ async function mockCollector(
async function boot(url: string) {
const ctx = new Context()
+ ctx.plugin(OTel)
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FEEDBACK_ONLY,
@@ -187,58 +189,13 @@ describe('OpenTelemetrySessionBackend wire', () => {
expect(end?.record.severityText).toBe('ERROR')
const assistant = ledger.find(r =>
r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'assistant/message'))
- const body = assistant?.record.body as {
- kvlistValue: { values: { key: string; value: unknown }[] }
+ const content = assistant?.record.attributes?.find(a => a.key === 'content')?.value.stringValue
+ expect(typeof content).toBe('string')
+ expect(JSON.parse(content as string)).toEqual(session.snapshotEvents().find(event => event.type === 'assistant/message'))
+ for (const { record } of ledger) {
+ expect(record).toMatchObject({ eventName: 'session-log', body: { stringValue: 'session-log' } })
+ expect(record.attributes).toContainEqual({ key: 'sessionId', value: { stringValue: session.id } })
}
- expect(body.kvlistValue.values.find(value => value.key === 'stream')?.value).toEqual({
- arrayValue: {
- values: [
- {
- kvlistValue: {
- values: [
- { key: 'type', value: { stringValue: 'text-chunks' } },
- { key: 'time0', value: { intValue: 1_000 } },
- { key: 'index', value: { intValue: 0 } },
- { key: 'dt', value: { arrayValue: { values: [{ intValue: 7 }] } } },
- {
- key: 'texts',
- value: {
- arrayValue: {
- values: [
- { stringValue: 'first complete chunk' },
- { stringValue: 'second complete chunk' },
- ],
- },
- },
- },
- ],
- },
- },
- {
- kvlistValue: {
- values: [
- { key: 'type', value: { stringValue: 'chunk' } },
- { key: 'time', value: { intValue: 1_007 } },
- {
- key: 'chunk',
- value: {
- kvlistValue: {
- values: [
- { key: 'type', value: { stringValue: 'finish' } },
- {
- key: 'reason',
- value: { kvlistValue: { values: [{ key: 'kind', value: { stringValue: 'stop' } }] } },
- },
- ],
- },
- },
- },
- ],
- },
- },
- ],
- },
- })
expect(eventTypes(captures)).not.toContain('manual')
expect(eventTypes(captures)).toEqual(session.snapshotEvents().map(event => event.type))
expect(ops).toHaveLength(0)
@@ -255,6 +212,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
}
})
const ctx = new Context()
+ ctx.plugin(OTel)
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FEEDBACK_ONLY,
@@ -280,7 +238,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
expect(allRecords(captures).some(r => r.scope.endsWith('/ops'))).toBe(false)
})
- it('bounds the SDK forceFlush wait when an in-flight transport never settles', async () => {
+ it('stops queued sends at the shutdown deadline while observing the active transport', async () => {
const gate = Promise.withResolvers()
const arrived = Promise.withResolvers()
const { url, captures } = await mockCollector(async (index) => {
@@ -290,6 +248,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
}
})
const ctx = new Context()
+ ctx.plugin(OTel)
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FEEDBACK_ONLY,
@@ -313,12 +272,14 @@ describe('OpenTelemetrySessionBackend wire', () => {
// the real provider promise remains clean after the test has proved the
// Cordis disposer no longer waits for it.
gate.resolve(true)
- await expect.poll(() => captures.length).toBeGreaterThanOrEqual(2)
+ await expect.poll(() => captures.length).toBe(1)
+ expect(JSON.stringify(captures)).not.toContain('second report')
})
it('passes exporter options beyond url and headers through to the SDK exporter', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
+ ctx.plugin(OTel)
await ctx.plugin(SessionStore)
// `compression` is a documented SDK exporter option; the advertised
// verbatim passthrough must hand it (and every other field) to the
@@ -360,6 +321,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
it('replays each session suffix only at the next feedback event', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
+ ctx.plugin(OTel)
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FEEDBACK_ONLY,
@@ -396,6 +358,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
it('ignores direct emits and non-canonical feedback in feedback-only mode', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
+ ctx.plugin(OTel)
await ctx.plugin(SessionStore)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
@@ -425,6 +388,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
it('constructs no disabled transport even when exporter options are present', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
+ ctx.plugin(OTel)
await ctx.plugin(SessionStore)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
@@ -458,18 +422,24 @@ describe('OpenTelemetrySessionBackend wire', () => {
const { url, captures } = await mockCollector()
const gatedCtx = new Context()
+
+ gatedCtx.plugin(OTel)
await gatedCtx.plugin(SessionStore)
const gated = await gatedCtx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url } })
expect(gatedCtx.sessionTelemetry.sharing).toBe('feedback-only')
await gated.dispose()
const disabledCtx = new Context()
+
+ disabledCtx.plugin(OTel)
await disabledCtx.plugin(SessionStore)
const disabled = await disabledCtx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.DISABLED })
expect(disabledCtx.sessionTelemetry.sharing).toBe('disabled')
await disabled.dispose()
const defaultCtx = new Context()
+
+ defaultCtx.plugin(OTel)
await defaultCtx.plugin(SessionStore)
const defaulted = await defaultCtx.plugin(OpenTelemetrySessionBackend, { exporter: { url } })
expect(defaultCtx.sessionTelemetry.sharing).toBe('feedback-only')
@@ -482,6 +452,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
it('defaults direct construction to feedback-only delivery', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
+ ctx.plugin(OTel)
try {
await ctx.plugin(SessionStore)
new OpenTelemetrySessionBackend(ctx, {
@@ -508,6 +479,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => {
it.each(['deepseek-official', 'mock', undefined])('uploads new text feedback for %s while the host stays alive', async (provider) => {
const { url, captures } = await mockCollector()
const ctx = new Context()
+ ctx.plugin(OTel)
try {
await ctx.plugin(SessionStore)
await ctx.plugin(OpenTelemetrySessionBackend, {
@@ -532,6 +504,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => {
it('does not upload ordinary events, inherited feedback, new/open/resume/fork or HMR', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
+ ctx.plugin(OTel)
await ctx.plugin(SessionStore)
const donor = Session.create(SessionId('stored-feedback'))
recordFeedback(donor, { text: 'old feedback is not a submission' })
@@ -561,6 +534,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => {
const { url, captures } = await mockCollector()
const root = mkdtempSync(join(tmpdir(), 'dsh-otel-live-'))
const ctx = new Context()
+ ctx.plugin(OTel)
try {
await ctx.plugin(SessionStore)
await ctx.plugin(JsonlPersistence, { root, compression: 'none' })
@@ -637,6 +611,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => {
const { url, captures } = await mockCollector()
const root = mkdtempSync(join(tmpdir(), 'dsh-otel-cold-fork-'))
const ctx = new Context()
+ ctx.plugin(OTel)
try {
await ctx.plugin(SessionStore)
await ctx.plugin(JsonlPersistence, { root, compression: 'none' })
@@ -692,6 +667,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => {
const { url, captures } = await mockCollector()
const root = mkdtempSync(join(tmpdir(), 'dsh-otel-cold-'))
const ctx = new Context()
+ ctx.plugin(OTel)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
try {
await ctx.plugin(SessionStore)
@@ -775,12 +751,14 @@ describe('OpenTelemetrySessionBackend config fails loud', () => {
[{ mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/],
])('rejects %j at plugin load', async (config, message) => {
const ctx = new Context()
+ ctx.plugin(OTel)
await ctx.plugin(SessionStore)
await expect(ctx.plugin(OpenTelemetrySessionBackend, config as Config)).rejects.toThrow(message)
})
it.each(['INVALID', 'FULL'])('rejects direct mode %s before reading transport config', async (mode) => {
const ctx = new Context()
+ ctx.plugin(OTel)
await ctx.plugin(SessionStore)
let exporterRead = false
const config = {
@@ -797,6 +775,7 @@ describe('OpenTelemetrySessionBackend config fails loud', () => {
it('does not read any transport setting in disabled mode', async () => {
const ctx = new Context()
+ ctx.plugin(OTel)
await ctx.plugin(SessionStore)
const transportRead = vi.fn(() => {
throw new Error('transport config was read')
@@ -826,7 +805,7 @@ describe('dsh-session-telemetry-otel real-load-path guard', () => {
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(module) as typeof OpenTelemetrySessionBackend
expect(unwrapped).toBe(OpenTelemetrySessionBackend)
- expect(unwrapped.inject).toEqual(['sessions'])
+ expect(unwrapped.inject).toEqual(['sessions', 'otel'])
expect(typeof unwrapped.Config).toBe('function')
})
@@ -836,9 +815,48 @@ describe('dsh-session-telemetry-otel real-load-path guard', () => {
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(module) as Parameters[0]
const ctx = new Context()
+ ctx.plugin(OTel)
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(unwrapped, { mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url } })
expect(ctx.sessionTelemetry).toBeInstanceOf(OpenTelemetrySessionBackend)
await fiber.dispose()
})
})
+
+it('reports oversized records without failing feedback or exporting their contents', async () => {
+ const { url, captures } = await mockCollector()
+ const ctx = new Context()
+ ctx.plugin(OTel)
+ try {
+ await ctx.plugin(SessionStore)
+ const warn = vi.spyOn(ctx.logger, 'warn')
+ const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
+ mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url }, maxRequestBytes: 1,
+ })
+ const session = ctx.sessions.create(SessionId('oversized'))
+ expect(() => { recordFeedback(session, { text: 'feedback remains local' }) }).not.toThrow()
+ await fiber.dispose()
+ expect(captures).toEqual([])
+ expect(warn).toHaveBeenCalledWith('Session log record rejected; content was not truncated', expect.any(Error))
+ } finally {
+ await ctx.fiber.dispose()
+ }
+})
+
+it('does not upload a record whose redaction policy removes its canonical envelope', async () => {
+ const { url, captures } = await mockCollector()
+ const { ctx, fiber } = await boot(url)
+ const warn = vi.spyOn(ctx.logger, 'warn')
+ try {
+ ctx.on('session-telemetry/record', (_record, next) => {
+ const { sourceEvent: _sourceEvent, ...redacted } = next()
+ return redacted
+ })
+ recordFeedback(ctx.sessions.create(SessionId('withheld-envelope')), { text: 'withheld' })
+ await fiber.dispose()
+ expect(captures).toEqual([])
+ expect(warn).toHaveBeenCalledWith('Session log record withheld: redaction removed sourceEvent')
+ } finally {
+ await ctx.fiber.dispose()
+ }
+})
diff --git a/packages/session/session-telemetry-otel/tsconfig.json b/packages/session/session-telemetry-otel/tsconfig.json
index 87f43bdc9a..08f78d0e6c 100644
--- a/packages/session/session-telemetry-otel/tsconfig.json
+++ b/packages/session/session-telemetry-otel/tsconfig.json
@@ -34,6 +34,9 @@
},
{
"path": "../../identity/anonymous-user-id"
+ },
+ {
+ "path": "../../telemetry/otel"
}
]
}
diff --git a/packages/session/session-telemetry/README.i18n.yaml b/packages/session/session-telemetry/README.i18n.yaml
index 894c36f1e5..dda64730e1 100644
--- a/packages/session/session-telemetry/README.i18n.yaml
+++ b/packages/session/session-telemetry/README.i18n.yaml
@@ -24,8 +24,8 @@
en: e3cc8ee7774a0ad5
zh: 3c039339870b8cf9
/deepseek-ai-dsh-session-telemetry/use-this-package/what-gets-captured:
- en: ed96f8ed3cf72fe8
- zh: b7f2b546b8639f43
+ en: 5b7fbbe1c55997e8
+ zh: d62d9118fd8df2f4
/deepseek-ai-dsh-session-telemetry/use-this-package/the-sharing-disclosure:
en: 5c325a8aaadf17c7
zh: 5c493d2333f82b28
@@ -36,8 +36,8 @@
en: cad1e9d195916553
zh: 2192f3ce5e2196bd
/deepseek-ai-dsh-session-telemetry/understand-the-implementation/design-concept:
- en: 3bac6e75e9e442eb
- zh: 88062e9d93a782eb
+ en: 56c449be11e6bac1
+ zh: 6b62969e93313c3e
/deepseek-ai-dsh-session-telemetry/understand-the-implementation/source-map:
en: 5dc345ec3dda38b8
zh: 189d673734a2c20e
diff --git a/packages/session/session-telemetry/README.md b/packages/session/session-telemetry/README.md
index c7d7668641..14d697b93c 100644
--- a/packages/session/session-telemetry/README.md
+++ b/packages/session/session-telemetry/README.md
@@ -39,6 +39,8 @@ A backend implements three members: `emit(record)` must be a non-blocking enqueu
Capture runs in one of two modes. `live` capture follows session events as they are appended, replays already-live sessions at mount time, and records lifecycle markers; `on-demand` capture reads the canonical session log only when the backend requests a prefix through `captureSession(session, throughSeq?)`. Coordinator options select whether stored history is included. Every canonical session event maps to one ledger record in order. An `assistant/message` or `assistant/attempt` record carries its complete embedded compact stream, including failed and retried output. Each ledger record also carries `session.id`, `session.format_version`, the numeric event identity, optional header facts, and a pre-mapped severity (`error` for `tool/result.isError`, `turn/end` error reasons, and `agent-error`; `info` otherwise).
+Ledger records carry `sourceEvent` with the owning Session id and a copied event envelope excluding `data`. The redaction waterfall receives `body` as the only payload copy. Backends can reconstruct a complete exported event from the envelope and redacted body without bypassing redaction.
+
### The sharing disclosure
@@ -63,7 +65,7 @@ This section explains the capture design; the observable behavior is fully cover
### Design concept
-The seam is built on one boundary: the harness's aspect ends at `emit()`. Complete event capture, redaction, and the handoff cursor live here; batching, retry, queueing, and loss policy are the reporting SDK's, deliberately not modelled or wrapped. The design and rejected alternatives are pinned in the [revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md).
+The capture package owns complete event capture, redaction, and handoff cursors. Redaction rules must preserve `sourceEvent` for OTel upload; returning a fresh record without it withholds the event with a diagnostic. Its cloned envelope excludes `data`, which is carried only in `body`. The OTel backend owns byte/count scheduling and uses SDK transport/retries. The [revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) owns capture and redaction rationale.
### Source map
diff --git a/packages/session/session-telemetry/README.zh.md b/packages/session/session-telemetry/README.zh.md
index 2e9bc7e661..5ac9405d53 100644
--- a/packages/session/session-telemetry/README.zh.md
+++ b/packages/session/session-telemetry/README.zh.md
@@ -39,6 +39,8 @@ kind: "package-library"
捕获以两种模式之一运行。`live` 捕获在追加时跟随会话事件、在挂载时回放已存活会话并记录生命周期标记;`on-demand` 捕获只在后端通过 `captureSession(session, throughSeq?)` 请求前缀时读取权威会话日志。协调器选项决定是否包含存储历史。每条权威会话事件都按顺序映射为一条 ledger 记录。`assistant/message` 或 `assistant/attempt` 记录会携带完整的嵌入式紧凑流,包括失败和重试输出。每条 ledger 记录还携带 `session.id`、`session.format_version`、数值型事件标识、可选 header 事实与预先映射的严重级别(`tool/result.isError`、`turn/end` 的错误原因与 `agent-error` 映射为 `error`;其余为 `info`)。
+ledger 记录携带 `sourceEvent`,其中包含所属 Session id 和不含 `data` 的事件信封副本。脱敏 waterfall 接收的 `body` 是唯一的载荷副本。后端可从信封和脱敏后的 body 重建完整导出事件,而不绕过脱敏。
+
### 共享披露
@@ -63,7 +65,7 @@ kind: "package-library"
### 设计理念
-seam 建立在一个边界之上:harness 的职责止于 `emit()`。完整事件捕获、脱敏与 handoff 游标都在这里;批处理、重试、排队与丢失策略属于上报 SDK,本包有意不建模也不包装。设计与被否决的替代方案见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md)。
+捕获包负责完整事件捕获、脱敏和交接游标。脱敏规则必须保留 `sourceEvent` 才能通过 OTel 上传;返回不含它的新记录会阻止该事件上传并产生诊断。复制的 envelope 不含 `data`,数据仅由 `body` 携带。OTel 后端负责字节/条数调度并使用 SDK 传输和重试。[恢复遥测决策记录](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md) 说明捕获和脱敏依据。
### 源码地图
diff --git a/packages/session/session-telemetry/src/coordinator.ts b/packages/session/session-telemetry/src/coordinator.ts
index 7f3b5d6052..fa21a5ae41 100644
--- a/packages/session/session-telemetry/src/coordinator.ts
+++ b/packages/session/session-telemetry/src/coordinator.ts
@@ -178,15 +178,17 @@ export class SessionTelemetryCoordinator {
/** Copy, redact, and hand one canonical event to the backend. */
private captureEvent(session: Session, event: SessionEvent): void {
+ const { data, ...envelope } = event
this.deliver(session, {
record: this.redact({
+ sourceEvent: { sessionId: session.id, envelope: structuredClone(envelope) },
channel: 'ledger',
time: event.time,
severity: severityOf(event),
attributes: identityOf(session, event),
// The canonical event object is mutable and the backend serializes
// later; append-time validation guarantees this clone cannot throw.
- body: structuredClone(event.data),
+ body: structuredClone(data),
}),
seq: event.seq,
})
diff --git a/packages/session/session-telemetry/src/index.ts b/packages/session/session-telemetry/src/index.ts
index 392df907f7..54347e4a16 100644
--- a/packages/session/session-telemetry/src/index.ts
+++ b/packages/session/session-telemetry/src/index.ts
@@ -7,7 +7,7 @@
* forwarding), live versus on-demand canonical-log capture, and the HMR
* cursor. Everything downstream of
* {@link SessionTelemetryBackend.emit} — batching, retry, queueing, and loss policy — is the
- * reporting SDK's territory and is deliberately not modelled here. The
+ * backend's responsibility 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.
*
@@ -15,6 +15,7 @@
*/
import { Context, Service } from '@deepseek-ai/cordis'
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/cordis' {
interface Context {
@@ -62,6 +63,8 @@ export type SessionTelemetrySeverity = 'info' | 'warn' | 'error'
* identity so they can never be mistaken for ledger rows.
*/
export interface SessionTelemetryRecord {
+ /** Canonical envelope without data; body carries the separately redacted payload. Absent for operational records. */
+ sourceEvent?: { sessionId: SessionId; envelope: Omit }
/** 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. */
diff --git a/packages/session/session-telemetry/tests/telemetry.spec.ts b/packages/session/session-telemetry/tests/telemetry.spec.ts
index 2d8c02ed64..aa02cda863 100644
--- a/packages/session/session-telemetry/tests/telemetry.spec.ts
+++ b/packages/session/session-telemetry/tests/telemetry.spec.ts
@@ -135,6 +135,12 @@ describe('SessionTelemetryCoordinator capture', () => {
;(message.body as { content: { text: string }[] }).content[0]!.text = 'tampered'
const logged = session.snapshotEvents()[1] as SessionEvent<'user/message'>
expect(logged.data.content[0]).toMatchObject({ text: 'hello' })
+ expect(message.sourceEvent?.sessionId).toBe(session.id)
+ expect(message.sourceEvent?.envelope).not.toHaveProperty('data')
+ expect(message.sourceEvent?.envelope).toMatchObject({ seq: logged.seq, time: logged.time, type: logged.type, surfaceOp: 'append' })
+ expect(message.sourceEvent?.envelope).not.toBe(logged)
+ message.sourceEvent!.envelope.time = 0
+ expect(logged.time).not.toBe(0)
})
it('captures a live request header without replaying previously withheld events', async () => {
diff --git a/packages/telemetry/README.i18n.yaml b/packages/telemetry/README.i18n.yaml
new file mode 100644
index 0000000000..8febf5c4f2
--- /dev/null
+++ b/packages/telemetry/README.i18n.yaml
@@ -0,0 +1,25 @@
+# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/telemetry/README.md
+/:
+ en: 53661eb6551dbf31
+ zh: 8cc1a84f4cb8a69f
+/telemetry-shared-reporting:
+ en: 0c5ec106d3250ce7
+ zh: ab9e19ea56f1f75f
+/telemetry-shared-reporting/summary:
+ en: 9dbe398017c89288
+ zh: 91b8c2de8176de4f
+/telemetry-shared-reporting/table-of-contents:
+ en: 09848f4d056e4fc1
+ zh: 8df6139cff0288f6
+/telemetry-shared-reporting/packages:
+ en: 0bc9ee219231fa9a
+ zh: 873150749266f259
+/telemetry-shared-reporting/related-documentation:
+ en: f550e93c6228156b
+ zh: 7414955502825abd
+/telemetry-shared-reporting/dev-note:
+ en: b75c8458928b72b9
+ zh: 003ba4e17b4b5269
diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md
new file mode 100644
index 0000000000..e77964022e
--- /dev/null
+++ b/packages/telemetry/README.md
@@ -0,0 +1,35 @@
+---
+description: "Shared telemetry transport plugins for business consumers."
+kind: "package-group"
+---
+
+# telemetry/ — shared reporting
+
+English | [中文](README.zh.md)
+
+## Summary
+
+This group provides reporting infrastructure shared by product analytics and feedback-authorized Session uploads. Business plugins select events, identities, authorization, and redaction; transport plugins own encoding and delivery.
+
+## Table of Contents
+
+- [Packages](#packages)
+- [Related documentation](#related-documentation)
+- [Dev Note](#dev-note)
+
+
+## Packages
+
+| Package | Role |
+|---|---|
+| [`otel`](otel/README.md) | Cordis service creating independent ordinary-event and byte-bounded Session-log channels |
+
+
+## Related documentation
+
+- [OTel subsystem](../../docs/subsystems/otel.md) — shared service and consumer ownership.
+
+
+## Dev Note
+
+None.
diff --git a/packages/telemetry/README.zh.md b/packages/telemetry/README.zh.md
new file mode 100644
index 0000000000..61e3ecd3df
--- /dev/null
+++ b/packages/telemetry/README.zh.md
@@ -0,0 +1,35 @@
+---
+description: "供业务调用方共享的遥测传输插件。"
+kind: "package-group"
+---
+
+# telemetry/ — 共享上报
+
+[English](README.md) | 中文
+
+## 概述
+
+本组提供产品埋点和反馈授权 Session 上传共用的上报基础设施。业务插件选择事件、身份、授权和脱敏规则;传输插件负责编码和投递。
+
+## 目录
+
+- [包](#packages)
+- [相关文档](#related-documentation)
+- [开发备注](#dev-note)
+
+
+## 包
+
+| 包 | 职责 |
+|---|---|
+| [`otel`](otel/README.zh.md) | 创建独立普通事件通道和受字节上限约束的 Session 日志通道的 Cordis 服务 |
+
+
+## 相关文档
+
+- [OTel 子系统](../../docs/subsystems/otel.zh.md) — 共享服务与调用方职责。
+
+
+## 开发备注
+
+无。
diff --git a/packages/telemetry/otel/README.i18n.yaml b/packages/telemetry/otel/README.i18n.yaml
new file mode 100644
index 0000000000..3eee76e8c9
--- /dev/null
+++ b/packages/telemetry/otel/README.i18n.yaml
@@ -0,0 +1,37 @@
+# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/telemetry/otel/README.md
+/:
+ en: 49f35d8bc0fe3689
+ zh: ef2fd903cca7d129
+/deepseek-ai-dsh-otel:
+ en: 925bc4650e9895d6
+ zh: 02f8917c4048f6e2
+/deepseek-ai-dsh-otel/summary:
+ en: e32c122f34c04593
+ zh: bea48183ab438454
+/deepseek-ai-dsh-otel/table-of-contents:
+ en: 23386452a66b2fbd
+ zh: 55b2121ae6059425
+/deepseek-ai-dsh-otel/use-this-package:
+ en: a295efbb5b488489
+ zh: 4baa7f0f78ce862d
+/deepseek-ai-dsh-otel/understand-the-implementation:
+ en: 79e1c09af1c84c06
+ zh: 10bf2f50a59c01f6
+/deepseek-ai-dsh-otel/further-exploration:
+ en: ee17b991de098cc0
+ zh: f90257e411921f2b
+/deepseek-ai-dsh-otel/model-experience:
+ en: 5b364b9451ced69f
+ zh: 025feb91ef96bdb8
+/deepseek-ai-dsh-otel/model-experience/kv-cache-effect:
+ en: 8be9251f0d5256ed
+ zh: d46143a02855c8bc
+/deepseek-ai-dsh-otel/known-limitations-and-deferred-work:
+ en: 5c3e29312d45ee52
+ zh: b148e3a6decc0f7d
+/deepseek-ai-dsh-otel/known-limitations-and-deferred-work/dev-note:
+ en: 0a1253c6f7b5e8b9
+ zh: 9f56528562c7c914
diff --git a/packages/telemetry/otel/README.md b/packages/telemetry/otel/README.md
new file mode 100644
index 0000000000..2f8bc0fe43
--- /dev/null
+++ b/packages/telemetry/otel/README.md
@@ -0,0 +1,70 @@
+---
+description: "Create independent OTLP reporting channels through the shared Cordis OTel service."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-otel
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Mount one `otel` service to create ordinary-event and Session-log reporting channels. Each channel has its own exporter, resource, instrumentation scope, and queue. Mounting alone creates no transport or identity and sends nothing. Business consumers own authorization, redaction, field selection, and their channel's disposal.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+
+## Use this package
+
+The base bundle mounts `@deepseek-ai/dsh-otel`. Independent compositions must mount it before consumers that inject `otel`. Call `ctx.otel.createEventReporter(options)` for ordinary analytics or `ctx.otel.createSessionLogReporter(options)` for complete Session events. Options explicitly supply endpoint, scope, resource attributes, queue settings, and a diagnostic callback; the service has no deployment defaults or automatic collection policy of its own.
+
+The returned channel belongs to the consumer. Register shutdown with the consumer's Cordis fiber and bound the complete drain with its configured deadline. Injection makes service replacement unload dependent consumers. Do not retain a channel after its consumer unloads. The product and Session adapters implement this ownership for UI and feedback callers.
+
+Ordinary channels use SDK count-based batching. Session channels preserve one complete event per record with `eventName: "session-log"`, `sessionId`, and a JSON string `content`, and enforce at most 4,000,000 uncompressed request bytes. Their byte limits, single-record rejection, serial transport settlement, and shutdown behavior are specified by the [Session adapter](../../session/session-telemetry-otel/README.md). A Session channel never shares a request or queue with ordinary events.
+
+Headers are explicit. The service does not add a channel header or inherit ambient authorization headers or TLS identity. Agent factories own their returned agents, including keepAlive. Scope names and versions are supplied by each consumer, so transport sharing does not change event attribution.
+
+
+## Understand the implementation
+
+`src/index.ts` registers the service; `event-log.ts` owns ordinary SDK batching; `session-log.ts` owns byte/count scheduling; `transport.ts` supplies the SDK JSON HTTP delegate. No global OTel provider is installed. The Session processor measures each record once, groups conservative sizes, and waits for the SDK concurrency queue to clear after every callback before sending another request.
+
+No runtime invariant companion is published: channel creation registers no independent domain state, and collector delivery cannot be inferred from local queues. Composition tests exercise independent channels and service removal; adapter tests cover dependent-fiber cleanup and feedback authorization.
+
+
+## Further Exploration
+
+- [OTel subsystem](../../../docs/subsystems/otel.md) — service ownership and API.
+- [Product adapter](../../host/product-telemetry-otel/README.md) — explicit analytics policy and configuration.
+- [Session adapter](../../session/session-telemetry-otel/README.md) — feedback authorization and configured upload limits.
+
+
+## Model Experience
+
+None, as the service delivers caller-selected records without changing model context.
+
+#### KV Cache effect
+
+None; reporting does not change model requests.
+
+## Known Limitations and Deferred Work
+
+
+
+Channels provide best-effort, memory-only delivery with the following limits.
+
+- The caller owns consent, redaction, resource identity, renderer-to-host RPC, and the outer shutdown deadline.
+- Queue overflow, oversized Session events, transport failure, and process exit can lose records. There is no durable outbox or warehouse acknowledgement.
+- Ordinary-event channels batch by count; their consumers must select event and batch sizes suitable for the collector.
+
+
+### Dev Note
+
+None.
diff --git a/packages/telemetry/otel/README.zh.md b/packages/telemetry/otel/README.zh.md
new file mode 100644
index 0000000000..111f30fc4b
--- /dev/null
+++ b/packages/telemetry/otel/README.zh.md
@@ -0,0 +1,70 @@
+---
+description: "通过共享 Cordis OTel 服务创建独立的 OTLP 上报通道。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-otel
+
+[English](README.md) | 中文
+
+## 概述
+
+挂载一个 `otel` 服务即可创建普通事件和 Session 日志上报通道。每个通道拥有独立的 exporter、resource、instrumentation scope 和队列。仅挂载不会创建传输、身份或发送数据。业务调用方负责授权、脱敏、字段选择及通道销毁。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与后续工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+
+## 使用本包
+
+base bundle 挂载 `@deepseek-ai/dsh-otel`。独立组合必须在注入 `otel` 的调用方之前挂载它。普通埋点调用 `ctx.otel.createEventReporter(options)`,完整 Session 事件调用 `ctx.otel.createSessionLogReporter(options)`。选项显式提供 endpoint、scope、resource attributes、队列设置和诊断回调;服务本身没有部署默认值或自动采集策略。
+
+返回的通道由调用方持有。将关闭注册到调用方的 Cordis fiber,并用配置的期限约束完整清空过程。注入机制使服务替换时卸载依赖方。调用方卸载后不得继续保留通道。产品和 Session 适配器为 UI 和反馈调用方实现这些职责。
+
+普通通道使用 SDK 按条数聚合。Session 通道将每条完整事件保留为一个记录,包含 `eventName: "session-log"`、`sessionId` 和 JSON 字符串 `content`,并保证未压缩请求不超过 4,000,000 字节。字节限制、单条拒绝、串行传输结束和关闭行为见 [Session 适配器](../../session/session-telemetry-otel/README.zh.md)。Session 通道不会与普通事件共享请求或队列。
+
+Header 显式提供。服务不会附加 channel header,也不继承环境中的授权 header 或 TLS 身份。Agent factory 负责返回的 agent,包括 keepAlive。每个调用方提供 scope 名称和版本,因此共享传输不会改变事件归属。
+
+
+## 理解实现
+
+`src/index.ts` 注册服务;`event-log.ts` 负责普通事件的 SDK 聚合;`session-log.ts` 负责字节和条数调度;`transport.ts` 提供 SDK JSON HTTP delegate。不安装全局 OTel provider。Session processor 对每条记录只计量一次,按保守大小组包,并在每次回调后等待 SDK 并发队列清理完成才发送下一请求。
+
+不发布运行时不变式伴生入口:通道创建不注册独立领域状态,无法从本地队列推断 collector 是否收到记录。组合测试覆盖独立通道和服务移除;适配器测试覆盖依赖 fiber 清理与反馈授权。
+
+
+## 进一步探索
+
+- [OTel 子系统](../../../docs/subsystems/otel.zh.md) — 服务职责与 API。
+- [产品适配器](../../host/product-telemetry-otel/README.zh.md) — 显式埋点策略与配置。
+- [Session 适配器](../../session/session-telemetry-otel/README.zh.md) — 反馈授权和上传限制配置。
+
+
+## 模型体验
+
+无,因为服务只投递调用方选择的记录,不改变模型上下文。
+
+#### KV Cache 影响
+
+无;上报不改变模型请求。
+
+## 已知限制与后续工作
+
+
+
+通道提供尽力而为的纯内存投递,具有以下限制。
+
+- 调用方负责同意、脱敏、resource 身份、renderer 到 host 的 RPC 及外层关闭期限。
+- 队列溢出、Session 事件超限、传输失败和进程退出可能丢失记录。不提供持久 outbox 或仓库确认。
+- 普通事件通道按条数聚合;调用方必须选择适合 collector 的事件和批次大小。
+
+
+### 开发备注
+
+无。
diff --git a/packages/telemetry/otel/package.json b/packages/telemetry/otel/package.json
new file mode 100644
index 0000000000..64d065bd62
--- /dev/null
+++ b/packages/telemetry/otel/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@deepseek-ai/dsh-otel",
+ "description": "Cordis service for independent ordinary-event and byte-bounded Session-log OTLP channels",
+ "version": "0.1.7-rc.2",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+ "directory": "packages/telemetry/otel"
+ },
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/types/**/*.d.ts"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@opentelemetry/api": "^1.9.1",
+ "@opentelemetry/api-logs": "^0.220.0",
+ "@opentelemetry/otlp-exporter-base": "^0.220.0",
+ "@opentelemetry/resources": "^2.9.0",
+ "@opentelemetry/sdk-logs": "^0.220.0",
+ "@opentelemetry/core": "2.9.0",
+ "@opentelemetry/otlp-transformer": "^0.220.0"
+ },
+ "peerDependencies": {
+ "@deepseek-ai/cordis": "workspace:~",
+ "@deepseek-ai/dsh-session": "workspace:*"
+ },
+ "devDependencies": {
+ "@deepseek-ai/cordis": "workspace:~",
+ "@deepseek-ai/dsh-session": "workspace:*"
+ }
+}
diff --git a/packages/telemetry/otel/src/event-log.ts b/packages/telemetry/otel/src/event-log.ts
new file mode 100644
index 0000000000..4f57f5fb1e
--- /dev/null
+++ b/packages/telemetry/otel/src/event-log.ts
@@ -0,0 +1,77 @@
+/** Ordinary event batching over an explicit SDK HTTP transport. */
+import type { Attributes } from '@opentelemetry/api'
+import { SeverityNumber, type Logger } from '@opentelemetry/api-logs'
+import { ExportResultCode } from '@opentelemetry/core'
+import { resourceFromAttributes } from '@opentelemetry/resources'
+import { BatchLogRecordProcessor, LoggerProvider, type BatchLogRecordProcessorOptions } from '@opentelemetry/sdk-logs'
+import type { SessionLogOptions } from './session-log.ts'
+import { createLogExporter } from './transport.ts'
+
+/** Scalar values accepted by the collector's Arrow attributes map. */
+export type OTelEventScalar = string | number | boolean
+
+/** Explicitly selected analytics fields; object values may contain scalars only. */
+export interface OTelEventRecord {
+ /** Product/DA-owned event name. */
+ eventName: string
+ /** Human-readable summary; never a prompt, response, credential, or file contents. */
+ body: string
+ /** Event occurrence time in Unix milliseconds. Observation time is assigned on enqueue. */
+ timestamp: number
+ /** OTel severity; omitted values use INFO. */
+ severityNumber?: SeverityNumber
+ /** Business fields selected by the caller; no automatic device or account identity. */
+ attributes?: Record>
+}
+
+/** Ordinary-event transport, resource, scope, and count-batching options. */
+export interface EventLogOptions {
+ exporter: SessionLogOptions['exporter']
+ resourceAttributes: Attributes
+ scope: { name: string; version?: string }
+ processor: Omit
+ onFailure: SessionLogOptions['onFailure']
+}
+
+/** One caller-owned ordinary-event queue, independent of every Session-log queue. */
+export class EventLogReporter {
+ private readonly provider: LoggerProvider
+ private readonly logger: Logger
+
+ /** @param options - explicit transport, resource, scope, queue, and diagnostic settings. */
+ constructor(options: EventLogOptions) {
+ const exporter = createLogExporter(options.exporter)
+ this.provider = new LoggerProvider({
+ resource: resourceFromAttributes(options.resourceAttributes),
+ processors: [new BatchLogRecordProcessor({
+ ...options.processor,
+ exporter: {
+ export: (records, callback) => {
+ exporter.export(records, (result) => {
+ if (result.code !== ExportResultCode.SUCCESS) options.onFailure('Product telemetry export failed', result.error)
+ callback(result)
+ })
+ },
+ forceFlush: () => exporter.forceFlush(),
+ shutdown: () => exporter.shutdown(),
+ },
+ })],
+ })
+ this.logger = this.provider.getLogger(options.scope.name, options.scope.version)
+ }
+
+ /**
+ * Enqueue caller-selected analytics fields without acknowledging delivery.
+ * @param record - the ordinary event to report.
+ */
+ emit(record: OTelEventRecord): void {
+ const severityNumber = record.severityNumber ?? SeverityNumber.INFO
+ this.logger.emit({ ...record, observedTimestamp: Date.now(), severityNumber, severityText: SeverityNumber[severityNumber] })
+ }
+
+ /**
+ * Drain the queue and release its SDK transport.
+ * @returns completion of SDK shutdown; callers own their outer deadline.
+ */
+ shutdown(): Promise { return this.provider.shutdown() }
+}
diff --git a/packages/telemetry/otel/src/index.ts b/packages/telemetry/otel/src/index.ts
new file mode 100644
index 0000000000..607b0c2908
--- /dev/null
+++ b/packages/telemetry/otel/src/index.ts
@@ -0,0 +1,34 @@
+/** Cordis entry for independent ordinary-event and Session-log OTLP channels. */
+import { Context, Service } from '@deepseek-ai/cordis'
+import { EventLogReporter, type EventLogOptions } from './event-log.ts'
+import { SessionLogReporter, type SessionLogOptions } from './session-log.ts'
+
+declare module '@deepseek-ai/cordis' {
+ interface Context {
+ otel: OTel
+ }
+}
+
+/** Shared transport provider. Mounting creates no queue, identity, or network connection. */
+export default class OTel extends Service {
+ constructor(ctx: Context) { super(ctx, 'otel') }
+
+ /**
+ * Create an independent ordinary-event channel with count-based batching.
+ * The injected consumer must drain it during its fiber disposal.
+ * @param options - transport, scope, resource, queue, and diagnostic settings selected by the consumer.
+ * @returns the caller-owned channel; no state is shared with other channels.
+ */
+ createEventReporter(options: EventLogOptions): EventLogReporter { return new EventLogReporter(options) }
+
+ /**
+ * Create an independent byte-bounded Session-log channel.
+ * Authorization and redaction precede reporting; the consumer owns shutdown and its outer deadline.
+ * @param options - transport, scope, resource, queue, and diagnostic settings selected by the consumer.
+ * @returns the caller-owned channel, preserving complete accepted events within the request byte ceiling.
+ */
+ createSessionLogReporter(options: SessionLogOptions): SessionLogReporter { return new SessionLogReporter(options) }
+}
+
+export type { EventLogOptions, OTelEventRecord, OTelEventScalar, EventLogReporter } from './event-log.ts'
+export type { SessionLogRecord, SessionLogOptions, SessionLogReporter } from './session-log.ts'
diff --git a/packages/telemetry/otel/src/session-log.ts b/packages/telemetry/otel/src/session-log.ts
new file mode 100644
index 0000000000..59ee612d29
--- /dev/null
+++ b/packages/telemetry/otel/src/session-log.ts
@@ -0,0 +1,224 @@
+/** Session-log records in an independent byte-bounded OTLP queue. */
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
+import type { Attributes } from '@opentelemetry/api'
+import { SeverityNumber } from '@opentelemetry/api-logs'
+import { ExportResultCode } from '@opentelemetry/core'
+import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'
+import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer'
+import { resourceFromAttributes } from '@opentelemetry/resources'
+import { LoggerProvider, type BatchLogRecordProcessorOptions, type LogRecordExporter, type ReadableLogRecord, type LogRecordProcessor } from '@opentelemetry/sdk-logs'
+import { createLogExporter } from './transport.ts'
+
+/** Collector request ceiling in uncompressed UTF-8 bytes, including the OTLP envelope. */
+export const SESSION_LOG_MAX_REQUEST_BYTES = 4_000_000
+
+/** One canonical event with its separately owned Session identity and redacted payload. */
+export interface SessionLogRecord {
+ sessionId: SessionId
+ /** Complete event envelope; data is the capture policy's exported copy. */
+ event: Omit & { data: unknown }
+ /** Additional capture metadata; sessionId and content are always assigned by the reporter. */
+ attributes?: Attributes
+ /** Omitted values use INFO. */
+ severityNumber?: SeverityNumber
+}
+
+/** Session-log transport and byte/count queue settings. */
+export interface SessionLogOptions {
+ /** Explicit destination and SDK transport options. */
+ exporter: OTLPExporterNodeConfigBase & {
+ /** Full HTTP(S) logs destination. */
+ url: string
+ }
+ /** Session-only queue settings, independent of product-event aggregation. */
+ processor?: Omit
+ /** May lower, but never exceed, the collector's 4,000,000-byte limit. */
+ maxRequestBytes?: number
+ /** Instrumentation scope supplied by the business owner. */
+ scope: { name: string; version?: string }
+ /** Application and anonymous identity carried on the OTLP resource. */
+ resourceAttributes: Attributes
+ /** Report rejected single records and network failures without recording their content. */
+ onFailure: (message: string, error?: Error) => void
+}
+
+/**
+ * Validate byte and queue settings before constructing an SDK pipeline.
+ * @param options - Session-specific limits supplied by the owning composition.
+ * @returns the resolved collector request limit.
+ */
+export function resolveSessionLogLimits(options: Pick): number {
+ const limit = options.maxRequestBytes ?? SESSION_LOG_MAX_REQUEST_BYTES
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > SESSION_LOG_MAX_REQUEST_BYTES) {
+ throw new Error(`session log maxRequestBytes must be an integer between 1 and ${SESSION_LOG_MAX_REQUEST_BYTES}`)
+ }
+ for (const key of ['maxQueueSize', 'maxExportBatchSize', 'scheduledDelayMillis', 'exportTimeoutMillis'] as const) {
+ const value = options.processor?.[key]
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 1 || value > 2_147_483_647)) {
+ throw new Error(`session log processor.${key} must be a positive integer no greater than 2147483647`)
+ }
+ }
+ const queue = options.processor?.maxQueueSize ?? 2048
+ const batch = options.processor?.maxExportBatchSize ?? 512
+ if (batch > queue) throw new Error('session log maxExportBatchSize must not exceed maxQueueSize')
+ return limit
+}
+
+/** Byte/count batching with one transport request in flight, including after a watchdog warning. */
+class SessionLogProcessor implements LogRecordProcessor {
+ private readonly queue: { record: ReadableLogRecord; bytes: number }[] = []
+ private bytes = 0
+ private timer: ReturnType | undefined
+ private active: Promise | undefined
+ private shutdownPromise: Promise | undefined
+ private stopped = false
+
+ constructor(
+ private readonly exporter: LogRecordExporter,
+ private readonly limit: number,
+ private readonly config: Required>,
+ private readonly warn: SessionLogOptions['onFailure'],
+ ) {}
+
+ onEmit(record: ReadableLogRecord): void {
+ if (this.stopped) return
+ if (this.queue.length >= this.config.maxQueueSize) {
+ this.warn('Session log queue is full; record rejected')
+ return
+ }
+ let bytes: number
+ try {
+ const serialized = JsonLogsSerializer.serializeRequest([record])
+ if (serialized === undefined) throw new Error('Session log serialization produced no request')
+ bytes = serialized.byteLength
+ } catch (error) {
+ this.warn('Session log serialization failed; record rejected', error instanceof Error ? error : new Error(String(error)))
+ return
+ }
+ if (bytes > this.limit) {
+ this.warn('Session log record rejected; content was not truncated', new Error(`Session log record exceeds maxRequestBytes: ${bytes} > ${this.limit}`))
+ return
+ }
+ this.queue.push({ record, bytes })
+ this.bytes += bytes
+ if (this.active !== undefined) return
+ if (this.queue.length >= this.config.maxExportBatchSize || this.bytes >= this.limit) {
+ void this.forceFlush()
+ } else if (this.timer === undefined) {
+ this.timer = setTimeout(() => { void this.forceFlush() }, this.config.scheduledDelayMillis)
+ this.timer.unref()
+ }
+ }
+
+ forceFlush(): Promise {
+ clearTimeout(this.timer)
+ this.timer = undefined
+ if (this.active !== undefined) return this.active
+ if (this.queue.length === 0) return Promise.resolve()
+ this.active = this.drain()
+ return this.active
+ }
+
+ private async drain(): Promise {
+ try {
+ while (!this.stopped && this.queue.length > 0) {
+ let bytes = 0
+ let count = 0
+ for (const entry of this.queue) {
+ if (count === this.config.maxExportBatchSize || bytes + entry.bytes > this.limit) break
+ bytes += entry.bytes
+ count++
+ }
+ // Each measured record includes its resource/scope envelope. The shared
+ // envelope in this provider's multi-record request cannot exceed their sum.
+ const records = this.queue.splice(0, count).map(entry => entry.record)
+ this.bytes -= bytes
+ await this.send(records)
+ // The export callback precedes removal from the SDK concurrency queue.
+ await this.exporter.forceFlush()
+ }
+ } finally { this.active = undefined }
+ }
+
+ private send(records: ReadableLogRecord[]): Promise {
+ return new Promise((resolve) => {
+ const timer = setTimeout(() => {
+ this.warn('Session log request exceeded exportTimeoutMillis; waiting for transport settlement')
+ }, this.config.exportTimeoutMillis)
+ timer.unref()
+ const finish = (error?: Error): void => {
+ clearTimeout(timer)
+ if (error !== undefined) this.warn('Session log export failed', error)
+ resolve()
+ }
+ try {
+ this.exporter.export(records, (result) => {
+ finish(result.code === ExportResultCode.SUCCESS
+ ? undefined : result.error ?? new Error('Session log HTTP export failed'))
+ })
+ } catch (error) {
+ finish(error instanceof Error ? error : new Error(String(error)))
+ }
+ })
+ }
+
+ stopPending(): void {
+ this.stopped = true
+ this.queue.length = 0
+ this.bytes = 0
+ clearTimeout(this.timer)
+ this.timer = undefined
+ }
+
+ shutdown(): Promise {
+ this.shutdownPromise ??= this.forceFlush().then(() => this.exporter.shutdown())
+ return this.shutdownPromise
+ }
+}
+
+/** Owns feedback-authorized Session logs; no product-event provider or queue is mounted. */
+export class SessionLogReporter {
+ private readonly provider: LoggerProvider
+ private readonly processor: SessionLogProcessor
+ private readonly logger: ReturnType
+
+ /** @param options - explicit transport, resource identity, queue limits, and diagnostics. */
+ constructor(options: SessionLogOptions) {
+ const limit = resolveSessionLogLimits(options)
+ this.processor = new SessionLogProcessor(createLogExporter(options.exporter), limit, {
+ maxQueueSize: options.processor?.maxQueueSize ?? 2048,
+ maxExportBatchSize: options.processor?.maxExportBatchSize ?? 512,
+ scheduledDelayMillis: options.processor?.scheduledDelayMillis ?? 1000,
+ exportTimeoutMillis: options.processor?.exportTimeoutMillis ?? 30000,
+ }, options.onFailure)
+ this.provider = new LoggerProvider({
+ logRecordLimits: { attributeValueLengthLimit: Infinity, attributeCountLimit: Infinity },
+ resource: resourceFromAttributes(options.resourceAttributes),
+ processors: [this.processor],
+ })
+ this.logger = this.provider.getLogger(options.scope.name, options.scope.version)
+ }
+
+ /**
+ * Enqueue one complete event without acknowledging network delivery.
+ * @param record - event with redacted data and its original Session id.
+ */
+ reportSessionLog(record: SessionLogRecord): void {
+ const severityNumber = record.severityNumber ?? SeverityNumber.INFO
+ this.logger.emit({
+ eventName: 'session-log', body: 'session-log',
+ timestamp: record.event.time, observedTimestamp: record.event.time,
+ severityNumber, severityText: SeverityNumber[severityNumber],
+ attributes: { ...record.attributes, sessionId: record.sessionId, content: JSON.stringify(record.event) },
+ })
+ }
+
+ /** Stop queued requests after the owning backend's shutdown deadline; an active transport may still settle. */
+ stopPending(): void { this.processor.stopPending() }
+
+ /**
+ * Drain queued requests and release the SDK transport.
+ * @returns completion after queued requests settle and the SDK transport shuts down.
+ */
+ shutdown(): Promise { return this.provider.shutdown() }
+}
diff --git a/packages/telemetry/otel/src/transport.ts b/packages/telemetry/otel/src/transport.ts
new file mode 100644
index 0000000000..1975a56f34
--- /dev/null
+++ b/packages/telemetry/otel/src/transport.ts
@@ -0,0 +1,27 @@
+/** Explicit OTLP JSON transport for feedback-authorized Session logs. */
+import { createOtlpHttpExportDelegate, getSharedConfigurationFromEnvironment, httpAgentFactoryFromOptions } from '@opentelemetry/otlp-exporter-base/node-http'
+import { getSharedConfigurationDefaults, mergeOtlpSharedConfigurationWithDefaults, OTLPExporterBase, type OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'
+import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer'
+import type { LogRecordExporter } from '@opentelemetry/sdk-logs'
+
+/**
+ * Create an SDK JSON exporter without inheriting another collector's headers or TLS identity.
+ * @param options - explicit endpoint, headers, agent, and SDK transport settings.
+ * @returns the exporter owned by one independent log pipeline.
+ */
+export function createLogExporter(options: OTLPExporterNodeConfigBase & { url: string }): LogRecordExporter {
+ const shared = mergeOtlpSharedConfigurationWithDefaults(options, getSharedConfigurationFromEnvironment('LOGS'), getSharedConfigurationDefaults())
+ const transport = {
+ ...shared,
+ url: options.url,
+ headers: async () => ({
+ 'Content-Type': 'application/json',
+ ...typeof options.headers === 'function' ? await options.headers() : options.headers,
+ }),
+ // An agent factory owns the returned agent, including its keepAlive setting.
+ agentFactory: typeof options.httpAgentOptions === 'function' ? options.httpAgentOptions
+ : httpAgentFactoryFromOptions({ keepAlive: options.keepAlive ?? true, ...options.httpAgentOptions }),
+ ...(options.userAgent === undefined ? {} : { userAgent: options.userAgent }),
+ }
+ return new OTLPExporterBase(createOtlpHttpExportDelegate(transport, JsonLogsSerializer))
+}
diff --git a/packages/telemetry/otel/tests/session-log.spec.ts b/packages/telemetry/otel/tests/session-log.spec.ts
new file mode 100644
index 0000000000..10299d95e8
--- /dev/null
+++ b/packages/telemetry/otel/tests/session-log.spec.ts
@@ -0,0 +1,302 @@
+import { createRequire } from 'node:module'
+import { Context } from '@deepseek-ai/cordis'
+import OTel from '../src/index.ts'
+import { Agent, createServer, type IncomingHttpHeaders } from 'node:http'
+import { once } from 'node:events'
+import { gunzipSync } from 'node:zlib'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { CompressionAlgorithm } from '@opentelemetry/otlp-exporter-base'
+import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer'
+import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+import { SESSION_LOG_MAX_REQUEST_BYTES, type SessionLogRecord, type SessionLogOptions } from '../src/session-log.ts'
+
+interface Capture {
+ bytes: number
+ headers: IncomingHttpHeaders
+ body: { resourceLogs: { resource: unknown; scopeLogs: { logRecords: Record[] }[] }[] }
+}
+const cleanup: (() => Promise)[] = []
+let ctx: Context
+afterEach(async () => {
+ try {
+ for (const dispose of cleanup.splice(0).reverse()) await dispose()
+ } finally {
+ vi.unstubAllEnvs()
+ vi.restoreAllMocks()
+ vi.useRealTimers()
+ }
+})
+
+beforeEach(async () => {
+ ctx = new Context()
+ cleanup.push(() => ctx.fiber.dispose())
+ await ctx.plugin(OTel)
+ vi.stubEnv('OTEL_EXPORTER_OTLP_COMPRESSION', undefined)
+ vi.stubEnv('OTEL_EXPORTER_OTLP_LOGS_COMPRESSION', undefined)
+})
+
+async function collector(statuses = [200]) {
+ const captures: Capture[] = []
+ const server = createServer((req, res) => {
+ const chunks: Buffer[] = []
+ req.on('data', chunk => chunks.push(chunk as Buffer))
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks)
+ const bytes = req.headers['content-encoding'] === 'gzip' ? gunzipSync(raw) : raw
+ captures.push({ bytes: bytes.length, headers: req.headers, body: JSON.parse(bytes.toString()) as Capture['body'] })
+ res.writeHead(statuses.shift() ?? 200, { 'content-type': 'application/json' }).end('{}')
+ })
+ })
+ cleanup.push(async () => {
+ const closed = once(server, 'close')
+ server.close()
+ server.closeAllConnections()
+ await closed
+ })
+ 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')
+ return { captures, endpoint: `http://127.0.0.1:${address.port}/v1/logs` }
+}
+
+function sessionRecord(text: string, seq = 0): SessionLogRecord {
+ return {
+ sessionId: SessionId('synthetic-session'),
+ event: { type: 'user/message', seq: SessionSeq(seq), time: 1_800_000_000_000, surfaceOp: 'append',
+ data: { content: [{ type: 'text', text }], nested: { items: [null, true, false, 0, 1.5, { text }] } } },
+ }
+}
+
+function logs(capture: Capture) {
+ return capture.body.resourceLogs.flatMap(resource => resource.scopeLogs.flatMap(scope => scope.logRecords))
+}
+
+function contents(captures: Capture[]): string[] {
+ return captures.flatMap(capture => logs(capture).map((record) => {
+ const attributes = record['attributes'] as { key: string; value: { stringValue: string } }[]
+ return attributes.find(attribute => attribute.key === 'content')!.value.stringValue
+ }))
+}
+
+function parseContent(content: string): unknown {
+ return JSON.parse(content)
+}
+
+function reporter(endpoint: string, overrides: Partial = {}) {
+ const onFailure = vi.fn()
+ const sender = ctx.otel.createSessionLogReporter({
+ scope: { name: '@deepseek-ai/dsh-session-telemetry-otel', version: (createRequire(import.meta.url)('../package.json') as { version: string }).version },
+ exporter: { url: endpoint, timeoutMillis: 1000 }, resourceAttributes: { 'service.name': 'session-test' },
+ processor: { scheduledDelayMillis: 60000 }, onFailure, ...overrides,
+ })
+ cleanup.push(() => sender.shutdown())
+ return { sender, onFailure }
+}
+
+it('preserves full nested events without truncation or ambient headers and retains scope version', async () => {
+ vi.stubEnv('OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT', '8')
+ vi.stubEnv('OTEL_EXPORTER_OTLP_HEADERS', 'Authorization=Bearer%20unrelated')
+ const target = await collector()
+ const { sender } = reporter(target.endpoint)
+ const record = sessionRecord('中文\n"quote"\\path 😀')
+ sender.reportSessionLog(record)
+ await sender.shutdown()
+ expect(contents(target.captures).map(parseContent)).toEqual([record.event])
+ expect(logs(target.captures[0]!)[0]).toMatchObject({ eventName: 'session-log' })
+ expect(target.captures[0]!.headers).not.toHaveProperty('authorization')
+ expect(target.captures[0]!.headers).not.toHaveProperty('x-channel')
+ expect(target.captures[0]!.body.resourceLogs[0]!.scopeLogs[0]).toMatchObject({
+ scope: { name: '@deepseek-ai/dsh-session-telemetry-otel', version: (createRequire(import.meta.url)('../package.json') as { version: string }).version },
+ })
+})
+
+it.each([CompressionAlgorithm.GZIP, CompressionAlgorithm.NONE])('bounds %s requests and preserves nested content', async (compression) => {
+ const target = await collector()
+ const { sender, onFailure } = reporter(target.endpoint, { exporter: { url: target.endpoint, compression, concurrencyLimit: 1 } })
+ const records = [0,1,2].map(seq => sessionRecord('中"\\'.repeat(100000), seq))
+ for (const record of records) sender.reportSessionLog(record)
+ await sender.shutdown()
+ expect(target.captures.length).toBeGreaterThan(1)
+ expect(target.captures.every(c => c.bytes <= SESSION_LOG_MAX_REQUEST_BYTES)).toBe(true)
+ expect(contents(target.captures).map(parseContent)).toEqual(records.map(r => r.event))
+ expect(onFailure).not.toHaveBeenCalled()
+})
+
+it('admits an exact request and diagnoses an oversized record only once', async () => {
+ const target = await collector()
+ const record = sessionRecord('边界"\\')
+ const baseline = reporter(target.endpoint).sender
+ baseline.reportSessionLog(record)
+ await baseline.shutdown()
+ const limit = target.captures[0]!.bytes
+ const exact = reporter(target.endpoint, { maxRequestBytes: limit }).sender
+ exact.reportSessionLog(record)
+ await exact.shutdown()
+ const small = reporter(target.endpoint, { maxRequestBytes: limit - 1 })
+ small.sender.reportSessionLog(record)
+ await small.sender.shutdown()
+ expect(target.captures.map(c => c.bytes)).toEqual([limit, limit])
+ expect(small.onFailure).toHaveBeenCalledTimes(1)
+ expect(small.onFailure).toHaveBeenCalledWith('Session log record rejected; content was not truncated', expect.any(Error))
+})
+
+it('continues after an oversized record and a failed HTTP request', async () => {
+ const target = await collector([400,200])
+ const { sender, onFailure } = reporter(target.endpoint, { maxRequestBytes: 1300 })
+ const records = [sessionRecord('first'.repeat(25)), sessionRecord('x'.repeat(4000000),1), sessionRecord('last'.repeat(25),2)]
+ for(const record of records) sender.reportSessionLog(record)
+ await sender.shutdown()
+ expect(contents(target.captures).map(parseContent)).toEqual([records[0]!.event,records[2]!.event])
+ expect(onFailure).toHaveBeenCalledTimes(2)
+})
+
+it.each([0,-1,1.5,4000001,Infinity])('rejects byte limit %s', (maxRequestBytes) => {
+ expect(() => reporter('http://collector.test/logs',{ maxRequestBytes })).toThrow('maxRequestBytes')
+})
+
+it.each(['maxQueueSize','maxExportBatchSize','scheduledDelayMillis','exportTimeoutMillis'] as const)('rejects invalid %s', (key) => {
+ expect(() => reporter('http://collector.test/logs',{ processor:{ [key]:0 } })).toThrow(`processor.${key}`)
+})
+
+it('rejects a batch count above the queue capacity', () => {
+ expect(() => reporter('http://collector.test/logs',{ processor:{ maxQueueSize:1,maxExportBatchSize:2 } })).toThrow('maxExportBatchSize must not exceed maxQueueSize')
+})
+
+it('honors explicit routing headers and caller-owned agent settings', async () => {
+ const target = await collector()
+ const agent = new Agent({ keepAlive:false })
+ cleanup.push(async () => {agent.destroy()})
+ const { sender } = reporter(target.endpoint,{ exporter:{ url:target.endpoint,userAgent:'session-test',keepAlive:true,httpAgentOptions:async () => agent,headers:async () => ({ 'x-channel':'test-channel' }) } })
+ sender.reportSessionLog(sessionRecord('agent'))
+ await sender.shutdown()
+ expect(target.captures[0]!.headers.connection).toBe('close')
+ expect(target.captures[0]!.headers['x-channel']).toBe('test-channel')
+ expect(target.captures[0]!.headers['user-agent']).toContain('session-test')
+})
+
+it.each([new Error('serialize'), 'serialize', undefined])('contains serializer failures %s', (failure) => {
+ const { sender,onFailure } = reporter('http://collector.test/logs')
+ vi.spyOn(JsonLogsSerializer,'serializeRequest').mockImplementationOnce(() => { if(failure===undefined)return undefined; throw failure })
+ sender.reportSessionLog(sessionRecord('invalid'))
+ expect(onFailure).toHaveBeenCalledWith('Session log serialization failed; record rejected',expect.any(Error))
+})
+
+it('measures each record once instead of serializing the full candidate batch', async () => {
+ const target = await collector()
+ const { sender } = reporter(target.endpoint,{ maxRequestBytes:1300 })
+ const serialize = vi.spyOn(JsonLogsSerializer,'serializeRequest')
+ const records = Array.from({ length:100 },(_,seq) => sessionRecord('batch'.repeat(20),seq))
+ for(const record of records)sender.reportSessionLog(record)
+ await sender.shutdown()
+ expect(contents(target.captures).map(parseContent)).toEqual(records.map(r=>r.event))
+ expect(serialize.mock.calls.every(([records])=>records.length===1)).toBe(true)
+ expect(serialize).toHaveBeenCalledTimes(records.length+target.captures.length)
+})
+
+it('keeps one transport slot across multiple byte batches and times each request independently', async () => {
+ vi.useFakeTimers()
+ const transport = await import('../src/transport.ts')
+ const sent: number[] = []
+ let active = 0
+ let peak = 0
+ vi.spyOn(transport, 'createLogExporter').mockReturnValue({
+ export(records, callback) {
+ active++
+ peak = Math.max(peak, active)
+ sent.push(...records.map(r => (JSON.parse(r.attributes.content as string) as { seq: number }).seq))
+ setTimeout(() => { active--; callback({ code: 0 }) }, 800)
+ },
+ forceFlush: async () => {}, shutdown: async () => {},
+ })
+ const { sender, onFailure } = reporter('http://collector.test/logs', {
+ exporter: { url: 'http://collector.test/logs', concurrencyLimit: 1 },
+ maxRequestBytes: 1300,
+ processor: { maxExportBatchSize: 3, maxQueueSize: 6, scheduledDelayMillis: 60000, exportTimeoutMillis: 1500 },
+ })
+ for(let seq = 0; seq < 6; seq++) sender.reportSessionLog(sessionRecord('content'.repeat(20), seq))
+ const done = sender.shutdown()
+ await vi.advanceTimersByTimeAsync(4800)
+ await done
+ expect(sent).toEqual([0,1,2,3,4,5])
+ expect(peak).toBe(1)
+ expect(onFailure).not.toHaveBeenCalled()
+})
+
+it('does not release the transport slot on a watchdog timeout or send queued records after the shutdown deadline', async () => {
+ vi.useFakeTimers()
+ const transport = await import('../src/transport.ts')
+ const callback = Promise.withResolvers<(result: { code: 0 }) => void>()
+ const send = vi.fn((_records, complete: (result: { code: 0 }) => void) => { callback.resolve(complete) })
+ vi.spyOn(transport, 'createLogExporter').mockReturnValue({ export: send, forceFlush: async () => {}, shutdown: async () => {} })
+ const { sender, onFailure } = reporter('http://collector.test/logs', {
+ processor: { maxExportBatchSize: 1, maxQueueSize: 2, exportTimeoutMillis: 100, scheduledDelayMillis: 1000 },
+ })
+ sender.reportSessionLog(sessionRecord('first'))
+ const finish = await callback.promise
+ cleanup.push(async () => { sender.stopPending(); finish({ code: 0 }) })
+ sender.reportSessionLog(sessionRecord('queued',1))
+ await vi.advanceTimersByTimeAsync(500)
+ expect(send).toHaveBeenCalledTimes(1)
+ expect(onFailure).toHaveBeenCalledWith('Session log request exceeded exportTimeoutMillis; waiting for transport settlement')
+ sender.stopPending()
+ sender.reportSessionLog(sessionRecord('after deadline',2))
+ const done = sender.shutdown()
+ finish({ code:0 })
+ await done
+ sender.reportSessionLog(sessionRecord('late',2))
+ expect(send).toHaveBeenCalledTimes(1)
+})
+
+it.each([new Error('transport'), 'transport', undefined])('contains transport failures %s and continues later requests', async (failure) => {
+ const transport = await import('../src/transport.ts')
+ const send = vi.fn()
+ .mockImplementationOnce((_records, callback) => { if(failure === undefined) callback({ code:1 }); else throw failure })
+ .mockImplementation((_records, callback) => { callback({ code:0 }) })
+ vi.spyOn(transport,'createLogExporter').mockReturnValue({ export:send,forceFlush:async()=>{},shutdown:async()=>{} })
+ const { sender,onFailure } = reporter('http://collector.test/logs',{ processor:{ maxExportBatchSize:1 } })
+ sender.reportSessionLog(sessionRecord('failed'))
+ sender.reportSessionLog(sessionRecord('next',1))
+ await sender.shutdown()
+ expect(send).toHaveBeenCalledTimes(2)
+ expect(onFailure).toHaveBeenCalledTimes(1)
+})
+
+it('bounds the queued record count while a transport request is unsettled', async () => {
+ const transport = await import('../src/transport.ts')
+ const release = Promise.withResolvers<(result:{ code:0 })=>void>()
+ vi.spyOn(transport,'createLogExporter').mockReturnValue({
+ export(_records,callback){release.resolve(callback)},forceFlush:async()=>{},shutdown:async()=>{},
+ })
+ const { sender,onFailure } = reporter('http://collector.test/logs',{ processor:{ maxExportBatchSize:1,maxQueueSize:2 } })
+ for(let seq=0;seq<4;seq++)sender.reportSessionLog(sessionRecord('queued',seq))
+ const finish = await release.promise
+ cleanup.push(async () => { sender.stopPending(); finish({ code: 0 }) })
+ expect(onFailure).toHaveBeenCalledWith('Session log queue is full; record rejected')
+ sender.stopPending()
+ const done=sender.shutdown()
+ finish({ code:0 })
+ await done
+})
+
+it('keeps ordinary events and Session logs in independent channels through one Cordis service', async () => {
+ const target = await collector()
+ const ordinary = ctx.otel.createEventReporter({
+ exporter: { url: target.endpoint },
+ resourceAttributes: { 'service.name': 'ordinary-test' },
+ scope: { name: 'ordinary-consumer', version: '1' },
+ processor: { scheduledDelayMillis: 60000 },
+ onFailure: () => { throw new Error('unexpected export failure') },
+ })
+ cleanup.push(() => ordinary.shutdown())
+ const { sender } = reporter(target.endpoint)
+ ordinary.emit({ eventName: 'ui.click', body: 'click', timestamp: Date.now() })
+ sender.reportSessionLog(sessionRecord('authorized'))
+ expect(target.captures).toEqual([])
+ await sender.shutdown()
+ expect(target.captures.flatMap(logs).map(r => r.eventName)).toEqual(['session-log'])
+ await ordinary.shutdown()
+ expect(target.captures.map(c => logs(c).map(r => r.eventName))).toEqual([['session-log'], ['ui.click']])
+ await ctx.fiber.dispose()
+ expect(ctx.get('otel')).toBeUndefined()
+})
diff --git a/packages/telemetry/otel/tsconfig.json b/packages/telemetry/otel/tsconfig.json
new file mode 100644
index 0000000000..e88100b9a0
--- /dev/null
+++ b/packages/telemetry/otel/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../core/session"
+ }
+ ]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 42e9fd2273..1f8212fb3c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1986,6 +1986,9 @@ importers:
'@deepseek-ai/dsh-mcp-resources':
specifier: workspace:*
version: link:../../mcp/mcp-resources
+ '@deepseek-ai/dsh-otel':
+ specifier: workspace:*
+ version: link:../../telemetry/otel
'@deepseek-ai/dsh-permission-presets':
specifier: workspace:*
version: link:../../interaction/permission-presets
@@ -8995,24 +8998,12 @@ importers:
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
- '@opentelemetry/api':
- specifier: ^1.9.1
- version: 1.9.1
'@opentelemetry/api-logs':
specifier: ^0.220.0
version: 0.220.0
- '@opentelemetry/core':
- specifier: 2.9.0
- version: 2.9.0(@opentelemetry/api@1.9.1)
'@opentelemetry/otlp-exporter-base':
specifier: ^0.220.0
version: 0.220.0(@opentelemetry/api@1.9.1)
- '@opentelemetry/otlp-transformer':
- 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)
@@ -9029,6 +9020,9 @@ importers:
'@deepseek-ai/dsh-loader-smoke':
specifier: workspace:*
version: link:../../test-support/loader-smoke
+ '@deepseek-ai/dsh-otel':
+ specifier: workspace:*
+ version: link:../../telemetry/otel
packages/host/webserver:
dependencies:
@@ -10938,21 +10932,12 @@ importers:
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
- '@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/otlp-exporter-base':
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)
@@ -10990,6 +10975,9 @@ importers:
'@deepseek-ai/dsh-message-feedback':
specifier: workspace:*
version: link:../../feedback/message-feedback
+ '@deepseek-ai/dsh-otel':
+ specifier: workspace:*
+ version: link:../../telemetry/otel
'@deepseek-ai/dsh-session':
specifier: workspace:*
version: link:../../core/session
@@ -12719,6 +12707,37 @@ importers:
specifier: workspace:~
version: link:../../../vendor/cordis
+ packages/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/core':
+ specifier: 2.9.0
+ version: 2.9.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/otlp-exporter-base':
+ specifier: ^0.220.0
+ version: 0.220.0(@opentelemetry/api@1.9.1)
+ '@opentelemetry/otlp-transformer':
+ 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)
+ devDependencies:
+ '@deepseek-ai/cordis':
+ specifier: workspace:~
+ version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:*
+ version: link:../../core/session
+
packages/terminal/terminal:
devDependencies:
'@deepseek-ai/cordis':
@@ -14055,6 +14074,9 @@ importers:
'@deepseek-ai/dsh-native-command':
specifier: workspace:*
version: link:../../packages/util/native-command
+ '@deepseek-ai/dsh-otel':
+ specifier: workspace:*
+ version: link:../../packages/telemetry/otel
'@deepseek-ai/dsh-output-retention':
specifier: workspace:*
version: link:../../packages/util/output-retention
@@ -16209,12 +16231,6 @@ packages:
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}
@@ -23525,15 +23541,6 @@ snapshots:
'@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
diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json
index 1e63e17605..a6be8976e6 100644
--- a/python/sdk-runtime/package.json
+++ b/python/sdk-runtime/package.json
@@ -162,6 +162,7 @@
"@deepseek-ai/dsh-web-search-perplexity": "workspace:*",
"@deepseek-ai/dsh-workflow": "workspace:*",
"@deepseek-ai/dsh-workflow-ptc": "workspace:*",
+ "@deepseek-ai/dsh-otel": "workspace:*",
"@deepseek-ai/dsh-workspace": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~"
}
diff --git a/scripts/archived-agent-notes.spec.ts b/scripts/archived-agent-notes.spec.ts
index f96b0b1393..4d3ce7e8d6 100644
--- a/scripts/archived-agent-notes.spec.ts
+++ b/scripts/archived-agent-notes.spec.ts
@@ -112,6 +112,37 @@ describe('archived Agent Notes', () => {
)
})
+ it.each([
+ {
+ path: 'feature/2026-08-10-durable-workflow-runs-in-chat.md',
+ before: 'sha256:f9f5290cd880908d17b1080ae5776f22e182f5253471f90cf8bead418b33b502',
+ after: 'sha256:6018de4a89ca99d6cbfd618aff1c8136b23d8e4854a30f7b12a602f0417cbdc2',
+ },
+ {
+ path: 'feature/2026-08-10-durable-workflow-runs-in-chat.zh.md',
+ before: 'sha256:f4ffd2700bc9c2a0cc4b9d90f84e1d0a576939e2e6f0b130c5b142fd8cd518b0',
+ after: 'sha256:0080859221773122022d5880efa3d52f29c0872b52d63e170250d94f214ff37a',
+ },
+ {
+ path: 'feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml',
+ before: 'sha256:0db3c21a3c8785e56d5d7b90df990b158069cd1cea664e62fc4786c6d7588450',
+ after: 'sha256:03eadb60c8ba4fde262cd61e3f911d8cd68ea4a58f57adeb8dd7bb9a97d08ca2',
+ },
+ ])('accepts only the authorized seal transition for $path', ({ path, before, after }) => {
+ const manifest = (hash: string): ArchiveManifest => ({ version: 1, files: { [path]: hash } })
+ expect(validateArchiveManifestExtension(manifest(before), manifest(after))).toEqual([])
+ expect(validateArchiveManifestExtension(manifest(after), manifest(before))).toEqual([
+ `${path}: sealed manifest hash changed`,
+ ])
+ expect(validateArchiveManifestExtension(manifest(before), manifest('sha256:' + '0'.repeat(64)))).toHaveLength(1)
+ expect(validateArchiveManifestExtension(manifest('sha256:' + '0'.repeat(64)), manifest(after))).toHaveLength(1)
+ expect(validateArchiveManifestExtension(manifest(before), { version: 1, files: {} })).toHaveLength(1)
+ expect(validateArchiveManifestExtension(
+ { version: 1, files: { 'process/other.md': before } },
+ { version: 1, files: { 'process/other.md': after } },
+ )).toHaveLength(1)
+ })
+
it('round-trips the deterministic manifest schema', () => {
const content = renderArchiveManifest({ 'process/z.md': `sha256:${'a'.repeat(64)}` })
expect(parseArchiveManifest(content)).toEqual({
diff --git a/scripts/archived-agent-notes.ts b/scripts/archived-agent-notes.ts
index 5f96550e62..ee5f3f6617 100644
--- a/scripts/archived-agent-notes.ts
+++ b/scripts/archived-agent-notes.ts
@@ -53,7 +53,26 @@ export function renderArchiveManifest(files: Readonly>):
}, null, 2)}\n`
}
-/** Reject changes or removals of entries sealed by a prior manifest. */
+// Exact seals for the authorized Figma-link removal and its bilingual sidecar.
+const figmaLinkRemovals = [
+ {
+ path: 'feature/2026-08-10-durable-workflow-runs-in-chat.md',
+ before: 'sha256:f9f5290cd880908d17b1080ae5776f22e182f5253471f90cf8bead418b33b502',
+ after: 'sha256:6018de4a89ca99d6cbfd618aff1c8136b23d8e4854a30f7b12a602f0417cbdc2',
+ },
+ {
+ path: 'feature/2026-08-10-durable-workflow-runs-in-chat.zh.md',
+ before: 'sha256:f4ffd2700bc9c2a0cc4b9d90f84e1d0a576939e2e6f0b130c5b142fd8cd518b0',
+ after: 'sha256:0080859221773122022d5880efa3d52f29c0872b52d63e170250d94f214ff37a',
+ },
+ {
+ path: 'feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml',
+ before: 'sha256:0db3c21a3c8785e56d5d7b90df990b158069cd1cea664e62fc4786c6d7588450',
+ after: 'sha256:03eadb60c8ba4fde262cd61e3f911d8cd68ea4a58f57adeb8dd7bb9a97d08ca2',
+ },
+]
+
+/** Reject sealed changes except the exact authorized Figma-link removal hashes. */
export function validateArchiveManifestExtension(
baseline: ArchiveManifest,
current: ArchiveManifest,
@@ -62,7 +81,9 @@ export function validateArchiveManifestExtension(
for (const [path, expected] of Object.entries(baseline.files)) {
const actual = current.files[path]
if (actual === undefined) errors.push(`${path}: sealed manifest entry is missing`)
- else if (actual !== expected) errors.push(`${path}: sealed manifest hash changed`)
+ else if (actual !== expected && !figmaLinkRemovals.some(change =>
+ change.path === path && change.before === expected && change.after === actual,
+ )) errors.push(`${path}: sealed manifest hash changed`)
}
return errors
}
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index facc1d7222..b62ff2606a 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -52,6 +52,7 @@ export { REGION_BEGIN, REGION_END }
export const SERVICE_PAGE: Record = {
speechToText: 'voice-input.md',
speechController: 'voice-input.md',
+ otel: 'otel.md',
productTelemetry: 'product-telemetry.md',
connection: 'web-server.md',
pluginManager: 'boot.md',
@@ -273,6 +274,12 @@ export const EVENT_WALK_EXEMPTIONS: Record = {
* appear on more than one page.
*/
export const LINK_MAP: Readonly> = {
+ EventLogOptions: 'otel.md',
+ EventLogReporter: 'otel.md',
+ SessionLogOptions: 'otel.md',
+ SessionLogReporter: 'otel.md',
+ OTelEventRecord: 'otel.md',
+ OTelEventScalar: 'otel.md',
ProductTelemetryRecord: 'product-telemetry.md',
ProductTelemetryScalar: 'product-telemetry.md',
WorkspaceChangesSummary: 'deliverables.md',
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index d9fcf17470..2d02cb8398 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -405,6 +405,14 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['llm-pi-ai'],
note: 'Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol.',
},
+ {
+ key: 'otel',
+ pkg: 'otel',
+ title: 'Shared OTel reporting channels',
+ mode: 'service',
+ consumers: ['host-product-telemetry-otel', 'session-telemetry-otel'],
+ note: 'Product analytics and Session feedback adapters create independent reporting channels through one injected service.',
+ },
{
key: 'productTelemetry',
pkg: 'host-product-telemetry-otel',
diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs
index 0ecbf072c2..653fa2c8dd 100644
--- a/scripts/install-lefthook.mjs
+++ b/scripts/install-lefthook.mjs
@@ -374,6 +374,18 @@ async function waitForLockTestBarrier(path) {
}
}
+/** Windows can deny access to a deleted lock until its last reader closes it. */
+async function accessInstallLock(operation, deadline) {
+ while (true) {
+ try {
+ return operation()
+ } catch (error) {
+ if (process.platform !== 'win32' || errorCode(error) !== 'EPERM' || Date.now() >= deadline) throw error
+ await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS))
+ }
+ }
+}
+
async function acquireInstallLock(commonDirectory) {
const lockPath = join(commonDirectory, INSTALL_LOCK)
const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS
@@ -382,7 +394,7 @@ async function acquireInstallLock(commonDirectory) {
let observeBarrier = process.env.DSH_TEST_LEFTHOOK_LOCK_OBSERVE_BARRIER
while (true) {
try {
- const lockHandle = openSync(lockPath, 'wx', 0o600)
+ const lockHandle = await accessInstallLock(() => openSync(lockPath, 'wx', 0o600), deadline)
let ownedStat
try {
ownedStat = fstatSync(lockHandle)
@@ -405,14 +417,14 @@ async function acquireInstallLock(commonDirectory) {
return () => releaseInstallLock(lockPath, ownedRecord, ownedStat)
} catch (error) {
if (errorCode(error) !== 'EEXIST') throw error
- const existingStat = installLockStat(lockPath)
+ const existingStat = await accessInstallLock(() => installLockStat(lockPath), deadline)
if (existingStat === undefined) continue
if (!existingStat.isFile() || existingStat.isSymbolicLink()) {
throw manualLockRecoveryError(lockPath, 'invalid')
}
- const existingRecord = readInstallLock(lockPath)
+ const existingRecord = await accessInstallLock(() => readInstallLock(lockPath), deadline)
if (existingRecord === undefined) continue
- const verifiedStat = installLockStat(lockPath)
+ const verifiedStat = await accessInstallLock(() => installLockStat(lockPath), deadline)
if (verifiedStat === undefined) continue
if (!verifiedStat.isFile() || verifiedStat.isSymbolicLink()) {
throw manualLockRecoveryError(lockPath, 'invalid')
diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts
index f31274a1d2..c4e9af44da 100644
--- a/scripts/install-lefthook.spec.ts
+++ b/scripts/install-lefthook.spec.ts
@@ -183,9 +183,10 @@ function runInstaller(
fixture: Fixture,
root: string,
extraEnv: NodeJS.ProcessEnv = {},
+ nodeArgs: string[] = [],
): Promise {
return new Promise((resolveResult, reject) => {
- const child = spawn(process.execPath, [installer], {
+ const child = spawn(process.execPath, [...nodeArgs, installer], {
cwd: root,
env: { ...fixture.env, ...extraEnv },
stdio: ['ignore', 'pipe', 'pipe'],
@@ -320,6 +321,56 @@ describe('worktree-local Lefthook installer', { timeout: 90_000 }, () => {
expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
})
+ it.each([
+ { operation: 'openSync', code: 'EPERM', expires: false },
+ { operation: 'readFileSync', code: 'EPERM', expires: false },
+ { operation: 'lstatSync', code: 'EPERM', expires: false },
+ { operation: 'openSync', code: 'EPERM', expires: true },
+ { operation: 'openSync', code: 'EACCES', expires: false },
+ ])('handles $operation $code with expired deadline=$expires', async ({ operation, code, expires }) => {
+ const fixture = createFixture()
+ const lockPath = installLockPath(fixture)
+ const probe = join(fixture.container, 'lock-access-probe')
+ const preload = join(fixture.container, 'lock-access.cjs')
+ if (operation !== 'openSync') {
+ writeFileSync(lockPath, `${process.pid} 00000000-0000-4000-8000-000000000001\n`)
+ }
+ // The subprocess owns the injected filesystem error and clock; the test process stays unchanged.
+ writeFileSync(preload, `
+const fs = require('node:fs')
+const { syncBuiltinESMExports } = require('node:module')
+const lockPath = ${JSON.stringify(lockPath)}
+const operation = ${JSON.stringify(operation)}
+const original = fs[operation]
+const now = Date.now
+let injected = false
+fs[operation] = function(path, ...args) {
+ if (path === lockPath && !injected) {
+ injected = true
+ fs.writeFileSync(${JSON.stringify(probe)}, 'injected')
+ if (operation !== 'openSync') fs.unlinkSync(lockPath)
+ if (${expires}) Date.now = () => now() + 31000
+ throw Object.assign(new Error('injected lock access failure'), { code: ${JSON.stringify(code)} })
+ }
+ return original.call(this, path, ...args)
+}
+syncBuiltinESMExports()
+`)
+
+ const result = await runInstaller(fixture, fixture.main, {}, ['--require', preload])
+
+ expect(readFileSync(probe, 'utf8')).toBe('injected')
+ const recovers = process.platform === 'win32' && code === 'EPERM' && !expires
+ expect(result.status, result.stderr).toBe(recovers ? 0 : 1)
+ if (recovers) {
+ expect(existsSync(join(hooksPath(fixture, fixture.main), 'pre-push'))).toBe(true)
+ expect(existsSync(lockPath)).toBe(false)
+ } else {
+ expect(result.stderr).toContain('injected lock access failure')
+ expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
+ }
+ })
+
it('waits for a concurrent installer to finish publishing its lock record', async () => {
const fixture = createFixture()
const lockPath = installLockPath(fixture)
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index f380273b70..d0d2c40581 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -50,6 +50,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/experimental/speech-to-text-sensevoice': { kind: 'none', reason: 'Local recognition returns transient text without modifying model context.' },
'packages/experimental/client-ui-voice-input': { kind: 'none', reason: 'Inserts reviewable text into the unsent draft without submitting to the Agent.' },
'packages/experimental/voice-input-bundle': { kind: 'none', reason: 'Composes dictation and preparation plugins without adding any model-facing contribution.' },
+ 'packages/telemetry/otel': { kind: 'none', reason: 'The service delivers caller-selected records without changing model context.' },
'packages/host/product-telemetry-otel': { kind: 'none', reason: 'The plugin exports explicit analytics records without contributing model context.' },
'packages/api/terminal-controller': { kind: 'none', reason: 'User-owned terminal processes and screen streams never enter model requests or Session events.' },
'packages/client/ui-sidebar-terminal': { kind: 'none', reason: 'The browser renders user terminal screens without exposing them to the model.' },
diff --git a/snapshots/web/feedback-release/feedback-release.expected.json b/snapshots/web/feedback-release/feedback-release.expected.json
index 994953b4ef..501d8582bf 100644
--- a/snapshots/web/feedback-release/feedback-release.expected.json
+++ b/snapshots/web/feedback-release/feedback-release.expected.json
@@ -1,5 +1,6 @@
{
"mode": "FEEDBACK_ONLY",
+ "eventName": "session-log",
"feedback": [
{
"type": "feedback/record",
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 61e0bbc5b7..d5828527d1 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -28,6 +28,7 @@
// not declaration path aliases, keep each package/vendor source compiled
// under its own tsconfig boundary.
"paths": {
+ "@deepseek-ai/dsh-otel": ["./packages/telemetry/otel/src"],
"@deepseek-ai/dsh-client-ui-settings-account": ["./packages/client/ui-settings-account/src/index.ts"],
"@deepseek-ai/dsh-client-ui-settings-account/client": ["./packages/client/ui-settings-account/src/client/index.ts"],
"@deepseek-ai/dsh-api-gateway/stream-protocol": ["./packages/api/gateway/src/stream-protocol.ts"],
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 4cba9938fb..abd449ee06 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -198,6 +198,7 @@
"scripts/client-bundle-purity.spec.ts"
],
"references": [
+ { "path": "./packages/telemetry/otel" },
{ "path": "./packages/experimental/speech-to-text" },
{ "path": "./packages/experimental/speech-to-text-sensevoice" },
{ "path": "./packages/experimental/api-speech-to-text" },