fix(cli): bound telemetry shutdown on signals

Refs #1356
This commit is contained in:
fz
2026-08-03 22:08:47 +08:00
parent 8eb97ab47e
commit ef23b88ad4
22 changed files with 454 additions and 70 deletions

View File

@@ -6,8 +6,9 @@
* record handed over by the seam onto `logger.emit()`. Per the seam's
* 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
* adds no knobs of its own on top of them.
* verbatim through the `exporter`/`processor` passthroughs. The one
* backend-owned policy is an outer shutdown deadline: the SDK's export
* timeout does not bound its preceding `forceFlush()` wait.
*
* @module @deepseek-ai/dsh-session-telemetry-otel
*/
@@ -33,10 +34,9 @@ import { resourceFromAttributes } from '@opentelemetry/resources'
const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
/**
* Plugin configuration: two verbatim SDK option shapes plus nothing else.
* `exporter.url` is the one field this package validates itself — required,
* no default, must parse as an `http(s)` URL — because a missing endpoint
* must fail at plugin load, not at first export.
* Plugin configuration: two verbatim SDK option shapes plus one DSH-owned
* shutdown bound. The package validates its endpoint and shutdown deadline
* because both must fail at plugin load rather than at first export or exit.
*/
export interface Config {
/**
@@ -54,21 +54,31 @@ 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 load-bearing `exporter.url` check lives in
* the constructor so its error message names the field. Both slots are opaque
* passthroughs: the SDK owns their shapes and validates its own options;
* 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({
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' },
@@ -90,6 +100,7 @@ export class TelemetryOtel extends Telemetry {
private readonly provider: LoggerProvider
private readonly ledger: Logger
private readonly ops: Logger
private readonly shutdownTimeoutMillis: number
constructor(ctx: Context, config: Config) {
super(ctx)
@@ -115,6 +126,11 @@ 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,
@@ -170,16 +186,26 @@ 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 emitted before this
* call, including the coordinator's dispose-time `shutdown` markers, is
* exported before the exporter closes. Awaited (and error-contained) by
* the coordinator's disposer.
* @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.
* @returns resolves when the SDK pipeline quiesces, or rejects at the configured deadline.
*/
shutdown(): Promise<void> {
return this.provider.shutdown()
async shutdown(): Promise<void> {
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 {
if (timer !== undefined) clearTimeout(timer)
}
}
}