@openclaw/diagnostics-otel 2026.2.15 → 2026.2.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/diagnostics-otel",
3
- "version": "2026.2.15",
3
+ "version": "2026.2.19",
4
4
  "description": "OpenClaw diagnostics OpenTelemetry exporter",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -30,6 +30,7 @@ const sdkStart = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
30
30
  const sdkShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
31
31
  const logEmit = vi.hoisted(() => vi.fn());
32
32
  const logShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
33
+ const traceExporterCtor = vi.hoisted(() => vi.fn());
33
34
 
34
35
  vi.mock("@opentelemetry/api", () => ({
35
36
  metrics: {
@@ -55,7 +56,11 @@ vi.mock("@opentelemetry/exporter-metrics-otlp-http", () => ({
55
56
  }));
56
57
 
57
58
  vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({
58
- OTLPTraceExporter: class {},
59
+ OTLPTraceExporter: class {
60
+ constructor(options?: unknown) {
61
+ traceExporterCtor(options);
62
+ }
63
+ },
59
64
  }));
60
65
 
61
66
  vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({
@@ -65,7 +70,6 @@ vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({
65
70
  vi.mock("@opentelemetry/sdk-logs", () => ({
66
71
  BatchLogRecordProcessor: class {},
67
72
  LoggerProvider: class {
68
- addLogRecordProcessor = vi.fn();
69
73
  getLogger = vi.fn(() => ({
70
74
  emit: logEmit,
71
75
  }));
@@ -91,9 +95,7 @@ vi.mock("@opentelemetry/resources", () => ({
91
95
  }));
92
96
 
93
97
  vi.mock("@opentelemetry/semantic-conventions", () => ({
94
- SemanticResourceAttributes: {
95
- SERVICE_NAME: "service.name",
96
- },
98
+ ATTR_SERVICE_NAME: "service.name",
97
99
  }));
98
100
 
99
101
  vi.mock("openclaw/plugin-sdk", async () => {
@@ -104,9 +106,38 @@ vi.mock("openclaw/plugin-sdk", async () => {
104
106
  };
105
107
  });
106
108
 
109
+ import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
107
110
  import { emitDiagnosticEvent } from "openclaw/plugin-sdk";
108
111
  import { createDiagnosticsOtelService } from "./service.js";
109
112
 
113
+ function createLogger() {
114
+ return {
115
+ info: vi.fn(),
116
+ warn: vi.fn(),
117
+ error: vi.fn(),
118
+ debug: vi.fn(),
119
+ };
120
+ }
121
+
122
+ function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext {
123
+ return {
124
+ config: {
125
+ diagnostics: {
126
+ enabled: true,
127
+ otel: {
128
+ enabled: true,
129
+ endpoint,
130
+ protocol: "http/protobuf",
131
+ traces: true,
132
+ metrics: false,
133
+ logs: false,
134
+ },
135
+ },
136
+ },
137
+ logger: createLogger(),
138
+ stateDir: "/tmp/openclaw-diagnostics-otel-test",
139
+ };
140
+ }
110
141
  describe("diagnostics-otel service", () => {
111
142
  beforeEach(() => {
112
143
  telemetryState.counters.clear();
@@ -118,6 +149,7 @@ describe("diagnostics-otel service", () => {
118
149
  sdkShutdown.mockClear();
119
150
  logEmit.mockClear();
120
151
  logShutdown.mockClear();
152
+ traceExporterCtor.mockClear();
121
153
  registerLogTransportMock.mockReset();
122
154
  });
123
155
 
@@ -130,7 +162,7 @@ describe("diagnostics-otel service", () => {
130
162
  });
131
163
 
132
164
  const service = createDiagnosticsOtelService();
133
- await service.start({
165
+ const ctx: OpenClawPluginServiceContext = {
134
166
  config: {
135
167
  diagnostics: {
136
168
  enabled: true,
@@ -144,13 +176,10 @@ describe("diagnostics-otel service", () => {
144
176
  },
145
177
  },
146
178
  },
147
- logger: {
148
- info: vi.fn(),
149
- warn: vi.fn(),
150
- error: vi.fn(),
151
- debug: vi.fn(),
152
- },
153
- });
179
+ logger: createLogger(),
180
+ stateDir: "/tmp/openclaw-diagnostics-otel-test",
181
+ };
182
+ await service.start(ctx);
154
183
 
155
184
  emitDiagnosticEvent({
156
185
  type: "webhook.received",
@@ -222,6 +251,46 @@ describe("diagnostics-otel service", () => {
222
251
  });
223
252
  expect(logEmit).toHaveBeenCalled();
224
253
 
225
- await service.stop?.();
254
+ await service.stop?.(ctx);
255
+ });
256
+
257
+ test("appends signal path when endpoint contains non-signal /v1 segment", async () => {
258
+ const service = createDiagnosticsOtelService();
259
+ const ctx = createTraceOnlyContext("https://www.comet.com/opik/api/v1/private/otel");
260
+ await service.start(ctx);
261
+
262
+ const options = traceExporterCtor.mock.calls[0]?.[0] as { url?: string } | undefined;
263
+ expect(options?.url).toBe("https://www.comet.com/opik/api/v1/private/otel/v1/traces");
264
+ await service.stop?.(ctx);
265
+ });
266
+
267
+ test("keeps already signal-qualified endpoint unchanged", async () => {
268
+ const service = createDiagnosticsOtelService();
269
+ const ctx = createTraceOnlyContext("https://collector.example.com/v1/traces");
270
+ await service.start(ctx);
271
+
272
+ const options = traceExporterCtor.mock.calls[0]?.[0] as { url?: string } | undefined;
273
+ expect(options?.url).toBe("https://collector.example.com/v1/traces");
274
+ await service.stop?.(ctx);
275
+ });
276
+
277
+ test("keeps signal-qualified endpoint unchanged when it has query params", async () => {
278
+ const service = createDiagnosticsOtelService();
279
+ const ctx = createTraceOnlyContext("https://collector.example.com/v1/traces?timeout=30s");
280
+ await service.start(ctx);
281
+
282
+ const options = traceExporterCtor.mock.calls[0]?.[0] as { url?: string } | undefined;
283
+ expect(options?.url).toBe("https://collector.example.com/v1/traces?timeout=30s");
284
+ await service.stop?.(ctx);
285
+ });
286
+
287
+ test("keeps signal-qualified endpoint unchanged when signal path casing differs", async () => {
288
+ const service = createDiagnosticsOtelService();
289
+ const ctx = createTraceOnlyContext("https://collector.example.com/v1/Traces");
290
+ await service.start(ctx);
291
+
292
+ const options = traceExporterCtor.mock.calls[0]?.[0] as { url?: string } | undefined;
293
+ expect(options?.url).toBe("https://collector.example.com/v1/Traces");
294
+ await service.stop?.(ctx);
226
295
  });
227
296
  });
package/src/service.ts CHANGED
@@ -1,6 +1,5 @@
1
- import type { SeverityNumber } from "@opentelemetry/api-logs";
2
- import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk";
3
1
  import { metrics, trace, SpanStatusCode } from "@opentelemetry/api";
2
+ import type { SeverityNumber } from "@opentelemetry/api-logs";
4
3
  import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
5
4
  import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
6
5
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
@@ -9,7 +8,8 @@ import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs
9
8
  import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
10
9
  import { NodeSDK } from "@opentelemetry/sdk-node";
11
10
  import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
12
- import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
11
+ import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
12
+ import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk";
13
13
  import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk";
14
14
 
15
15
  const DEFAULT_SERVICE_NAME = "openclaw";
@@ -23,7 +23,8 @@ function resolveOtelUrl(endpoint: string | undefined, path: string): string | un
23
23
  if (!endpoint) {
24
24
  return undefined;
25
25
  }
26
- if (endpoint.includes("/v1/")) {
26
+ const endpointWithoutQueryOrFragment = endpoint.split(/[?#]/, 1)[0] ?? endpoint;
27
+ if (/\/v1\/(?:traces|metrics|logs)$/i.test(endpointWithoutQueryOrFragment)) {
27
28
  return endpoint;
28
29
  }
29
30
  return `${endpoint}/${path}`;
@@ -39,6 +40,20 @@ function resolveSampleRate(value: number | undefined): number | undefined {
39
40
  return value;
40
41
  }
41
42
 
43
+ function formatError(err: unknown): string {
44
+ if (err instanceof Error) {
45
+ return err.stack ?? err.message;
46
+ }
47
+ if (typeof err === "string") {
48
+ return err;
49
+ }
50
+ try {
51
+ return JSON.stringify(err);
52
+ } catch {
53
+ return String(err);
54
+ }
55
+ }
56
+
42
57
  export function createDiagnosticsOtelService(): OpenClawPluginService {
43
58
  let sdk: NodeSDK | null = null;
44
59
  let logProvider: LoggerProvider | null = null;
@@ -74,7 +89,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
74
89
  }
75
90
 
76
91
  const resource = resourceFromAttributes({
77
- [SemanticResourceAttributes.SERVICE_NAME]: serviceName,
92
+ [ATTR_SERVICE_NAME]: serviceName,
78
93
  });
79
94
 
80
95
  const traceUrl = resolveOtelUrl(endpoint, "v1/traces");
@@ -117,7 +132,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
117
132
  : {}),
118
133
  });
119
134
 
120
- sdk.start();
135
+ try {
136
+ await sdk.start();
137
+ } catch (err) {
138
+ ctx.logger.error(`diagnostics-otel: failed to start SDK: ${formatError(err)}`);
139
+ throw err;
140
+ }
121
141
  }
122
142
 
123
143
  const logSeverityMap: Record<string, SeverityNumber> = {
@@ -210,115 +230,122 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
210
230
  ...(logUrl ? { url: logUrl } : {}),
211
231
  ...(headers ? { headers } : {}),
212
232
  });
213
- const processor = new BatchLogRecordProcessor(
233
+ const logProcessor = new BatchLogRecordProcessor(
214
234
  logExporter,
215
235
  typeof otel.flushIntervalMs === "number"
216
236
  ? { scheduledDelayMillis: Math.max(1000, otel.flushIntervalMs) }
217
237
  : {},
218
238
  );
219
- logProvider = new LoggerProvider({ resource, processors: [processor] });
239
+ logProvider = new LoggerProvider({
240
+ resource,
241
+ processors: [logProcessor],
242
+ });
220
243
  const otelLogger = logProvider.getLogger("openclaw");
221
244
 
222
245
  stopLogTransport = registerLogTransport((logObj) => {
223
- const safeStringify = (value: unknown) => {
224
- try {
225
- return JSON.stringify(value);
226
- } catch {
227
- return String(value);
228
- }
229
- };
230
- const meta = (logObj as Record<string, unknown>)._meta as
231
- | {
232
- logLevelName?: string;
233
- date?: Date;
234
- name?: string;
235
- parentNames?: string[];
236
- path?: {
237
- filePath?: string;
238
- fileLine?: string;
239
- fileColumn?: string;
240
- filePathWithLine?: string;
241
- method?: string;
242
- };
246
+ try {
247
+ const safeStringify = (value: unknown) => {
248
+ try {
249
+ return JSON.stringify(value);
250
+ } catch {
251
+ return String(value);
243
252
  }
244
- | undefined;
245
- const logLevelName = meta?.logLevelName ?? "INFO";
246
- const severityNumber = logSeverityMap[logLevelName] ?? (9 as SeverityNumber);
247
-
248
- const numericArgs = Object.entries(logObj)
249
- .filter(([key]) => /^\d+$/.test(key))
250
- .toSorted((a, b) => Number(a[0]) - Number(b[0]))
251
- .map(([, value]) => value);
252
-
253
- let bindings: Record<string, unknown> | undefined;
254
- if (typeof numericArgs[0] === "string" && numericArgs[0].trim().startsWith("{")) {
255
- try {
256
- const parsed = JSON.parse(numericArgs[0]);
257
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
258
- bindings = parsed as Record<string, unknown>;
259
- numericArgs.shift();
253
+ };
254
+ const meta = (logObj as Record<string, unknown>)._meta as
255
+ | {
256
+ logLevelName?: string;
257
+ date?: Date;
258
+ name?: string;
259
+ parentNames?: string[];
260
+ path?: {
261
+ filePath?: string;
262
+ fileLine?: string;
263
+ fileColumn?: string;
264
+ filePathWithLine?: string;
265
+ method?: string;
266
+ };
267
+ }
268
+ | undefined;
269
+ const logLevelName = meta?.logLevelName ?? "INFO";
270
+ const severityNumber = logSeverityMap[logLevelName] ?? (9 as SeverityNumber);
271
+
272
+ const numericArgs = Object.entries(logObj)
273
+ .filter(([key]) => /^\d+$/.test(key))
274
+ .toSorted((a, b) => Number(a[0]) - Number(b[0]))
275
+ .map(([, value]) => value);
276
+
277
+ let bindings: Record<string, unknown> | undefined;
278
+ if (typeof numericArgs[0] === "string" && numericArgs[0].trim().startsWith("{")) {
279
+ try {
280
+ const parsed = JSON.parse(numericArgs[0]);
281
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
282
+ bindings = parsed as Record<string, unknown>;
283
+ numericArgs.shift();
284
+ }
285
+ } catch {
286
+ // ignore malformed json bindings
260
287
  }
261
- } catch {
262
- // ignore malformed json bindings
263
288
  }
264
- }
265
289
 
266
- let message = "";
267
- if (numericArgs.length > 0 && typeof numericArgs[numericArgs.length - 1] === "string") {
268
- message = String(numericArgs.pop());
269
- } else if (numericArgs.length === 1) {
270
- message = safeStringify(numericArgs[0]);
271
- numericArgs.length = 0;
272
- }
273
- if (!message) {
274
- message = "log";
275
- }
290
+ let message = "";
291
+ if (numericArgs.length > 0 && typeof numericArgs[numericArgs.length - 1] === "string") {
292
+ message = String(numericArgs.pop());
293
+ } else if (numericArgs.length === 1) {
294
+ message = safeStringify(numericArgs[0]);
295
+ numericArgs.length = 0;
296
+ }
297
+ if (!message) {
298
+ message = "log";
299
+ }
276
300
 
277
- const attributes: Record<string, string | number | boolean> = {
278
- "openclaw.log.level": logLevelName,
279
- };
280
- if (meta?.name) {
281
- attributes["openclaw.logger"] = meta.name;
282
- }
283
- if (meta?.parentNames?.length) {
284
- attributes["openclaw.logger.parents"] = meta.parentNames.join(".");
285
- }
286
- if (bindings) {
287
- for (const [key, value] of Object.entries(bindings)) {
288
- if (
289
- typeof value === "string" ||
290
- typeof value === "number" ||
291
- typeof value === "boolean"
292
- ) {
293
- attributes[`openclaw.${key}`] = value;
294
- } else if (value != null) {
295
- attributes[`openclaw.${key}`] = safeStringify(value);
301
+ const attributes: Record<string, string | number | boolean> = {
302
+ "openclaw.log.level": logLevelName,
303
+ };
304
+ if (meta?.name) {
305
+ attributes["openclaw.logger"] = meta.name;
306
+ }
307
+ if (meta?.parentNames?.length) {
308
+ attributes["openclaw.logger.parents"] = meta.parentNames.join(".");
309
+ }
310
+ if (bindings) {
311
+ for (const [key, value] of Object.entries(bindings)) {
312
+ if (
313
+ typeof value === "string" ||
314
+ typeof value === "number" ||
315
+ typeof value === "boolean"
316
+ ) {
317
+ attributes[`openclaw.${key}`] = value;
318
+ } else if (value != null) {
319
+ attributes[`openclaw.${key}`] = safeStringify(value);
320
+ }
296
321
  }
297
322
  }
298
- }
299
- if (numericArgs.length > 0) {
300
- attributes["openclaw.log.args"] = safeStringify(numericArgs);
301
- }
302
- if (meta?.path?.filePath) {
303
- attributes["code.filepath"] = meta.path.filePath;
304
- }
305
- if (meta?.path?.fileLine) {
306
- attributes["code.lineno"] = Number(meta.path.fileLine);
307
- }
308
- if (meta?.path?.method) {
309
- attributes["code.function"] = meta.path.method;
310
- }
311
- if (meta?.path?.filePathWithLine) {
312
- attributes["openclaw.code.location"] = meta.path.filePathWithLine;
313
- }
323
+ if (numericArgs.length > 0) {
324
+ attributes["openclaw.log.args"] = safeStringify(numericArgs);
325
+ }
326
+ if (meta?.path?.filePath) {
327
+ attributes["code.filepath"] = meta.path.filePath;
328
+ }
329
+ if (meta?.path?.fileLine) {
330
+ attributes["code.lineno"] = Number(meta.path.fileLine);
331
+ }
332
+ if (meta?.path?.method) {
333
+ attributes["code.function"] = meta.path.method;
334
+ }
335
+ if (meta?.path?.filePathWithLine) {
336
+ attributes["openclaw.code.location"] = meta.path.filePathWithLine;
337
+ }
314
338
 
315
- otelLogger.emit({
316
- body: message,
317
- severityText: logLevelName,
318
- severityNumber,
319
- attributes,
320
- timestamp: meta?.date ?? new Date(),
321
- });
339
+ otelLogger.emit({
340
+ body: message,
341
+ severityText: logLevelName,
342
+ severityNumber,
343
+ attributes,
344
+ timestamp: meta?.date ?? new Date(),
345
+ });
346
+ } catch (err) {
347
+ ctx.logger.error(`diagnostics-otel: log transport failed: ${formatError(err)}`);
348
+ }
322
349
  });
323
350
  }
324
351
 
@@ -571,43 +598,49 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
571
598
  };
572
599
 
573
600
  unsubscribe = onDiagnosticEvent((evt: DiagnosticEventPayload) => {
574
- switch (evt.type) {
575
- case "model.usage":
576
- recordModelUsage(evt);
577
- return;
578
- case "webhook.received":
579
- recordWebhookReceived(evt);
580
- return;
581
- case "webhook.processed":
582
- recordWebhookProcessed(evt);
583
- return;
584
- case "webhook.error":
585
- recordWebhookError(evt);
586
- return;
587
- case "message.queued":
588
- recordMessageQueued(evt);
589
- return;
590
- case "message.processed":
591
- recordMessageProcessed(evt);
592
- return;
593
- case "queue.lane.enqueue":
594
- recordLaneEnqueue(evt);
595
- return;
596
- case "queue.lane.dequeue":
597
- recordLaneDequeue(evt);
598
- return;
599
- case "session.state":
600
- recordSessionState(evt);
601
- return;
602
- case "session.stuck":
603
- recordSessionStuck(evt);
604
- return;
605
- case "run.attempt":
606
- recordRunAttempt(evt);
607
- return;
608
- case "diagnostic.heartbeat":
609
- recordHeartbeat(evt);
610
- return;
601
+ try {
602
+ switch (evt.type) {
603
+ case "model.usage":
604
+ recordModelUsage(evt);
605
+ return;
606
+ case "webhook.received":
607
+ recordWebhookReceived(evt);
608
+ return;
609
+ case "webhook.processed":
610
+ recordWebhookProcessed(evt);
611
+ return;
612
+ case "webhook.error":
613
+ recordWebhookError(evt);
614
+ return;
615
+ case "message.queued":
616
+ recordMessageQueued(evt);
617
+ return;
618
+ case "message.processed":
619
+ recordMessageProcessed(evt);
620
+ return;
621
+ case "queue.lane.enqueue":
622
+ recordLaneEnqueue(evt);
623
+ return;
624
+ case "queue.lane.dequeue":
625
+ recordLaneDequeue(evt);
626
+ return;
627
+ case "session.state":
628
+ recordSessionState(evt);
629
+ return;
630
+ case "session.stuck":
631
+ recordSessionStuck(evt);
632
+ return;
633
+ case "run.attempt":
634
+ recordRunAttempt(evt);
635
+ return;
636
+ case "diagnostic.heartbeat":
637
+ recordHeartbeat(evt);
638
+ return;
639
+ }
640
+ } catch (err) {
641
+ ctx.logger.error(
642
+ `diagnostics-otel: event handler failed (${evt.type}): ${formatError(err)}`,
643
+ );
611
644
  }
612
645
  });
613
646