From fe6c9bfef4f66369f882ea82f522c03cc1ab7392 Mon Sep 17 00:00:00 2001 From: "fangxiu.wf" Date: Fri, 15 May 2026 16:00:51 +0800 Subject: [PATCH] feat(openclaw): add event_t JSONL emission for pilot integration Add an event-level JSONL output channel alongside the existing OTLP trace exporter so loongsuite-pilot can ingest each LLM/tool event as one line of event_t schema and fan it out to SLS / JSONL / HTTP sinks. The two paths are independent: - endpoint set, log_enabled unset/false -> OTLP-only (existing default) - endpoint unset, log_enabled true -> JSONL-only (no OTLP) - endpoint set, log_enabled true -> dual-mode (both paths run) - both unset -> plugin refuses to activate Plugin reads ~/.openclaw/otel-config.json (new shared config file, separate from openclaw.json) for log_enabled / log_dir / log_filename_format / captureMessageContent. Plugin-config priority: openclaw.json plugins.entries > otel-config.json > env (OPENCLAW_LOG_ENABLED / OPENCLAW_LOG_DIR / OPENCLAW_CAPTURE_MESSAGE_CONTENT / OPENCLAW_TELEMETRY_DEBUG) > default. Output: /openclaw-YYYY-MM-DD.jsonl, appended via fs.appendFileSync, matching pilot BaseHookInput's hook filename format. Each line carries required event_t fields: time_unix_nano, event.id, event.name, session.id, user.id, agent.type ("openclaw"), turn.id, step.id; plus event-specific fields (gen_ai.provider.name, gen_ai.request.model, gen_ai.usage.*_tokens, gen_ai.tool.*, response.finish_reasons, error.*). JSONL listeners are registered in a self-contained module (src/jsonl-hooks.ts) that maintains its own minimal per-runId state, so the OTLP trace path is untouched. Write failures are logged at warn level and never propagate. Tests: 14 unit cases for JsonlEmitter + builders + readSharedOtelConfig; 4 end-to-end cases simulating one LLM turn with tool call, double-fire dedup, captureMessageContent toggle, and step.id increment across turns. All 129 existing tests still pass. Bumps version to 0.1.4-beta. Co-Authored-By: Claude Opus 4.7 --- .../CHANGELOG.md | 18 + .../README.md | 52 +++ .../package.json | 2 +- .../src/index.ts | 97 +++++- .../src/jsonl-emitter.ts | 312 ++++++++++++++++++ .../src/jsonl-hooks.ts | 285 ++++++++++++++++ .../src/types.ts | 9 + .../test/integration-jsonl.test.ts | 267 +++++++++++++++ .../test/jsonl-emitter.test.ts | 275 +++++++++++++++ 9 files changed, 1313 insertions(+), 4 deletions(-) create mode 100644 opentelemetry-instrumentation-openclaw/src/jsonl-emitter.ts create mode 100644 opentelemetry-instrumentation-openclaw/src/jsonl-hooks.ts create mode 100644 opentelemetry-instrumentation-openclaw/test/integration-jsonl.test.ts create mode 100644 opentelemetry-instrumentation-openclaw/test/jsonl-emitter.test.ts diff --git a/opentelemetry-instrumentation-openclaw/CHANGELOG.md b/opentelemetry-instrumentation-openclaw/CHANGELOG.md index 20e5d96..b4681bc 100644 --- a/opentelemetry-instrumentation-openclaw/CHANGELOG.md +++ b/opentelemetry-instrumentation-openclaw/CHANGELOG.md @@ -2,6 +2,24 @@ 本文档记录 `opentelemetry-instrumentation-openclaw` 的重要变更。 +## [0.1.4-beta] - 2026-05-15 + +### 新增 + +- **事件级 JSONL 输出**:与 `loongsuite-pilot` 集成的事件级数据通道。在 OTLP trace 上报之外,插件可同时把每条 LLM/工具事件按 `event_t` schema 写入本地 JSONL 文件,供 pilot 增量读取并扇出到 SLS / JSONL / HTTP。 + - 配置字段:`log_enabled` / `log_dir` / `log_filename_format`(目前仅支持 `'hook'`)/ `captureMessageContent` + - 共享配置文件:`~/.openclaw/otel-config.json`(供插件与 pilot 协商;`captureMessageContent=false` 默认不写消息内容) + - 环境变量降级:`OPENCLAW_LOG_ENABLED` / `OPENCLAW_LOG_DIR` / `OPENCLAW_CAPTURE_MESSAGE_CONTENT` / `OPENCLAW_TELEMETRY_DEBUG` + - 工作模式:`endpoint` 与 `log_enabled` 至少一项必填;两者可同时启用(双模式),也可单独启用 JSONL 路径(无需 OTLP endpoint) + - 文件命名:`/openclaw-YYYY-MM-DD.jsonl`,与 pilot `BaseHookInput` 的 hook 模式对齐 + - 新增 `src/jsonl-emitter.ts`:JSONL 写盘 + event_t 字段构造工具 + - 新增 `src/jsonl-hooks.ts`:独立 hook 监听器集,与 OTLP trace 路径完全解耦 + +### 测试 + +- 新增 `test/jsonl-emitter.test.ts`(14 个用例,覆盖字段构造、写盘、错误兜底、共享 config 解析) +- 新增 `test/integration-jsonl.test.ts`(4 个端到端用例,模拟一轮 LLM + tool 调用、双触发去重、消息内容采集开关、跨轮 step 自增) + ## [0.1.3-beta] - 2026-05-07 ### 背景 diff --git a/opentelemetry-instrumentation-openclaw/README.md b/opentelemetry-instrumentation-openclaw/README.md index 7b599d6..07298f6 100644 --- a/opentelemetry-instrumentation-openclaw/README.md +++ b/opentelemetry-instrumentation-openclaw/README.md @@ -196,6 +196,58 @@ For `resourceAttributes`, config file values override environment variable value --- +## Event-level JSONL Output (loongsuite-pilot Integration) + +In addition to OTLP traces, the plugin can emit each LLM/tool event as a JSONL line in the [`event_t` schema](https://code.alibaba-inc.com/yt348264/ai-agent-audit/blob/main/docs/guide/architecture.md), suitable for ingestion by [`loongsuite-pilot`](https://github.com/sls-loongsuite/loongsuite-pilot) (SLS / JSONL / HTTP fan-out). + +### Modes + +| `endpoint` | `log_enabled` | Behavior | +|---|---|---| +| set | unset / false | OTLP-only (existing default behavior) | +| unset | true | JSONL-only (no OTLP) | +| set | true | Dual-mode (both paths run independently) | +| unset | unset / false | Plugin refuses to activate | + +### Shared config: `~/.openclaw/otel-config.json` + +Used by both pilot installer and the plugin (independent of `~/.openclaw/openclaw.json`). + +```json +{ + "log_enabled": true, + "log_dir": "~/.loongsuite-pilot/logs/openclaw", + "log_filename_format": "hook", + "captureMessageContent": false +} +``` + +| Field | Type | Default | Description | +|---|---|---|---| +| `log_enabled` | boolean | `false` | Enable JSONL emission | +| `log_dir` | string | — | Output directory; supports `~` expansion | +| `log_filename_format` | string | `"hook"` | Output filename format. Currently only `"hook"` is supported, producing `/openclaw-YYYY-MM-DD.jsonl` | +| `captureMessageContent` | boolean | `false` | When `true`, include `gen_ai.input.messages` / `gen_ai.output.messages` in JSONL records | + +Plugin-config priority for any field: `openclaw.json plugins.entries.config` > `~/.openclaw/otel-config.json` > env > default. + +### Environment variables + +| Variable | Equivalent | +|---|---| +| `OPENCLAW_LOG_ENABLED` | `log_enabled` | +| `OPENCLAW_LOG_DIR` | `log_dir` | +| `OPENCLAW_CAPTURE_MESSAGE_CONTENT` | `captureMessageContent` | +| `OPENCLAW_TELEMETRY_DEBUG` | `debug` (JSONL-only mode) | + +### JSONL schema (per record) + +Each record is one JSON object with `event.name` ∈ `{llm.request, llm.response, tool.call, tool.result}`. Required fields: `time_unix_nano`, `event.id`, `event.name`, `session.id`, `user.id`, `agent.type` (always `"openclaw"`), `turn.id`, `step.id`. Optional fields per event type include `gen_ai.provider.name`, `gen_ai.request.model`, `gen_ai.response.model`, `gen_ai.usage.*_tokens`, `gen_ai.tool.name`, `gen_ai.tool.call.id`, `gen_ai.tool.call.arguments`, `gen_ai.tool.call.result`, `tool.result.duration`, `tool.result.status`, `error.type`, `error.message`. + +Files are appended via `fs.appendFileSync`; write failures are logged at warn-level and never propagate to the gateway. + +--- + ## Uninstall ```bash diff --git a/opentelemetry-instrumentation-openclaw/package.json b/opentelemetry-instrumentation-openclaw/package.json index b0f48f0..f19f054 100644 --- a/opentelemetry-instrumentation-openclaw/package.json +++ b/opentelemetry-instrumentation-openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@loongsuite/opentelemetry-instrumentation-openclaw", - "version": "0.1.3-beta", + "version": "0.1.4-beta", "description": "OpenTelemetry instrumentation for OpenClaw — report AI agent execution traces to any OTLP-compatible backend", "type": "module", "license": "Apache-2.0", diff --git a/opentelemetry-instrumentation-openclaw/src/index.ts b/opentelemetry-instrumentation-openclaw/src/index.ts index c439347..aee4a87 100644 --- a/opentelemetry-instrumentation-openclaw/src/index.ts +++ b/opentelemetry-instrumentation-openclaw/src/index.ts @@ -68,6 +68,8 @@ import { GEN_AI_OUTPUT_MESSAGES, ERROR_TYPE, } from "@loongsuite/opentelemetry-util-genai"; +import { JsonlEmitter, readSharedOtelConfig } from "./jsonl-emitter.js"; +import { registerJsonlHooks } from "./jsonl-hooks.js"; // --------------------------------------------------------------------------- // Constants @@ -386,7 +388,14 @@ const armsTracePlugin: OpenClawPlugin = { activate(api: OpenClawPluginApi) { const pluginConfig = asRecord(api.pluginConfig) || {}; const legacyConfig = getPluginEntryConfig(api.config, LEGACY_PLUGIN_ID); - const resolvedConfig = mergeDefinedValues(legacyConfig || {}, pluginConfig); + // Shared otel-config.json is the pilot integration contract: pilot installer + // writes log_dir/log_enabled there; users may also place their own settings. + // Priority: pluginConfig (openclaw.json plugins.entries) > otel-config.json > env > default. + const sharedOtelConfig = readSharedOtelConfig("~/.openclaw/otel-config.json"); + const resolvedConfig = mergeDefinedValues( + mergeDefinedValues(legacyConfig || {}, sharedOtelConfig), + pluginConfig, + ); const endpoint = pluginConfig.endpoint as string | undefined; const endpointResolved = resolvedConfig.endpoint as string | undefined; @@ -412,9 +421,62 @@ const armsTracePlugin: OpenClawPlugin = { ); } - if (!finalEndpoint) { + // Resolve JSONL emission settings (independent of OTLP). + const finalLogEnabled = + (resolvedConfig.log_enabled as boolean | undefined) ?? + (process.env.OPENCLAW_LOG_ENABLED === "true" + || process.env.OPENCLAW_LOG_ENABLED === "1" + || false); + const finalLogDir = + (resolvedConfig.log_dir as string | undefined) + || process.env.OPENCLAW_LOG_DIR + || ""; + const finalLogFilenameFormat = + ((resolvedConfig.log_filename_format as string | undefined) === "hook" + ? "hook" + : "hook") as "hook"; + const finalCaptureMessageContent = + (resolvedConfig.captureMessageContent as boolean | undefined) + || process.env.OPENCLAW_CAPTURE_MESSAGE_CONTENT === "true" + || process.env.OPENCLAW_CAPTURE_MESSAGE_CONTENT === "1" + || false; + + if (!finalEndpoint && !finalLogEnabled) { api.logger.error( - "[ArmsTrace] Missing required configuration: 'endpoint' must be provided (config or ARMS_OTLP_ENDPOINT env)", + "[ArmsTrace] Missing required configuration: at least one of 'endpoint' (OTLP) or 'log_enabled' (JSONL for pilot) must be provided", + ); + return; + } + + // JSONL-only mode: register JSONL hook listeners and skip OTLP setup. + if (!finalEndpoint && finalLogEnabled) { + if (!finalLogDir) { + api.logger.error( + "[ArmsTrace] log_enabled=true but 'log_dir' is missing (config or OPENCLAW_LOG_DIR env)", + ); + return; + } + const finalDebugJsonlOnly = + (resolvedConfig.debug as boolean | undefined) + || process.env.OPENCLAW_TELEMETRY_DEBUG === "true" + || process.env.OPENCLAW_TELEMETRY_DEBUG === "1" + || false; + const jsonlOnlyEmitter = new JsonlEmitter({ + logDir: finalLogDir, + filenameFormat: finalLogFilenameFormat, + captureMessageContent: finalCaptureMessageContent, + logger: api.logger, + }); + const enabledHooksList = resolvedConfig.enabledHooks as string[] | undefined; + const disposeJsonlHooks = registerJsonlHooks(api, jsonlOnlyEmitter, { + enabledHooks: enabledHooksList, + debug: finalDebugJsonlOnly, + }); + api.on("gateway_stop", () => { + try { disposeJsonlHooks(); } catch { /* ignore */ } + }); + api.logger.info( + `[ArmsTrace] Plugin activated in JSONL-only mode (log_dir: ${finalLogDir})`, ); return; } @@ -466,6 +528,32 @@ const armsTracePlugin: OpenClawPlugin = { const exporter = new ArmsExporter(api, config); + // Dual-mode: when log_enabled is true alongside OTLP, register a parallel + // JSONL hook-listener set so pilot can ingest event-level JSONL while + // OTLP traces go to the configured backend. + let disposeJsonlInDualMode: (() => void) | null = null; + if (finalLogEnabled) { + if (finalLogDir) { + const dualModeEmitter = new JsonlEmitter({ + logDir: finalLogDir, + filenameFormat: finalLogFilenameFormat, + captureMessageContent: finalCaptureMessageContent, + logger: api.logger, + }); + disposeJsonlInDualMode = registerJsonlHooks(api, dualModeEmitter, { + enabledHooks: config.enabledHooks, + debug: config.debug, + }); + api.logger.info( + `[ArmsTrace] JSONL emission enabled (log_dir: ${finalLogDir})`, + ); + } else { + api.logger.warn( + "[ArmsTrace] log_enabled=true but log_dir is missing; JSONL emission disabled", + ); + } + } + if (config.enableTracePropagation) { installPropagation({ targetUrls: config.propagationTargetUrls, @@ -1347,6 +1435,9 @@ const armsTracePlugin: OpenClawPlugin = { } pendingToolCalls.clear(); pendingAssistantByTraceId.clear(); + if (disposeJsonlInDualMode) { + try { disposeJsonlInDualMode(); } catch { /* ignore */ } + } await exporter.dispose(); }); diff --git a/opentelemetry-instrumentation-openclaw/src/jsonl-emitter.ts b/opentelemetry-instrumentation-openclaw/src/jsonl-emitter.ts new file mode 100644 index 0000000..7fa0daa --- /dev/null +++ b/opentelemetry-instrumentation-openclaw/src/jsonl-emitter.ts @@ -0,0 +1,312 @@ +// Copyright 2026 Alibaba Group Holding Limited +// SPDX-License-Identifier: Apache-2.0 + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; +import type { OpenClawPluginApi } from "./types.js"; + +const AGENT_TYPE = "openclaw"; + +export interface JsonlEmitterOptions { + logDir: string; + filenameFormat?: "hook"; + captureMessageContent?: boolean; + logger: OpenClawPluginApi["logger"]; +} + +type EventName = "llm.request" | "llm.response" | "tool.call" | "tool.result"; + +export interface EventTRecord { + time_unix_nano: string; + "event.id": string; + "event.name": EventName; + "session.id": string; + "user.id": string; + "agent.type": "openclaw"; + "turn.id"?: string; + "step.id"?: string; + "gen_ai.provider.name"?: string; + "gen_ai.request.model"?: string; + "gen_ai.response.model"?: string; + "gen_ai.usage.input_tokens"?: number; + "gen_ai.usage.output_tokens"?: number; + "gen_ai.usage.cache_read.input_tokens"?: number; + "gen_ai.usage.total_tokens"?: number; + "gen_ai.input.messages"?: unknown; + "gen_ai.output.messages"?: unknown; + "gen_ai.tool.name"?: string; + "gen_ai.tool.call.id"?: string; + "gen_ai.tool.call.arguments"?: unknown; + "gen_ai.tool.call.result"?: unknown; + "tool.result.duration"?: number; + "tool.result.status"?: string; + "error.type"?: string; + "error.message"?: string; + "response.finish_reasons"?: string; +} + +export interface RecordBaseContext { + sessionId: string; + userId?: string; + runId?: string; + stepId?: string | number; +} + +function expandHome(p: string): string { + if (p.startsWith("~")) { + return path.join(os.homedir(), p.slice(1)); + } + return p; +} + +function nowUnixNano(): string { + return String(BigInt(Date.now()) * 1_000_000n); +} + +function todayDateString(): string { + const d = new Date(); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; +} + +function fallbackUserId(): string { + try { + return os.userInfo().username || "unknown"; + } catch { + return "unknown"; + } +} + +function pruneUndefined(obj: T): T { + const o = obj as Record; + for (const key of Object.keys(o)) { + if (o[key] === undefined) { + delete o[key]; + } + } + return obj; +} + +function makeRecordBase( + ctx: RecordBaseContext, + eventName: EventName, +): EventTRecord { + const rec: EventTRecord = { + time_unix_nano: nowUnixNano(), + "event.id": randomUUID(), + "event.name": eventName, + "session.id": ctx.sessionId || "", + "user.id": ctx.userId || fallbackUserId(), + "agent.type": AGENT_TYPE, + }; + if (ctx.runId) rec["turn.id"] = ctx.runId; + if (ctx.stepId !== undefined) rec["step.id"] = String(ctx.stepId); + return rec; +} + +export interface LlmRequestInput { + provider?: string; + model?: string; + systemPrompt?: string; + prompt?: string; + historyMessages?: Array<{ role: string; content: unknown }>; +} + +export function buildLlmRequestRecord( + ctx: RecordBaseContext, + input: LlmRequestInput, + captureMessageContent: boolean, +): EventTRecord { + const rec = makeRecordBase(ctx, "llm.request"); + if (input.provider) rec["gen_ai.provider.name"] = input.provider; + if (input.model) rec["gen_ai.request.model"] = input.model; + if (captureMessageContent) { + const messages: unknown[] = []; + if (input.systemPrompt) { + messages.push({ role: "system", content: input.systemPrompt }); + } + if (Array.isArray(input.historyMessages)) { + messages.push(...input.historyMessages); + } + if (input.prompt) { + messages.push({ role: "user", content: input.prompt }); + } + if (messages.length > 0) { + rec["gen_ai.input.messages"] = messages; + } + } + return pruneUndefined(rec); +} + +export interface LlmResponseInput { + provider?: string; + model?: string; + assistantTexts?: string[]; + assistantContent?: unknown; + finishReason?: string; + usage?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; + }; +} + +export function buildLlmResponseRecord( + ctx: RecordBaseContext, + input: LlmResponseInput, + captureMessageContent: boolean, +): EventTRecord { + const rec = makeRecordBase(ctx, "llm.response"); + if (input.provider) rec["gen_ai.provider.name"] = input.provider; + if (input.model) rec["gen_ai.response.model"] = input.model; + if (input.usage) { + if (typeof input.usage.input === "number") { + rec["gen_ai.usage.input_tokens"] = input.usage.input; + } + if (typeof input.usage.output === "number") { + rec["gen_ai.usage.output_tokens"] = input.usage.output; + } + if (typeof input.usage.cacheRead === "number") { + rec["gen_ai.usage.cache_read.input_tokens"] = input.usage.cacheRead; + } + if (typeof input.usage.total === "number") { + rec["gen_ai.usage.total_tokens"] = input.usage.total; + } else if ( + typeof input.usage.input === "number" + && typeof input.usage.output === "number" + ) { + rec["gen_ai.usage.total_tokens"] = input.usage.input + input.usage.output; + } + } + if (input.finishReason) rec["response.finish_reasons"] = input.finishReason; + if (captureMessageContent) { + const content = + input.assistantContent !== undefined + ? input.assistantContent + : input.assistantTexts && input.assistantTexts.length > 0 + ? input.assistantTexts.map((t) => ({ role: "assistant", content: t })) + : undefined; + if (content !== undefined) { + rec["gen_ai.output.messages"] = content; + } + } + return pruneUndefined(rec); +} + +export function buildToolCallRecord( + ctx: RecordBaseContext, + toolName: string, + toolCallId: string, + args: unknown, +): EventTRecord { + const rec = makeRecordBase(ctx, "tool.call"); + rec["gen_ai.tool.name"] = toolName; + rec["gen_ai.tool.call.id"] = toolCallId; + if (args !== undefined) rec["gen_ai.tool.call.arguments"] = args; + return pruneUndefined(rec); +} + +export interface ToolResultInput { + result?: unknown; + durationMs?: number; + error?: string; +} + +export function buildToolResultRecord( + ctx: RecordBaseContext, + toolName: string, + toolCallId: string, + input: ToolResultInput, +): EventTRecord { + const rec = makeRecordBase(ctx, "tool.result"); + rec["gen_ai.tool.name"] = toolName; + rec["gen_ai.tool.call.id"] = toolCallId; + if (input.error) { + rec["error.type"] = "tool_error"; + rec["error.message"] = input.error; + rec["tool.result.status"] = "error"; + } else { + rec["tool.result.status"] = "ok"; + if (input.result !== undefined) { + rec["gen_ai.tool.call.result"] = input.result; + } + } + if (typeof input.durationMs === "number") { + rec["tool.result.duration"] = input.durationMs; + } + return pruneUndefined(rec); +} + +export class JsonlEmitter { + private readonly logDir: string; + private readonly filenameFormat: "hook"; + private readonly logger: OpenClawPluginApi["logger"]; + private warnedDirCreate = false; + + readonly captureMessageContent: boolean; + + constructor(opts: JsonlEmitterOptions) { + this.logDir = expandHome(opts.logDir); + this.filenameFormat = opts.filenameFormat || "hook"; + this.captureMessageContent = opts.captureMessageContent || false; + this.logger = opts.logger; + } + + private ensureDir(): boolean { + try { + fs.mkdirSync(this.logDir, { recursive: true }); + return true; + } catch (err) { + if (!this.warnedDirCreate) { + this.warnedDirCreate = true; + this.logger.warn( + `[ArmsTrace] JSONL emitter: failed to create log dir ${this.logDir}: ${String(err)}`, + ); + } + return false; + } + } + + private currentLogFile(): string { + // filenameFormat 'hook' → -YYYY-MM-DD.jsonl, matches pilot BaseHookInput + return path.join(this.logDir, `${AGENT_TYPE}-${todayDateString()}.jsonl`); + } + + emit(record: EventTRecord): void { + if (!this.ensureDir()) return; + const filePath = this.currentLogFile(); + try { + const line = JSON.stringify(record) + "\n"; + fs.appendFileSync(filePath, line, "utf-8"); + } catch (err) { + this.logger.warn( + `[ArmsTrace] JSONL emitter: write failed for ${filePath}: ${String(err)}`, + ); + } + } +} + +/** + * Read shared otel-config.json located at /.openclaw/otel-config.json. + * Returns an object with possibly-set log_enabled / log_dir / log_filename_format / + * captureMessageContent / debug. Missing file or parse errors return {}. + */ +export function readSharedOtelConfig( + configPath: string, +): Record { + try { + const raw = fs.readFileSync(expandHome(configPath), "utf-8"); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} diff --git a/opentelemetry-instrumentation-openclaw/src/jsonl-hooks.ts b/opentelemetry-instrumentation-openclaw/src/jsonl-hooks.ts new file mode 100644 index 0000000..f8f1e53 --- /dev/null +++ b/opentelemetry-instrumentation-openclaw/src/jsonl-hooks.ts @@ -0,0 +1,285 @@ +// Copyright 2026 Alibaba Group Holding Limited +// SPDX-License-Identifier: Apache-2.0 + +import { + JsonlEmitter, + buildLlmRequestRecord, + buildLlmResponseRecord, + buildToolCallRecord, + buildToolResultRecord, +} from "./jsonl-emitter.js"; +import type { + AfterToolCallEvent, + BeforeToolCallEvent, + LlmInputEvent, + LlmOutputEvent, + MessageReceivedEvent, + OpenClawPluginApi, + PluginHookContext, +} from "./types.js"; + +/** + * Per-run state for JSONL emission. + * - userId is captured from message_received and reused across the turn. + * - stepCounter increments on each llm_input. + * - llmResponseEmittedSteps prevents duplicate llm.response when both + * llm_output and before_message_write fire. + */ +interface JsonlRunState { + sessionId?: string; + userId?: string; + stepCounter: number; + llmResponseEmittedSteps: Set; + lastUsage?: LlmOutputEvent["usage"]; + lastAssistantTexts?: string[]; +} + +function getStateForRun( + store: Map, + runId: string, +): JsonlRunState { + let s = store.get(runId); + if (!s) { + s = { + stepCounter: 0, + llmResponseEmittedSteps: new Set(), + }; + store.set(runId, s); + } + return s; +} + +const STATE_TTL_MS = 30 * 60 * 1_000; +const STATE_SWEEP_INTERVAL_MS = 10 * 60 * 1_000; + +/** + * Register a self-contained set of hook listeners that emit event_t schema + * JSONL records via the supplied JsonlEmitter. Independent of the OTLP + * trace path: maintains its own minimal per-run state. + * + * Returns a dispose function that stops the periodic state sweeper. + */ +export function registerJsonlHooks( + api: OpenClawPluginApi, + emitter: JsonlEmitter, + options: { enabledHooks?: string[]; debug?: boolean } = {}, +): () => void { + const enabled = (name: string) => + !options.enabledHooks || options.enabledHooks.includes(name); + const captureMessageContent = emitter.captureMessageContent; + + const stateByRun = new Map(); + // Record the most recent user (from message_received) so subsequent + // llm_input on the same channel can pick up user.id. + let lastUserId: string | undefined; + let lastUserSessionId: string | undefined; + const stateTouchedAt = new Map(); + + const touch = (runId: string) => { + stateTouchedAt.set(runId, Date.now()); + }; + + const sweepTimer = setInterval(() => { + const now = Date.now(); + for (const [runId, ts] of stateTouchedAt) { + if (now - ts > STATE_TTL_MS) { + stateByRun.delete(runId); + stateTouchedAt.delete(runId); + } + } + }, STATE_SWEEP_INTERVAL_MS); + sweepTimer.unref?.(); + + if (enabled("message_received")) { + api.on( + "message_received", + (event: MessageReceivedEvent, _ctx: PluginHookContext) => { + if (event.from && !event.from.startsWith("agent/")) { + lastUserId = event.from; + } + // sessionId may not be on the event; ctx.sessionKey is fallback + const ctxSessionKey = (_ctx.sessionKey as string | undefined) + || (_ctx.channelId as string | undefined); + if (ctxSessionKey) { + lastUserSessionId = ctxSessionKey; + } + }, + ); + } + + if (enabled("llm_input")) { + api.on( + "llm_input", + (event: LlmInputEvent, ctx: PluginHookContext) => { + const runId = event.runId || `run-${Date.now()}`; + const state = getStateForRun(stateByRun, runId); + state.stepCounter += 1; + state.sessionId = event.sessionId || state.sessionId + || (ctx.sessionKey as string | undefined) + || lastUserSessionId + || ""; + if (!state.userId) state.userId = lastUserId; + touch(runId); + try { + const rec = buildLlmRequestRecord( + { + sessionId: state.sessionId || "", + userId: state.userId, + runId, + stepId: state.stepCounter, + }, + { + provider: event.provider, + model: event.model, + systemPrompt: event.systemPrompt, + prompt: event.prompt, + historyMessages: event.historyMessages, + }, + captureMessageContent, + ); + emitter.emit(rec); + } catch (err) { + if (options.debug) { + api.logger.warn( + `[ArmsTrace] JSONL llm_input emission failed: ${String(err)}`, + ); + } + } + }, + ); + } + + if (enabled("llm_output")) { + api.on( + "llm_output", + (event: LlmOutputEvent, _ctx: PluginHookContext) => { + const runId = event.runId || ""; + if (!runId) return; + const state = getStateForRun(stateByRun, runId); + state.lastUsage = event.usage; + state.lastAssistantTexts = event.assistantTexts; + state.sessionId = event.sessionId || state.sessionId || ""; + if (state.llmResponseEmittedSteps.has(state.stepCounter)) { + return; + } + state.llmResponseEmittedSteps.add(state.stepCounter); + touch(runId); + try { + const rec = buildLlmResponseRecord( + { + sessionId: state.sessionId || "", + userId: state.userId, + runId, + stepId: state.stepCounter, + }, + { + provider: event.provider, + model: event.model, + assistantTexts: event.assistantTexts, + usage: event.usage, + }, + captureMessageContent, + ); + emitter.emit(rec); + } catch (err) { + if (options.debug) { + api.logger.warn( + `[ArmsTrace] JSONL llm_output emission failed: ${String(err)}`, + ); + } + } + }, + ); + } + + if (enabled("before_tool_call")) { + api.on( + "before_tool_call", + (event: BeforeToolCallEvent, _ctx: PluginHookContext) => { + const runId = event.runId || ""; + const state = runId ? getStateForRun(stateByRun, runId) : null; + if (state) touch(runId); + const toolCallId = event.toolCallId || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + try { + const rec = buildToolCallRecord( + { + sessionId: state?.sessionId || "", + userId: state?.userId, + runId, + stepId: state?.stepCounter, + }, + event.toolName, + toolCallId, + event.params, + ); + emitter.emit(rec); + } catch (err) { + if (options.debug) { + api.logger.warn( + `[ArmsTrace] JSONL before_tool_call emission failed: ${String(err)}`, + ); + } + } + }, + ); + } + + if (enabled("after_tool_call")) { + api.on( + "after_tool_call", + (event: AfterToolCallEvent, _ctx: PluginHookContext) => { + const runId = event.runId || ""; + const state = runId ? getStateForRun(stateByRun, runId) : null; + if (state) touch(runId); + const toolCallId = event.toolCallId || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + try { + const rec = buildToolResultRecord( + { + sessionId: state?.sessionId || "", + userId: state?.userId, + runId, + stepId: state?.stepCounter, + }, + event.toolName, + toolCallId, + { + result: event.result, + durationMs: event.durationMs, + error: event.error, + }, + ); + emitter.emit(rec); + } catch (err) { + if (options.debug) { + api.logger.warn( + `[ArmsTrace] JSONL after_tool_call emission failed: ${String(err)}`, + ); + } + } + }, + ); + } + + if (enabled("agent_end")) { + api.on("agent_end", (_event, _ctx: PluginHookContext) => { + // No emission here; just clean up state. agent_end fires when a turn + // wraps; runId state can be evicted to bound memory. + // Defer slightly so any in-flight llm_output can still find state. + setTimeout(() => { + const cutoff = Date.now() - 5_000; + for (const [runId, ts] of stateTouchedAt) { + if (ts < cutoff) { + stateByRun.delete(runId); + stateTouchedAt.delete(runId); + } + } + }, 1_000); + }); + } + + return () => { + clearInterval(sweepTimer); + stateByRun.clear(); + stateTouchedAt.clear(); + }; +} diff --git a/opentelemetry-instrumentation-openclaw/src/types.ts b/opentelemetry-instrumentation-openclaw/src/types.ts index f634982..8be936c 100644 --- a/opentelemetry-instrumentation-openclaw/src/types.ts +++ b/opentelemetry-instrumentation-openclaw/src/types.ts @@ -55,6 +55,15 @@ export interface ArmsTraceConfig { propagationTargetUrls?: string[]; resourceAttributes?: Record; globalSpanAttributes?: Record; + + // Event-level JSONL emission (for loongsuite-pilot integration). + // When enabled, the plugin writes each LLM/tool event as a JSONL line + // to /openclaw-YYYY-MM-DD.jsonl in event_t schema, in addition + // to the OTLP trace export. Both paths are independent. + log_enabled?: boolean; + log_dir?: string; + log_filename_format?: "hook"; + captureMessageContent?: boolean; } export type SpanType = diff --git a/opentelemetry-instrumentation-openclaw/test/integration-jsonl.test.ts b/opentelemetry-instrumentation-openclaw/test/integration-jsonl.test.ts new file mode 100644 index 0000000..42f917f --- /dev/null +++ b/opentelemetry-instrumentation-openclaw/test/integration-jsonl.test.ts @@ -0,0 +1,267 @@ +// Copyright 2026 Alibaba Group Holding Limited +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { JsonlEmitter } from "../src/jsonl-emitter.js"; +import { registerJsonlHooks } from "../src/jsonl-hooks.js"; +import type { + AfterToolCallEvent, + BeforeToolCallEvent, + LlmInputEvent, + LlmOutputEvent, + MessageReceivedEvent, + OpenClawPluginApi, +} from "../src/types.js"; + +type HookHandler = (event: unknown, ctx: Record) => Promise | void; + +interface HookBus { + handlers: Map; + fire(name: string, event: unknown, ctx?: Record): Promise; + api: OpenClawPluginApi; +} + +function makeApi(): HookBus { + const handlers = new Map(); + const api: OpenClawPluginApi = { + config: {}, + pluginConfig: {}, + runtime: { version: "test" }, + logger: { info: () => {}, warn: () => {}, error: () => {} }, + on(name, handler) { + const list = handlers.get(name) || []; + list.push(handler as HookHandler); + handlers.set(name, list); + }, + }; + return { + handlers, + api, + async fire(name, event, ctx = {}) { + const list = handlers.get(name) || []; + for (const h of list) { + await h(event, ctx); + } + }, + }; +} + +function todayJsonl(dir: string): string { + const d = new Date(); + return path.join( + dir, + `openclaw-${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}.jsonl`, + ); +} + +function readLines(file: string): Array> { + if (!fs.existsSync(file)) return []; + return fs + .readFileSync(file, "utf-8") + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => JSON.parse(l) as Record); +} + +describe("registerJsonlHooks — end-to-end one turn", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-e2e-")); + }); + + afterEach(() => { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ } + }); + + it("emits 4 lines for one LLM turn with one tool call", async () => { + const bus = makeApi(); + const emitter = new JsonlEmitter({ + logDir: tmpDir, + captureMessageContent: false, + logger: bus.api.logger, + }); + registerJsonlHooks(bus.api, emitter); + + await bus.fire("message_received", { + from: "alice", + content: "hi", + } satisfies MessageReceivedEvent, { sessionKey: "sess-A" }); + + await bus.fire("llm_input", { + runId: "run-1", + sessionId: "sess-A", + provider: "openai", + model: "gpt-4o", + systemPrompt: "you are helpful", + prompt: "hi", + historyMessages: [], + imagesCount: 0, + } satisfies LlmInputEvent); + + await bus.fire("before_tool_call", { + runId: "run-1", + toolName: "shell", + toolCallId: "call-1", + params: { cmd: "ls" }, + } satisfies BeforeToolCallEvent); + + await bus.fire("after_tool_call", { + runId: "run-1", + toolName: "shell", + toolCallId: "call-1", + params: { cmd: "ls" }, + result: { stdout: "ok" }, + durationMs: 12, + } satisfies AfterToolCallEvent); + + await bus.fire("llm_output", { + runId: "run-1", + sessionId: "sess-A", + provider: "openai", + model: "gpt-4o", + assistantTexts: ["all good"], + usage: { input: 5, output: 7, total: 12 }, + } satisfies LlmOutputEvent); + + const lines = readLines(todayJsonl(tmpDir)); + expect(lines.length).toBe(4); + + const names = lines.map((l) => l["event.name"]); + expect(names).toEqual([ + "llm.request", + "tool.call", + "tool.result", + "llm.response", + ]); + + // user.id propagated from message_received + for (const l of lines) { + expect(l["user.id"]).toBe("alice"); + expect(l["session.id"]).toBe("sess-A"); + expect(l["agent.type"]).toBe("openclaw"); + expect(l["turn.id"]).toBe("run-1"); + } + + // step.id stays consistent within the turn + const steps = lines.map((l) => l["step.id"]); + expect(new Set(steps).size).toBe(1); + }); + + it("does not double-emit llm.response when llm_output fires twice for same step", async () => { + const bus = makeApi(); + const emitter = new JsonlEmitter({ + logDir: tmpDir, + captureMessageContent: false, + logger: bus.api.logger, + }); + registerJsonlHooks(bus.api, emitter); + + await bus.fire("llm_input", { + runId: "r", + sessionId: "s", + provider: "p", + model: "m", + prompt: "x", + historyMessages: [], + imagesCount: 0, + } satisfies LlmInputEvent); + await bus.fire("llm_output", { + runId: "r", + sessionId: "s", + provider: "p", + model: "m", + assistantTexts: ["a"], + usage: { input: 1, output: 1 }, + } satisfies LlmOutputEvent); + await bus.fire("llm_output", { + runId: "r", + sessionId: "s", + provider: "p", + model: "m", + assistantTexts: ["b"], + usage: { input: 2, output: 2 }, + } satisfies LlmOutputEvent); + + const lines = readLines(todayJsonl(tmpDir)); + const responses = lines.filter((l) => l["event.name"] === "llm.response"); + expect(responses.length).toBe(1); + }); + + it("captureMessageContent=true includes input/output messages", async () => { + const bus = makeApi(); + const emitter = new JsonlEmitter({ + logDir: tmpDir, + captureMessageContent: true, + logger: bus.api.logger, + }); + registerJsonlHooks(bus.api, emitter); + + await bus.fire("llm_input", { + runId: "r", + sessionId: "s", + provider: "p", + model: "m", + prompt: "what time is it", + historyMessages: [], + imagesCount: 0, + } satisfies LlmInputEvent); + await bus.fire("llm_output", { + runId: "r", + sessionId: "s", + provider: "p", + model: "m", + assistantTexts: ["it is noon"], + usage: { input: 1, output: 2 }, + } satisfies LlmOutputEvent); + + const lines = readLines(todayJsonl(tmpDir)); + const req = lines.find((l) => l["event.name"] === "llm.request"); + const res = lines.find((l) => l["event.name"] === "llm.response"); + expect(req).toBeDefined(); + expect(res).toBeDefined(); + expect(req!["gen_ai.input.messages"]).toBeDefined(); + expect(res!["gen_ai.output.messages"]).toBeDefined(); + }); + + it("step.id increments across multiple LLM turns within same run", async () => { + const bus = makeApi(); + const emitter = new JsonlEmitter({ + logDir: tmpDir, + captureMessageContent: false, + logger: bus.api.logger, + }); + registerJsonlHooks(bus.api, emitter); + + for (let i = 1; i <= 3; i += 1) { + await bus.fire("llm_input", { + runId: "run-Z", + sessionId: "sess", + provider: "p", + model: "m", + prompt: `turn ${i}`, + historyMessages: [], + imagesCount: 0, + } satisfies LlmInputEvent); + await bus.fire("llm_output", { + runId: "run-Z", + sessionId: "sess", + provider: "p", + model: "m", + assistantTexts: [`reply ${i}`], + usage: { input: i, output: i }, + } satisfies LlmOutputEvent); + } + + const lines = readLines(todayJsonl(tmpDir)); + expect(lines.length).toBe(6); + + const requestSteps = lines + .filter((l) => l["event.name"] === "llm.request") + .map((l) => Number(l["step.id"])); + expect(requestSteps).toEqual([1, 2, 3]); + }); +}); diff --git a/opentelemetry-instrumentation-openclaw/test/jsonl-emitter.test.ts b/opentelemetry-instrumentation-openclaw/test/jsonl-emitter.test.ts new file mode 100644 index 0000000..78466b2 --- /dev/null +++ b/opentelemetry-instrumentation-openclaw/test/jsonl-emitter.test.ts @@ -0,0 +1,275 @@ +// Copyright 2026 Alibaba Group Holding Limited +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + JsonlEmitter, + buildLlmRequestRecord, + buildLlmResponseRecord, + buildToolCallRecord, + buildToolResultRecord, + readSharedOtelConfig, +} from "../src/jsonl-emitter.js"; + +const noopLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-jsonl-test-")); +} + +function readJsonl(filePath: string): unknown[] { + const text = fs.readFileSync(filePath, "utf-8"); + return text + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => JSON.parse(l)); +} + +function todayJsonlFile(dir: string): string { + const d = new Date(); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return path.join(dir, `openclaw-${yyyy}-${mm}-${dd}.jsonl`); +} + +describe("buildLlmRequestRecord", () => { + it("contains required event_t fields", () => { + const rec = buildLlmRequestRecord( + { sessionId: "sess-1", userId: "u-1", runId: "run-1", stepId: 1 }, + { provider: "openai", model: "gpt-4o", prompt: "hi" }, + false, + ); + expect(rec["event.name"]).toBe("llm.request"); + expect(rec["session.id"]).toBe("sess-1"); + expect(rec["user.id"]).toBe("u-1"); + expect(rec["agent.type"]).toBe("openclaw"); + expect(rec["turn.id"]).toBe("run-1"); + expect(rec["step.id"]).toBe("1"); + expect(rec["gen_ai.provider.name"]).toBe("openai"); + expect(rec["gen_ai.request.model"]).toBe("gpt-4o"); + expect(rec["time_unix_nano"]).toMatch(/^\d+$/); + expect(rec["event.id"]).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("excludes input.messages when captureMessageContent=false", () => { + const rec = buildLlmRequestRecord( + { sessionId: "s", runId: "r", stepId: 1 }, + { prompt: "secret" }, + false, + ); + expect(rec["gen_ai.input.messages"]).toBeUndefined(); + }); + + it("includes input.messages when captureMessageContent=true", () => { + const rec = buildLlmRequestRecord( + { sessionId: "s", runId: "r", stepId: 1 }, + { + systemPrompt: "you are helpful", + prompt: "hi", + historyMessages: [{ role: "user", content: "earlier" }], + }, + true, + ); + expect(Array.isArray(rec["gen_ai.input.messages"])).toBe(true); + const msgs = rec["gen_ai.input.messages"] as unknown[]; + expect(msgs.length).toBe(3); // system + history + current user + }); +}); + +describe("buildLlmResponseRecord", () => { + it("captures usage tokens", () => { + const rec = buildLlmResponseRecord( + { sessionId: "s", runId: "r", stepId: 1 }, + { + provider: "openai", + model: "gpt-4o", + finishReason: "stop", + usage: { input: 10, output: 20, total: 30, cacheRead: 5 }, + }, + false, + ); + expect(rec["gen_ai.usage.input_tokens"]).toBe(10); + expect(rec["gen_ai.usage.output_tokens"]).toBe(20); + expect(rec["gen_ai.usage.total_tokens"]).toBe(30); + expect(rec["gen_ai.usage.cache_read.input_tokens"]).toBe(5); + expect(rec["response.finish_reasons"]).toBe("stop"); + }); + + it("computes total when not provided", () => { + const rec = buildLlmResponseRecord( + { sessionId: "s", runId: "r", stepId: 1 }, + { usage: { input: 7, output: 13 } }, + false, + ); + expect(rec["gen_ai.usage.total_tokens"]).toBe(20); + }); +}); + +describe("buildToolCallRecord / buildToolResultRecord", () => { + it("tool.call carries tool name + args", () => { + const rec = buildToolCallRecord( + { sessionId: "s", runId: "r", stepId: 1 }, + "shell", + "call-1", + { cmd: "ls" }, + ); + expect(rec["event.name"]).toBe("tool.call"); + expect(rec["gen_ai.tool.name"]).toBe("shell"); + expect(rec["gen_ai.tool.call.id"]).toBe("call-1"); + expect(rec["gen_ai.tool.call.arguments"]).toEqual({ cmd: "ls" }); + }); + + it("tool.result on success", () => { + const rec = buildToolResultRecord( + { sessionId: "s", runId: "r", stepId: 1 }, + "shell", + "call-1", + { result: { stdout: "ok" }, durationMs: 42 }, + ); + expect(rec["event.name"]).toBe("tool.result"); + expect(rec["tool.result.status"]).toBe("ok"); + expect(rec["gen_ai.tool.call.result"]).toEqual({ stdout: "ok" }); + expect(rec["tool.result.duration"]).toBe(42); + expect(rec["error.type"]).toBeUndefined(); + }); + + it("tool.result on error", () => { + const rec = buildToolResultRecord( + { sessionId: "s", runId: "r", stepId: 1 }, + "shell", + "call-1", + { error: "boom" }, + ); + expect(rec["tool.result.status"]).toBe("error"); + expect(rec["error.type"]).toBe("tool_error"); + expect(rec["error.message"]).toBe("boom"); + expect(rec["gen_ai.tool.call.result"]).toBeUndefined(); + }); +}); + +describe("JsonlEmitter", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + + afterEach(() => { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ } + }); + + it("creates log dir and appends one JSONL line per emit", () => { + const emitter = new JsonlEmitter({ + logDir: tmpDir, + captureMessageContent: false, + logger: noopLogger, + }); + emitter.emit( + buildLlmRequestRecord( + { sessionId: "s", userId: "u", runId: "r", stepId: 1 }, + { provider: "p", model: "m", prompt: "x" }, + false, + ), + ); + emitter.emit( + buildLlmResponseRecord( + { sessionId: "s", userId: "u", runId: "r", stepId: 1 }, + { provider: "p", model: "m", finishReason: "stop", usage: { input: 1, output: 2, total: 3 } }, + false, + ), + ); + const file = todayJsonlFile(tmpDir); + expect(fs.existsSync(file)).toBe(true); + const lines = readJsonl(file); + expect(lines.length).toBe(2); + expect((lines[0] as Record)["event.name"]).toBe("llm.request"); + expect((lines[1] as Record)["event.name"]).toBe("llm.response"); + }); + + it("appends instead of overwriting on second emit", () => { + const emitter = new JsonlEmitter({ + logDir: tmpDir, + captureMessageContent: false, + logger: noopLogger, + }); + for (let i = 0; i < 5; i += 1) { + emitter.emit( + buildToolCallRecord( + { sessionId: "s", runId: "r", stepId: 1 }, + "tool-x", + `call-${i}`, + { i }, + ), + ); + } + const lines = readJsonl(todayJsonlFile(tmpDir)); + expect(lines.length).toBe(5); + }); + + it("write failure is silent (logger.warn called)", () => { + let warnCalled = false; + const logger = { + info: () => {}, + warn: () => { warnCalled = true; }, + error: () => {}, + }; + // Use a path that cannot be created (file exists where dir is expected). + const blocker = path.join(tmpDir, "blocker"); + fs.writeFileSync(blocker, "x"); + const emitter = new JsonlEmitter({ + logDir: path.join(blocker, "subdir"), + captureMessageContent: false, + logger, + }); + emitter.emit( + buildLlmRequestRecord( + { sessionId: "s", runId: "r", stepId: 1 }, + {}, + false, + ), + ); + expect(warnCalled).toBe(true); + }); +}); + +describe("readSharedOtelConfig", () => { + it("returns {} for missing file", () => { + expect(readSharedOtelConfig("/tmp/__nonexistent_openclaw_otel_config.json")) + .toEqual({}); + }); + + it("parses a valid file", () => { + const dir = makeTmpDir(); + try { + const file = path.join(dir, "otel-config.json"); + fs.writeFileSync(file, JSON.stringify({ + log_enabled: true, + log_dir: "/tmp/foo", + })); + const cfg = readSharedOtelConfig(file); + expect(cfg.log_enabled).toBe(true); + expect(cfg.log_dir).toBe("/tmp/foo"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns {} for malformed JSON", () => { + const dir = makeTmpDir(); + try { + const file = path.join(dir, "bad.json"); + fs.writeFileSync(file, "not json{{"); + expect(readSharedOtelConfig(file)).toEqual({}); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +});