fix(feedback): report shared anonymous user id
This commit is contained in:
@@ -35,23 +35,21 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-command-feedback": "^0.0.1",
|
||||
"@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",
|
||||
"@deepseek-ai/dsh-user-id": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-feedback": "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:^",
|
||||
"@deepseek-ai/dsh-user-id": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type TelemetrySeverity,
|
||||
} from '@deepseek-ai/dsh-session-telemetry'
|
||||
import { APP_IDENTITY } from '@deepseek-ai/dsh-llm'
|
||||
import { getOrCreateAnonymousUserId } from './user-id.ts'
|
||||
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
|
||||
import {
|
||||
BatchLogRecordProcessor,
|
||||
LoggerProvider,
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* 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 hooks 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 hook). */
|
||||
randomUUID?: () => string
|
||||
}
|
||||
|
||||
/** Process-lifetime memo keyed by resolved file path, so distinct test homes never share an id. */
|
||||
const memo = new Map<string, AnonymousUserId>()
|
||||
|
||||
/** 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 hooks.
|
||||
* @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
|
||||
}
|
||||
@@ -13,7 +13,7 @@ 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 { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { recordFeedback } from '@deepseek-ai/dsh-command-feedback'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { existsSync, 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)) {
|
||||
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 hook 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 cannot contain files, without persisting', () => {
|
||||
const home = tempHome()
|
||||
const blocked = join(home, 'blocked')
|
||||
writeFileSync(blocked, 'occupied\n')
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -30,10 +30,7 @@
|
||||
"path": "../session-telemetry"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
"path": "../user-id"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
Reference in New Issue
Block a user