@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.
- package/CHANGELOG.md +35 -0
- package/README.md +24 -8
- package/docs/agent-subagent-observability-requirements.md +4 -3
- package/docs/compatibility-matrix.md +14 -3
- package/docs/configuration.md +2 -2
- package/docs/extension-integration.md +20 -13
- package/docs/reference/03-pi-event-and-session-model.md +6 -6
- package/docs/reference/04-telemetry-semantic-conventions.md +1 -0
- package/docs/reference/06-security-privacy-redaction.md +13 -6
- package/docs/reference/08-query-grafana-integration.md +30 -7
- package/docs/reference/11-deployment-runbooks.md +21 -0
- package/docs/reference/12-configuration-reference.md +30 -4
- package/docs/review-validation.md +10 -0
- package/examples/integrations/subagent-runner.ts +8 -5
- package/examples/observme.yaml +2 -2
- package/package.json +12 -4
- package/src/commands/obs-agents.ts +17 -12
- package/src/commands/obs-backfill.ts +2 -2
- package/src/commands/obs-command-support.ts +2 -1
- package/src/commands/obs-cost.ts +15 -7
- package/src/commands/obs-health.ts +22 -2
- package/src/commands/obs-loki-summary.ts +4 -8
- package/src/commands/obs-session.ts +35 -28
- package/src/commands/obs-status.ts +56 -13
- package/src/commands/obs-tools.ts +19 -8
- package/src/commands/obs-trace.ts +9 -0
- package/src/commands/obs.ts +6 -1
- package/src/config/bootstrap-project-config.ts +49 -32
- package/src/config/defaults.ts +0 -2
- package/src/config/load-config.ts +270 -92
- package/src/config/project-paths.ts +318 -6
- package/src/config/query-limits.ts +10 -0
- package/src/config/schema.ts +9 -8
- package/src/config/transport-security.ts +107 -0
- package/src/config/validate.ts +68 -11
- package/src/extension.ts +2 -12
- package/src/integration.ts +6 -2
- package/src/otel/logs.ts +24 -13
- package/src/otel/metrics.ts +24 -13
- package/src/otel/otlp-endpoint.ts +41 -2
- package/src/otel/otlp-http-options.ts +11 -0
- package/src/otel/sdk.ts +155 -9
- package/src/otel/shutdown.ts +39 -11
- package/src/otel/traces.ts +34 -16
- package/src/pi/agent-tree-tracker.ts +14 -1
- package/src/pi/compatibility.ts +30 -0
- package/src/pi/event-handlers/agent-turn.ts +16 -9
- package/src/pi/event-handlers/lifecycle.ts +207 -75
- package/src/pi/event-handlers/llm.ts +17 -9
- package/src/pi/event-handlers/session-events.ts +53 -19
- package/src/pi/event-handlers/tool-bash.ts +21 -27
- package/src/pi/handler-internals.ts +16 -26
- package/src/pi/handler-runtime.ts +142 -55
- package/src/pi/handler-types.ts +30 -15
- package/src/pi/handlers.ts +12 -1
- package/src/pi/integration-api.ts +27 -15
- package/src/pi/session-correlation.ts +167 -0
- package/src/pi/subagent-spawn.ts +164 -31
- package/src/privacy/redact.ts +574 -68
- package/src/privacy/secret-patterns.ts +6 -1
- package/src/query/grafana-readiness.ts +18 -2
- package/src/query/grafana-transport.ts +150 -27
- package/src/query/grafana-url.ts +36 -0
- package/src/query/grafana.ts +4 -136
- package/src/query/loki.ts +2 -4
- package/src/query/prometheus.ts +8 -10
- package/src/query/tempo.ts +2 -4
- package/src/query/trace-link.ts +186 -0
- package/src/safety/display-bounds.ts +84 -0
- package/src/semconv/metrics.ts +1 -0
- 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
|
-
|
|
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);
|
package/src/integration.ts
CHANGED
|
@@ -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?:
|
|
67
|
-
readonly outcome?:
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
package/src/otel/metrics.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meter } from "@opentelemetry/api";
|
|
2
|
-
import { createNoopMeter
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
3
|
-
|
|
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 {
|
|
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
|
-
|
|
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.#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/src/otel/shutdown.ts
CHANGED
|
@@ -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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return {
|
|
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
|
|
79
|
+
async function settleOperation(
|
|
72
80
|
operation: "flush" | "shutdown",
|
|
73
81
|
action: () => Promise<void> | void,
|
|
74
|
-
): Promise<
|
|
75
|
-
|
|
76
|
-
|
|
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"):
|
|
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
|
|
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
|
-
|
|
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 {
|