@senad-d/observme 0.1.2 → 0.1.4

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 (35) hide show
  1. package/.env.example +4 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +15 -4
  4. package/dashboards/observme-agent-node-graphs.json +5 -5
  5. package/dashboards/observme-agents.json +169 -4
  6. package/dashboards/observme-alerts.yaml +16 -3
  7. package/dashboards/observme-overview.json +6 -6
  8. package/dashboards/observme-trace-journey.json +4 -4
  9. package/docs/agent-subagent-observability-requirements.md +15 -7
  10. package/docs/compatibility-matrix.md +10 -2
  11. package/docs/configuration.md +5 -0
  12. package/docs/extension-integration.md +14 -2
  13. package/docs/reference/04-telemetry-semantic-conventions.md +38 -1
  14. package/docs/reference/05-otel-pipeline-and-collector.md +23 -10
  15. package/docs/reference/09-dashboards-alerts-slos.md +132 -3
  16. package/docs/reference/10-testing-release-operations.md +44 -14
  17. package/docs/reference/11-deployment-runbooks.md +61 -5
  18. package/docs/reference/12-configuration-reference.md +18 -1
  19. package/docs/review-validation.md +17 -0
  20. package/examples/README.md +7 -2
  21. package/examples/collector.yaml +8 -8
  22. package/examples/observme.yaml +1 -0
  23. package/package.json +3 -1
  24. package/src/config/bootstrap-project-config.ts +1 -0
  25. package/src/config/defaults.ts +1 -0
  26. package/src/config/load-config.ts +11 -0
  27. package/src/config/schema.ts +9 -0
  28. package/src/config/validate.ts +20 -1
  29. package/src/integration.ts +46 -4
  30. package/src/pi/active-agent-lease.ts +101 -0
  31. package/src/pi/event-handlers/lifecycle.ts +120 -46
  32. package/src/pi/handler-runtime.ts +21 -3
  33. package/src/pi/handler-types.ts +10 -2
  34. package/src/pi/integration-api.ts +184 -13
  35. package/src/semconv/metrics.ts +6 -0
@@ -11,6 +11,7 @@ The npm package ships configuration examples and an extension-integration exampl
11
11
  - Local self-signed TLS and IPv4 preference are enabled for that profile.
12
12
  - Prompt, response, thinking, tool, Bash, and path capture remain disabled.
13
13
  - Redaction remains enabled and unsafe capture remains disabled.
14
+ - Metrics export every 15 seconds and renew a 60-second active-agent lease; keep producer and Prometheus clocks synchronized within 5 seconds.
14
15
 
15
16
  To use it in a trusted project:
16
17
 
@@ -27,13 +28,17 @@ Read [`../docs/configuration.md`](../docs/configuration.md) for the quick guide
27
28
 
28
29
  - traces are exported to Tempo;
29
30
  - logs are exported to Loki;
30
- - metrics are remote-written to Mimir;
31
+ - metrics are exposed on a Prometheus scrape endpoint;
32
+ - the five-minute `metric_expiration` applies to all exported metric types only as stale-series/cardinality cleanup, not active-agent liveness;
33
+ - lease-aware PromQL joins the positive lifecycle claim and lease on generated `observme_instance_id`; raw `observme_active_agents` sums are diagnostics, not production live totals;
31
34
  - high-cardinality workflow, agent, session, and spawn attributes are removed from metrics;
32
35
  - accidental content attributes are removed from logs as defense in depth.
33
36
 
34
37
  The example uses Collector components commonly provided by the Collector Contrib distribution. Verify that your deployed distribution includes every receiver, processor, and exporter, then replace backend endpoints and security settings for your environment.
35
38
 
36
- Read [`../docs/reference/05-otel-pipeline-and-collector.md`](../docs/reference/05-otel-pipeline-and-collector.md) for pipeline design and [`../docs/reference/11-deployment-runbooks.md`](../docs/reference/11-deployment-runbooks.md) for deployment and incident checks.
39
+ Read [`../docs/reference/05-otel-pipeline-and-collector.md`](../docs/reference/05-otel-pipeline-and-collector.md) for pipeline design, [`../docs/reference/09-dashboards-alerts-slos.md`](../docs/reference/09-dashboards-alerts-slos.md#131-canonical-active-agent-promql) for canonical queries and raw-query migration, and [`../docs/reference/11-deployment-runbooks.md`](../docs/reference/11-deployment-runbooks.md) for deployment and incident checks.
40
+
41
+ For GitHub Actions, prefer graceful Pi/sidecar shutdown and an `if: always()` cleanup step, but do not depend on either after force cancellation. Lease expiry provides correctness without a Collector restart. GitHub-hosted runner clocks meet the supported expectation; self-hosted runners must use reliable time synchronization.
37
42
 
38
43
  ## `integrations/subagent-runner.ts`
39
44
 
@@ -1,4 +1,4 @@
1
- # Production-oriented Collector reference for Tempo, Loki, and Mimir.
1
+ # Production-oriented Collector reference for Tempo, Loki, and Prometheus.
2
2
  # Verify that your Collector distribution contains every configured component,
3
3
  # replace backend endpoints/security for your deployment, and never add raw
4
4
  # content or high-cardinality execution identifiers to metric labels.
@@ -97,14 +97,14 @@ exporters:
97
97
  retry_on_failure:
98
98
  enabled: true
99
99
 
100
- prometheusremotewrite/mimir:
101
- endpoint: http://mimir:9009/api/v1/push
100
+ prometheus:
101
+ endpoint: 0.0.0.0:8889
102
+ # Exporter-wide stale-series/cardinality cleanup for gauges, counters, and
103
+ # histograms. Five minutes exceeds ObservMe's default one-minute active-agent
104
+ # lease; lease-aware PromQL, not expiration, determines liveness.
105
+ metric_expiration: 5m
102
106
  resource_to_telemetry_conversion:
103
107
  enabled: true
104
- sending_queue:
105
- enabled: true
106
- retry_on_failure:
107
- enabled: true
108
108
 
109
109
  debug:
110
110
  verbosity: basic
@@ -120,7 +120,7 @@ service:
120
120
  metrics:
121
121
  receivers: [otlp]
122
122
  processors: [memory_limiter, resource/observme, resource/drop_high_cardinality_metric_attrs, batch]
123
- exporters: [prometheusremotewrite/mimir]
123
+ exporters: [prometheus]
124
124
 
125
125
  logs:
126
126
  receivers: [otlp]
@@ -61,6 +61,7 @@ observme:
61
61
  enabled: true
62
62
  exportIntervalMillis: 15000
63
63
  exportTimeoutMillis: 3000
64
+ activeAgentLeaseDurationMillis: 60000
64
65
 
65
66
  logs:
66
67
  enabled: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senad-d/observme",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "ObservMe: a Pi extension that instruments Pi agent sessions and exports OpenTelemetry traces, metrics, and logs to an external observability stack (OTel Collector, Grafana Tempo/Loki/Prometheus).",
6
6
  "license": "MIT",
@@ -57,7 +57,9 @@
57
57
  "lint:fix": "eslint . --fix",
58
58
  "format:check": "node scripts/check-format.mjs",
59
59
  "test": "node --test test/*.test.mjs test/*.test.ts",
60
+ "test:active-agent-lease:contracts": "node --test test/active-agent-lease.test.ts test/alerts.test.mjs test/cardinality.test.ts test/config-defaults.test.mjs test/config-loader.test.mjs test/config-validation.test.mjs test/dashboards.test.mjs test/metrics.test.ts test/pi-handlers.test.mjs test/semconv-metrics.test.mjs",
60
61
  "test:integration:collector": "node --test test/integration/collector-debug.test.mjs",
62
+ "test:integration:active-agent-lease": "node --test test/integration/active-agent-lease.test.mjs",
61
63
  "test:integration:grafana-stack": "node --test test/integration/grafana-stack.test.mjs",
62
64
  "test:coverage": "node scripts/test-coverage.mjs",
63
65
  "smoke:discover": "node scripts/smoke-observability.mjs",
@@ -101,6 +101,7 @@ export const PROJECT_OBSERVME_YAML_TEMPLATE = `observme:
101
101
  enabled: true
102
102
  exportIntervalMillis: 15000
103
103
  exportTimeoutMillis: 3000
104
+ activeAgentLeaseDurationMillis: 60000
104
105
 
105
106
  logs:
106
107
  enabled: true
@@ -60,6 +60,7 @@ export const defaultObservMeConfig = {
60
60
  enabled: true,
61
61
  exportIntervalMillis: 15000,
62
62
  exportTimeoutMillis: 3000,
63
+ activeAgentLeaseDurationMillis: 60000,
63
64
  },
64
65
  logs: {
65
66
  enabled: true,
@@ -147,6 +147,11 @@ export function envToConfig(env: NodeJS.ProcessEnv = process.env): DeepPartial<O
147
147
  setString(config, ["otlp", "signalEndpoints", "metrics"], env.OBSERVME_OTLP_METRICS_ENDPOINT);
148
148
  setString(config, ["otlp", "signalEndpoints", "logs"], env.OBSERVME_OTLP_LOGS_ENDPOINT);
149
149
  setNumber(config, ["otlp", "timeoutMs"], env.OBSERVME_OTLP_TIMEOUT_MS);
150
+ setNumberForValidation(
151
+ config,
152
+ ["metrics", "activeAgentLeaseDurationMillis"],
153
+ env.OBSERVME_ACTIVE_AGENT_LEASE_DURATION_MS,
154
+ );
150
155
  setAuthorizationHeader(config, env.OBSERVME_OTLP_TOKEN);
151
156
  setNumber(config, ["workflow", "maxDepthWarning"], env.OBSERVME_WORKFLOW_MAX_DEPTH_WARNING);
152
157
  setNumber(config, ["workflow", "maxFanoutWarning"], env.OBSERVME_WORKFLOW_MAX_FANOUT_WARNING);
@@ -592,6 +597,12 @@ function setNumber(target: DeepPartial<ObservMeConfig>, path: string[], value: s
592
597
  setPathValue(target as Record<string, unknown>, path, parsed);
593
598
  }
594
599
 
600
+ function setNumberForValidation(target: DeepPartial<ObservMeConfig>, path: string[], value: string | undefined) {
601
+ if (value === undefined || value === "") return;
602
+ const parsed = Number(value);
603
+ setPathValue(target as Record<string, unknown>, path, Number.isFinite(parsed) ? parsed : value);
604
+ }
605
+
595
606
  function setBoolean(target: DeepPartial<ObservMeConfig>, path: string[], value: string | undefined) {
596
607
  const parsed = parseBooleanEnv(value);
597
608
  if (parsed === undefined) return;
@@ -8,6 +8,10 @@ export type ObservMeEnvironment = (typeof observMeEnvironments)[number];
8
8
  export type OtlpProtocol = "http/protobuf";
9
9
  export type PrivacyPathMode = (typeof privacyPathModes)[number];
10
10
 
11
+ export const ACTIVE_AGENT_LEASE_DURATION_MILLIS_MINIMUM = 10_000;
12
+ export const ACTIVE_AGENT_LEASE_DURATION_MILLIS_MAXIMUM = 300_000;
13
+ export const ACTIVE_AGENT_LEASE_EXPORT_SAFETY_MARGIN_MILLIS = 5_000;
14
+
11
15
  export interface OtlpTlsConfig {
12
16
  enabled: boolean;
13
17
  insecureSkipVerify: boolean;
@@ -71,6 +75,7 @@ export interface MetricsConfig {
71
75
  enabled: boolean;
72
76
  exportIntervalMillis: number;
73
77
  exportTimeoutMillis: number;
78
+ activeAgentLeaseDurationMillis: number;
74
79
  labels?: string[];
75
80
  }
76
81
 
@@ -288,6 +293,10 @@ export const observMeConfigSchema = Type.Object(
288
293
  enabled: Type.Boolean(),
289
294
  exportIntervalMillis: positiveIntegerSchema,
290
295
  exportTimeoutMillis: positiveIntegerSchema,
296
+ activeAgentLeaseDurationMillis: Type.Integer({
297
+ minimum: ACTIVE_AGENT_LEASE_DURATION_MILLIS_MINIMUM,
298
+ maximum: ACTIVE_AGENT_LEASE_DURATION_MILLIS_MAXIMUM,
299
+ }),
291
300
  labels: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
292
301
  },
293
302
  { additionalProperties: false },
@@ -1,6 +1,9 @@
1
1
  import { Compile } from "typebox/compile";
2
2
  import { defaultObservMeConfig } from "./defaults.ts";
3
- import { observMeConfigSchema } from "./schema.ts";
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";
6
9
 
@@ -58,6 +61,7 @@ const knownConfigValidationIssueCodes = new Set([
58
61
  "insecure_production_transport",
59
62
  "invalid_signal_endpoint_path",
60
63
  "high_cardinality_metric_label",
64
+ "active_agent_lease_too_short_for_export_interval",
61
65
  "custom_redaction_pattern_limit",
62
66
  "custom_redaction_pattern_too_long",
63
67
  "custom_redaction_pattern_unsupported_construct",
@@ -82,6 +86,7 @@ export function validateObservMeConfig(
82
86
  ...validateTransportSecurity(config),
83
87
  ...validateSignalEndpoints(config),
84
88
  ...validateMetricLabels(config),
89
+ ...validateActiveAgentLeaseDuration(config),
85
90
  ...validateCustomRedactionPatternConfig(config),
86
91
  ...validateProjectTrust(options),
87
92
  ...validateLineageEnvironment(config, options.env ?? process.env),
@@ -307,6 +312,20 @@ function validateMetricLabels(config: ObservMeConfig): ValidationIssue[] {
307
312
  }));
308
313
  }
309
314
 
315
+ function validateActiveAgentLeaseDuration(config: ObservMeConfig): ValidationIssue[] {
316
+ const requiredDuration =
317
+ (2 * config.metrics.exportIntervalMillis) + ACTIVE_AGENT_LEASE_EXPORT_SAFETY_MARGIN_MILLIS;
318
+ if (config.metrics.activeAgentLeaseDurationMillis >= requiredDuration) return [];
319
+
320
+ return [
321
+ {
322
+ code: "active_agent_lease_too_short_for_export_interval",
323
+ message:
324
+ "metrics.activeAgentLeaseDurationMillis must be at least twice metrics.exportIntervalMillis plus the clock-skew safety margin.",
325
+ },
326
+ ];
327
+ }
328
+
310
329
  function validateCustomRedactionPatternConfig(config: ObservMeConfig): ValidationIssue[] {
311
330
  return validateCustomRedactionPatterns(config.privacy.customRedactionPatterns).map(issue => ({
312
331
  code: issue.code,
@@ -10,6 +10,10 @@ export type ObservMeChildStatus = "starting" | "active" | "completed" | "failed"
10
10
  export type ObservMeJoinStatus = "completed" | "failed" | "cancelled" | "timeout" | "unknown" | "waiting";
11
11
  export type ObservMeIntegrationFailureReason =
12
12
  | "session_unavailable"
13
+ | "invalid_request"
14
+ | "spawn_already_exists"
15
+ | "wait_already_exists"
16
+ | "join_already_exists"
13
17
  | "spawn_not_found"
14
18
  | "wait_not_found"
15
19
  | "join_not_found"
@@ -118,17 +122,55 @@ interface IntegrationResponseHolder {
118
122
  }
119
123
 
120
124
  export function requestObservMeIntegration(host: ObservMeIntegrationHost): ObservMeIntegrationApi | undefined {
125
+ const events = resolveIntegrationEventBus(host);
126
+ if (!events) return undefined;
127
+
121
128
  const holder: IntegrationResponseHolder = {};
122
129
  const request: ObservMeIntegrationRequest = {
123
130
  supportedVersions: [OBSERVME_INTEGRATION_VERSION],
124
131
  respond: receiveObservMeIntegration.bind(undefined, holder),
125
132
  };
126
133
 
127
- host.events.emit(OBSERVME_INTEGRATION_CHANNEL, request);
134
+ try {
135
+ events.emit(OBSERVME_INTEGRATION_CHANNEL, request);
136
+ } catch {
137
+ return undefined;
138
+ }
128
139
  return holder.api;
129
140
  }
130
141
 
131
- function receiveObservMeIntegration(holder: IntegrationResponseHolder, api: ObservMeIntegrationApi): void {
132
- if (api.version !== OBSERVME_INTEGRATION_VERSION || holder.api) return;
133
- holder.api = api;
142
+ function resolveIntegrationEventBus(host: unknown): ObservMeIntegrationEventBus | undefined {
143
+ if (!host || typeof host !== "object") return undefined;
144
+ try {
145
+ const events = (host as Partial<ObservMeIntegrationHost>).events;
146
+ return events && typeof events.emit === "function" ? events : undefined;
147
+ } catch {
148
+ return undefined;
149
+ }
150
+ }
151
+
152
+ function receiveObservMeIntegration(holder: IntegrationResponseHolder, value: unknown): void {
153
+ if (holder.api || !isObservMeIntegrationApi(value)) return;
154
+ holder.api = value;
155
+ }
156
+
157
+ function isObservMeIntegrationApi(value: unknown): value is ObservMeIntegrationApi {
158
+ if (!value || typeof value !== "object") return false;
159
+
160
+ try {
161
+ const api = value as Partial<ObservMeIntegrationApi>;
162
+ return (
163
+ api.version === OBSERVME_INTEGRATION_VERSION &&
164
+ typeof api.getContext === "function" &&
165
+ typeof api.startSubagent === "function" &&
166
+ typeof api.completeSubagent === "function" &&
167
+ typeof api.failSubagent === "function" &&
168
+ typeof api.startWait === "function" &&
169
+ typeof api.endWait === "function" &&
170
+ typeof api.startJoin === "function" &&
171
+ typeof api.endJoin === "function"
172
+ );
173
+ } catch {
174
+ return false;
175
+ }
134
176
  }
@@ -0,0 +1,101 @@
1
+ import type { Attributes, ObservableCallback, ObservableGauge, ObservableResult } from "@opentelemetry/api";
2
+
3
+ export interface ActiveAgentLeaseController {
4
+ readonly active: boolean;
5
+ readonly disposed: boolean;
6
+ activate: () => void;
7
+ deactivate: () => void;
8
+ dispose: () => void;
9
+ }
10
+
11
+ export interface CreateActiveAgentLeaseOptions {
12
+ readonly instrument: ObservableGauge;
13
+ readonly leaseDurationMillis: number;
14
+ readonly attributes?: Attributes;
15
+ readonly wallClockNow?: () => number;
16
+ readonly enabled?: boolean;
17
+ }
18
+
19
+ interface ActiveAgentLeaseState {
20
+ readonly leaseDurationMillis: number;
21
+ readonly attributes: Attributes;
22
+ readonly wallClockNow: () => number;
23
+ active: boolean;
24
+ disposed: boolean;
25
+ }
26
+
27
+ export function createActiveAgentLease(
28
+ options: CreateActiveAgentLeaseOptions,
29
+ ): ActiveAgentLeaseController | undefined {
30
+ if (options.enabled === false) return undefined;
31
+ return new SessionActiveAgentLease(options);
32
+ }
33
+
34
+ export function computeActiveAgentLeaseExpiryUnixSeconds(
35
+ wallClockUnixMillis: number,
36
+ leaseDurationMillis: number,
37
+ ): number | undefined {
38
+ if (!Number.isFinite(wallClockUnixMillis) || !Number.isFinite(leaseDurationMillis)) return undefined;
39
+
40
+ const expiryMillis = wallClockUnixMillis + leaseDurationMillis;
41
+ if (!Number.isFinite(expiryMillis)) return undefined;
42
+
43
+ const expiryUnixSeconds = expiryMillis / 1000;
44
+ return Number.isFinite(expiryUnixSeconds) ? expiryUnixSeconds : undefined;
45
+ }
46
+
47
+ function observeActiveAgentLease(state: ActiveAgentLeaseState, result: ObservableResult): void {
48
+ if (!state.active || state.disposed) return;
49
+
50
+ const expiryUnixSeconds = computeActiveAgentLeaseExpiryUnixSeconds(
51
+ state.wallClockNow(),
52
+ state.leaseDurationMillis,
53
+ );
54
+ if (expiryUnixSeconds === undefined) return;
55
+
56
+ result.observe(expiryUnixSeconds, state.attributes);
57
+ }
58
+
59
+ class SessionActiveAgentLease implements ActiveAgentLeaseController {
60
+ readonly #instrument: ObservableGauge;
61
+ readonly #state: ActiveAgentLeaseState;
62
+ readonly #callback: ObservableCallback;
63
+
64
+ constructor(options: CreateActiveAgentLeaseOptions) {
65
+ this.#instrument = options.instrument;
66
+ this.#state = {
67
+ leaseDurationMillis: options.leaseDurationMillis,
68
+ attributes: { ...options.attributes },
69
+ wallClockNow: options.wallClockNow ?? Date.now,
70
+ active: false,
71
+ disposed: false,
72
+ };
73
+ this.#callback = observeActiveAgentLease.bind(undefined, this.#state);
74
+ this.#instrument.addCallback(this.#callback);
75
+ }
76
+
77
+ get active(): boolean {
78
+ return this.#state.active;
79
+ }
80
+
81
+ get disposed(): boolean {
82
+ return this.#state.disposed;
83
+ }
84
+
85
+ activate(): void {
86
+ if (this.#state.disposed) return;
87
+ this.#state.active = true;
88
+ }
89
+
90
+ deactivate(): void {
91
+ this.#state.active = false;
92
+ }
93
+
94
+ dispose(): void {
95
+ if (this.#state.disposed) return;
96
+
97
+ this.#state.active = false;
98
+ this.#state.disposed = true;
99
+ this.#instrument.removeCallback(this.#callback);
100
+ }
101
+ }
@@ -183,45 +183,54 @@ async function handleSessionStart(
183
183
  options.requireCompleteParentEnvelope ?? (options.trustedParentContext === true && recoveryCorrelation === undefined),
184
184
  failOpenInvalidPropagation: true,
185
185
  });
186
- const session = await startTelemetryFn({ config, lineage, now: options.now });
187
- session.now = options.now ?? session.now ?? monotonicNowMs;
188
- updateObsStatusRuntimeState({ config: session.config, configDiagnostics: loadedConfig.diagnostics });
189
- clearObsStatusExportError();
190
- state.session = session;
191
- const attributes = buildSessionAttributes(event, ctx, session.config, lineage, recovery);
192
- const labels = metricLabels(session.config, lineage);
193
-
194
- session.sessionAttributes = attributes;
195
- const traceParent = resolveSessionTraceParent(lineage);
196
- session.sessionSpan = startActiveRootSpan(session, SPAN_NAMES.PI_SESSION, attributes, "session", traceParent);
197
- emitConfigRejectionDiagnostic(session, loadedConfig.diagnostics, ctx);
198
- recordSessionTracePropagationFailure(session, traceParent);
199
- startObsSessionRuntimeState({
200
- sessionId: readString(attributes, SESSION_ATTRIBUTES.PI_SESSION_ID),
201
- traceId: readSpanTraceId(session.sessionSpan),
202
- traceUrlTemplate: session.config.query.links.traceUrlTemplate,
203
- });
204
- startObsAgentsRuntimeState({
186
+ const session = await startTelemetryFn({
187
+ config,
205
188
  lineage,
206
- agentTree: session.agentTree,
207
- sessionId: readString(attributes, SESSION_ATTRIBUTES.PI_SESSION_ID),
208
- traceId: readSpanTraceId(session.sessionSpan),
189
+ now: options.now,
190
+ wallClockNow: options.wallClockNow,
209
191
  });
210
- session.workflowStartedAtMs = session.now();
211
- session.metrics.sessionsStarted.add(1, labels);
212
- session.metrics.activeAgents.add(1, labels);
213
- session.activeAgentRecorded = true;
214
- session.sessionSpan.addEvent(LOG_EVENT_NAMES.SESSION_STARTED, attributes);
215
- emitLifecycleLog(session.logger, LOG_EVENT_NAMES.SESSION_STARTED, attributes);
216
- if (recovery.resumed && session.config.replayOnStart) emitStartupReplayTelemetry(session, attributes);
192
+ session.now = options.now ?? session.now ?? monotonicNowMs;
193
+ state.session = session;
217
194
 
218
- if (isRootWorkflow(lineage)) {
219
- session.metrics.workflowsStarted.add(1, labels);
220
- emitLifecycleLog(session.logger, LOG_EVENT_NAMES.WORKFLOW_STARTED, attributes);
195
+ try {
196
+ updateObsStatusRuntimeState({ config: session.config, configDiagnostics: loadedConfig.diagnostics });
197
+ clearObsStatusExportError();
198
+ const attributes = buildSessionAttributes(event, ctx, session.config, lineage, recovery);
199
+ const labels = metricLabels(session.config, lineage);
200
+
201
+ session.sessionAttributes = attributes;
202
+ const traceParent = resolveSessionTraceParent(lineage);
203
+ session.sessionSpan = startActiveRootSpan(session, SPAN_NAMES.PI_SESSION, attributes, "session", traceParent);
204
+ emitConfigRejectionDiagnostic(session, loadedConfig.diagnostics, ctx);
205
+ recordSessionTracePropagationFailure(session, traceParent);
206
+ startObsSessionRuntimeState({
207
+ sessionId: readString(attributes, SESSION_ATTRIBUTES.PI_SESSION_ID),
208
+ traceId: readSpanTraceId(session.sessionSpan),
209
+ traceUrlTemplate: session.config.query.links.traceUrlTemplate,
210
+ });
211
+ startObsAgentsRuntimeState({
212
+ lineage,
213
+ agentTree: session.agentTree,
214
+ sessionId: readString(attributes, SESSION_ATTRIBUTES.PI_SESSION_ID),
215
+ traceId: readSpanTraceId(session.sessionSpan),
216
+ });
217
+ session.workflowStartedAtMs = session.now();
218
+ session.metrics.sessionsStarted.add(1, labels);
219
+ session.sessionSpan.addEvent(LOG_EVENT_NAMES.SESSION_STARTED, attributes);
220
+ emitLifecycleLog(session.logger, LOG_EVENT_NAMES.SESSION_STARTED, attributes);
221
+ if (recovery.resumed && session.config.replayOnStart) emitStartupReplayTelemetry(session, attributes);
222
+
223
+ if (isRootWorkflow(lineage)) {
224
+ session.metrics.workflowsStarted.add(1, labels);
225
+ emitLifecycleLog(session.logger, LOG_EVENT_NAMES.WORKFLOW_STARTED, attributes);
226
+ }
227
+
228
+ await ctx.ui?.setStatus?.(EXTENSION_STATUS_KEY, EXTENSION_STATUS_VALUE);
229
+ activateSessionActiveAgent(session, labels);
230
+ } catch (error) {
231
+ await cleanUpFailedSessionStart(session, ctx, state);
232
+ throw error;
221
233
  }
222
-
223
- await ctx.ui?.setStatus?.(EXTENSION_STATUS_KEY, EXTENSION_STATUS_VALUE);
224
- state.session = session;
225
234
  }
226
235
 
227
236
  function createSessionShutdownHandler(state: HandlerSessionState): Handler {
@@ -429,6 +438,26 @@ function recordDuplicateSessionStartShutdownError(session: ObservMeTelemetrySess
429
438
  );
430
439
  }
431
440
 
441
+ async function cleanUpFailedSessionStart(
442
+ session: ObservMeTelemetrySession,
443
+ ctx: ObservMeHandlerContext,
444
+ state: HandlerSessionState,
445
+ ): Promise<void> {
446
+ const labels = metricLabels(session.config, session.lineage);
447
+
448
+ try {
449
+ deactivateSessionActiveAgent(session, labels);
450
+ endAllActiveSpans(session);
451
+ endActiveSpan(session, session.sessionSpan);
452
+ await clearExtensionStatus(ctx);
453
+ await recordControllerOperationResult(session, "flush");
454
+ await recordControllerOperationResult(session, "shutdown");
455
+ } finally {
456
+ disposeSessionActiveAgentLease(session);
457
+ clearTelemetrySessionRuntimeState(session, state);
458
+ }
459
+ }
460
+
432
461
  async function shutDownTelemetrySession(
433
462
  session: ObservMeTelemetrySession,
434
463
  event: unknown,
@@ -439,19 +468,64 @@ async function shutDownTelemetrySession(
439
468
  const shutdownAttributes = buildShutdownAttributes(event, session);
440
469
  const failed = workflowFailed(event);
441
470
 
442
- session.metrics.sessionsShutdown.add(1, labels);
443
- if (session.activeAgentRecorded) session.metrics.activeAgents.add(-1, labels);
444
- recordWorkflowShutdownTelemetry(session, shutdownAttributes, failed, labels);
445
- endAllActiveSpans(session);
446
- session.sessionSpan?.addEvent(LOG_EVENT_NAMES.SESSION_SHUTDOWN, shutdownAttributes);
447
- if (failed) session.sessionSpan?.setStatus({ code: SpanStatusCode.ERROR });
448
- endActiveSpan(session, session.sessionSpan);
449
- await ctx.ui?.setStatus?.(EXTENSION_STATUS_KEY, undefined);
450
- await recordControllerOperationResult(session, "flush");
451
- await recordControllerOperationResult(session, "shutdown");
471
+ try {
472
+ deactivateSessionActiveAgent(session, labels);
473
+ session.metrics.sessionsShutdown.add(1, labels);
474
+ recordWorkflowShutdownTelemetry(session, shutdownAttributes, failed, labels);
475
+ endAllActiveSpans(session);
476
+ session.sessionSpan?.addEvent(LOG_EVENT_NAMES.SESSION_SHUTDOWN, shutdownAttributes);
477
+ if (failed) session.sessionSpan?.setStatus({ code: SpanStatusCode.ERROR });
478
+ endActiveSpan(session, session.sessionSpan);
479
+ await clearExtensionStatus(ctx);
480
+ await recordControllerOperationResult(session, "flush");
481
+ await recordControllerOperationResult(session, "shutdown");
482
+ } finally {
483
+ disposeSessionActiveAgentLease(session);
484
+ clearTelemetrySessionRuntimeState(session, state);
485
+ }
486
+ }
487
+
488
+ function activateSessionActiveAgent(
489
+ session: ObservMeTelemetrySession,
490
+ labels: Record<string, string>,
491
+ ): void {
492
+ if (session.activeAgentRecorded) return;
493
+
494
+ session.metrics.activeAgents.add(1, labels);
495
+ session.activeAgentRecorded = true;
496
+ session.activeAgentLease?.activate();
497
+ }
498
+
499
+ function deactivateSessionActiveAgent(
500
+ session: ObservMeTelemetrySession,
501
+ labels: Record<string, string>,
502
+ ): void {
503
+ session.activeAgentLease?.deactivate();
504
+ if (!session.activeAgentRecorded) return;
505
+
506
+ session.activeAgentRecorded = false;
507
+ session.metrics.activeAgents.add(-1, labels);
508
+ }
509
+
510
+ function disposeSessionActiveAgentLease(session: ObservMeTelemetrySession): void {
511
+ session.activeAgentLease?.dispose();
512
+ }
513
+
514
+ function clearTelemetrySessionRuntimeState(
515
+ session: ObservMeTelemetrySession,
516
+ state: HandlerSessionState,
517
+ ): void {
452
518
  clearObsSessionRuntimeState();
453
519
  clearObsAgentsRuntimeState();
454
- state.session = undefined;
520
+ if (state.session === session) state.session = undefined;
521
+ }
522
+
523
+ async function clearExtensionStatus(ctx: ObservMeHandlerContext): Promise<void> {
524
+ try {
525
+ await ctx.ui?.setStatus?.(EXTENSION_STATUS_KEY, undefined);
526
+ } catch {
527
+ return;
528
+ }
455
529
  }
456
530
 
457
531
  async function recordControllerOperationResult(
@@ -11,6 +11,7 @@ import { inheritTenantSaltEnvironment } from "../privacy/hash.ts";
11
11
  import { LOG_ATTRIBUTES, RESOURCE_ATTRIBUTES } from "../semconv/attributes.ts";
12
12
  import {
13
13
  LOG_EVENT_NAMES,
14
+ OBSERVME_AGENT_LEASE_METRIC_OPTIONS,
14
15
  OBSERVME_COUNTER_METRIC_NAMES,
15
16
  OBSERVME_GAUGE_METRIC_NAMES,
16
17
  OBSERVME_HISTOGRAM_METRIC_NAMES,
@@ -18,6 +19,7 @@ import {
18
19
  OFFICIAL_GENAI_METRIC_NAMES,
19
20
  } from "../semconv/metrics.ts";
20
21
  import { BoundedMap, type BoundedMapEviction } from "../util/bounded-map.ts";
22
+ import { createActiveAgentLease } from "./active-agent-lease.ts";
21
23
  import type { AgentLineageContext } from "./agent-lineage.ts";
22
24
  import { buildResourceLineageAttributes } from "./agent-lineage.ts";
23
25
  import type { AgentTreeNode } from "./agent-tree-tracker.ts";
@@ -30,6 +32,7 @@ import {
30
32
  evictToolCallState,
31
33
  evictWaitJoinState,
32
34
  isRecord,
35
+ metricLabels,
33
36
  normalizeMetricValue,
34
37
  readBoolean,
35
38
  readString,
@@ -180,6 +183,16 @@ export async function startSessionTelemetry(options: StartSessionTelemetryOption
180
183
  const metrics = createObservMeMetrics(metricSdk.meter);
181
184
  const sessionReference: HandlerSessionState = {};
182
185
  const getTelemetryDropTarget = resolveSessionTelemetryDropTarget.bind(undefined, sessionReference, metrics);
186
+ const spans = createSpanRegistry(config, metrics, getTelemetryDropTarget);
187
+ const agentTree = createAgentTreeTracker(config, options.lineage, metrics, getTelemetryDropTarget);
188
+ const turnSequences = createTurnSequenceRegistry(config, metrics, getTelemetryDropTarget);
189
+ const activeAgentLease = createActiveAgentLease({
190
+ instrument: metrics.agentLeaseExpiresUnixTimeSeconds,
191
+ leaseDurationMillis: config.metrics.activeAgentLeaseDurationMillis,
192
+ attributes: metricLabels(config, options.lineage),
193
+ wallClockNow: options.wallClockNow,
194
+ enabled: config.metrics.enabled,
195
+ });
183
196
  const session: ObservMeTelemetrySession = {
184
197
  config,
185
198
  lineage: options.lineage,
@@ -188,14 +201,15 @@ export async function startSessionTelemetry(options: StartSessionTelemetryOption
188
201
  meter: metricSdk.meter,
189
202
  logger: logSdk.logger,
190
203
  metrics,
191
- spans: createSpanRegistry(config, metrics, getTelemetryDropTarget),
192
- agentTree: createAgentTreeTracker(config, options.lineage, metrics, getTelemetryDropTarget),
204
+ spans,
205
+ activeAgentLease,
206
+ agentTree,
193
207
  now: options.now ?? monotonicNowMs,
194
208
  activeAgentRecorded: false,
195
209
  agentRunSequence: 0,
196
210
  llmRequestSequence: 0,
197
211
  toolCallSequence: 0,
198
- turnSequences: createTurnSequenceRegistry(config, metrics, getTelemetryDropTarget),
212
+ turnSequences,
199
213
  };
200
214
 
201
215
  sessionReference.session = session;
@@ -252,6 +266,10 @@ export function createObservMeMetrics(meter: TelemetryMeter): ObservMeMetrics {
252
266
  eventsObserved: meter.createCounter(OBSERVME_COUNTER_METRIC_NAMES.EVENTS_OBSERVED_TOTAL),
253
267
  activeSpans: meter.createUpDownCounter(OBSERVME_GAUGE_METRIC_NAMES.ACTIVE_SPANS),
254
268
  activeAgents: meter.createUpDownCounter(OBSERVME_GAUGE_METRIC_NAMES.ACTIVE_AGENTS),
269
+ agentLeaseExpiresUnixTimeSeconds: meter.createObservableGauge(
270
+ OBSERVME_GAUGE_METRIC_NAMES.AGENT_LEASE_EXPIRES_UNIXTIME_SECONDS,
271
+ OBSERVME_AGENT_LEASE_METRIC_OPTIONS,
272
+ ),
255
273
  workflowDurationMs: createHistogram(meter, OBSERVME_HISTOGRAM_METRIC_NAMES.WORKFLOW_DURATION_MS),
256
274
  agentRunDurationMs: createHistogram(meter, OBSERVME_HISTOGRAM_METRIC_NAMES.AGENT_RUN_DURATION_MS),
257
275
  agentLifetimeDurationMs: createHistogram(meter, OBSERVME_HISTOGRAM_METRIC_NAMES.AGENT_LIFETIME_DURATION_MS),