@senad-d/observme 0.1.3 → 0.1.5
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/.env.example +4 -0
- package/CHANGELOG.md +48 -0
- package/README.md +36 -11
- package/dashboards/observme-agent-node-graphs.json +5 -5
- package/dashboards/observme-agents.json +169 -4
- package/dashboards/observme-alerts.yaml +16 -3
- package/dashboards/observme-overview.json +6 -6
- package/dashboards/observme-trace-journey.json +4 -4
- package/docs/agent-subagent-observability-requirements.md +19 -10
- package/docs/compatibility-matrix.md +21 -4
- package/docs/configuration.md +7 -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 +39 -1
- package/docs/reference/05-otel-pipeline-and-collector.md +23 -10
- package/docs/reference/06-security-privacy-redaction.md +13 -6
- package/docs/reference/08-query-grafana-integration.md +30 -7
- package/docs/reference/09-dashboards-alerts-slos.md +132 -3
- package/docs/reference/10-testing-release-operations.md +44 -14
- package/docs/reference/11-deployment-runbooks.md +82 -5
- package/docs/reference/12-configuration-reference.md +48 -5
- package/docs/review-validation.md +17 -0
- package/examples/README.md +7 -2
- package/examples/collector.yaml +8 -8
- package/examples/integrations/subagent-runner.ts +8 -5
- package/examples/observme.yaml +3 -2
- package/package.json +14 -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 +50 -32
- package/src/config/defaults.ts +1 -2
- package/src/config/load-config.ts +270 -81
- package/src/config/project-paths.ts +318 -6
- package/src/config/query-limits.ts +10 -0
- package/src/config/schema.ts +18 -8
- package/src/config/transport-security.ts +107 -0
- package/src/config/validate.ts +88 -12
- 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/active-agent-lease.ts +101 -0
- package/src/pi/agent-tree-tracker.ts +14 -1
- package/src/pi/compatibility.ts +121 -0
- package/src/pi/event-handlers/agent-turn.ts +16 -9
- package/src/pi/event-handlers/lifecycle.ts +312 -106
- 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 +159 -54
- package/src/pi/handler-types.ts +40 -17
- 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 +7 -0
- package/src/semconv/values.ts +2 -0
package/src/config/validate.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { Compile } from "typebox/compile";
|
|
2
2
|
import { defaultObservMeConfig } from "./defaults.ts";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ACTIVE_AGENT_LEASE_EXPORT_SAFETY_MARGIN_MILLIS,
|
|
5
|
+
observMeConfigSchema,
|
|
6
|
+
} from "./schema.ts";
|
|
4
7
|
import type { ObservMeConfig } from "./schema.ts";
|
|
5
8
|
import { validateCustomRedactionPatterns } from "../privacy/redact.ts";
|
|
9
|
+
import { classifyOtlpEndpointFailure } from "../otel/otlp-endpoint.ts";
|
|
10
|
+
import {
|
|
11
|
+
classifyGrafanaUrlSecurityFailure,
|
|
12
|
+
formatGrafanaUrlSecurityFailure,
|
|
13
|
+
} from "../query/grafana-url.ts";
|
|
6
14
|
|
|
7
15
|
export interface ValidationIssue {
|
|
8
16
|
code: string;
|
|
@@ -54,14 +62,22 @@ const maximumConfigDiagnosticIssueCount = 1_000_000;
|
|
|
54
62
|
const unknownConfigValidationIssueCode = "unknown_config_validation_issue";
|
|
55
63
|
const knownConfigValidationIssueCodes = new Set([
|
|
56
64
|
"invalid_config_shape",
|
|
65
|
+
"config_source_malformed",
|
|
66
|
+
"config_source_rejected",
|
|
67
|
+
"config_source_unreadable",
|
|
57
68
|
"unsafe_capture_without_redaction",
|
|
58
69
|
"insecure_production_transport",
|
|
70
|
+
"invalid_otlp_endpoint",
|
|
59
71
|
"invalid_signal_endpoint_path",
|
|
72
|
+
"embedded_grafana_url_credentials",
|
|
60
73
|
"high_cardinality_metric_label",
|
|
74
|
+
"active_agent_lease_too_short_for_export_interval",
|
|
61
75
|
"custom_redaction_pattern_limit",
|
|
62
76
|
"custom_redaction_pattern_too_long",
|
|
63
77
|
"custom_redaction_pattern_unsupported_construct",
|
|
64
78
|
"custom_redaction_pattern_nested_quantifier",
|
|
79
|
+
"custom_redaction_pattern_ambiguous_alternation",
|
|
80
|
+
"custom_redaction_pattern_ambiguous_repetition",
|
|
65
81
|
"custom_redaction_pattern_empty_match",
|
|
66
82
|
"invalid_custom_redaction_pattern",
|
|
67
83
|
"untrusted_project_config_read",
|
|
@@ -80,8 +96,10 @@ export function validateObservMeConfig(
|
|
|
80
96
|
const issues = [
|
|
81
97
|
...validateRedactionBoundary(config),
|
|
82
98
|
...validateTransportSecurity(config),
|
|
83
|
-
...
|
|
99
|
+
...validateOtlpEndpoints(config),
|
|
100
|
+
...validateGrafanaUrl(config),
|
|
84
101
|
...validateMetricLabels(config),
|
|
102
|
+
...validateActiveAgentLeaseDuration(config),
|
|
85
103
|
...validateCustomRedactionPatternConfig(config),
|
|
86
104
|
...validateProjectTrust(options),
|
|
87
105
|
...validateLineageEnvironment(config, options.env ?? process.env),
|
|
@@ -248,7 +266,15 @@ function validateTransportSecurity(config: ObservMeConfig): ValidationIssue[] {
|
|
|
248
266
|
...validateProductionHttpEndpoint("otlp.signalEndpoints.traces", config.otlp.signalEndpoints?.traces),
|
|
249
267
|
...validateProductionHttpEndpoint("otlp.signalEndpoints.metrics", config.otlp.signalEndpoints?.metrics),
|
|
250
268
|
...validateProductionHttpEndpoint("otlp.signalEndpoints.logs", config.otlp.signalEndpoints?.logs),
|
|
269
|
+
...validateProductionTlsVerificationBypass(
|
|
270
|
+
"otlp.tls.insecureSkipVerify",
|
|
271
|
+
config.otlp.tls.insecureSkipVerify,
|
|
272
|
+
),
|
|
251
273
|
...validateProductionHttpEndpoint("query.grafana.url", config.query.grafana.url),
|
|
274
|
+
...validateProductionTlsVerificationBypass(
|
|
275
|
+
"query.grafana.tls.insecureSkipVerify",
|
|
276
|
+
config.query.grafana.tls.insecureSkipVerify,
|
|
277
|
+
),
|
|
252
278
|
];
|
|
253
279
|
}
|
|
254
280
|
|
|
@@ -263,30 +289,54 @@ function validateProductionHttpEndpoint(name: string, endpoint: string | undefin
|
|
|
263
289
|
];
|
|
264
290
|
}
|
|
265
291
|
|
|
292
|
+
function validateProductionTlsVerificationBypass(name: string, insecureSkipVerify: boolean): ValidationIssue[] {
|
|
293
|
+
if (!insecureSkipVerify) return [];
|
|
294
|
+
|
|
295
|
+
return [
|
|
296
|
+
{
|
|
297
|
+
code: "insecure_production_transport",
|
|
298
|
+
message: `${name} must be false in production unless privacy.allowInsecureTransport is true.`,
|
|
299
|
+
},
|
|
300
|
+
];
|
|
301
|
+
}
|
|
302
|
+
|
|
266
303
|
function isHttpEndpoint(endpoint: string): boolean {
|
|
267
304
|
return endpoint.trim().toLowerCase().startsWith("http://");
|
|
268
305
|
}
|
|
269
306
|
|
|
270
|
-
function
|
|
271
|
-
if (config.otlp.protocol !== "http/protobuf") return [];
|
|
272
|
-
|
|
307
|
+
function validateOtlpEndpoints(config: ObservMeConfig): ValidationIssue[] {
|
|
273
308
|
const endpoints = config.otlp.signalEndpoints;
|
|
274
|
-
if (!endpoints) return [];
|
|
275
309
|
|
|
276
310
|
return [
|
|
277
|
-
...
|
|
278
|
-
...validateSignalEndpoint("
|
|
279
|
-
...validateSignalEndpoint("
|
|
311
|
+
...validateOtlpEndpoint("otlp.endpoint", config.otlp.endpoint),
|
|
312
|
+
...validateSignalEndpoint("otlp.signalEndpoints.traces", endpoints?.traces, "/v1/traces"),
|
|
313
|
+
...validateSignalEndpoint("otlp.signalEndpoints.metrics", endpoints?.metrics, "/v1/metrics"),
|
|
314
|
+
...validateSignalEndpoint("otlp.signalEndpoints.logs", endpoints?.logs, "/v1/logs"),
|
|
280
315
|
];
|
|
281
316
|
}
|
|
282
317
|
|
|
283
|
-
function
|
|
284
|
-
|
|
318
|
+
function validateOtlpEndpoint(name: string, endpoint: string): ValidationIssue[] {
|
|
319
|
+
const failureClass = classifyOtlpEndpointFailure(endpoint);
|
|
320
|
+
if (!failureClass) return [];
|
|
321
|
+
|
|
322
|
+
return [
|
|
323
|
+
{
|
|
324
|
+
code: "invalid_otlp_endpoint",
|
|
325
|
+
message: `${name} is invalid (${failureClass}).`,
|
|
326
|
+
},
|
|
327
|
+
];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function validateSignalEndpoint(name: string, endpoint: string | undefined, requiredPath: string): ValidationIssue[] {
|
|
331
|
+
if (!endpoint) return [];
|
|
332
|
+
|
|
333
|
+
const endpointIssues = validateOtlpEndpoint(name, endpoint);
|
|
334
|
+
if (endpointIssues.length > 0 || endpointPathMatches(endpoint, requiredPath)) return endpointIssues;
|
|
285
335
|
|
|
286
336
|
return [
|
|
287
337
|
{
|
|
288
338
|
code: "invalid_signal_endpoint_path",
|
|
289
|
-
message: `${
|
|
339
|
+
message: `${name} must end with ${requiredPath}.`,
|
|
290
340
|
},
|
|
291
341
|
];
|
|
292
342
|
}
|
|
@@ -299,6 +349,18 @@ function endpointPathMatches(endpoint: string, requiredPath: string): boolean {
|
|
|
299
349
|
}
|
|
300
350
|
}
|
|
301
351
|
|
|
352
|
+
function validateGrafanaUrl(config: ObservMeConfig): ValidationIssue[] {
|
|
353
|
+
const failureClass = classifyGrafanaUrlSecurityFailure(config.query.grafana.url);
|
|
354
|
+
if (!failureClass) return [];
|
|
355
|
+
|
|
356
|
+
return [
|
|
357
|
+
{
|
|
358
|
+
code: "embedded_grafana_url_credentials",
|
|
359
|
+
message: formatGrafanaUrlSecurityFailure(failureClass),
|
|
360
|
+
},
|
|
361
|
+
];
|
|
362
|
+
}
|
|
363
|
+
|
|
302
364
|
function validateMetricLabels(config: ObservMeConfig): ValidationIssue[] {
|
|
303
365
|
const labels = config.metrics.labels ?? [];
|
|
304
366
|
return labels.filter(isForbiddenMetricLabel).map(label => ({
|
|
@@ -307,6 +369,20 @@ function validateMetricLabels(config: ObservMeConfig): ValidationIssue[] {
|
|
|
307
369
|
}));
|
|
308
370
|
}
|
|
309
371
|
|
|
372
|
+
function validateActiveAgentLeaseDuration(config: ObservMeConfig): ValidationIssue[] {
|
|
373
|
+
const requiredDuration =
|
|
374
|
+
(2 * config.metrics.exportIntervalMillis) + ACTIVE_AGENT_LEASE_EXPORT_SAFETY_MARGIN_MILLIS;
|
|
375
|
+
if (config.metrics.activeAgentLeaseDurationMillis >= requiredDuration) return [];
|
|
376
|
+
|
|
377
|
+
return [
|
|
378
|
+
{
|
|
379
|
+
code: "active_agent_lease_too_short_for_export_interval",
|
|
380
|
+
message:
|
|
381
|
+
"metrics.activeAgentLeaseDurationMillis must be at least twice metrics.exportIntervalMillis plus the clock-skew safety margin.",
|
|
382
|
+
},
|
|
383
|
+
];
|
|
384
|
+
}
|
|
385
|
+
|
|
310
386
|
function validateCustomRedactionPatternConfig(config: ObservMeConfig): ValidationIssue[] {
|
|
311
387
|
return validateCustomRedactionPatterns(config.privacy.customRedactionPatterns).map(issue => ({
|
|
312
388
|
code: issue.code,
|
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 { assertObservMePiCompatibility } 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
|
+
assertObservMePiCompatibility(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
|
+
}
|