import type { ObservMeConfig } from "../config/schema.ts"; import { readDiagnosticMessage, sanitizeDiagnosticText } from "../diagnostics/sanitize.ts"; import type { AgentLineageContext } from "../pi/agent-lineage.ts"; import type { BoundedOtelOperationResult, OtelOperationSettlement, OtelShutdownLogSink, ShutdownableOtelSdk, } from "./shutdown.ts"; import { flushOtelSdk, shutdownOtelSdk } from "./shutdown.ts"; // `failed` is terminal for startup; shutdown cleanup can remain owned for observation or retry. export type OtelSdkLifecycleState = | "idle" | "starting" | "started" | "failed" | "shutting_down" | "shutdown_pending" | "shutdown_failed" | "shutdown"; export interface StartOtelSdkFactoryOptions { readonly config: ObservMeConfig; readonly agent?: AgentLineageContext; } export interface SessionScopedOtelSdk extends ShutdownableOtelSdk { start?: () => Promise | void; } export type SessionScopedOtelSdkFactory = (options: StartOtelSdkFactoryOptions) => SessionScopedOtelSdk; export interface ObservMeOtelSdkControllerOptions { readonly config: ObservMeConfig; readonly agent?: AgentLineageContext; readonly sdkFactory?: SessionScopedOtelSdkFactory; readonly logger?: OtelShutdownLogSink; } export interface ObservMeOtelSdkControllerSnapshot { readonly state: OtelSdkLifecycleState; readonly started: boolean; readonly shutdown: boolean; } export type OtelStartupCleanupRetry = () => Promise; export class ObservMeOtelSdkController { readonly #config: ObservMeConfig; readonly #agent?: AgentLineageContext; readonly #sdkFactory: SessionScopedOtelSdkFactory; readonly #logger?: OtelShutdownLogSink; #sdk?: SessionScopedOtelSdk; #startPromise?: Promise; #shutdownPromise?: Promise; #pendingShutdown?: Promise; #startupCleanupResult?: BoundedOtelOperationResult; #state: OtelSdkLifecycleState = "idle"; constructor(options: ObservMeOtelSdkControllerOptions) { this.#config = options.config; this.#agent = options.agent; this.#sdkFactory = options.sdkFactory ?? createNoopSessionScopedOtelSdk; this.#logger = options.logger; } get state(): OtelSdkLifecycleState { return this.#state; } get sdk(): SessionScopedOtelSdk | undefined { return this.#sdk; } snapshot(): ObservMeOtelSdkControllerSnapshot { return { state: this.#state, started: this.#state === "started", shutdown: this.#state === "shutdown", }; } async start(): Promise { if (this.#state === "started" && this.#sdk) return this.#sdk; if (this.#state === "starting" && this.#startPromise) return this.#startPromise; if (this.#state === "failed") throw new Error("ObservMe OTEL SDK controller cannot be restarted after failed startup."); if (this.#state === "shutdown") throw new Error("ObservMe OTEL SDK controller cannot be restarted after shutdown."); if (this.#state !== "idle") { throw new Error(`ObservMe OTEL SDK controller cannot start while cleanup state is ${this.#state}.`); } this.#state = "starting"; this.#startPromise = this.startOnce(); return this.#startPromise; } private async startOnce(): Promise { try { this.#sdk = this.#sdkFactory({ config: this.#config, agent: this.#agent }); await this.#sdk.start?.(); this.#state = "started"; return this.#sdk; } catch (error) { if (error instanceof ObservMeOtelStartupError) { this.#startupCleanupResult = error.cleanup ?? completedShutdown(); } else { this.#startupCleanupResult = sanitizeStartupCleanupResult( await shutdownOtelSdk( this.#sdk, this.#config.shutdown.flushTimeoutMs, this.#logger, ), ); } this.#state = "failed"; const cleanup = this.#startupCleanupResult; if (operationCompleted(cleanup)) { this.#sdk = undefined; throw toOtelStartupError(error, cleanup); } this.observeFailedStartupCleanup(cleanup); throw toOtelStartupError(error, cleanup, this.retryFailedStartupCleanup.bind(this)); } finally { this.#startPromise = undefined; } } async flush(timeoutMs: number = this.#config.shutdown.flushTimeoutMs): Promise { return flushOtelSdk(this.#sdk, timeoutMs, this.#logger); } async shutdown(timeoutMs: number = this.#config.shutdown.flushTimeoutMs): Promise { if (this.#state === "shutdown") return completedShutdown(); if (this.#state === "failed" && operationCompleted(this.#startupCleanupResult)) { return this.#startupCleanupResult ?? completedShutdown(); } if (this.#state === "shutting_down" && this.#shutdownPromise) return this.#shutdownPromise; if (this.#pendingShutdown) return pendingShutdown(this.#pendingShutdown); this.#state = "shutting_down"; this.#shutdownPromise = this.shutdownOnce(timeoutMs); return this.#shutdownPromise; } private async shutdownOnce(timeoutMs: number): Promise { try { const result = await shutdownOtelSdk(this.#sdk, timeoutMs, this.#logger); this.applyShutdownResult(result); return result; } finally { this.#shutdownPromise = undefined; } } private retryFailedStartupCleanup(): Promise { return this.shutdown(this.#config.shutdown.flushTimeoutMs); } private observeFailedStartupCleanup(result: BoundedOtelOperationResult): void { if (!result.timedOut || !result.settlement) return; this.#pendingShutdown = result.settlement; void result.settlement.then(this.observePendingFailedStartupCleanup.bind(this, result.settlement)); } private observePendingFailedStartupCleanup( pending: Promise, settlement: OtelOperationSettlement, ): void { if (this.#pendingShutdown !== pending) return; this.#pendingShutdown = undefined; this.#startupCleanupResult = sanitizeStartupCleanupResult(settlement); if (operationCompleted(settlement)) this.#sdk = undefined; } private applyShutdownResult(result: BoundedOtelOperationResult): void { if (result.timedOut && result.settlement) { this.#pendingShutdown = result.settlement; this.#state = "shutdown_pending"; void result.settlement.then(this.observePendingShutdown.bind(this, result.settlement)); return; } if (result.error || !result.completed) { this.#state = "shutdown_failed"; return; } this.releaseShutdownOwnership(); } private observePendingShutdown( pending: Promise, settlement: OtelOperationSettlement, ): void { if (this.#pendingShutdown !== pending) return; this.#pendingShutdown = undefined; if (settlement.error || !settlement.completed) { this.#state = "shutdown_failed"; return; } this.releaseShutdownOwnership(); } private releaseShutdownOwnership(): void { this.#sdk = undefined; this.#state = "shutdown"; } } export function toOtelStartupError( error: unknown, cleanup?: BoundedOtelOperationResult, retryCleanup?: OtelStartupCleanupRetry, ): ObservMeOtelStartupError { if (error instanceof ObservMeOtelStartupError && !cleanup && !retryCleanup) return error; const safeCleanup = cleanup ? sanitizeStartupCleanupResult(cleanup) : undefined; if (error instanceof ObservMeOtelStartupError) { return new ObservMeOtelStartupError( error.message, safeCleanup ?? error.cleanup, retryCleanup ?? error.retryCleanup, ); } const detail = sanitizeDiagnosticText(readDiagnosticMessage(error)); return new ObservMeOtelStartupError( `ObservMe OTEL startup failed: ${detail}. ${startupCleanupGuidance(safeCleanup)}`, safeCleanup, retryCleanup, ); } export class ObservMeOtelStartupError extends Error { readonly cleanup?: BoundedOtelOperationResult; readonly retryCleanup?: OtelStartupCleanupRetry; constructor( message: string, cleanup?: BoundedOtelOperationResult, retryCleanup?: OtelStartupCleanupRetry, ) { super(message); this.name = "ObservMeOtelStartupError"; this.cleanup = cleanup; this.retryCleanup = retryCleanup; } } export function createOtelSdkController(options: ObservMeOtelSdkControllerOptions): ObservMeOtelSdkController { return new ObservMeOtelSdkController(options); } export async function startOtelSdk(options: ObservMeOtelSdkControllerOptions): Promise { const controller = createOtelSdkController(options); await controller.start(); return controller; } export function createNoopSessionScopedOtelSdk(): SessionScopedOtelSdk { return new NoopSessionScopedOtelSdk(); } function startupCleanupGuidance(cleanup: BoundedOtelOperationResult | undefined): string { if (!cleanup) return "Check OTLP settings and Collector availability before retrying."; if (cleanup.timedOut) { return "Cleanup exceeded its timeout; telemetry startup remains deferred until cleanup settles or succeeds on retry."; } if (cleanup.error) return "Cleanup also failed; telemetry startup remains deferred until cleanup succeeds on retry."; return "Started providers were cleaned up; check OTLP settings and Collector availability before retrying."; } function operationCompleted(result: BoundedOtelOperationResult | undefined): boolean { return result?.completed === true && !result.timedOut && !result.error; } function completedShutdown(): BoundedOtelOperationResult { return { operation: "shutdown", completed: true, timedOut: false }; } function pendingShutdown(settlement: Promise): BoundedOtelOperationResult { return { operation: "shutdown", completed: false, timedOut: true, settlement }; } function sanitizeStartupCleanupResult(result: BoundedOtelOperationResult): BoundedOtelOperationResult { if (!result.error) return result; return { ...result, error: new Error(sanitizeDiagnosticText(readDiagnosticMessage(result.error))), }; } class NoopSessionScopedOtelSdk implements SessionScopedOtelSdk { async start(): Promise { await Promise.resolve(); } async forceFlush(): Promise { await Promise.resolve(); } async shutdown(): Promise { await Promise.resolve(); } }