|
|
@@ -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)
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|