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:
@@ -7,8 +7,8 @@
|
||||
* boundary axiom, everything downstream of that call (batching, retry,
|
||||
* queueing, loss policy) is the SDK's documented behavior, configured
|
||||
* verbatim through the `exporter`/`processor` passthroughs. This package owns
|
||||
* only whether capture is live, feedback-triggered from the canonical log, or
|
||||
* disabled.
|
||||
* capture mode and an outer shutdown deadline: the SDK's export timeout does
|
||||
* not bound its preceding `forceFlush()` wait.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-telemetry-otel
|
||||
*/
|
||||
@@ -25,6 +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 {
|
||||
BatchLogRecordProcessor,
|
||||
LoggerProvider,
|
||||
@@ -72,9 +73,9 @@ function assertNever(value: never): never {
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin configuration: one sharing policy plus two verbatim SDK option
|
||||
* shapes. `exporter.url` is required for modes that upload and unused for
|
||||
* `DISABLED`.
|
||||
* Plugin configuration: one sharing policy, two verbatim SDK option shapes,
|
||||
* and one DSH-owned shutdown bound. Uploading modes validate their endpoint
|
||||
* and shutdown deadline at plugin load; `DISABLED` reads neither.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Sharing policy; defaults to immediate `FULL` delivery. */
|
||||
@@ -94,22 +95,32 @@ export interface Config {
|
||||
* which this plugin fills); the SDK owns and documents these knobs.
|
||||
*/
|
||||
processor?: Omit<BatchLogRecordProcessorOptions, 'exporter'>
|
||||
/** Maximum time spent awaiting the SDK provider's complete shutdown path. */
|
||||
shutdownTimeoutMillis?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Schemastery validator for {@link Config}; cordis runs it before the plugin
|
||||
* starts. Shape-level only — the mode-dependent `exporter.url` check lives in
|
||||
* the constructor so its error message names the field. Both SDK slots are
|
||||
* opaque passthroughs: the SDK owns their shapes and validates its own
|
||||
* options; re-declaring them field-by-field here would violate the boundary
|
||||
* axiom (and silently drop every field not re-declared).
|
||||
* starts. Shape-level only — load-bearing value checks live in the constructor
|
||||
* so their errors name the fields. Both SDK slots are opaque passthroughs:
|
||||
* the SDK owns their shapes and validates its own options;
|
||||
* re-declaring them field-by-field here would violate the boundary axiom
|
||||
* (and silently drop every field not re-declared).
|
||||
*/
|
||||
export const Config: z<Config> = z.object({
|
||||
mode: z.union(Object.values(TelemetryMode)).default(DEFAULT_TELEMETRY_MODE),
|
||||
exporter: z.any(),
|
||||
processor: z.any(),
|
||||
shutdownTimeoutMillis: z.number(),
|
||||
})
|
||||
|
||||
/** Default outer allowance for the SDK's complete shutdown sequence. */
|
||||
export const DEFAULT_SHUTDOWN_TIMEOUT_MILLIS = 3_000
|
||||
|
||||
// Node clamps larger timer delays to one millisecond. This is a runtime
|
||||
// protocol limit, not a deployment default.
|
||||
const MAX_TIMER_DELAY_MILLIS = 2_147_483_647
|
||||
|
||||
/** Severity mapping from the seam's three-level vocabulary to OTel severity numbers. */
|
||||
const SEVERITY: Record<TelemetrySeverity, { severityNumber: SeverityNumber; severityText: string }> = {
|
||||
info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' },
|
||||
@@ -129,6 +140,7 @@ export class TelemetryOtel extends Telemetry {
|
||||
|
||||
private readonly directEmit: TelemetryBackend['emit']
|
||||
private readonly provider: LoggerProvider | undefined
|
||||
private readonly shutdownTimeoutMillis: number
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
const mode = resolveMode(config.mode)
|
||||
@@ -136,6 +148,7 @@ export class TelemetryOtel extends Telemetry {
|
||||
if (mode === TelemetryMode.DISABLED) {
|
||||
this.directEmit = DROP_RECORD
|
||||
this.provider = undefined
|
||||
this.shutdownTimeoutMillis = DEFAULT_SHUTDOWN_TIMEOUT_MILLIS
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'feedback/record') ctx.logger.warn(DISABLED_FEEDBACK_WARNING)
|
||||
})
|
||||
@@ -164,10 +177,19 @@ export class TelemetryOtel extends Telemetry {
|
||||
if (batchSize !== undefined && (!Number.isInteger(batchSize) || batchSize < 1)) {
|
||||
throw new Error(`session-telemetry-otel: processor.maxExportBatchSize must be a positive integer, got ${String(batchSize)}`)
|
||||
}
|
||||
const shutdownTimeoutMillis = config.shutdownTimeoutMillis ?? DEFAULT_SHUTDOWN_TIMEOUT_MILLIS
|
||||
if (!Number.isFinite(shutdownTimeoutMillis) || shutdownTimeoutMillis <= 0 || shutdownTimeoutMillis > MAX_TIMER_DELAY_MILLIS) {
|
||||
throw new Error(`session-telemetry-otel: shutdownTimeoutMillis must be a positive finite number no greater than ${MAX_TIMER_DELAY_MILLIS}, got ${String(shutdownTimeoutMillis)}`)
|
||||
}
|
||||
this.shutdownTimeoutMillis = shutdownTimeoutMillis
|
||||
this.provider = new LoggerProvider({
|
||||
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({
|
||||
@@ -238,17 +260,29 @@ export class TelemetryOtel extends Telemetry {
|
||||
// the revival Agent Note.
|
||||
|
||||
/**
|
||||
* Delegate disposal to the SDK's shutdown contract: drain the queue and
|
||||
* quiesce. With no concurrent `forceFlush()` in the process (see above),
|
||||
* shutdown's internal drain is complete — everything handed to the SDK
|
||||
* before this call is exported before the exporter closes. In `FULL`, that
|
||||
* includes dispose-time `shutdown` markers; `FEEDBACK_ONLY` creates no ops
|
||||
* records. Awaited (and error-contained) by the coordinator's disposer. A
|
||||
* disabled backend resolves immediately.
|
||||
* @returns resolves when the SDK pipeline has quiesced.
|
||||
* Ask the SDK to drain and quiesce, but reject after the backend-owned
|
||||
* deadline. OTel's processor export timeout wraps `exportCompleted` only;
|
||||
* shutdown awaits `exporter.forceFlush()` first, which can remain pending
|
||||
* when the transport never obtains a socket. The provider promise remains
|
||||
* observed after the deadline so a later rejection cannot become unhandled.
|
||||
* `DISABLED` has no provider and resolves immediately.
|
||||
* @returns resolves when the SDK pipeline quiesces or is disabled, or rejects at the configured deadline.
|
||||
*/
|
||||
shutdown(): Promise<void> {
|
||||
return this.provider === undefined ? Promise.resolve() : this.provider.shutdown()
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.provider === undefined) return
|
||||
const providerShutdown = this.provider.shutdown()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`session-telemetry-otel: provider shutdown exceeded ${this.shutdownTimeoutMillis}ms`))
|
||||
}, this.shutdownTimeoutMillis)
|
||||
})
|
||||
try {
|
||||
await Promise.race([providerShutdown, deadline])
|
||||
} finally {
|
||||
/* v8 ignore else -- the Promise executor assigns timer synchronously before this race starts. */
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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 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
|
||||
}
|
||||
Reference in New Issue
Block a user