diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml new file mode 100644 index 0000000000..7bcfa9f599 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md +2026-07-31-telemetry-anonymous-user-id.md: 3c8e3324cb418eac48cb5ae780c55bbcbaf3caa1 +2026-07-31-telemetry-anonymous-user-id.zh.md: 9ff6cf35a90d4087b4ab75987dc9244210e3d46a diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md new file mode 100644 index 0000000000..3c8e3324cb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md @@ -0,0 +1,44 @@ +# Agent Note: Telemetry anonymous user id ($DSH_HOME/.userid) and the OTel Resource user.id + +Status: implemented + +English | [中文](2026-07-31-telemetry-anonymous-user-id.zh.md) + +## Problem + +Session telemetry is mounted by default ([default-mount Note](2026-07-31-web-telemetry-default-mount.md)), but the OTel Resource carried only `service.name`/`service.version` — no user-level identity at all, so the collector could neither aggregate per user nor count active users. The only prior ruling on point was an unimplemented one to derive a user id by hashing the hostname/local IP; the dsh-sdk toolchain keeps its own anonymous id (`$DSH_HOME/telemetry.json`), but that is the launcher feed's private fact, unrelated to the OTel feed. The OTel feed needed an anonymous user identity with clean semantics. + +## Decision + +The `session-telemetry-otel` package's own module `src/user-id.ts` owns the OTel feed's user identity: `getOrCreateAnonymousUserId()` returns the bare UUID line in `$DSH_HOME/.userid` (resolved by `resolveDshHome`, `$DSH_HOME` > `~/.dsh`), minting and persisting a random UUID v4 on first use; the backend constructor carries it as the Resource's `user.id` (the OTel semconv user attribute), once per export batch. This identity belongs to the OTel feed alone; the dsh-sdk launcher telemetry keeps its own anonymous-id store (`telemetry.json`), and the two are not shared (the first cut unified both feeds through a shared util package; the user reconsidered and pulled it back — no shared package before a second real consumer exists, revisit when a feed-correlation need appears). + +| Ruling | Value | Rationale | +|---|---|---| +| Id source | Random UUID v4, never derived from the hostname, network address, or git remote | A derived id is reversible, making "anonymous" a fiction | +| Storage form | `.userid`, a bare UUID line plus newline, no JSON wrapper | Identity is a standalone fact, not something filed under one telemetry feed's file name/format | +| IO form | Synchronous IO + a process-lifetime memo keyed by resolved file path | `TelemetryOtel`'s constructor is synchronous (async would reshape plugin loading); one disk touch per process, and mid-run file deletion never affects the running process | +| Concurrent first launch | Settled by an exclusive-create (`wx`) write; the loser rereads the winner's id | Covers common concurrency (a reread landing in the winner's microsecond create-to-write window can still yield one id per process for that run, converging on the persisted value next launch — a telemetry-grade consequence, accepted) | +| Loss semantics | File deleted → next launch mints a fresh id; loss is accepted | An anonymous identity has no recovery value; recoverability demands derivation material, which conflicts with anonymity | +| Write failure | Best-effort: return the in-memory id | Telemetry is never blocked by a read-only home | +| Report position | Resource attribute, not per-record attributes | Once per batch suffices for Resource-dimension aggregation; per-record injection would touch the seam contract and grow the wire | +| semconv dependency | `@opentelemetry/semantic-conventions` is not imported | One string constant does not justify a dependency | +| Home | A module inside `session-telemetry-otel`, not a shared util package | Repo rule: split a package only for a second real consumer; the sdk launcher feed keeps its own store, and no real correlation need exists | +| Separate switch | None | Identity follows the telemetry master switch (`DSH_TELEMETRY_DISABLED`); telemetry off means nothing reports | + +## Alternatives considered + +| Rejected | One-line reason | +|---|---| +| Hostname/IP-hash-derived id (the prior ruling) | Reversible means not anonymous; the random UUID is semantically clean — the user ruled to supersede | +| user.id on every record's attributes (Claude Code's shape) | Touches the session-telemetry seam contract or injects per record, growing the wire; once per batch on the Resource already aggregates | +| A shared util package unifying both feeds (the first cut) | The only real consumer is the OTel backend; switching the sdk launcher onto it was unification for its own sake — the user reconsidered and pulled it back, to be re-extracted when a correlation need appears | +| Reusing telemetry.json instead of a new file | The file name/JSON format files the identity under the launcher feed's naming; the OTel feed's identity is a standalone fact | +| AppCLIEntry reading the id and injecting via config patch | Every surface entry needs wiring; a runtime fact inside deployment config conflates the two | +| Housing it in `@deepseek-ai/dsh-paths` | paths is pure path computation with zero IO; a persisting identity capability would pollute the package boundary | + +## Consequences + +- One `$DSH_HOME` is one stable user in the OTel feed; separate homes are separate users by construction, with no cross-home linking mechanism. +- The OTel feed and the launcher feed each hold their own id (`.userid` vs `telemetry.json`) and cannot be correlated — the direct cost of not extracting a shared package, to be unified when a real correlation need appears. +- Deleting `.userid` resets the identity (effective next launch); on an unwritable home each process holds its own in-memory id until the home becomes writable. +- The [default-mount Note](2026-07-31-web-telemetry-default-mount.md)'s identity follow-up is closed for the anonymous-user-id part by this decision; hostname/surface dimensions, the redaction rule, and the usage-metrics track remain open. diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md new file mode 100644 index 0000000000..9ff6cf35a9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md @@ -0,0 +1,44 @@ +# Agent Note: telemetry 匿名用户 id($DSH_HOME/.userid)与 OTel Resource user.id + +Status: implemented + +[English](2026-07-31-telemetry-anonymous-user-id.md) | 中文 + +## Problem + +session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry-default-mount.md)),但 OTel Resource 只有 `service.name`/`service.version`,没有任何用户级标识——接收端无法按用户聚合、无法数活跃用户。此前唯一相关口径是一条未实现的「hostname/本机 IP 哈希派生 user.id」裁定;dsh-sdk 工具链另有自用的匿名 id(`$DSH_HOME/telemetry.json`),但那是 launcher 回流的私有事实,与 OTel 回流无关。需要给 OTel 回流一个语义干净的匿名用户身份。 + +## Decision + +`session-telemetry-otel` 包内模块 `src/user-id.ts` 是 OTel 回流用户身份的属主:`getOrCreateAnonymousUserId()` 返回 `$DSH_HOME/.userid`(`resolveDshHome` 解析,`$DSH_HOME` > `~/.dsh`)中的裸 UUID 行,首用生成随机 UUID v4 并落盘;backend 构造时把它作为 Resource 的 `user.id`(OTel semconv 标准用户属性)随每批导出携带一次。该身份只属于 OTel 回流;dsh-sdk launcher telemetry 保留自己的匿名 id 存储(`telemetry.json`),两者不共享(初版曾做公用 util 包统一两条回流,用户复议后收回:在有第二个真实消费者之前不抽公共包,回流关联需求出现时再议)。 + +| 裁定 | 取值 | 理由 | +|---|---|---| +| id 来源 | 随机 UUID v4,绝不从 hostname/网络地址/git remote 派生 | 派生 id 可反查,「匿名」名不副实 | +| 存储形态 | `.userid` 裸 UUID 行 + 换行,无 JSON 包装 | 身份是独立事实,不挂在某条 telemetry 链路的文件命名/格式下 | +| 读写形态 | 同步 IO + 进程内按解析后文件路径 memo | `TelemetryOtel` 构造函数是同步的(async 迫使插件装载改形);一进程一次盘 IO,运行中删文件不影响本进程 | +| 并发首启 | `wx` 独占写裁决,落败方重读胜者 id | 覆盖常见并发(重读撞进胜者建档-写入微秒窗仍可能各持一 id 一次运行,下次启动收敛到落盘值——telemetry 级后果,接受) | +| 丢失语义 | 文件被删 → 下次启动换新 id,接受丢失 | 匿名身份无恢复价值;可恢复性要求派生材料,与匿名冲突 | +| 写失败 | best-effort 返回内存 id | telemetry 永不因 home 只读被阻塞 | +| 上报位置 | Resource 属性,非逐条 attributes | 每批一次即够接收端按 Resource 维度聚合;逐条注入要动 seam 契约且涨 wire 体积 | +| semconv 依赖 | 不引 `@opentelemetry/semantic-conventions` 包 | 一个字符串常量不值一个依赖 | +| 落点 | `session-telemetry-otel` 包内模块,非公共 util 包 | 仓规「有第二个真实消费者才拆包」;sdk launcher 回流保留自有存储,无现实关联需求 | +| 单独开关 | 无 | 身份跟随 telemetry 整体开关(`DSH_TELEMETRY_DISABLED`);关 telemetry 即整体不报 | + +## Alternatives considered + +| 被拒 | 一句话理由 | +|---|---| +| hostname/IP 哈希派生 id(此前口径) | 可反查即非匿名;随机 UUID 语义干净,用户裁决取代 | +| user.id 放每条 record 的 attributes(Claude Code 形态) | 要动 session-telemetry seam 契约或逐条注入,wire 体积涨;Resource 每批一次已满足聚合 | +| 公用 util 包统一两条回流(初版实现) | 唯一现实消费者是 OTel backend;sdk launcher 换用它只是为统一而统一——用户复议收回,回流关联需求出现时再抽包 | +| 复用 telemetry.json 不新建文件 | 文件名/JSON 格式把身份挂在 launcher 链路命名下;OTel 回流身份是独立事实 | +| AppCLIEntry 读好 id 经 config patch 注入 | 每个 surface 入口都要接线;config 里传运行时事实与部署配置混淆 | +| 挂进 `@deepseek-ai/dsh-paths` | paths 是纯路径计算零 IO;带持久化的身份能力会污染包边界 | + +## Consequences + +- 一个 `$DSH_HOME` 在 OTel 回流中是一个稳定用户;不同 home 在构造上就是不同用户,无跨 home 关联机制。 +- OTel 回流与 launcher 回流各有各的 id(`.userid` 与 `telemetry.json`),无法互相关联——这是「不抽公共包」的直接代价,等真实关联需求出现再统一。 +- 删除 `.userid` 即重置身份(下次启动生效);home 不可写时每进程各自持有一个内存 id 直至恢复可写。 +- [默认挂载 Note](2026-07-31-web-telemetry-default-mount.md) 的身份 follow-up 中「匿名用户 id」项由本决定关闭;hostname/surface 维度与脱敏规则、usage-metrics track 仍是待办。 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml index 97793a906a..dd15a55a21 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.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/implemented/feature/2026-07-31-web-telemetry-default-mount.md -2026-07-31-web-telemetry-default-mount.md: 6c1fdaa8719ee01726b51db9a469ff659cbac476 -2026-07-31-web-telemetry-default-mount.zh.md: b447832527ba9731097cd0776060db11ee4dfc30 +2026-07-31-web-telemetry-default-mount.md: e9ec7d0cda37db44e753c9aee572763b7e24ada6 +2026-07-31-web-telemetry-default-mount.zh.md: 68b411d0668772ce81d7f323c2d286714a223ca4 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md index 6c1fdaa871..e9ec7d0cda 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md @@ -35,5 +35,5 @@ The keyless integration test `apps/cli/tests/telemetry-web.e2e.ts` pins the depl ## Consequences - A developer running `dsh web` without a local collector POSTs to the production endpoint every 10s (silent failure when unreachable; no OTel diag logger is registered); local development sets `DSH_TELEMETRY_DISABLED=1` or points `DSH_TELEMETRY_OTLP_URL` locally. -- **No redaction rule is mounted yet**: exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `telemetry/record` rules first — the redaction rule, identity Resource attributes (hostname / anonymous user id / surface), and the usage-metrics track are the explicit follow-ups of this decision. +- **No redaction rule is mounted yet**: exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `telemetry/record` rules first — the redaction rule, the remaining identity Resource attributes (hostname / surface; the anonymous user id shipped via the [anonymous-user-id Note](2026-07-31-telemetry-anonymous-user-id.md)), and the usage-metrics track are the explicit follow-ups of this decision. - Test rigs reusing this tree (e.g. `apps/web/tests/scaffold.ts`) must explicitly disable the row, or fixture sessions stream to whatever collector the environment happens to name. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md index b447832527..68b411d066 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md @@ -35,5 +35,5 @@ Status: implemented ## Consequences - 无本地 collector 的开发者跑 `dsh web` 会对生产 endpoint 每 10s 发一次 POST(联不通则静默失败,OTel diag logger 未注册);本地开发设 `DSH_TELEMETRY_DISABLED=1` 或 `DSH_TELEMETRY_OTLP_URL` 指本地。 -- **当前零脱敏规则挂载**:导出即原始捕获副本(用户/助手消息全文、工具参数与结果、system prompt、`session.cwd` 本地路径)。跨信任边界前必须挂 `telemetry/record` 规则——脱敏规则、身份 Resource 维度(hostname/匿名 user id/surface)、使用数据 metrics 轨三件是本决策明确的后续工作。 +- **当前零脱敏规则挂载**:导出即原始捕获副本(用户/助手消息全文、工具参数与结果、system prompt、`session.cwd` 本地路径)。跨信任边界前必须挂 `telemetry/record` 规则——脱敏规则、其余身份 Resource 维度(hostname/surface;匿名 user id 已由[匿名用户 id Note](2026-07-31-telemetry-anonymous-user-id.md)落地)、使用数据 metrics 轨是本决策明确的后续工作。 - 复用这棵树的测试载具(如 `apps/web/tests/scaffold.ts`)须显式关停该行,否则 fixture 会话会流向 env 里碰巧存在的 collector。 diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 5e0940d241..041e7939bc 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -105,7 +105,9 @@ # DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty # DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the # process out (the launchers patch the row disabled; config cannot disable -# a row). The exporter/processor values bound the shutdown drain to ~1s +# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid, +# random UUID; delete the file to reset the identity) as the Resource's +# user.id. The exporter/processor values 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), maxExportBatchSize == maxQueueSize diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4b9b42df34..ce0d6d393e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1229,7 +1229,7 @@ export interface Config { Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:40`](../packages/telemetry/session-telemetry-otel/src/index.ts) +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:41`](../packages/telemetry/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/docs/module-graph.md b/docs/module-graph.md index 1a75d881e7..004ca5aa03 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -682,8 +682,10 @@ flowchart TD pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout + pkg_session_telemetry_otel --> pkg_brand pkg_session_telemetry_otel --> pkg_invariants pkg_session_telemetry_otel --> pkg_llm + pkg_session_telemetry_otel --> pkg_paths pkg_session_telemetry_otel --> pkg_session pkg_session_telemetry_otel --> pkg_session_telemetry pkg_agent_loop --> pkg_agent @@ -1157,7 +1159,7 @@ flowchart TD | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | -| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index b1a2052a3f..db61f28a67 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/README.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 packages/telemetry/session-telemetry-otel/README.md -README.md: 9b208e291e77bee50d9d4fd14808268dca75f2db -README.zh.md: 76de1bf1ad58a0239907f3b63c672177874c7966 +README.md: ad4a97868c28dc3873c839490aa506271459e249 +README.zh.md: f1ad73ddf66aacc30a9024a9290df2c44686efe9 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 9b208e291e..ad4a97868c 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. It composes the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and maps each record the seam hands over onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use. +The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. It composes the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and maps each record the seam hands over onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use, plus `user.id` — the harness home's anonymous user id this package owns (`src/user-id.ts`: `$DSH_HOME/.userid`, a random UUID minted on first use; deleting the file resets the identity), carried once per export batch on the Resource rather than per record. ## Config diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 76de1bf1ad..f1ad73ddf6 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。它原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把 seam 交接过来的每条记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源。 +[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。它原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把 seam 交接过来的每条记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm` 的 `APP_IDENTITY`,与归因标头同源;另有 `user.id`——本包自有的 harness home 匿名用户 id(`src/user-id.ts`:`$DSH_HOME/.userid`,首用生成随机 UUID;删除该文件即重置身份),随 Resource 每批导出携带一次而非逐条携带。 ## 配置 diff --git a/packages/telemetry/session-telemetry-otel/package.json b/packages/telemetry/session-telemetry-otel/package.json index 7be8c04ce4..49e9ef6cad 100644 --- a/packages/telemetry/session-telemetry-otel/package.json +++ b/packages/telemetry/session-telemetry-otel/package.json @@ -36,16 +36,20 @@ "schemastery": "^3.18.0" }, "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-telemetry": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-telemetry": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 85dd75f275..55caa1037b 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -17,6 +17,7 @@ import z from 'schemastery' import type { Context } from 'cordis' import { Telemetry, TelemetryCoordinator, type TelemetryRecord, type TelemetrySeverity } from '@deepseek-ai/dsh-session-telemetry' import { APP_IDENTITY } from '@deepseek-ai/dsh-llm' +import { getOrCreateAnonymousUserId } from './user-id.ts' import { BatchLogRecordProcessor, LoggerProvider, @@ -118,6 +119,10 @@ export class TelemetryOtel extends Telemetry { resource: resourceFromAttributes({ 'service.name': APP_IDENTITY.product, 'service.version': APP_IDENTITY.version, + // OTel semconv's standard user attribute, carried once per export + // batch on the Resource rather than per record: the collector + // aggregates by Resource, and the id is process-stable anyway. + 'user.id': getOrCreateAnonymousUserId(), }), processors: [ new BatchLogRecordProcessor({ diff --git a/packages/telemetry/session-telemetry-otel/src/user-id.ts b/packages/telemetry/session-telemetry-otel/src/user-id.ts new file mode 100644 index 0000000000..f06d9856fe --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/src/user-id.ts @@ -0,0 +1,103 @@ +/** + * Per-harness-home anonymous user id for the OTel Resource. + * + * The id is a random UUID persisted as a bare line in `.userid` inside the + * harness home resolved by {@link resolveDshHome} (`$DSH_HOME` > `~/.dsh`), + * and never derived from the hostname, network address, git remote, or any + * other identifying source — a derived id would make "anonymous" a fiction. + * The id is scoped to the harness home, not the machine: every process + * sharing one `$DSH_HOME` reports the same id, and deleting the file simply + * mints a fresh identity on the next launch (loss is accepted by design). + * This identity belongs to the OTel feed alone; the dsh-sdk launcher + * telemetry keeps its own separate store. + * + * Reads and writes are synchronous so the backend constructor can call this + * on its boot path, and the result is memoized per resolved file path: one + * process touches the disk once, and a file deleted mid-run keeps the + * process's id until the next launch. + * + * @module @deepseek-ai/dsh-session-telemetry-otel/user-id + */ + +import { randomUUID } from 'node:crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import type { Branded } from '@deepseek-ai/dsh-brand' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' + +/** A harness-home-scoped anonymous user id (random UUID v4). */ +export type AnonymousUserId = Branded<'AnonymousUserId'> + +/** File inside the harness home storing the id: a bare UUID line, no wrapper format. */ +export const USER_ID_FILE_NAME = '.userid' + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** Ambient seams for locating and generating the id; every field has a default. */ +export interface AnonymousUserIdOptions { + /** Environment consulted for `DSH_HOME`; defaults to `process.env`. */ + env?: NodeJS.ProcessEnv + /** UUID generator; defaults to `crypto.randomUUID` (test seam). */ + randomUUID?: () => string +} + +/** Process-lifetime memo keyed by resolved file path, so distinct test homes never share an id. */ +const memo = new Map() + +/** Read a valid persisted id from the file, or `undefined` when absent/corrupt. */ +function readPersistedId(file: string): AnonymousUserId | undefined { + let text: string + try { + text = readFileSync(file, 'utf8') + } catch { + // Absent or unreadable: the caller mints and persists a fresh id. + return undefined + } + const value = text.trim() + return UUID_PATTERN.test(value) ? (value as AnonymousUserId) : undefined +} + +/** + * Return the harness home's anonymous user id, creating and persisting one on + * first use. A concurrent first launch is settled by an exclusive-create + * write: the loser rereads the winner's id. (A reread landing in the winner's + * narrow create-to-write window can still yield two per-process ids for that + * run; the next launch converges on the persisted one.) Persistence is + * best-effort — a write failure (read-only home) still returns a usable id + * for the current run so telemetry is never blocked. + * @param options - home-location and UUID-generation seams. + * @returns the stable per-harness-home anonymous user id. + */ +export function getOrCreateAnonymousUserId(options: AnonymousUserIdOptions = {}): AnonymousUserId { + const file = join(resolveDshHome(undefined, options.env ?? process.env), USER_ID_FILE_NAME) + const cached = memo.get(file) + if (cached !== undefined) return cached + + let id = readPersistedId(file) + if (id === undefined) { + const generate = options.randomUUID ?? randomUUID + const created = generate() as AnonymousUserId + try { + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, `${created}\n`, { encoding: 'utf8', flag: 'wx' }) + id = created + } catch { + // A wx refusal (EEXIST) covers both a concurrent winner and a + // pre-existing corrupt file: the reread adopts a valid winner, and an + // invalid reread falls through to the overwrite path. Non-EEXIST + // failures (read-only home) land there too, accepted best-effort below. + id = readPersistedId(file) + if (id === undefined) { + try { + writeFileSync(file, `${created}\n`, 'utf8') + } catch { + // Best-effort persistence: keep the fresh id in memory even when the + // home is unwritable, so this run still reports a consistent id. + } + id = created + } + } + } + memo.set(file, id) + return id +} diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index cccb90ed43..6be7f81977 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -5,11 +5,15 @@ * for the default-exported Service class. */ -import { afterEach, describe, expect, it } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' import { createServer, type Server } from 'node:http' import { once } from 'node:events' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { gunzipSync } from 'node:zlib' import { Context } from 'cordis' +import { getOrCreateAnonymousUserId } from '../src/user-id.ts' import Loader from '@cordisjs/plugin-loader' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import TelemetryOtel, { Config } from '../src/index.ts' @@ -37,6 +41,21 @@ interface OtlpLogsRequest { const servers: Server[] = [] +// The backend resolves the harness home's anonymous user id at construction; +// pin DSH_HOME to a temp dir so the suite never touches the ambient ~/.dsh. +let tempHome: string +let previousDshHome: string | undefined +beforeAll(() => { + tempHome = mkdtempSync(join(tmpdir(), 'dsh-otel-home-')) + previousDshHome = process.env.DSH_HOME + process.env.DSH_HOME = tempHome +}) +afterAll(() => { + if (previousDshHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousDshHome + rmSync(tempHome, { recursive: true, force: true }) +}) + afterEach(async () => { for (const server of servers.splice(0)) { server.close() @@ -104,6 +123,7 @@ describe('TelemetryOtel wire', () => { const resource = first.body.resourceLogs[0]!.resource.attributes expect(resource).toContainEqual({ key: 'service.name', value: { stringValue: 'deepseek-harness' } }) + expect(resource).toContainEqual({ key: 'user.id', value: { stringValue: getOrCreateAnonymousUserId() } }) const records = allRecords(captures) const ledger = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel') diff --git a/packages/telemetry/session-telemetry-otel/tests/user-id.spec.ts b/packages/telemetry/session-telemetry-otel/tests/user-id.spec.ts new file mode 100644 index 0000000000..f4d9505546 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tests/user-id.spec.ts @@ -0,0 +1,107 @@ +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + USER_ID_FILE_NAME, + getOrCreateAnonymousUserId, +} from '../src/user-id.ts' + +const dirs: string[] = [] + +function tempHome(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-userid-')) + dirs.push(dir) + return dir +} + +afterEach(() => { + for (const dir of dirs.splice(0)) { + chmodSync(dir, 0o700) + rmSync(dir, { recursive: true, force: true }) + } +}) + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +describe('getOrCreateAnonymousUserId', () => { + it('creates, persists, and returns a bare UUID line on first use', () => { + const home = tempHome() + const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } }) + expect(id).toMatch(UUID) + expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`) + }) + + it('creates the home directory when missing', () => { + const home = join(tempHome(), 'nested', 'home') + const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } }) + expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`) + }) + + it('returns the persisted id on subsequent calls, tolerating surrounding whitespace', () => { + const home = tempHome() + const existing = '01234567-89ab-4cde-8f01-23456789abcd' + writeFileSync(join(home, USER_ID_FILE_NAME), ` ${existing}\n\n`, 'utf8') + expect(getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })).toBe(existing) + }) + + it('overwrites a corrupt file with a fresh id', () => { + const home = tempHome() + writeFileSync(join(home, USER_ID_FILE_NAME), 'not-a-uuid\n', 'utf8') + const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } }) + expect(id).toMatch(UUID) + expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`) + }) + + it('adopts a concurrent winner: exclusive create loses to an id written after the initial read', () => { + const home = tempHome() + const winner = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + const file = join(home, USER_ID_FILE_NAME) + // The generator seam runs between the initial read (absent) and the wx + // write, so planting the winner here simulates the concurrent first launch. + const id = getOrCreateAnonymousUserId({ + env: { DSH_HOME: home }, + randomUUID: () => { + writeFileSync(file, `${winner}\n`, 'utf8') + return 'ffffffff-0000-4000-8000-000000000000' + }, + }) + expect(id).toBe(winner) + }) + + it('returns a usable id when the home is unwritable, without persisting', () => { + const home = tempHome() + const blocked = join(home, 'blocked') + mkdirSync(blocked) + chmodSync(blocked, 0o500) + const id = getOrCreateAnonymousUserId({ env: { DSH_HOME: blocked } }) + expect(id).toMatch(UUID) + expect(existsSync(join(blocked, USER_ID_FILE_NAME))).toBe(false) + }) + + it('memoizes per resolved home for the process lifetime: one read, deletion-proof', () => { + const home = tempHome() + const first = getOrCreateAnonymousUserId({ env: { DSH_HOME: home } }) + rmSync(join(home, USER_ID_FILE_NAME)) + expect(getOrCreateAnonymousUserId({ env: { DSH_HOME: home } })).toBe(first) + }) + + it('keeps distinct homes on distinct ids', () => { + const a = getOrCreateAnonymousUserId({ env: { DSH_HOME: tempHome() } }) + const b = getOrCreateAnonymousUserId({ env: { DSH_HOME: tempHome() } }) + expect(a).not.toBe(b) + }) + + it('reads process.env by default', () => { + const home = tempHome() + const previous = process.env.DSH_HOME + process.env.DSH_HOME = home + try { + const id = getOrCreateAnonymousUserId() + expect(readFileSync(join(home, USER_ID_FILE_NAME), 'utf8')).toBe(`${id}\n`) + } finally { + if (previous === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previous + } + }) +}) diff --git a/packages/telemetry/session-telemetry-otel/tsconfig.json b/packages/telemetry/session-telemetry-otel/tsconfig.json index 9512133cf7..8acfe15b33 100644 --- a/packages/telemetry/session-telemetry-otel/tsconfig.json +++ b/packages/telemetry/session-telemetry-otel/tsconfig.json @@ -26,6 +26,12 @@ { "path": "../session-telemetry" }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/paths" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96a8df6f8f..b6a697dbb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5199,12 +5199,18 @@ importers: '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session