From bff581ffb3ee47f25f766d93e04ff1052cc31ac5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:27:36 +0800 Subject: [PATCH 1/3] feat(telemetry): anonymous user id from $DSH_HOME/.userid, reported as OTel Resource user.id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session-telemetry-otel owns the identity as its own module (src/user-id.ts): getOrCreateAnonymousUserId() returns the bare UUID line in $DSH_HOME/.userid, minting a random UUID v4 on first use — synchronous IO with a process-lifetime memo keyed by resolved path, an exclusive-create (wx) settle for concurrent first launches, best-effort on read-only homes, and deletion resetting the identity on the next launch by design. The backend carries the id as the Resource's user.id (the OTel semconv user attribute), once per export batch alongside service.name/service.version. The identity belongs to the OTel feed alone; the dsh-sdk launcher telemetry keeps its own separate store. --- apps/cli/config/base.cordis.yml | 4 +- docs/config-catalog.md | 2 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/README.zh.md | 2 +- .../session-telemetry-otel/package.json | 4 + .../session-telemetry-otel/src/index.ts | 5 + .../session-telemetry-otel/src/user-id.ts | 103 +++++++++++++++++ .../session-telemetry-otel/tests/otel.spec.ts | 22 +++- .../tests/user-id.spec.ts | 107 ++++++++++++++++++ .../session-telemetry-otel/tsconfig.json | 6 + pnpm-lock.yaml | 6 + 12 files changed, 260 insertions(+), 7 deletions(-) create mode 100644 packages/telemetry/session-telemetry-otel/src/user-id.ts create mode 100644 packages/telemetry/session-telemetry-otel/tests/user-id.spec.ts 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 f3d855bcd5..a918750790 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/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 From ec5399e0746ae8f4e3ce33202debda9a072eddd1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:27:41 +0800 Subject: [PATCH 2/3] doc: agent note for the telemetry anonymous user id --- ...7-31-telemetry-anonymous-user-id.i18n.yaml | 6 +++ .../2026-07-31-telemetry-anonymous-user-id.md | 44 +++++++++++++++++++ ...26-07-31-telemetry-anonymous-user-id.zh.md | 44 +++++++++++++++++++ ...7-31-web-telemetry-default-mount.i18n.yaml | 4 +- .../2026-07-31-web-telemetry-default-mount.md | 2 +- ...26-07-31-web-telemetry-default-mount.zh.md | 2 +- 6 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md 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。 From ec3761f66e07f2d1a9ffdb7c95d02b78d9c6fb4b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:33:39 +0800 Subject: [PATCH 3/3] fix: ci --- docs/module-graph.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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) |