mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(otel): complete diagnostics-otel OpenTelemetry v2 API migration (#12897)
* fix(otel): complete diagnostics-otel OpenTelemetry v2 API migration * chore(format): align otel files with updated oxfmt config * chore(format): apply updated oxfmt spacing to otel diagnostics
This commit is contained in:
@@ -70,7 +70,6 @@ vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({
|
||||
vi.mock("@opentelemetry/sdk-logs", () => ({
|
||||
BatchLogRecordProcessor: class {},
|
||||
LoggerProvider: class {
|
||||
addLogRecordProcessor = vi.fn();
|
||||
getLogger = vi.fn(() => ({
|
||||
emit: logEmit,
|
||||
}));
|
||||
@@ -96,9 +95,7 @@ vi.mock("@opentelemetry/resources", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@opentelemetry/semantic-conventions", () => ({
|
||||
SemanticResourceAttributes: {
|
||||
SERVICE_NAME: "service.name",
|
||||
},
|
||||
ATTR_SERVICE_NAME: "service.name",
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk", async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs
|
||||
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
||||
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||
import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
|
||||
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
|
||||
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
||||
import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk";
|
||||
import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk";
|
||||
|
||||
@@ -40,6 +40,20 @@ function resolveSampleRate(value: number | undefined): number | undefined {
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.stack ?? err.message;
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(err);
|
||||
} catch {
|
||||
return String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
let sdk: NodeSDK | null = null;
|
||||
let logProvider: LoggerProvider | null = null;
|
||||
@@ -75,7 +89,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
}
|
||||
|
||||
const resource = resourceFromAttributes({
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
|
||||
[ATTR_SERVICE_NAME]: serviceName,
|
||||
});
|
||||
|
||||
const traceUrl = resolveOtelUrl(endpoint, "v1/traces");
|
||||
@@ -118,7 +132,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
: {}),
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
try {
|
||||
await sdk.start();
|
||||
} catch (err) {
|
||||
ctx.logger.error(`diagnostics-otel: failed to start SDK: ${formatError(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const logSeverityMap: Record<string, SeverityNumber> = {
|
||||
@@ -211,115 +230,122 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
...(logUrl ? { url: logUrl } : {}),
|
||||
...(headers ? { headers } : {}),
|
||||
});
|
||||
const processor = new BatchLogRecordProcessor(
|
||||
const logProcessor = new BatchLogRecordProcessor(
|
||||
logExporter,
|
||||
typeof otel.flushIntervalMs === "number"
|
||||
? { scheduledDelayMillis: Math.max(1000, otel.flushIntervalMs) }
|
||||
: {},
|
||||
);
|
||||
logProvider = new LoggerProvider({ resource, processors: [processor] });
|
||||
logProvider = new LoggerProvider({
|
||||
resource,
|
||||
processors: [logProcessor],
|
||||
});
|
||||
const otelLogger = logProvider.getLogger("openclaw");
|
||||
|
||||
stopLogTransport = registerLogTransport((logObj) => {
|
||||
const safeStringify = (value: unknown) => {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
const meta = (logObj as Record<string, unknown>)._meta as
|
||||
| {
|
||||
logLevelName?: string;
|
||||
date?: Date;
|
||||
name?: string;
|
||||
parentNames?: string[];
|
||||
path?: {
|
||||
filePath?: string;
|
||||
fileLine?: string;
|
||||
fileColumn?: string;
|
||||
filePathWithLine?: string;
|
||||
method?: string;
|
||||
};
|
||||
try {
|
||||
const safeStringify = (value: unknown) => {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
| undefined;
|
||||
const logLevelName = meta?.logLevelName ?? "INFO";
|
||||
const severityNumber = logSeverityMap[logLevelName] ?? (9 as SeverityNumber);
|
||||
};
|
||||
const meta = (logObj as Record<string, unknown>)._meta as
|
||||
| {
|
||||
logLevelName?: string;
|
||||
date?: Date;
|
||||
name?: string;
|
||||
parentNames?: string[];
|
||||
path?: {
|
||||
filePath?: string;
|
||||
fileLine?: string;
|
||||
fileColumn?: string;
|
||||
filePathWithLine?: string;
|
||||
method?: string;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
const logLevelName = meta?.logLevelName ?? "INFO";
|
||||
const severityNumber = logSeverityMap[logLevelName] ?? (9 as SeverityNumber);
|
||||
|
||||
const numericArgs = Object.entries(logObj)
|
||||
.filter(([key]) => /^\d+$/.test(key))
|
||||
.toSorted((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.map(([, value]) => value);
|
||||
const numericArgs = Object.entries(logObj)
|
||||
.filter(([key]) => /^\d+$/.test(key))
|
||||
.toSorted((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.map(([, value]) => value);
|
||||
|
||||
let bindings: Record<string, unknown> | undefined;
|
||||
if (typeof numericArgs[0] === "string" && numericArgs[0].trim().startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(numericArgs[0]);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
bindings = parsed as Record<string, unknown>;
|
||||
numericArgs.shift();
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed json bindings
|
||||
}
|
||||
}
|
||||
|
||||
let message = "";
|
||||
if (numericArgs.length > 0 && typeof numericArgs[numericArgs.length - 1] === "string") {
|
||||
message = String(numericArgs.pop());
|
||||
} else if (numericArgs.length === 1) {
|
||||
message = safeStringify(numericArgs[0]);
|
||||
numericArgs.length = 0;
|
||||
}
|
||||
if (!message) {
|
||||
message = "log";
|
||||
}
|
||||
|
||||
const attributes: Record<string, string | number | boolean> = {
|
||||
"openclaw.log.level": logLevelName,
|
||||
};
|
||||
if (meta?.name) {
|
||||
attributes["openclaw.logger"] = meta.name;
|
||||
}
|
||||
if (meta?.parentNames?.length) {
|
||||
attributes["openclaw.logger.parents"] = meta.parentNames.join(".");
|
||||
}
|
||||
if (bindings) {
|
||||
for (const [key, value] of Object.entries(bindings)) {
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
attributes[`openclaw.${key}`] = value;
|
||||
} else if (value != null) {
|
||||
attributes[`openclaw.${key}`] = safeStringify(value);
|
||||
let bindings: Record<string, unknown> | undefined;
|
||||
if (typeof numericArgs[0] === "string" && numericArgs[0].trim().startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(numericArgs[0]);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
bindings = parsed as Record<string, unknown>;
|
||||
numericArgs.shift();
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed json bindings
|
||||
}
|
||||
}
|
||||
}
|
||||
if (numericArgs.length > 0) {
|
||||
attributes["openclaw.log.args"] = safeStringify(numericArgs);
|
||||
}
|
||||
if (meta?.path?.filePath) {
|
||||
attributes["code.filepath"] = meta.path.filePath;
|
||||
}
|
||||
if (meta?.path?.fileLine) {
|
||||
attributes["code.lineno"] = Number(meta.path.fileLine);
|
||||
}
|
||||
if (meta?.path?.method) {
|
||||
attributes["code.function"] = meta.path.method;
|
||||
}
|
||||
if (meta?.path?.filePathWithLine) {
|
||||
attributes["openclaw.code.location"] = meta.path.filePathWithLine;
|
||||
}
|
||||
|
||||
otelLogger.emit({
|
||||
body: message,
|
||||
severityText: logLevelName,
|
||||
severityNumber,
|
||||
attributes,
|
||||
timestamp: meta?.date ?? new Date(),
|
||||
});
|
||||
let message = "";
|
||||
if (numericArgs.length > 0 && typeof numericArgs[numericArgs.length - 1] === "string") {
|
||||
message = String(numericArgs.pop());
|
||||
} else if (numericArgs.length === 1) {
|
||||
message = safeStringify(numericArgs[0]);
|
||||
numericArgs.length = 0;
|
||||
}
|
||||
if (!message) {
|
||||
message = "log";
|
||||
}
|
||||
|
||||
const attributes: Record<string, string | number | boolean> = {
|
||||
"openclaw.log.level": logLevelName,
|
||||
};
|
||||
if (meta?.name) {
|
||||
attributes["openclaw.logger"] = meta.name;
|
||||
}
|
||||
if (meta?.parentNames?.length) {
|
||||
attributes["openclaw.logger.parents"] = meta.parentNames.join(".");
|
||||
}
|
||||
if (bindings) {
|
||||
for (const [key, value] of Object.entries(bindings)) {
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
attributes[`openclaw.${key}`] = value;
|
||||
} else if (value != null) {
|
||||
attributes[`openclaw.${key}`] = safeStringify(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (numericArgs.length > 0) {
|
||||
attributes["openclaw.log.args"] = safeStringify(numericArgs);
|
||||
}
|
||||
if (meta?.path?.filePath) {
|
||||
attributes["code.filepath"] = meta.path.filePath;
|
||||
}
|
||||
if (meta?.path?.fileLine) {
|
||||
attributes["code.lineno"] = Number(meta.path.fileLine);
|
||||
}
|
||||
if (meta?.path?.method) {
|
||||
attributes["code.function"] = meta.path.method;
|
||||
}
|
||||
if (meta?.path?.filePathWithLine) {
|
||||
attributes["openclaw.code.location"] = meta.path.filePathWithLine;
|
||||
}
|
||||
|
||||
otelLogger.emit({
|
||||
body: message,
|
||||
severityText: logLevelName,
|
||||
severityNumber,
|
||||
attributes,
|
||||
timestamp: meta?.date ?? new Date(),
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.error(`diagnostics-otel: log transport failed: ${formatError(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -572,43 +598,49 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
};
|
||||
|
||||
unsubscribe = onDiagnosticEvent((evt: DiagnosticEventPayload) => {
|
||||
switch (evt.type) {
|
||||
case "model.usage":
|
||||
recordModelUsage(evt);
|
||||
return;
|
||||
case "webhook.received":
|
||||
recordWebhookReceived(evt);
|
||||
return;
|
||||
case "webhook.processed":
|
||||
recordWebhookProcessed(evt);
|
||||
return;
|
||||
case "webhook.error":
|
||||
recordWebhookError(evt);
|
||||
return;
|
||||
case "message.queued":
|
||||
recordMessageQueued(evt);
|
||||
return;
|
||||
case "message.processed":
|
||||
recordMessageProcessed(evt);
|
||||
return;
|
||||
case "queue.lane.enqueue":
|
||||
recordLaneEnqueue(evt);
|
||||
return;
|
||||
case "queue.lane.dequeue":
|
||||
recordLaneDequeue(evt);
|
||||
return;
|
||||
case "session.state":
|
||||
recordSessionState(evt);
|
||||
return;
|
||||
case "session.stuck":
|
||||
recordSessionStuck(evt);
|
||||
return;
|
||||
case "run.attempt":
|
||||
recordRunAttempt(evt);
|
||||
return;
|
||||
case "diagnostic.heartbeat":
|
||||
recordHeartbeat(evt);
|
||||
return;
|
||||
try {
|
||||
switch (evt.type) {
|
||||
case "model.usage":
|
||||
recordModelUsage(evt);
|
||||
return;
|
||||
case "webhook.received":
|
||||
recordWebhookReceived(evt);
|
||||
return;
|
||||
case "webhook.processed":
|
||||
recordWebhookProcessed(evt);
|
||||
return;
|
||||
case "webhook.error":
|
||||
recordWebhookError(evt);
|
||||
return;
|
||||
case "message.queued":
|
||||
recordMessageQueued(evt);
|
||||
return;
|
||||
case "message.processed":
|
||||
recordMessageProcessed(evt);
|
||||
return;
|
||||
case "queue.lane.enqueue":
|
||||
recordLaneEnqueue(evt);
|
||||
return;
|
||||
case "queue.lane.dequeue":
|
||||
recordLaneDequeue(evt);
|
||||
return;
|
||||
case "session.state":
|
||||
recordSessionState(evt);
|
||||
return;
|
||||
case "session.stuck":
|
||||
recordSessionStuck(evt);
|
||||
return;
|
||||
case "run.attempt":
|
||||
recordRunAttempt(evt);
|
||||
return;
|
||||
case "diagnostic.heartbeat":
|
||||
recordHeartbeat(evt);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.logger.error(
|
||||
`diagnostics-otel: event handler failed (${evt.type}): ${formatError(err)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -167,34 +167,76 @@ export type DiagnosticEventInput = DiagnosticEventPayload extends infer Event
|
||||
? Omit<Event, "seq" | "ts">
|
||||
: never
|
||||
: never;
|
||||
let seq = 0;
|
||||
const listeners = new Set<(evt: DiagnosticEventPayload) => void>();
|
||||
|
||||
type DiagnosticEventsGlobalState = {
|
||||
seq: number;
|
||||
listeners: Set<(evt: DiagnosticEventPayload) => void>;
|
||||
dispatchDepth: number;
|
||||
};
|
||||
|
||||
function getDiagnosticEventsState(): DiagnosticEventsGlobalState {
|
||||
const globalStore = globalThis as typeof globalThis & {
|
||||
__openclawDiagnosticEventsState?: DiagnosticEventsGlobalState;
|
||||
};
|
||||
if (!globalStore.__openclawDiagnosticEventsState) {
|
||||
globalStore.__openclawDiagnosticEventsState = {
|
||||
seq: 0,
|
||||
listeners: new Set<(evt: DiagnosticEventPayload) => void>(),
|
||||
dispatchDepth: 0,
|
||||
};
|
||||
}
|
||||
return globalStore.__openclawDiagnosticEventsState;
|
||||
}
|
||||
|
||||
export function isDiagnosticsEnabled(config?: OpenClawConfig): boolean {
|
||||
return config?.diagnostics?.enabled === true;
|
||||
}
|
||||
|
||||
export function emitDiagnosticEvent(event: DiagnosticEventInput) {
|
||||
const state = getDiagnosticEventsState();
|
||||
if (state.dispatchDepth > 100) {
|
||||
console.error(
|
||||
`[diagnostic-events] recursion guard tripped at depth=${state.dispatchDepth}, dropping type=${event.type}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const enriched = {
|
||||
...event,
|
||||
seq: (seq += 1),
|
||||
seq: (state.seq += 1),
|
||||
ts: Date.now(),
|
||||
} satisfies DiagnosticEventPayload;
|
||||
for (const listener of listeners) {
|
||||
state.dispatchDepth += 1;
|
||||
for (const listener of state.listeners) {
|
||||
try {
|
||||
listener(enriched);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? (err.stack ?? err.message)
|
||||
: typeof err === "string"
|
||||
? err
|
||||
: String(err);
|
||||
console.error(
|
||||
`[diagnostic-events] listener error type=${enriched.type} seq=${enriched.seq}: ${errorMessage}`,
|
||||
);
|
||||
// Ignore listener failures.
|
||||
}
|
||||
}
|
||||
state.dispatchDepth -= 1;
|
||||
}
|
||||
|
||||
export function onDiagnosticEvent(listener: (evt: DiagnosticEventPayload) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
const state = getDiagnosticEventsState();
|
||||
state.listeners.add(listener);
|
||||
return () => {
|
||||
state.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function resetDiagnosticEventsForTest(): void {
|
||||
seq = 0;
|
||||
listeners.clear();
|
||||
const state = getDiagnosticEventsState();
|
||||
state.seq = 0;
|
||||
state.listeners.clear();
|
||||
state.dispatchDepth = 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user