@senad-d/observme 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +24 -8
  3. package/docs/agent-subagent-observability-requirements.md +4 -3
  4. package/docs/compatibility-matrix.md +14 -3
  5. package/docs/configuration.md +2 -2
  6. package/docs/extension-integration.md +20 -13
  7. package/docs/reference/03-pi-event-and-session-model.md +6 -6
  8. package/docs/reference/04-telemetry-semantic-conventions.md +1 -0
  9. package/docs/reference/06-security-privacy-redaction.md +13 -6
  10. package/docs/reference/08-query-grafana-integration.md +30 -7
  11. package/docs/reference/11-deployment-runbooks.md +21 -0
  12. package/docs/reference/12-configuration-reference.md +30 -4
  13. package/docs/review-validation.md +10 -0
  14. package/examples/integrations/subagent-runner.ts +8 -5
  15. package/examples/observme.yaml +2 -2
  16. package/package.json +12 -4
  17. package/src/commands/obs-agents.ts +17 -12
  18. package/src/commands/obs-backfill.ts +2 -2
  19. package/src/commands/obs-command-support.ts +2 -1
  20. package/src/commands/obs-cost.ts +15 -7
  21. package/src/commands/obs-health.ts +22 -2
  22. package/src/commands/obs-loki-summary.ts +4 -8
  23. package/src/commands/obs-session.ts +35 -28
  24. package/src/commands/obs-status.ts +56 -13
  25. package/src/commands/obs-tools.ts +19 -8
  26. package/src/commands/obs-trace.ts +9 -0
  27. package/src/commands/obs.ts +6 -1
  28. package/src/config/bootstrap-project-config.ts +49 -32
  29. package/src/config/defaults.ts +0 -2
  30. package/src/config/load-config.ts +270 -92
  31. package/src/config/project-paths.ts +318 -6
  32. package/src/config/query-limits.ts +10 -0
  33. package/src/config/schema.ts +9 -8
  34. package/src/config/transport-security.ts +107 -0
  35. package/src/config/validate.ts +68 -11
  36. package/src/extension.ts +2 -12
  37. package/src/integration.ts +6 -2
  38. package/src/otel/logs.ts +24 -13
  39. package/src/otel/metrics.ts +24 -13
  40. package/src/otel/otlp-endpoint.ts +41 -2
  41. package/src/otel/otlp-http-options.ts +11 -0
  42. package/src/otel/sdk.ts +155 -9
  43. package/src/otel/shutdown.ts +39 -11
  44. package/src/otel/traces.ts +34 -16
  45. package/src/pi/agent-tree-tracker.ts +14 -1
  46. package/src/pi/compatibility.ts +30 -0
  47. package/src/pi/event-handlers/agent-turn.ts +16 -9
  48. package/src/pi/event-handlers/lifecycle.ts +207 -75
  49. package/src/pi/event-handlers/llm.ts +17 -9
  50. package/src/pi/event-handlers/session-events.ts +53 -19
  51. package/src/pi/event-handlers/tool-bash.ts +21 -27
  52. package/src/pi/handler-internals.ts +16 -26
  53. package/src/pi/handler-runtime.ts +142 -55
  54. package/src/pi/handler-types.ts +30 -15
  55. package/src/pi/handlers.ts +12 -1
  56. package/src/pi/integration-api.ts +27 -15
  57. package/src/pi/session-correlation.ts +167 -0
  58. package/src/pi/subagent-spawn.ts +164 -31
  59. package/src/privacy/redact.ts +574 -68
  60. package/src/privacy/secret-patterns.ts +6 -1
  61. package/src/query/grafana-readiness.ts +18 -2
  62. package/src/query/grafana-transport.ts +150 -27
  63. package/src/query/grafana-url.ts +36 -0
  64. package/src/query/grafana.ts +4 -136
  65. package/src/query/loki.ts +2 -4
  66. package/src/query/prometheus.ts +8 -10
  67. package/src/query/tempo.ts +2 -4
  68. package/src/query/trace-link.ts +186 -0
  69. package/src/safety/display-bounds.ts +84 -0
  70. package/src/semconv/metrics.ts +1 -0
  71. package/src/semconv/values.ts +2 -0
package/src/extension.ts CHANGED
@@ -1,28 +1,18 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { registerObsCommand } from "./commands/obs.ts";
3
+ import { assertObservMePiCapabilities } from "./pi/compatibility.ts";
3
4
  import { registerHandlers } from "./pi/handlers.ts";
4
-
5
- const extensionPiApiCompatibilityErrorMessage =
6
- "ObservMe/Pi API compatibility error: expected Pi ExtensionAPI with on(eventName, handler) and registerCommand(name, options) before registering ObservMe.";
7
5
  const partialInitializationErrorMessage =
8
6
  "ObservMe extension initialization failed while registering /obs after Pi event handlers were already registered. Pi ExtensionAPI does not expose unregister hooks for event handlers or slash commands, so ObservMe cannot roll back partial registration; restart Pi after fixing command registration.";
9
7
 
10
8
  export default function observme(pi: ExtensionAPI): void {
11
- assertRegistrationApiAvailable(pi);
9
+ assertObservMePiCapabilities(pi);
12
10
  // Only the Pi process environment is eligible for launcher-provided lineage.
13
11
  // Session config loading keeps trusted project .env values out of this boundary.
14
12
  registerHandlers(pi, { trustedParentContext: true });
15
13
  registerObsCommandWithPartialInitializationDiagnostic(pi);
16
14
  }
17
15
 
18
- function assertRegistrationApiAvailable(pi: ExtensionAPI): void {
19
- const api = pi as Partial<ExtensionAPI> | null | undefined;
20
-
21
- if (!api || typeof api.on !== "function" || typeof api.registerCommand !== "function") {
22
- throw new TypeError(extensionPiApiCompatibilityErrorMessage);
23
- }
24
- }
25
-
26
16
  function registerObsCommandWithPartialInitializationDiagnostic(pi: ExtensionAPI): void {
27
17
  try {
28
18
  registerObsCommand(pi);
@@ -7,14 +7,18 @@ export type ObservMeSpawnType = "command" | "tool" | "extension" | "unknown";
7
7
  export type ObservMeSpawnReason = "delegated_task" | "parallel_search" | "review" | "tool_wrapper" | "unknown";
8
8
  export type ObservMeAgentWaitReason = "dependency" | "rate_limit" | "child_running" | "unknown";
9
9
  export type ObservMeChildStatus = "starting" | "active" | "completed" | "failed" | "cancelled" | "orphaned";
10
+ export type ObservMeTerminalChildStatus = Extract<ObservMeChildStatus, "completed" | "failed" | "cancelled">;
10
11
  export type ObservMeJoinStatus = "completed" | "failed" | "cancelled" | "timeout" | "unknown" | "waiting";
11
12
  export type ObservMeIntegrationFailureReason =
12
13
  | "session_unavailable"
13
14
  | "invalid_request"
14
15
  | "spawn_already_exists"
16
+ | "child_agent_already_exists"
15
17
  | "wait_already_exists"
16
18
  | "join_already_exists"
17
19
  | "spawn_not_found"
20
+ | "child_agent_mismatch"
21
+ | "invalid_terminal_transition"
18
22
  | "wait_not_found"
19
23
  | "join_not_found"
20
24
  | "operation_failed";
@@ -63,8 +67,8 @@ export interface ObservMeStartedSubagent {
63
67
 
64
68
  export interface ObservMeCompleteSubagentOptions {
65
69
  readonly childAgentId?: string;
66
- readonly childStatus?: ObservMeChildStatus;
67
- readonly outcome?: "completed" | "failed" | "cancelled";
70
+ readonly childStatus?: ObservMeTerminalChildStatus;
71
+ readonly outcome?: ObservMeTerminalChildStatus;
68
72
  }
69
73
 
70
74
  export interface ObservMeFailSubagentOptions {
package/src/otel/logs.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Logger } from "@opentelemetry/api-logs";
2
- import { createNoopLogger, logs } from "@opentelemetry/api-logs";
2
+ import { createNoopLogger } from "@opentelemetry/api-logs";
3
3
  import type { Resource } from "@opentelemetry/resources";
4
4
  import { resourceFromAttributes } from "@opentelemetry/resources";
5
5
  import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
@@ -7,6 +7,7 @@ import type { LogRecordExporter, LogRecordProcessor } from "@opentelemetry/sdk-l
7
7
  import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
8
8
  import type { LogsBatchConfig, ObservMeConfig } from "../config/schema.ts";
9
9
  import { appendOtlpSignalPath } from "./otlp-endpoint.ts";
10
+ import { buildOtlpHttpAgentOptions, type OtlpHttpAgentOptions } from "./otlp-http-options.ts";
10
11
  import type { StartOtelSdkFactoryOptions } from "./sdk.ts";
11
12
 
12
13
  export const OBSERVME_LOGGER_NAME = "@senad-d/observme";
@@ -24,6 +25,7 @@ export interface OtlpLogExporterOptions {
24
25
  readonly url: string;
25
26
  readonly headers: Record<string, string>;
26
27
  readonly timeoutMillis: number;
28
+ readonly httpAgentOptions: OtlpHttpAgentOptions;
27
29
  }
28
30
 
29
31
  export interface LogProviderOptions {
@@ -45,7 +47,6 @@ export type LogProviderFactory = (options: LogProviderOptions) => LogProviderLik
45
47
  export interface ObservMeLogSdkOptions {
46
48
  readonly config: ObservMeConfig;
47
49
  readonly loggerName?: string;
48
- readonly registerGlobal?: boolean;
49
50
  readonly exporterFactory?: LogExporterFactory;
50
51
  readonly processorFactory?: LogRecordProcessorFactory;
51
52
  readonly loggerProviderFactory?: LogProviderFactory;
@@ -62,10 +63,11 @@ const noopLogger = createNoopLogger();
62
63
  export class ObservMeLogSdk {
63
64
  readonly #config: ObservMeConfig;
64
65
  readonly #loggerName: string;
65
- readonly #registerGlobal: boolean;
66
66
  readonly #exporterFactory: LogExporterFactory;
67
67
  readonly #processorFactory: LogRecordProcessorFactory;
68
68
  readonly #loggerProviderFactory: LogProviderFactory;
69
+ #exporter?: LogRecordExporter;
70
+ #processor?: LogRecordProcessor;
69
71
  #provider?: LogProviderLike;
70
72
  #logger: Logger = noopLogger;
71
73
  #state: LogPipelineState = "idle";
@@ -73,7 +75,6 @@ export class ObservMeLogSdk {
73
75
  constructor(options: ObservMeLogSdkOptions) {
74
76
  this.#config = options.config;
75
77
  this.#loggerName = options.loggerName ?? OBSERVME_LOGGER_NAME;
76
- this.#registerGlobal = options.registerGlobal ?? true;
77
78
  this.#exporterFactory = options.exporterFactory ?? createOtlpLogExporter;
78
79
  this.#processorFactory = options.processorFactory ?? createBatchLogRecordProcessor;
79
80
  this.#loggerProviderFactory = options.loggerProviderFactory ?? createLogProvider;
@@ -89,21 +90,20 @@ export class ObservMeLogSdk {
89
90
 
90
91
  start(): void {
91
92
  if (this.#state === "started" || this.#state === "disabled") return;
92
- if (!this.#config.logs.enabled) {
93
+ if (!this.#config.enabled || !this.#config.logs.enabled) {
93
94
  this.#state = "disabled";
94
95
  return;
95
96
  }
96
97
 
97
98
  const wiring = buildLogExporterWiring(this.#config);
98
- const exporter = this.#exporterFactory(wiring.exporter);
99
- const processor = this.#processorFactory(exporter, wiring.batch);
99
+ this.#exporter = this.#exporterFactory(wiring.exporter);
100
+ this.#processor = this.#processorFactory(this.#exporter, wiring.batch);
100
101
  this.#provider = this.#loggerProviderFactory({
101
102
  resource: createLogResource(this.#config),
102
- processors: [processor],
103
+ processors: [this.#processor],
103
104
  forceFlushTimeoutMillis: this.#config.shutdown.flushTimeoutMs,
104
105
  });
105
106
 
106
- if (this.#registerGlobal) logs.setGlobalLoggerProvider(this.#provider);
107
107
  this.#logger = this.#provider.getLogger(this.#loggerName);
108
108
  this.#state = "started";
109
109
  }
@@ -113,9 +113,19 @@ export class ObservMeLogSdk {
113
113
  }
114
114
 
115
115
  async shutdown(): Promise<void> {
116
- await this.#provider?.shutdown?.();
117
- this.#state = "shutdown";
118
- this.#logger = noopLogger;
116
+ if (this.#state === "shutdown") return;
117
+
118
+ try {
119
+ if (this.#provider?.shutdown) await this.#provider.shutdown();
120
+ else if (this.#processor) await this.#processor.shutdown();
121
+ else await this.#exporter?.shutdown();
122
+ } finally {
123
+ this.#provider = undefined;
124
+ this.#processor = undefined;
125
+ this.#exporter = undefined;
126
+ this.#state = "shutdown";
127
+ this.#logger = noopLogger;
128
+ }
119
129
  }
120
130
  }
121
131
 
@@ -125,7 +135,7 @@ export function createLogSessionScopedOtelSdk(options: StartOtelSdkFactoryOption
125
135
 
126
136
  export function buildLogExporterWiring(config: ObservMeConfig): LogExporterWiring {
127
137
  return {
128
- enabled: config.logs.enabled,
138
+ enabled: config.enabled && config.logs.enabled,
129
139
  exporter: buildOtlpLogExporterOptions(config),
130
140
  batch: { ...config.logs.batch },
131
141
  };
@@ -136,6 +146,7 @@ export function buildOtlpLogExporterOptions(config: ObservMeConfig): OtlpLogExpo
136
146
  url: resolveLogEndpoint(config),
137
147
  headers: { ...config.otlp.headers },
138
148
  timeoutMillis: config.otlp.timeoutMs,
149
+ httpAgentOptions: buildOtlpHttpAgentOptions(config),
139
150
  };
140
151
  }
141
152
 
@@ -1,5 +1,5 @@
1
1
  import type { Meter } from "@opentelemetry/api";
2
- import { createNoopMeter, metrics } from "@opentelemetry/api";
2
+ import { createNoopMeter } from "@opentelemetry/api";
3
3
  import type { Resource } from "@opentelemetry/resources";
4
4
  import { resourceFromAttributes } from "@opentelemetry/resources";
5
5
  import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
@@ -7,6 +7,7 @@ import type { IMetricReader, PushMetricExporter } from "@opentelemetry/sdk-metri
7
7
  import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
8
8
  import type { MetricsConfig, ObservMeConfig } from "../config/schema.ts";
9
9
  import { appendOtlpSignalPath } from "./otlp-endpoint.ts";
10
+ import { buildOtlpHttpAgentOptions, type OtlpHttpAgentOptions } from "./otlp-http-options.ts";
10
11
  import type { StartOtelSdkFactoryOptions } from "./sdk.ts";
11
12
 
12
13
  export const OBSERVME_METER_NAME = "@senad-d/observme";
@@ -23,6 +24,7 @@ export interface OtlpMetricExporterOptions {
23
24
  readonly url: string;
24
25
  readonly headers: Record<string, string>;
25
26
  readonly timeoutMillis: number;
27
+ readonly httpAgentOptions: OtlpHttpAgentOptions;
26
28
  }
27
29
 
28
30
  export interface MetricReaderOptions {
@@ -48,7 +50,6 @@ export type MetricProviderFactory = (options: MetricProviderOptions) => MetricPr
48
50
  export interface ObservMeMetricSdkOptions {
49
51
  readonly config: ObservMeConfig;
50
52
  readonly meterName?: string;
51
- readonly registerGlobal?: boolean;
52
53
  readonly exporterFactory?: MetricExporterFactory;
53
54
  readonly readerFactory?: MetricReaderFactory;
54
55
  readonly meterProviderFactory?: MetricProviderFactory;
@@ -65,10 +66,11 @@ const noopMeter = createNoopMeter();
65
66
  export class ObservMeMetricSdk {
66
67
  readonly #config: ObservMeConfig;
67
68
  readonly #meterName: string;
68
- readonly #registerGlobal: boolean;
69
69
  readonly #exporterFactory: MetricExporterFactory;
70
70
  readonly #readerFactory: MetricReaderFactory;
71
71
  readonly #meterProviderFactory: MetricProviderFactory;
72
+ #exporter?: PushMetricExporter;
73
+ #reader?: IMetricReader;
72
74
  #provider?: MetricProviderLike;
73
75
  #meter: Meter = noopMeter;
74
76
  #state: MetricPipelineState = "idle";
@@ -76,7 +78,6 @@ export class ObservMeMetricSdk {
76
78
  constructor(options: ObservMeMetricSdkOptions) {
77
79
  this.#config = options.config;
78
80
  this.#meterName = options.meterName ?? OBSERVME_METER_NAME;
79
- this.#registerGlobal = options.registerGlobal ?? true;
80
81
  this.#exporterFactory = options.exporterFactory ?? createOtlpMetricExporter;
81
82
  this.#readerFactory = options.readerFactory ?? createPeriodicMetricReader;
82
83
  this.#meterProviderFactory = options.meterProviderFactory ?? createMetricProvider;
@@ -92,20 +93,19 @@ export class ObservMeMetricSdk {
92
93
 
93
94
  start(): void {
94
95
  if (this.#state === "started" || this.#state === "disabled") return;
95
- if (!this.#config.metrics.enabled) {
96
+ if (!this.#config.enabled || !this.#config.metrics.enabled) {
96
97
  this.#state = "disabled";
97
98
  return;
98
99
  }
99
100
 
100
101
  const wiring = buildMetricExporterWiring(this.#config);
101
- const exporter = this.#exporterFactory(wiring.exporter);
102
- const reader = this.#readerFactory(exporter, wiring.reader);
102
+ this.#exporter = this.#exporterFactory(wiring.exporter);
103
+ this.#reader = this.#readerFactory(this.#exporter, wiring.reader);
103
104
  this.#provider = this.#meterProviderFactory({
104
105
  resource: createMetricResource(this.#config),
105
- readers: [reader],
106
+ readers: [this.#reader],
106
107
  });
107
108
 
108
- if (this.#registerGlobal) metrics.setGlobalMeterProvider(this.#provider);
109
109
  this.#meter = this.#provider.getMeter(this.#meterName);
110
110
  this.#state = "started";
111
111
  }
@@ -115,9 +115,19 @@ export class ObservMeMetricSdk {
115
115
  }
116
116
 
117
117
  async shutdown(): Promise<void> {
118
- await this.#provider?.shutdown?.();
119
- this.#state = "shutdown";
120
- this.#meter = noopMeter;
118
+ if (this.#state === "shutdown") return;
119
+
120
+ try {
121
+ if (this.#provider?.shutdown) await this.#provider.shutdown();
122
+ else if (this.#reader) await this.#reader.shutdown();
123
+ else await this.#exporter?.shutdown();
124
+ } finally {
125
+ this.#provider = undefined;
126
+ this.#reader = undefined;
127
+ this.#exporter = undefined;
128
+ this.#state = "shutdown";
129
+ this.#meter = noopMeter;
130
+ }
121
131
  }
122
132
  }
123
133
 
@@ -127,7 +137,7 @@ export function createMetricSessionScopedOtelSdk(options: StartOtelSdkFactoryOpt
127
137
 
128
138
  export function buildMetricExporterWiring(config: ObservMeConfig): MetricExporterWiring {
129
139
  return {
130
- enabled: config.metrics.enabled,
140
+ enabled: config.enabled && config.metrics.enabled,
131
141
  exporter: buildOtlpMetricExporterOptions(config),
132
142
  reader: {
133
143
  exportIntervalMillis: config.metrics.exportIntervalMillis,
@@ -141,6 +151,7 @@ export function buildOtlpMetricExporterOptions(config: ObservMeConfig): OtlpMetr
141
151
  url: resolveMetricEndpoint(config),
142
152
  headers: { ...config.otlp.headers },
143
153
  timeoutMillis: config.otlp.timeoutMs,
154
+ httpAgentOptions: buildOtlpHttpAgentOptions(config),
144
155
  };
145
156
  }
146
157
 
@@ -1,6 +1,45 @@
1
+ export type OtlpEndpointFailureClass =
2
+ | "unresolved_placeholder"
3
+ | "malformed_url"
4
+ | "unsupported_protocol"
5
+ | "embedded_credentials"
6
+ | "query_not_supported"
7
+ | "fragment_not_supported";
8
+
9
+ export function classifyOtlpEndpointFailure(endpoint: string): OtlpEndpointFailureClass | undefined {
10
+ if (endpoint.includes("${")) return "unresolved_placeholder";
11
+ if (/\s/u.test(endpoint)) return "malformed_url";
12
+
13
+ const parsedEndpoint = parseEndpoint(endpoint);
14
+ if (!parsedEndpoint) return "malformed_url";
15
+ if (parsedEndpoint.protocol !== "http:" && parsedEndpoint.protocol !== "https:") return "unsupported_protocol";
16
+ if (parsedEndpoint.username || parsedEndpoint.password) return "embedded_credentials";
17
+ if (endpoint.includes("?")) return "query_not_supported";
18
+ if (endpoint.includes("#")) return "fragment_not_supported";
19
+ return undefined;
20
+ }
21
+
1
22
  export function appendOtlpSignalPath(baseEndpoint: string, signalPath: string): string {
2
- const trimmedBaseEndpoint = removeTrailingSlashes(baseEndpoint);
3
- return `${trimmedBaseEndpoint}${signalPath}`;
23
+ const failureClass = classifyOtlpEndpointFailure(baseEndpoint);
24
+ if (failureClass) throw new TypeError(`OTLP endpoint is invalid (${failureClass}).`);
25
+
26
+ const endpoint = new URL(baseEndpoint);
27
+ endpoint.pathname = `${removeTrailingSlashes(endpoint.pathname)}${normalizeSignalPath(signalPath)}`;
28
+ return endpoint.href;
29
+ }
30
+
31
+ function parseEndpoint(endpoint: string): URL | undefined {
32
+ try {
33
+ return new URL(endpoint);
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ }
38
+
39
+ function normalizeSignalPath(signalPath: string): string {
40
+ let start = 0;
41
+ while (start < signalPath.length && signalPath[start] === "/") start += 1;
42
+ return `/${signalPath.slice(start)}`;
4
43
  }
5
44
 
6
45
  function removeTrailingSlashes(value: string): string {
@@ -0,0 +1,11 @@
1
+ import type { ObservMeConfig } from "../config/schema.ts";
2
+
3
+ export interface OtlpHttpAgentOptions {
4
+ readonly rejectUnauthorized: boolean;
5
+ }
6
+
7
+ export function buildOtlpHttpAgentOptions(config: ObservMeConfig): OtlpHttpAgentOptions {
8
+ return {
9
+ rejectUnauthorized: !config.otlp.tls.insecureSkipVerify,
10
+ };
11
+ }
package/src/otel/sdk.ts CHANGED
@@ -1,9 +1,24 @@
1
1
  import type { ObservMeConfig } from "../config/schema.ts";
2
+ import { readDiagnosticMessage, sanitizeDiagnosticText } from "../diagnostics/sanitize.ts";
2
3
  import type { AgentLineageContext } from "../pi/agent-lineage.ts";
3
- import type { BoundedOtelOperationResult, OtelShutdownLogSink, ShutdownableOtelSdk } from "./shutdown.ts";
4
+ import type {
5
+ BoundedOtelOperationResult,
6
+ OtelOperationSettlement,
7
+ OtelShutdownLogSink,
8
+ ShutdownableOtelSdk,
9
+ } from "./shutdown.ts";
4
10
  import { flushOtelSdk, shutdownOtelSdk } from "./shutdown.ts";
5
11
 
6
- export type OtelSdkLifecycleState = "idle" | "starting" | "started" | "shutting_down" | "shutdown";
12
+ // `failed` is terminal for startup; shutdown cleanup can remain owned for observation or retry.
13
+ export type OtelSdkLifecycleState =
14
+ | "idle"
15
+ | "starting"
16
+ | "started"
17
+ | "failed"
18
+ | "shutting_down"
19
+ | "shutdown_pending"
20
+ | "shutdown_failed"
21
+ | "shutdown";
7
22
 
8
23
  export interface StartOtelSdkFactoryOptions {
9
24
  readonly config: ObservMeConfig;
@@ -35,6 +50,10 @@ export class ObservMeOtelSdkController {
35
50
  readonly #sdkFactory: SessionScopedOtelSdkFactory;
36
51
  readonly #logger?: OtelShutdownLogSink;
37
52
  #sdk?: SessionScopedOtelSdk;
53
+ #startPromise?: Promise<SessionScopedOtelSdk>;
54
+ #shutdownPromise?: Promise<BoundedOtelOperationResult>;
55
+ #pendingShutdown?: Promise<OtelOperationSettlement>;
56
+ #startupCleanupResult?: BoundedOtelOperationResult;
38
57
  #state: OtelSdkLifecycleState = "idle";
39
58
 
40
59
  constructor(options: ObservMeOtelSdkControllerOptions) {
@@ -62,13 +81,42 @@ export class ObservMeOtelSdkController {
62
81
 
63
82
  async start(): Promise<SessionScopedOtelSdk> {
64
83
  if (this.#state === "started" && this.#sdk) return this.#sdk;
84
+ if (this.#state === "starting" && this.#startPromise) return this.#startPromise;
85
+ if (this.#state === "failed") throw new Error("ObservMe OTEL SDK controller cannot be restarted after failed startup.");
65
86
  if (this.#state === "shutdown") throw new Error("ObservMe OTEL SDK controller cannot be restarted after shutdown.");
87
+ if (this.#state !== "idle") {
88
+ throw new Error(`ObservMe OTEL SDK controller cannot start while cleanup state is ${this.#state}.`);
89
+ }
66
90
 
67
91
  this.#state = "starting";
68
- this.#sdk = this.#sdkFactory({ config: this.#config, agent: this.#agent });
69
- await this.#sdk.start?.();
70
- this.#state = "started";
71
- return this.#sdk;
92
+ this.#startPromise = this.startOnce();
93
+ return this.#startPromise;
94
+ }
95
+
96
+ private async startOnce(): Promise<SessionScopedOtelSdk> {
97
+ try {
98
+ this.#sdk = this.#sdkFactory({ config: this.#config, agent: this.#agent });
99
+ await this.#sdk.start?.();
100
+ this.#state = "started";
101
+ return this.#sdk;
102
+ } catch (error) {
103
+ if (error instanceof ObservMeOtelStartupError) {
104
+ this.#startupCleanupResult = error.cleanup ?? completedShutdown();
105
+ } else {
106
+ this.#startupCleanupResult = sanitizeStartupCleanupResult(
107
+ await shutdownOtelSdk(
108
+ this.#sdk,
109
+ this.#config.shutdown.flushTimeoutMs,
110
+ this.#logger,
111
+ ),
112
+ );
113
+ }
114
+ this.#sdk = undefined;
115
+ this.#state = "failed";
116
+ throw toOtelStartupError(error, this.#startupCleanupResult);
117
+ } finally {
118
+ this.#startPromise = undefined;
119
+ }
72
120
  }
73
121
 
74
122
  async flush(timeoutMs: number = this.#config.shutdown.flushTimeoutMs): Promise<BoundedOtelOperationResult> {
@@ -76,12 +124,86 @@ export class ObservMeOtelSdkController {
76
124
  }
77
125
 
78
126
  async shutdown(timeoutMs: number = this.#config.shutdown.flushTimeoutMs): Promise<BoundedOtelOperationResult> {
79
- if (this.#state === "shutdown") return { operation: "shutdown", completed: true, timedOut: false };
127
+ if (this.#state === "shutdown") return completedShutdown();
128
+ if (this.#state === "failed") return this.#startupCleanupResult ?? completedShutdown();
129
+ if (this.#state === "shutting_down" && this.#shutdownPromise) return this.#shutdownPromise;
130
+ if (this.#state === "shutdown_pending" && this.#pendingShutdown) {
131
+ return pendingShutdown(this.#pendingShutdown);
132
+ }
80
133
 
81
134
  this.#state = "shutting_down";
82
- const result = await shutdownOtelSdk(this.#sdk, timeoutMs, this.#logger);
135
+ this.#shutdownPromise = this.shutdownOnce(timeoutMs);
136
+ return this.#shutdownPromise;
137
+ }
138
+
139
+ private async shutdownOnce(timeoutMs: number): Promise<BoundedOtelOperationResult> {
140
+ try {
141
+ const result = await shutdownOtelSdk(this.#sdk, timeoutMs, this.#logger);
142
+ this.applyShutdownResult(result);
143
+ return result;
144
+ } finally {
145
+ this.#shutdownPromise = undefined;
146
+ }
147
+ }
148
+
149
+ private applyShutdownResult(result: BoundedOtelOperationResult): void {
150
+ if (result.timedOut && result.settlement) {
151
+ this.#pendingShutdown = result.settlement;
152
+ this.#state = "shutdown_pending";
153
+ void result.settlement.then(this.observePendingShutdown.bind(this, result.settlement));
154
+ return;
155
+ }
156
+
157
+ if (result.error || !result.completed) {
158
+ this.#state = "shutdown_failed";
159
+ return;
160
+ }
161
+
162
+ this.releaseShutdownOwnership();
163
+ }
164
+
165
+ private observePendingShutdown(
166
+ pending: Promise<OtelOperationSettlement>,
167
+ settlement: OtelOperationSettlement,
168
+ ): void {
169
+ if (this.#pendingShutdown !== pending) return;
170
+
171
+ this.#pendingShutdown = undefined;
172
+ if (settlement.error || !settlement.completed) {
173
+ this.#state = "shutdown_failed";
174
+ return;
175
+ }
176
+
177
+ this.releaseShutdownOwnership();
178
+ }
179
+
180
+ private releaseShutdownOwnership(): void {
181
+ this.#sdk = undefined;
83
182
  this.#state = "shutdown";
84
- return result;
183
+ }
184
+ }
185
+
186
+ export function toOtelStartupError(
187
+ error: unknown,
188
+ cleanup?: BoundedOtelOperationResult,
189
+ ): Error {
190
+ if (error instanceof ObservMeOtelStartupError) return error;
191
+
192
+ const detail = sanitizeDiagnosticText(readDiagnosticMessage(error));
193
+ const safeCleanup = cleanup ? sanitizeStartupCleanupResult(cleanup) : undefined;
194
+ return new ObservMeOtelStartupError(
195
+ `ObservMe OTEL startup failed: ${detail}. ${startupCleanupGuidance(safeCleanup)}`,
196
+ safeCleanup,
197
+ );
198
+ }
199
+
200
+ export class ObservMeOtelStartupError extends Error {
201
+ readonly cleanup?: BoundedOtelOperationResult;
202
+
203
+ constructor(message: string, cleanup?: BoundedOtelOperationResult) {
204
+ super(message);
205
+ this.name = "ObservMeOtelStartupError";
206
+ this.cleanup = cleanup;
85
207
  }
86
208
  }
87
209
 
@@ -99,6 +221,30 @@ export function createNoopSessionScopedOtelSdk(): SessionScopedOtelSdk {
99
221
  return new NoopSessionScopedOtelSdk();
100
222
  }
101
223
 
224
+ function startupCleanupGuidance(cleanup: BoundedOtelOperationResult | undefined): string {
225
+ if (!cleanup) return "Check OTLP settings and Collector availability before retrying.";
226
+ if (cleanup.timedOut) return "Cleanup exceeded its timeout; restart Pi before retrying.";
227
+ if (cleanup.error) return "Cleanup also failed; restart Pi before retrying.";
228
+ return "Started providers were cleaned up; check OTLP settings and Collector availability before retrying.";
229
+ }
230
+
231
+ function completedShutdown(): BoundedOtelOperationResult {
232
+ return { operation: "shutdown", completed: true, timedOut: false };
233
+ }
234
+
235
+ function pendingShutdown(settlement: Promise<OtelOperationSettlement>): BoundedOtelOperationResult {
236
+ return { operation: "shutdown", completed: false, timedOut: true, settlement };
237
+ }
238
+
239
+ function sanitizeStartupCleanupResult(result: BoundedOtelOperationResult): BoundedOtelOperationResult {
240
+ if (!result.error) return result;
241
+
242
+ return {
243
+ ...result,
244
+ error: new Error(sanitizeDiagnosticText(readDiagnosticMessage(result.error))),
245
+ };
246
+ }
247
+
102
248
  class NoopSessionScopedOtelSdk implements SessionScopedOtelSdk {
103
249
  async start(): Promise<void> {
104
250
  await Promise.resolve();
@@ -9,11 +9,19 @@ export interface ShutdownableOtelSdk extends FlushableOtelSdk {
9
9
  shutdown?: () => Promise<void> | void;
10
10
  }
11
11
 
12
+ export interface OtelOperationSettlement {
13
+ readonly operation: "flush" | "shutdown";
14
+ readonly completed: boolean;
15
+ readonly timedOut: false;
16
+ readonly error?: unknown;
17
+ }
18
+
12
19
  export interface BoundedOtelOperationResult {
13
20
  readonly operation: "flush" | "shutdown";
14
21
  readonly completed: boolean;
15
22
  readonly timedOut: boolean;
16
23
  readonly error?: unknown;
24
+ readonly settlement?: Promise<OtelOperationSettlement>;
17
25
  }
18
26
 
19
27
  export interface OtelShutdownLogSink {
@@ -54,10 +62,10 @@ export async function runBoundedOtelOperation(
54
62
  const timeoutResult = timeoutOperation(operation, normalizedTimeoutMs, timeoutController.signal);
55
63
 
56
64
  try {
57
- const completed = completeOperation(operation, action);
58
- return await Promise.race([completed, timeoutResult]);
59
- } catch (error) {
60
- return { operation, completed: false, timedOut: false, error };
65
+ const settlement = settleOperation(operation, action);
66
+ const result = await Promise.race([settlement, timeoutResult]);
67
+ if (!result.timedOut) return result;
68
+ return { ...result, settlement };
61
69
  } finally {
62
70
  timeoutController.abort();
63
71
  }
@@ -68,12 +76,16 @@ function normalizeTimeoutMs(timeoutMs: number): number {
68
76
  return timeoutMs;
69
77
  }
70
78
 
71
- async function completeOperation(
79
+ async function settleOperation(
72
80
  operation: "flush" | "shutdown",
73
81
  action: () => Promise<void> | void,
74
- ): Promise<BoundedOtelOperationResult> {
75
- await action();
76
- return completedOperation(operation);
82
+ ): Promise<OtelOperationSettlement> {
83
+ try {
84
+ await action();
85
+ return completedOperation(operation);
86
+ } catch (error) {
87
+ return { operation, completed: false, timedOut: false, error };
88
+ }
77
89
  }
78
90
 
79
91
  async function timeoutOperation(
@@ -84,17 +96,33 @@ async function timeoutOperation(
84
96
  return delay(timeoutMs, { operation, completed: false, timedOut: true } as const, { signal });
85
97
  }
86
98
 
87
- function completedOperation(operation: "flush" | "shutdown"): BoundedOtelOperationResult {
99
+ function completedOperation(operation: "flush" | "shutdown"): OtelOperationSettlement {
88
100
  return { operation, completed: true, timedOut: false };
89
101
  }
90
102
 
91
103
  function logBoundedOperationIssue(result: BoundedOtelOperationResult, logger: OtelShutdownLogSink | undefined): void {
92
104
  if (result.timedOut) {
93
- logger?.warn?.(`ObservMe OTEL ${result.operation} exceeded timeout and was abandoned.`);
105
+ warnOtelOperation(logger, `ObservMe OTEL ${result.operation} exceeded timeout and remains pending.`);
106
+ void result.settlement?.then(logLateOperationIssue.bind(undefined, logger));
94
107
  return;
95
108
  }
96
109
 
97
- if (result.error) logger?.warn?.(`ObservMe OTEL ${result.operation} failed: ${formatError(result.error)}`);
110
+ logLateOperationIssue(logger, result);
111
+ }
112
+
113
+ function logLateOperationIssue(
114
+ logger: OtelShutdownLogSink | undefined,
115
+ result: Pick<BoundedOtelOperationResult, "operation" | "error">,
116
+ ): void {
117
+ if (result.error) warnOtelOperation(logger, `ObservMe OTEL ${result.operation} failed: ${formatError(result.error)}`);
118
+ }
119
+
120
+ function warnOtelOperation(logger: OtelShutdownLogSink | undefined, message: string): void {
121
+ try {
122
+ logger?.warn?.(message);
123
+ } catch {
124
+ return;
125
+ }
98
126
  }
99
127
 
100
128
  function formatError(error: unknown): string {