Merge updated feedback base into telemetry stack
# Conflicts: # .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml # .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md # .agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml # docs/config-catalog.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/package.json # packages/feedback/command-feedback/README.i18n.yaml # packages/telemetry/README.i18n.yaml # packages/telemetry/README.md # packages/telemetry/README.zh.md # packages/telemetry/session-telemetry-otel/README.i18n.yaml # packages/telemetry/session-telemetry-otel/README.md # packages/telemetry/session-telemetry-otel/README.zh.md # packages/telemetry/session-telemetry-otel/package.json # packages/telemetry/session-telemetry-otel/src/index.ts # packages/telemetry/session-telemetry-otel/tests/otel.spec.ts # packages/telemetry/session-telemetry/README.i18n.yaml # packages/telemetry/session-telemetry/README.zh.md # pnpm-lock.yaml
This commit is contained in:
@@ -5,11 +5,15 @@
|
||||
* for the default-exported Service class.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, expectTypeOf, it, vi } 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 { recordFeedback } from '@deepseek-ai/dsh-command-feedback'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -39,6 +43,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()
|
||||
@@ -103,8 +122,8 @@ describe('TelemetryOtel wire', () => {
|
||||
const { url, captures } = await mockCollector()
|
||||
const { ctx, fiber } = await boot(url)
|
||||
const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
|
||||
ctx.telemetry.emit({
|
||||
channel: 'ledger',
|
||||
time: Date.now(),
|
||||
@@ -121,6 +140,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')
|
||||
@@ -164,7 +184,7 @@ describe('TelemetryOtel wire', () => {
|
||||
processor: { scheduledDelayMillis: 10 },
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('drain'), { meta: {} })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await arrived.promise
|
||||
|
||||
const disposal = fiber.dispose()
|
||||
@@ -178,6 +198,38 @@ describe('TelemetryOtel wire', () => {
|
||||
expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } })
|
||||
})
|
||||
|
||||
it('bounds the SDK forceFlush wait when an in-flight transport never settles', async () => {
|
||||
const gate = Promise.withResolvers<boolean>()
|
||||
const arrived = Promise.withResolvers<boolean>()
|
||||
const { url, captures } = await mockCollector(async (index) => {
|
||||
if (index === 0) {
|
||||
arrived.resolve(true)
|
||||
await gate.promise
|
||||
}
|
||||
})
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(TelemetryOtel, {
|
||||
exporter: { url, timeoutMillis: 60_000 },
|
||||
processor: { scheduledDelayMillis: 10, exportTimeoutMillis: 60_000 },
|
||||
shutdownTimeoutMillis: 50,
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('bounded-shutdown'), { meta: {} })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await arrived.promise
|
||||
|
||||
const started = performance.now()
|
||||
await fiber.dispose()
|
||||
expect(performance.now() - started).toBeLessThan(1_000)
|
||||
expect(captures).toHaveLength(0)
|
||||
|
||||
// The outer deadline cannot cancel the SDK transport. Let it finish so
|
||||
// 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)
|
||||
})
|
||||
|
||||
it('passes exporter options beyond url and headers through to the SDK exporter', async () => {
|
||||
const { url, captures } = await mockCollector()
|
||||
const ctx = new Context()
|
||||
@@ -189,7 +241,7 @@ describe('TelemetryOtel wire', () => {
|
||||
exporter: { url, compression: 'gzip' },
|
||||
} as Config)
|
||||
const session = ctx.sessions.create(SessionId('gzip'), { meta: {} })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await fiber.dispose()
|
||||
|
||||
expect(captures.length).toBeGreaterThan(0)
|
||||
@@ -204,7 +256,7 @@ describe('TelemetryOtel wire', () => {
|
||||
const { ctx, fiber } = await boot(url)
|
||||
ctx.on('telemetry/record', (_record, next) => ({ ...next(), severity: 'warn' }))
|
||||
const session = ctx.sessions.create(SessionId('warn'), { meta: {} })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
// No flush(): the coordinator's optional-call forwarding no-ops, and the
|
||||
// batch processor owns export cadence end to end (see the backend note).
|
||||
expect('flush' in ctx.telemetry && ctx.telemetry.flush !== undefined).toBe(false)
|
||||
@@ -233,11 +285,11 @@ describe('TelemetryOtel wire', () => {
|
||||
return next()
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('feedback-only'), { meta: {} })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
recordFeedback(session, 'first report')
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
recordFeedback(session, 'second report')
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
await fiber.dispose()
|
||||
|
||||
const types = allRecords(captures).flatMap(({ record }) =>
|
||||
@@ -259,7 +311,7 @@ describe('TelemetryOtel wire', () => {
|
||||
exporter: { url },
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('no-feedback'), { meta: {} })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
ctx.telemetry.emit({
|
||||
channel: 'ledger',
|
||||
time: Date.now(),
|
||||
@@ -292,7 +344,7 @@ describe('TelemetryOtel wire', () => {
|
||||
processor: { maxExportBatchSize: 0 },
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('disabled'), { meta: {} })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
recordFeedback(session, 'local report')
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
@@ -318,7 +370,7 @@ describe('TelemetryOtel wire', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
new TelemetryOtel(ctx, { exporter: { url } })
|
||||
const session = ctx.sessions.create(SessionId('direct-default'), { meta: {} })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
expect(eventTypes(captures)).toContain('turn/start')
|
||||
@@ -345,6 +397,8 @@ describe('TelemetryOtel config fails loud', () => {
|
||||
// splices empty batches forever — dispose would hang, so reject at load.
|
||||
[{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
|
||||
[{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
|
||||
[{ exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/],
|
||||
[{ exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/],
|
||||
])('rejects %j at plugin load', async (config, message) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -366,6 +420,30 @@ describe('TelemetryOtel config fails loud', () => {
|
||||
expect(() => new TelemetryOtel(ctx, config)).toThrow(/unsupported mode "INVALID"/)
|
||||
expect(exporterRead).toBe(false)
|
||||
})
|
||||
|
||||
it('does not read any transport setting in disabled mode', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const transportRead = vi.fn(() => {
|
||||
throw new Error('transport config was read')
|
||||
})
|
||||
const config = {
|
||||
mode: TelemetryMode.DISABLED,
|
||||
get exporter() {
|
||||
return transportRead()
|
||||
},
|
||||
get processor() {
|
||||
return transportRead()
|
||||
},
|
||||
get shutdownTimeoutMillis() {
|
||||
return transportRead()
|
||||
},
|
||||
} as unknown as Config
|
||||
|
||||
new TelemetryOtel(ctx, config)
|
||||
expect(transportRead).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh-session-telemetry-otel real-load-path guard', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user