@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 +1 -1
- package/src/service.test.ts +77 -11
- package/src/service.ts +167 -134
package/package.json
CHANGED
package/src/service.test.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
|
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({
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
|