@openclaw/diagnostics-otel 2026.2.17 → 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.17",
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 () => {
@@ -108,6 +110,34 @@ import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
108
110
  import { emitDiagnosticEvent } from "openclaw/plugin-sdk";
109
111
  import { createDiagnosticsOtelService } from "./service.js";
110
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
+ }
111
141
  describe("diagnostics-otel service", () => {
112
142
  beforeEach(() => {
113
143
  telemetryState.counters.clear();
@@ -119,6 +149,7 @@ describe("diagnostics-otel service", () => {
119
149
  sdkShutdown.mockClear();
120
150
  logEmit.mockClear();
121
151
  logShutdown.mockClear();
152
+ traceExporterCtor.mockClear();
122
153
  registerLogTransportMock.mockReset();
123
154
  });
124
155
 
@@ -145,12 +176,7 @@ describe("diagnostics-otel service", () => {
145
176
  },
146
177
  },
147
178
  },
148
- logger: {
149
- info: vi.fn(),
150
- warn: vi.fn(),
151
- error: vi.fn(),
152
- debug: vi.fn(),
153
- },
179
+ logger: createLogger(),
154
180
  stateDir: "/tmp/openclaw-diagnostics-otel-test",
155
181
  };
156
182
  await service.start(ctx);
@@ -227,4 +253,44 @@ describe("diagnostics-otel service", () => {
227
253
 
228
254
  await service.stop?.(ctx);
229
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);
295
+ });
230
296
  });
package/src/service.ts CHANGED
@@ -8,7 +8,7 @@ import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs
8
8
  import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
9
9
  import { NodeSDK } from "@opentelemetry/sdk-node";
10
10
  import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
11
- import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
11
+ import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
12
12
  import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk";
13
13
  import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk";
14
14
 
@@ -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