oh-langfuse 0.1.25 → 0.1.26
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/bin/cli.js +38 -10
- package/codex_langfuse_notify.py +283 -64
- package/langfuse_hook.py +247 -46
- package/package.json +10 -5
- package/scripts/metrics-utils.mjs +126 -0
- package/scripts/opencode-langfuse-setup.mjs +233 -45
- package/scripts/real-self-verify.mjs +148 -8
- package/scripts/update-langfuse-runtime.mjs +178 -0
- package/scripts/update-utils.mjs +20 -0
|
@@ -115,10 +115,11 @@ function getPatchedLangfuseDistIndexJs() {
|
|
|
115
115
|
// It is intentionally self-contained (no extra build steps).
|
|
116
116
|
return [
|
|
117
117
|
'import { LangfuseSpanProcessor } from "@langfuse/otel";',
|
|
118
|
-
'import { promises as fs } from "node:fs";',
|
|
119
|
-
'import os from "node:os";',
|
|
120
|
-
'import path from "node:path";',
|
|
121
|
-
'import { NodeSDK } from "@opentelemetry/sdk-node";',
|
|
118
|
+
'import { promises as fs } from "node:fs";',
|
|
119
|
+
'import os from "node:os";',
|
|
120
|
+
'import path from "node:path";',
|
|
121
|
+
'import { NodeSDK } from "@opentelemetry/sdk-node";',
|
|
122
|
+
'import { trace } from "@opentelemetry/api";',
|
|
122
123
|
"",
|
|
123
124
|
"const USER_CONFIG_PATH = path.join(",
|
|
124
125
|
" os.homedir(),",
|
|
@@ -145,26 +146,141 @@ function getPatchedLangfuseDistIndexJs() {
|
|
|
145
146
|
" }",
|
|
146
147
|
"};",
|
|
147
148
|
"",
|
|
148
|
-
"const createUserIdSpanProcessor = (userId) => ({",
|
|
149
|
-
" onStart: (span) => {",
|
|
150
|
-
' span.setAttribute("langfuse.user.id", userId);',
|
|
151
|
-
' span.setAttribute("user.id", userId);',
|
|
152
|
-
' span.setAttribute("ai.telemetry.metadata.userId", userId);',
|
|
153
|
-
' span.setAttribute("langfuse.
|
|
154
|
-
' span.setAttribute("langfuse.
|
|
155
|
-
' span.setAttribute("langfuse.
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
' span.setAttribute("langfuse.
|
|
159
|
-
' span.setAttribute("
|
|
160
|
-
' span.setAttribute("
|
|
161
|
-
' span.setAttribute("langfuse.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
"
|
|
167
|
-
"
|
|
149
|
+
"const createUserIdSpanProcessor = (userId) => ({",
|
|
150
|
+
" onStart: (span) => {",
|
|
151
|
+
' span.setAttribute("langfuse.user.id", userId);',
|
|
152
|
+
' span.setAttribute("user.id", userId);',
|
|
153
|
+
' span.setAttribute("ai.telemetry.metadata.userId", userId);',
|
|
154
|
+
' span.setAttribute("oh.langfuse.source", "opencode");',
|
|
155
|
+
' span.setAttribute("oh.langfuse.user_id", userId);',
|
|
156
|
+
' span.setAttribute("oh.langfuse.metrics_schema_version", "1.0");',
|
|
157
|
+
' span.setAttribute("langfuse.trace.metadata.langfuse_user_id", userId);',
|
|
158
|
+
' span.setAttribute("langfuse.observation.metadata.langfuse_user_id", userId);',
|
|
159
|
+
' span.setAttribute("langfuse.metadata.langfuse_user_id", userId);',
|
|
160
|
+
' span.setAttribute("langfuse.observation.metadata.source", "opencode");',
|
|
161
|
+
' span.setAttribute("langfuse.observation.metadata.user_id", userId);',
|
|
162
|
+
' span.setAttribute("langfuse.observation.metadata.metrics_schema_version", "1.0");',
|
|
163
|
+
" },",
|
|
164
|
+
" onEnd: (span) => {",
|
|
165
|
+
' if (typeof span.setAttribute !== "function") return;',
|
|
166
|
+
' span.setAttribute("langfuse.user.id", userId);',
|
|
167
|
+
' span.setAttribute("user.id", userId);',
|
|
168
|
+
' span.setAttribute("ai.telemetry.metadata.userId", userId);',
|
|
169
|
+
' span.setAttribute("oh.langfuse.source", "opencode");',
|
|
170
|
+
' span.setAttribute("oh.langfuse.user_id", userId);',
|
|
171
|
+
' span.setAttribute("oh.langfuse.metrics_schema_version", "1.0");',
|
|
172
|
+
' span.setAttribute("langfuse.trace.metadata.langfuse_user_id", userId);',
|
|
173
|
+
' span.setAttribute("langfuse.observation.metadata.langfuse_user_id", userId);',
|
|
174
|
+
' span.setAttribute("langfuse.metadata.langfuse_user_id", userId);',
|
|
175
|
+
' span.setAttribute("langfuse.observation.metadata.source", "opencode");',
|
|
176
|
+
' span.setAttribute("langfuse.observation.metadata.user_id", userId);',
|
|
177
|
+
' span.setAttribute("langfuse.observation.metadata.metrics_schema_version", "1.0");',
|
|
178
|
+
" },",
|
|
179
|
+
" forceFlush: async () => {},",
|
|
180
|
+
" shutdown: async () => {},",
|
|
181
|
+
"});",
|
|
182
|
+
"",
|
|
183
|
+
"const metricNumber = (value) => {",
|
|
184
|
+
' if (typeof value === "number") return Number.isFinite(value) && value >= 0 ? value : undefined;',
|
|
185
|
+
' if (typeof value === "string" && value.trim().startsWith("{")) {',
|
|
186
|
+
" try {",
|
|
187
|
+
" const parsed = JSON.parse(value);",
|
|
188
|
+
" return metricNumber(parsed.intValue ?? parsed.doubleValue ?? parsed.value);",
|
|
189
|
+
" } catch {",
|
|
190
|
+
" return undefined;",
|
|
191
|
+
" }",
|
|
192
|
+
" }",
|
|
193
|
+
" const n = Number(value);",
|
|
194
|
+
" return Number.isFinite(n) && n >= 0 ? n : undefined;",
|
|
195
|
+
"};",
|
|
196
|
+
"",
|
|
197
|
+
"const writeSpanAttribute = (span, key, value) => {",
|
|
198
|
+
" if (value === undefined || value === null) return;",
|
|
199
|
+
' if (typeof span.setAttribute === "function") {',
|
|
200
|
+
" span.setAttribute(key, value);",
|
|
201
|
+
" return;",
|
|
202
|
+
" }",
|
|
203
|
+
' if (span.attributes && typeof span.attributes === "object") {',
|
|
204
|
+
" span.attributes[key] = value;",
|
|
205
|
+
" }",
|
|
206
|
+
"};",
|
|
207
|
+
"",
|
|
208
|
+
"const writeMetric = (span, key, value) => writeSpanAttribute(span, `langfuse.observation.metadata.${key}`, value);",
|
|
209
|
+
"",
|
|
210
|
+
"const writeOpencodeMetricAttributes = (span, userId) => {",
|
|
211
|
+
" const attrs = span.attributes ?? {};",
|
|
212
|
+
' writeSpanAttribute(span, "oh.langfuse.source", "opencode");',
|
|
213
|
+
' writeSpanAttribute(span, "oh.langfuse.metrics_schema_version", "1.0");',
|
|
214
|
+
' if (userId) writeSpanAttribute(span, "oh.langfuse.user_id", userId);',
|
|
215
|
+
' writeMetric(span, "source", "opencode");',
|
|
216
|
+
' writeMetric(span, "metrics_schema_version", "1.0");',
|
|
217
|
+
' if (userId) writeMetric(span, "user_id", userId);',
|
|
218
|
+
' const hasModel = attrs["ai.model.id"] || attrs["ai.model.provider"] || attrs["gen_ai.request.model"];',
|
|
219
|
+
' const isTool = attrs["ai.toolCall.name"] || attrs["ai.toolCall.id"];',
|
|
220
|
+
" if (!hasModel || isTool) return false;",
|
|
221
|
+
' if (typeof span.updateName === "function") span.updateName("AI Interaction");',
|
|
222
|
+
' const sessionId = String(attrs["ai.request.headers.x-opencode-session"] || attrs["session.id"] || "unknown");',
|
|
223
|
+
' const requestId = String(attrs["ai.request.headers.x-opencode-request"] || attrs["ai.response.id"] || attrs["ai.operationId"] || "unknown");',
|
|
224
|
+
' const spanId = span.spanContext?.().spanId || attrs["span.id"] || "unknown";',
|
|
225
|
+
' const provider = attrs["ai.model.provider"] || attrs["gen_ai.system"] || "";',
|
|
226
|
+
' const modelId = attrs["ai.model.id"] || attrs["gen_ai.request.model"] || attrs["ai.response.model"] || "";',
|
|
227
|
+
' const model = provider && modelId ? `${provider}/${modelId}` : provider || modelId || undefined;',
|
|
228
|
+
' const inputTokens = metricNumber(attrs["ai.usage.inputTokens"] ?? attrs["ai.usage.promptTokens"] ?? attrs["gen_ai.usage.input_tokens"]);',
|
|
229
|
+
' const outputTokens = metricNumber(attrs["ai.usage.outputTokens"] ?? attrs["ai.usage.completionTokens"] ?? attrs["gen_ai.usage.output_tokens"]);',
|
|
230
|
+
' const totalTokens = metricNumber(attrs["ai.usage.totalTokens"]) ?? (inputTokens !== undefined && outputTokens !== undefined ? inputTokens + outputTokens : undefined);',
|
|
231
|
+
' const cacheReadTokens = metricNumber(attrs["ai.usage.cachedInputTokens"] ?? attrs["ai.usage.inputTokenDetails.cacheReadTokens"]);',
|
|
232
|
+
' const reasoningTokens = metricNumber(attrs["ai.usage.reasoningTokens"] ?? attrs["ai.usage.outputTokenDetails.reasoningTokens"]);',
|
|
233
|
+
" const tokenAvailable = [inputTokens, outputTokens, totalTokens, cacheReadTokens, reasoningTokens].some((value) => value !== undefined);",
|
|
234
|
+
' writeMetric(span, "session_id", sessionId);',
|
|
235
|
+
' writeMetric(span, "interaction_id", `opencode:${userId || "unknown"}:${sessionId}:${requestId}:${spanId}`);',
|
|
236
|
+
' writeMetric(span, "interaction_count", 1);',
|
|
237
|
+
' writeMetric(span, "user_message_count", 1);',
|
|
238
|
+
' writeMetric(span, "assistant_message_count", 1);',
|
|
239
|
+
' writeMetric(span, "token_metrics_available", tokenAvailable);',
|
|
240
|
+
' writeMetric(span, "tool_call_count", 0);',
|
|
241
|
+
' writeMetric(span, "tool_result_count", 0);',
|
|
242
|
+
' writeMetric(span, "skill_use_count", 0);',
|
|
243
|
+
' writeMetric(span, "model", model);',
|
|
244
|
+
' writeMetric(span, "input_tokens", inputTokens);',
|
|
245
|
+
' writeMetric(span, "output_tokens", outputTokens);',
|
|
246
|
+
' writeMetric(span, "total_tokens", totalTokens);',
|
|
247
|
+
' writeMetric(span, "cache_read_tokens", cacheReadTokens);',
|
|
248
|
+
' writeMetric(span, "reasoning_tokens", reasoningTokens);',
|
|
249
|
+
" return true;",
|
|
250
|
+
"};",
|
|
251
|
+
"",
|
|
252
|
+
"const createMetricsSpanProcessor = (userId) => ({",
|
|
253
|
+
" onStart: (span) => {",
|
|
254
|
+
" writeOpencodeMetricAttributes(span, userId);",
|
|
255
|
+
" },",
|
|
256
|
+
" onEnd: (span) => {",
|
|
257
|
+
" writeOpencodeMetricAttributes(span, userId);",
|
|
258
|
+
" },",
|
|
259
|
+
" forceFlush: async () => {},",
|
|
260
|
+
" shutdown: async () => {},",
|
|
261
|
+
"});",
|
|
262
|
+
"",
|
|
263
|
+
"const eventPayload = (event) => event?.properties ?? event?.body ?? event ?? {};",
|
|
264
|
+
"const eventPart = (event) => {",
|
|
265
|
+
" const payload = eventPayload(event);",
|
|
266
|
+
" return payload.part ?? payload.properties?.part ?? payload;",
|
|
267
|
+
"};",
|
|
268
|
+
"const pickEventString = (...values) => {",
|
|
269
|
+
" for (const value of values) {",
|
|
270
|
+
' if (typeof value === "string" && value.trim()) return value.trim();',
|
|
271
|
+
" }",
|
|
272
|
+
' return "";',
|
|
273
|
+
"};",
|
|
274
|
+
"const tokenMetricsFromPart = (part) => {",
|
|
275
|
+
" const tokens = part?.tokens ?? part?.usage ?? {};",
|
|
276
|
+
" return {",
|
|
277
|
+
" input: metricNumber(tokens.input ?? tokens.input_tokens ?? tokens.inputTokens),",
|
|
278
|
+
" output: metricNumber(tokens.output ?? tokens.output_tokens ?? tokens.outputTokens),",
|
|
279
|
+
" total: metricNumber(tokens.total ?? tokens.total_tokens ?? tokens.totalTokens),",
|
|
280
|
+
" cacheRead: metricNumber(tokens.cache?.read ?? tokens.cacheRead ?? tokens.cache_read_tokens ?? tokens.cachedInputTokens),",
|
|
281
|
+
" reasoning: metricNumber(tokens.reasoning ?? tokens.reasoning_tokens ?? tokens.reasoningTokens),",
|
|
282
|
+
" };",
|
|
283
|
+
"};",
|
|
168
284
|
"",
|
|
169
285
|
"export const LangfusePlugin = async ({ client }) => {",
|
|
170
286
|
" const publicKey = process.env.LANGFUSE_PUBLIC_KEY;",
|
|
@@ -185,31 +301,103 @@ function getPatchedLangfuseDistIndexJs() {
|
|
|
185
301
|
" return {};",
|
|
186
302
|
" }",
|
|
187
303
|
"",
|
|
188
|
-
" const processor = new LangfuseSpanProcessor({ publicKey, secretKey, baseUrl, environment });",
|
|
189
|
-
" const spanProcessors = [processor];",
|
|
190
|
-
" if (userId) spanProcessors.push(createUserIdSpanProcessor(userId));",
|
|
304
|
+
" const processor = new LangfuseSpanProcessor({ publicKey, secretKey, baseUrl, environment });",
|
|
305
|
+
" const spanProcessors = [processor, createMetricsSpanProcessor(userId)];",
|
|
306
|
+
" if (userId) spanProcessors.push(createUserIdSpanProcessor(userId));",
|
|
191
307
|
"",
|
|
192
|
-
" const sdk = new NodeSDK({ spanProcessors });",
|
|
193
|
-
" sdk.start();",
|
|
194
|
-
"",
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
"",
|
|
198
|
-
"
|
|
199
|
-
|
|
308
|
+
" const sdk = new NodeSDK({ spanProcessors });",
|
|
309
|
+
" sdk.start();",
|
|
310
|
+
" const metricsTracer = trace.getTracer('oh-langfuse-opencode-metrics');",
|
|
311
|
+
" const messageTextById = new Map();",
|
|
312
|
+
" const emittedMessageIds = new Set();",
|
|
313
|
+
"",
|
|
314
|
+
' log("info", `OTEL tracing initialized -> ${baseUrl}`);',
|
|
315
|
+
' if (userId) log("info", `LANGFUSE userId configured -> ${userId}`);',
|
|
316
|
+
"",
|
|
317
|
+
" let shutdownStarted = false;",
|
|
318
|
+
" const flush = async (reason) => {",
|
|
319
|
+
" try {",
|
|
320
|
+
' log("info", `Flushing OTEL spans on ${reason}`);',
|
|
321
|
+
" await processor.forceFlush();",
|
|
322
|
+
" } catch (error) {",
|
|
323
|
+
' log("warn", `OTEL forceFlush failed on ${reason}: ${error?.message ?? error}`);',
|
|
324
|
+
" }",
|
|
325
|
+
" };",
|
|
326
|
+
" const shutdown = async (reason) => {",
|
|
327
|
+
" if (shutdownStarted) return;",
|
|
328
|
+
" shutdownStarted = true;",
|
|
329
|
+
" try {",
|
|
330
|
+
' log("info", `Shutting down OTEL SDK on ${reason}`);',
|
|
331
|
+
" await sdk.shutdown();",
|
|
332
|
+
" } catch (error) {",
|
|
333
|
+
' log("warn", `OTEL shutdown failed on ${reason}: ${error?.message ?? error}`);',
|
|
334
|
+
" }",
|
|
335
|
+
" };",
|
|
336
|
+
" process.once('beforeExit', async () => {",
|
|
337
|
+
" await flush('process.beforeExit');",
|
|
338
|
+
" await shutdown('process.beforeExit');",
|
|
339
|
+
" });",
|
|
340
|
+
" const originalExit = process.exit.bind(process);",
|
|
341
|
+
" process.exit = ((code) => {",
|
|
342
|
+
" void shutdown('process.exit').finally(() => originalExit(code));",
|
|
343
|
+
" });",
|
|
344
|
+
"",
|
|
345
|
+
" const recordInteractionMetric = (event) => {",
|
|
346
|
+
" const payload = eventPayload(event);",
|
|
347
|
+
" const part = eventPart(event);",
|
|
348
|
+
" const partType = part?.type ?? '';",
|
|
349
|
+
" const sessionId = pickEventString(part?.sessionID, part?.sessionId, payload?.sessionID, payload?.sessionId, payload?.session?.id);",
|
|
350
|
+
" const messageId = pickEventString(part?.messageID, part?.messageId, payload?.messageID, payload?.messageId, payload?.message?.id);",
|
|
351
|
+
" if (partType === 'text' && messageId && typeof part.text === 'string') {",
|
|
352
|
+
" messageTextById.set(messageId, part.text);",
|
|
353
|
+
" return;",
|
|
354
|
+
" }",
|
|
355
|
+
" if (partType !== 'step-finish' || !messageId || emittedMessageIds.has(messageId)) return;",
|
|
356
|
+
" emittedMessageIds.add(messageId);",
|
|
357
|
+
" const tokenMetrics = tokenMetricsFromPart(part);",
|
|
358
|
+
" const total = tokenMetrics.total ?? (tokenMetrics.input !== undefined && tokenMetrics.output !== undefined ? tokenMetrics.input + tokenMetrics.output : undefined);",
|
|
359
|
+
" const tokenAvailable = [tokenMetrics.input, tokenMetrics.output, total, tokenMetrics.cacheRead, tokenMetrics.reasoning].some((value) => value !== undefined);",
|
|
360
|
+
" const span = metricsTracer.startSpan('AI Interaction');",
|
|
361
|
+
" const text = messageTextById.get(messageId) || '';",
|
|
362
|
+
' span.setAttribute("oh.langfuse.source", "opencode");',
|
|
363
|
+
' span.setAttribute("oh.langfuse.user_id", userId || "");',
|
|
364
|
+
' span.setAttribute("oh.langfuse.metrics_schema_version", "1.0");',
|
|
365
|
+
' span.setAttribute("langfuse.observation.metadata.source", "opencode");',
|
|
366
|
+
' span.setAttribute("langfuse.observation.metadata.user_id", userId || "");',
|
|
367
|
+
' span.setAttribute("langfuse.observation.metadata.session_id", sessionId || "unknown");',
|
|
368
|
+
' span.setAttribute("langfuse.observation.metadata.interaction_id", `opencode:${userId || "unknown"}:${sessionId || "unknown"}:${messageId}`);',
|
|
369
|
+
' span.setAttribute("langfuse.observation.metadata.metrics_schema_version", "1.0");',
|
|
370
|
+
' span.setAttribute("langfuse.observation.metadata.interaction_count", 1);',
|
|
371
|
+
' span.setAttribute("langfuse.observation.metadata.user_message_count", 1);',
|
|
372
|
+
' span.setAttribute("langfuse.observation.metadata.assistant_message_count", 1);',
|
|
373
|
+
' span.setAttribute("langfuse.observation.metadata.token_metrics_available", tokenAvailable);',
|
|
374
|
+
' span.setAttribute("langfuse.observation.metadata.tool_call_count", 0);',
|
|
375
|
+
' span.setAttribute("langfuse.observation.metadata.tool_result_count", 0);',
|
|
376
|
+
' span.setAttribute("langfuse.observation.metadata.skill_use_count", 0);',
|
|
377
|
+
' if (tokenMetrics.input !== undefined) span.setAttribute("langfuse.observation.metadata.input_tokens", tokenMetrics.input);',
|
|
378
|
+
' if (tokenMetrics.output !== undefined) span.setAttribute("langfuse.observation.metadata.output_tokens", tokenMetrics.output);',
|
|
379
|
+
' if (total !== undefined) span.setAttribute("langfuse.observation.metadata.total_tokens", total);',
|
|
380
|
+
' if (tokenMetrics.cacheRead !== undefined) span.setAttribute("langfuse.observation.metadata.cache_read_tokens", tokenMetrics.cacheRead);',
|
|
381
|
+
' if (tokenMetrics.reasoning !== undefined) span.setAttribute("langfuse.observation.metadata.reasoning_tokens", tokenMetrics.reasoning);',
|
|
382
|
+
' if (text) span.setAttribute("langfuse.observation.metadata.output_text_preview", text.slice(0, 512));',
|
|
383
|
+
" span.end();",
|
|
384
|
+
" };",
|
|
385
|
+
"",
|
|
386
|
+
" return {",
|
|
387
|
+
" config: async (config) => {",
|
|
200
388
|
" if (!config.experimental?.openTelemetry) {",
|
|
201
389
|
' log("warn", "OpenTelemetry experimental feature is disabled in Opencode config - tracing disabled");',
|
|
202
390
|
" }",
|
|
203
|
-
" },",
|
|
204
|
-
" event: async ({ event }) => {",
|
|
205
|
-
|
|
206
|
-
'
|
|
207
|
-
"
|
|
208
|
-
"
|
|
209
|
-
|
|
210
|
-
'
|
|
211
|
-
" await
|
|
212
|
-
" }",
|
|
391
|
+
" },",
|
|
392
|
+
" event: async ({ event }) => {",
|
|
393
|
+
" const eventType = event?.type ?? '';",
|
|
394
|
+
' if (eventType === "message.part.updated") recordInteractionMetric(event);',
|
|
395
|
+
' if (eventType === "session.idle" || eventType === "message.updated" || eventType === "message.part.updated" || eventType === "session.updated") {',
|
|
396
|
+
" await flush(eventType);",
|
|
397
|
+
" }",
|
|
398
|
+
' if (eventType === "server.instance.disposed") {',
|
|
399
|
+
" await shutdown('server.instance.disposed');",
|
|
400
|
+
" }",
|
|
213
401
|
" },",
|
|
214
402
|
" };",
|
|
215
403
|
"};",
|
|
@@ -89,8 +89,24 @@ function shellNeeded(command) {
|
|
|
89
89
|
return process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
function windowsShellQuote(value) {
|
|
93
|
+
return `"${String(value).replace(/"/g, '\\"')}"`;
|
|
94
|
+
}
|
|
95
|
+
|
|
92
96
|
function run(command, args, options = {}) {
|
|
93
|
-
const
|
|
97
|
+
const useShell = shellNeeded(command);
|
|
98
|
+
const result = useShell
|
|
99
|
+
? spawnSync([command, ...args].map(windowsShellQuote).join(" "), {
|
|
100
|
+
cwd: options.cwd || rootDir,
|
|
101
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
102
|
+
encoding: "utf8",
|
|
103
|
+
stdio: options.stdio || "pipe",
|
|
104
|
+
timeout: options.timeoutMs,
|
|
105
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
106
|
+
windowsHide: true,
|
|
107
|
+
shell: true,
|
|
108
|
+
})
|
|
109
|
+
: spawnSync(command, args, {
|
|
94
110
|
cwd: options.cwd || rootDir,
|
|
95
111
|
env: { ...process.env, ...(options.env || {}) },
|
|
96
112
|
encoding: "utf8",
|
|
@@ -98,7 +114,6 @@ function run(command, args, options = {}) {
|
|
|
98
114
|
timeout: options.timeoutMs,
|
|
99
115
|
maxBuffer: 20 * 1024 * 1024,
|
|
100
116
|
windowsHide: true,
|
|
101
|
-
shell: shellNeeded(command),
|
|
102
117
|
});
|
|
103
118
|
return {
|
|
104
119
|
command,
|
|
@@ -163,11 +178,7 @@ function setupTarget(target, config, args) {
|
|
|
163
178
|
|
|
164
179
|
function validationPrompt(marker, target, customPrompt) {
|
|
165
180
|
if (customPrompt) return String(customPrompt).replaceAll("{marker}", marker).replaceAll("{target}", target);
|
|
166
|
-
return
|
|
167
|
-
`This is an automated real self-verification run for ${packageJson.name}.`,
|
|
168
|
-
`Validation marker: ${marker}`,
|
|
169
|
-
"Reply with the validation marker exactly once and do not run tools.",
|
|
170
|
-
].join("\n");
|
|
181
|
+
return `Automated real self-verification run for ${packageJson.name}. Validation marker: ${marker}. Reply with the validation marker exactly once and do not run tools.`;
|
|
171
182
|
}
|
|
172
183
|
|
|
173
184
|
function triggerClaude(prompt, args, env) {
|
|
@@ -281,6 +292,133 @@ function idOf(value) {
|
|
|
281
292
|
return value?.id || value?.traceId || value?.trace_id || "";
|
|
282
293
|
}
|
|
283
294
|
|
|
295
|
+
function traceIdOfFound(found) {
|
|
296
|
+
return found?.item?.traceId || found?.item?.trace_id || (String(found?.kind || "").startsWith("trace") ? found.id : "");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function metadataValue(item, key) {
|
|
300
|
+
const metadata = item?.metadata || {};
|
|
301
|
+
if (metadata[key] !== undefined) return metadata[key];
|
|
302
|
+
const attrs = metadata.attributes || {};
|
|
303
|
+
const prefixed = `langfuse.observation.metadata.${key}`;
|
|
304
|
+
if (attrs[prefixed] !== undefined) return attrs[prefixed];
|
|
305
|
+
const ohKey = `oh.langfuse.${key}`;
|
|
306
|
+
if (attrs[ohKey] !== undefined) return attrs[ohKey];
|
|
307
|
+
if (metadata.source === "opencode" || attrs["oh.langfuse.source"] === "opencode") {
|
|
308
|
+
if (key === "interaction_id") return `opencode:${metadata.user_id || attrs["oh.langfuse.user_id"] || "unknown"}:${item?.id || item?.traceId || item?.trace_id || "unknown"}`;
|
|
309
|
+
if (key === "token_metrics_available") {
|
|
310
|
+
return [
|
|
311
|
+
"ai.usage.inputTokens",
|
|
312
|
+
"ai.usage.outputTokens",
|
|
313
|
+
"ai.usage.totalTokens",
|
|
314
|
+
"ai.usage.promptTokens",
|
|
315
|
+
"ai.usage.completionTokens",
|
|
316
|
+
].some((attrKey) => attrs[attrKey] !== undefined);
|
|
317
|
+
}
|
|
318
|
+
if (key === "tool_call_count" || key === "skill_use_count") return 0;
|
|
319
|
+
const tokenAttrMap = {
|
|
320
|
+
input_tokens: ["ai.usage.inputTokens", "ai.usage.promptTokens"],
|
|
321
|
+
output_tokens: ["ai.usage.outputTokens", "ai.usage.completionTokens"],
|
|
322
|
+
total_tokens: ["ai.usage.totalTokens"],
|
|
323
|
+
cache_read_tokens: ["ai.usage.cachedInputTokens", "ai.usage.inputTokenDetails.cacheReadTokens"],
|
|
324
|
+
reasoning_tokens: ["ai.usage.reasoningTokens", "ai.usage.outputTokenDetails.reasoningTokens"],
|
|
325
|
+
};
|
|
326
|
+
for (const attrKey of tokenAttrMap[key] || []) {
|
|
327
|
+
if (attrs[attrKey] !== undefined) return attrs[attrKey];
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function directMetadataValue(item, key) {
|
|
334
|
+
const metadata = item?.metadata || {};
|
|
335
|
+
return metadata[key];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function hasMetadataKey(item, key) {
|
|
339
|
+
return metadataValue(item, key) !== undefined;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isAiInteractionObservation(item) {
|
|
343
|
+
if (item?.name === "AI Interaction" || metadataValue(item, "interaction_count") === 1 || metadataValue(item, "interaction_count") === "1") {
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
const metadata = item?.metadata || {};
|
|
347
|
+
const attrs = metadata.attributes || {};
|
|
348
|
+
const isOpencode = metadata.source === "opencode" || attrs["oh.langfuse.source"] === "opencode";
|
|
349
|
+
const hasModel = attrs["ai.model.id"] !== undefined || attrs["ai.model.provider"] !== undefined;
|
|
350
|
+
const isTool = attrs["ai.toolCall.name"] !== undefined || attrs["ai.toolCall.id"] !== undefined;
|
|
351
|
+
return isOpencode && hasModel && !isTool && item?.type === "GENERATION";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function observationsForTrace(config, traceId, since) {
|
|
355
|
+
if (!traceId) return [];
|
|
356
|
+
const params = { limit: 100, traceId };
|
|
357
|
+
try {
|
|
358
|
+
return dataArray(await langfuseGet(config, "/observations", params));
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (error.status !== 400) throw error;
|
|
361
|
+
}
|
|
362
|
+
const fallback = dataArray(await langfuseGetLenient(config, "/observations", { limit: 100, fromTimestamp: since.toISOString() }));
|
|
363
|
+
return fallback.filter((item) => item.traceId === traceId || item.trace_id === traceId);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function verifyMetricObservations(config, found, { since, target }) {
|
|
367
|
+
const traceId = traceIdOfFound(found);
|
|
368
|
+
let observations = await observationsForTrace(config, traceId, since);
|
|
369
|
+
|
|
370
|
+
if (!observations.length && Array.isArray(found?.item?.observations)) {
|
|
371
|
+
observations = found.item.observations.filter((item) => typeof item === "object");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const interactions = observations.filter(isAiInteractionObservation);
|
|
375
|
+
if (!interactions.length) {
|
|
376
|
+
throw new Error(`Metric verification failed for ${target}: AI Interaction observation was not found for trace ${traceId || found.id}.`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const byInteractionId = new Map();
|
|
380
|
+
for (const item of interactions) {
|
|
381
|
+
const interactionId = target === "opencode"
|
|
382
|
+
? directMetadataValue(item, "interaction_id") || metadataValue(item, "interaction_id")
|
|
383
|
+
: metadataValue(item, "interaction_id");
|
|
384
|
+
if (!interactionId) {
|
|
385
|
+
throw new Error(`Metric verification failed for ${target}: AI Interaction is missing interaction_id.`);
|
|
386
|
+
}
|
|
387
|
+
byInteractionId.set(interactionId, (byInteractionId.get(interactionId) || 0) + 1);
|
|
388
|
+
for (const key of ["user_id", "token_metrics_available", "tool_call_count", "skill_use_count", "metrics_schema_version"]) {
|
|
389
|
+
if (!hasMetadataKey(item, key)) {
|
|
390
|
+
throw new Error(`Metric verification failed for ${target}: AI Interaction is missing ${key}.`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (target === "opencode") {
|
|
394
|
+
for (const key of ["interaction_id", "interaction_count", "token_metrics_available", "tool_call_count", "skill_use_count", "input_tokens", "output_tokens", "total_tokens"]) {
|
|
395
|
+
if (directMetadataValue(item, key) === undefined) {
|
|
396
|
+
throw new Error(`Metric verification failed for ${target}: visible metadata is missing ${key}.`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const tokenAvailable = metadataValue(item, "token_metrics_available");
|
|
401
|
+
for (const tokenKey of ["input_tokens", "output_tokens", "total_tokens"]) {
|
|
402
|
+
const value = metadataValue(item, tokenKey);
|
|
403
|
+
if (tokenAvailable === false || tokenAvailable === "false") {
|
|
404
|
+
if (value !== null && value !== undefined) {
|
|
405
|
+
throw new Error(`Metric verification failed for ${target}: ${tokenKey} must be null or absent when token metrics are unavailable.`);
|
|
406
|
+
}
|
|
407
|
+
} else if (value !== undefined && value !== null && Number.isNaN(Number(value))) {
|
|
408
|
+
throw new Error(`Metric verification failed for ${target}: ${tokenKey} is not numeric.`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
for (const [interactionId, count] of byInteractionId.entries()) {
|
|
414
|
+
if (count !== 1) {
|
|
415
|
+
throw new Error(`Metric verification failed for ${target}: interaction_id ${interactionId} appeared ${count} times.`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return { traceId, interactionCount: interactions.length };
|
|
420
|
+
}
|
|
421
|
+
|
|
284
422
|
async function findLangfuseMarker(config, marker, { since, target }) {
|
|
285
423
|
const baseParams = {
|
|
286
424
|
limit: 100,
|
|
@@ -415,7 +553,9 @@ async function main() {
|
|
|
415
553
|
|
|
416
554
|
const found = await pollLangfuse(config, marker, { ...args, since, target });
|
|
417
555
|
console.log(`[OK] Langfuse marker found for ${target}: ${found.kind} ${found.id || ""}`.trim());
|
|
418
|
-
|
|
556
|
+
const metrics = await verifyMetricObservations(config, found, { since, target });
|
|
557
|
+
console.log(`[OK] Langfuse metrics found for ${target}: AI Interaction x${metrics.interactionCount}`);
|
|
558
|
+
results.push({ target, marker, langfuse: { kind: found.kind, id: found.id || "", traceId: metrics.traceId }, metrics });
|
|
419
559
|
}
|
|
420
560
|
|
|
421
561
|
console.log("");
|