oh-langfuse 0.1.28 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-langfuse",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
@@ -51,6 +51,82 @@ function ratio(numerator, denominator) {
51
51
  return numerator / denominator;
52
52
  }
53
53
 
54
+ function normalizeSkillNames(names) {
55
+ if (!Array.isArray(names)) return [];
56
+ const out = [];
57
+ const seen = new Set();
58
+ for (const raw of names) {
59
+ const name = String(raw || "").trim();
60
+ if (!name || seen.has(name)) continue;
61
+ seen.add(name);
62
+ out.push(name);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function escapeRegExp(value) {
68
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
69
+ }
70
+
71
+ function collectStrings(value, out = []) {
72
+ if (value == null) return out;
73
+ if (typeof value === "string") {
74
+ out.push(value);
75
+ return out;
76
+ }
77
+ if (typeof value === "number" || typeof value === "boolean") return out;
78
+ if (Array.isArray(value)) {
79
+ for (const item of value) collectStrings(item, out);
80
+ return out;
81
+ }
82
+ if (typeof value === "object") {
83
+ for (const [key, item] of Object.entries(value)) {
84
+ out.push(key);
85
+ collectStrings(item, out);
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+
91
+ export function detectOpencodeSkillNames(source, knownSkills = []) {
92
+ const skills = normalizeSkillNames(knownSkills);
93
+ if (skills.length === 0) return [];
94
+ const haystack = collectStrings(source).join("\n");
95
+ const found = [];
96
+ for (const skillName of skills) {
97
+ const pattern = new RegExp(`(^|[^A-Za-z0-9_-])${escapeRegExp(skillName)}([^A-Za-z0-9_-]|$)`, "i");
98
+ if (pattern.test(haystack)) found.push(skillName);
99
+ }
100
+ return found;
101
+ }
102
+
103
+ function pickString(...values) {
104
+ for (const value of values) {
105
+ if (typeof value === "string" && value.trim()) return value.trim();
106
+ }
107
+ return "";
108
+ }
109
+
110
+ export function countOpencodeToolActivity(source) {
111
+ const payload = source?.properties ?? source?.body ?? source ?? {};
112
+ const part = payload?.part ?? payload?.properties?.part ?? payload;
113
+ const eventType = pickString(source?.type, payload?.type);
114
+ const partType = pickString(part?.type);
115
+ const toolName = pickString(part?.tool, part?.toolName, part?.name, payload?.tool, payload?.toolName, payload?.name);
116
+ const callId = pickString(part?.callID, part?.callId, payload?.callID, payload?.callId, part?.id);
117
+ const status = pickString(part?.state?.status, part?.status, payload?.state?.status, payload?.status).toLowerCase();
118
+ const hasToolInput = part?.state?.input !== undefined || part?.input !== undefined || payload?.input !== undefined;
119
+ const hasToolOutput = part?.state?.output !== undefined || part?.output !== undefined || payload?.output !== undefined;
120
+ const isToolCall = eventType === "tool_use" || eventType === "tool_result" || partType === "tool" || partType === "tool_use" || Boolean(toolName) || hasToolInput;
121
+ const isToolResult = eventType === "tool_result" || status === "completed" || status === "success" || hasToolOutput;
122
+
123
+ return {
124
+ toolCallCount: isToolCall ? 1 : 0,
125
+ toolResultCount: isToolCall && isToolResult ? 1 : 0,
126
+ toolCallId: callId || "",
127
+ };
128
+ }
129
+
54
130
  export function buildInteractionMetadata(options = {}) {
55
131
  const source = String(options.source || "unknown");
56
132
  const sessionId = String(options.sessionId || options.session_id || "unknown");
@@ -58,9 +134,10 @@ export function buildInteractionMetadata(options = {}) {
58
134
  const tokenMetrics = normalizeTokenMetrics(options.tokenMetrics);
59
135
  const toolCallCount = Number(options.toolCallCount ?? options.tool_call_count ?? 0) || 0;
60
136
  const toolResultCount = Number(options.toolResultCount ?? options.tool_result_count ?? 0) || 0;
61
- const skillUseCount = Number(options.skillUseCount ?? options.skill_use_count ?? 0) || 0;
137
+ const skillNames = normalizeSkillNames(options.skillNames ?? options.skill_names);
138
+ const skillUseCount = skillNames.length || Number(options.skillUseCount ?? options.skill_use_count ?? 0) || 0;
62
139
 
63
- return {
140
+ const metadata = {
64
141
  source,
65
142
  user_id: String(options.userId || options.user_id || ""),
66
143
  session_id: sessionId,
@@ -83,6 +160,13 @@ export function buildInteractionMetadata(options = {}) {
83
160
  tokens_per_tool_call: ratio(tokenMetrics.total_tokens, toolCallCount),
84
161
  },
85
162
  };
163
+
164
+ if (skillNames.length) {
165
+ metadata.skill_names = skillNames;
166
+ metadata.skill_names_json = JSON.stringify(skillNames);
167
+ }
168
+
169
+ return metadata;
86
170
  }
87
171
 
88
172
  export function buildOpencodeMetricAttributes(options = {}) {
@@ -94,6 +178,9 @@ export function buildOpencodeMetricAttributes(options = {}) {
94
178
  const provider = attrs["ai.model.provider"] || attrs["gen_ai.system"] || "";
95
179
  const modelId = attrs["ai.model.id"] || attrs["gen_ai.request.model"] || attrs["ai.response.model"] || "";
96
180
  const model = provider && modelId ? `${provider}/${modelId}` : provider || modelId || null;
181
+ const skillNames = normalizeSkillNames(options.skillNames ?? options.skill_names);
182
+ const toolCallCount = Number(options.toolCallCount ?? options.tool_call_count ?? 0) || 0;
183
+ const toolResultCount = Number(options.toolResultCount ?? options.tool_result_count ?? 0) || 0;
97
184
  const tokenMetrics = normalizeTokenMetrics({
98
185
  input: attrs["ai.usage.inputTokens"] ?? attrs["ai.usage.promptTokens"] ?? attrs["gen_ai.usage.input_tokens"],
99
186
  output: attrs["ai.usage.outputTokens"] ?? attrs["ai.usage.completionTokens"] ?? attrs["gen_ai.usage.output_tokens"],
@@ -111,13 +198,18 @@ export function buildOpencodeMetricAttributes(options = {}) {
111
198
  "langfuse.observation.metadata.interaction_count": 1,
112
199
  "langfuse.observation.metadata.user_message_count": 1,
113
200
  "langfuse.observation.metadata.assistant_message_count": 1,
114
- "langfuse.observation.metadata.tool_call_count": 0,
115
- "langfuse.observation.metadata.tool_result_count": 0,
116
- "langfuse.observation.metadata.skill_use_count": 0,
201
+ "langfuse.observation.metadata.tool_call_count": toolCallCount,
202
+ "langfuse.observation.metadata.tool_result_count": toolResultCount,
203
+ "langfuse.observation.metadata.skill_use_count": skillNames.length,
117
204
  "langfuse.observation.metadata.token_metrics_available": tokenMetrics.token_metrics_available,
118
205
  "langfuse.observation.metadata.model": model,
119
206
  };
120
207
 
208
+ if (skillNames.length) {
209
+ out["langfuse.observation.metadata.skill_names"] = skillNames;
210
+ out["langfuse.observation.metadata.skill_names_json"] = JSON.stringify(skillNames);
211
+ }
212
+
121
213
  for (const key of ["input_tokens", "output_tokens", "total_tokens", "cache_read_tokens", "reasoning_tokens"]) {
122
214
  if (tokenMetrics[key] != null) out[`langfuse.observation.metadata.${key}`] = tokenMetrics[key];
123
215
  }
@@ -271,6 +271,24 @@ function getPatchedLangfuseDistIndexJs() {
271
271
  " }",
272
272
  ' return "";',
273
273
  "};",
274
+ "const countOpencodeToolActivity = (event) => {",
275
+ " const payload = eventPayload(event);",
276
+ " const part = eventPart(event);",
277
+ " const eventType = pickEventString(event?.type, payload?.type);",
278
+ " const partType = pickEventString(part?.type);",
279
+ " const toolName = pickEventString(part?.tool, part?.toolName, part?.name, payload?.tool, payload?.toolName, payload?.name);",
280
+ " const callId = pickEventString(part?.callID, part?.callId, payload?.callID, payload?.callId, part?.id);",
281
+ " const status = pickEventString(part?.state?.status, part?.status, payload?.state?.status, payload?.status).toLowerCase();",
282
+ " const hasToolInput = part?.state?.input !== undefined || part?.input !== undefined || payload?.input !== undefined;",
283
+ " const hasToolOutput = part?.state?.output !== undefined || part?.output !== undefined || payload?.output !== undefined;",
284
+ " const isToolCall = eventType === 'tool_use' || eventType === 'tool_result' || partType === 'tool' || partType === 'tool_use' || Boolean(toolName) || hasToolInput;",
285
+ " const isToolResult = eventType === 'tool_result' || status === 'completed' || status === 'success' || hasToolOutput;",
286
+ " return {",
287
+ " toolCallCount: isToolCall ? 1 : 0,",
288
+ " toolResultCount: isToolCall && isToolResult ? 1 : 0,",
289
+ " toolCallId: callId || '',",
290
+ " };",
291
+ "};",
274
292
  "const tokenMetricsFromPart = (part) => {",
275
293
  " const tokens = part?.tokens ?? part?.usage ?? {};",
276
294
  " return {",
@@ -281,8 +299,84 @@ function getPatchedLangfuseDistIndexJs() {
281
299
  " reasoning: metricNumber(tokens.reasoning ?? tokens.reasoning_tokens ?? tokens.reasoningTokens),",
282
300
  " };",
283
301
  "};",
284
- "",
285
- "export const LangfusePlugin = async ({ client }) => {",
302
+ "",
303
+ "const normalizeSkillNames = (names) => {",
304
+ " if (!Array.isArray(names)) return [];",
305
+ " const out = [];",
306
+ " const seen = new Set();",
307
+ " for (const raw of names) {",
308
+ " const name = String(raw || '').trim();",
309
+ " if (!name || seen.has(name)) continue;",
310
+ " seen.add(name);",
311
+ " out.push(name);",
312
+ " }",
313
+ " return out;",
314
+ "};",
315
+ "",
316
+ "const escapeRegExp = (value) => String(value).replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');",
317
+ "",
318
+ "const collectStrings = (value, out = []) => {",
319
+ " if (value == null) return out;",
320
+ " if (typeof value === 'string') {",
321
+ " out.push(value);",
322
+ " return out;",
323
+ " }",
324
+ " if (typeof value === 'number' || typeof value === 'boolean') return out;",
325
+ " if (Array.isArray(value)) {",
326
+ " for (const item of value) collectStrings(item, out);",
327
+ " return out;",
328
+ " }",
329
+ " if (typeof value === 'object') {",
330
+ " for (const [key, item] of Object.entries(value)) {",
331
+ " out.push(key);",
332
+ " collectStrings(item, out);",
333
+ " }",
334
+ " }",
335
+ " return out;",
336
+ "};",
337
+ "",
338
+ "const detectOpencodeSkillNames = (source, knownSkills = []) => {",
339
+ " const skills = normalizeSkillNames(knownSkills);",
340
+ " if (skills.length === 0) return [];",
341
+ " const haystack = collectStrings(source).join('\\n');",
342
+ " const found = [];",
343
+ " for (const skillName of skills) {",
344
+ " const pattern = new RegExp(`(^|[^A-Za-z0-9_-])${escapeRegExp(skillName)}([^A-Za-z0-9_-]|$)`, 'i');",
345
+ " if (pattern.test(haystack)) found.push(skillName);",
346
+ " }",
347
+ " return found;",
348
+ "};",
349
+ "",
350
+ "const collectKnownSkillNames = async () => {",
351
+ " const dirs = [",
352
+ " path.join(process.cwd(), '.opencode', 'skill'),",
353
+ " path.join(process.cwd(), '.opencode', 'skills'),",
354
+ " path.join(os.homedir(), '.config', 'opencode', 'skill'),",
355
+ " path.join(os.homedir(), '.config', 'opencode', 'skills'),",
356
+ " path.join(os.homedir(), '.opencode', 'skill'),",
357
+ " path.join(os.homedir(), '.opencode', 'skills'),",
358
+ " ];",
359
+ " const names = new Set();",
360
+ " for (const dir of dirs) {",
361
+ " try {",
362
+ " const entries = await fs.readdir(dir, { withFileTypes: true });",
363
+ " for (const entry of entries) {",
364
+ " if (!entry.isDirectory() || entry.name.startsWith('.')) continue;",
365
+ " try {",
366
+ " await fs.access(path.join(dir, entry.name, 'SKILL.md'));",
367
+ " names.add(entry.name);",
368
+ " } catch {",
369
+ " // Ignore helper folders that are not OpenCode skills.",
370
+ " }",
371
+ " }",
372
+ " } catch {",
373
+ " // Optional skill directories are allowed to be absent.",
374
+ " }",
375
+ " }",
376
+ " return [...names];",
377
+ "};",
378
+ "",
379
+ "export const LangfusePlugin = async ({ client }) => {",
286
380
  " const publicKey = process.env.LANGFUSE_PUBLIC_KEY;",
287
381
  " const secretKey = process.env.LANGFUSE_SECRET_KEY;",
288
382
  ' const baseUrl = process.env.LANGFUSE_BASEURL ?? "https://cloud.langfuse.com";',
@@ -308,11 +402,19 @@ function getPatchedLangfuseDistIndexJs() {
308
402
  " const sdk = new NodeSDK({ spanProcessors });",
309
403
  " sdk.start();",
310
404
  " const metricsTracer = trace.getTracer('oh-langfuse-opencode-metrics');",
405
+ " const knownSkillNames = await collectKnownSkillNames();",
311
406
  " const messageTextById = new Map();",
407
+ " const skillNamesByMessageId = new Map();",
408
+ " const skillNamesBySessionId = new Map();",
409
+ " const toolCallIdsByMessageId = new Map();",
410
+ " const toolCallIdsBySessionId = new Map();",
411
+ " const toolResultIdsByMessageId = new Map();",
412
+ " const toolResultIdsBySessionId = new Map();",
312
413
  " const emittedMessageIds = new Set();",
313
414
  "",
314
415
  ' log("info", `OTEL tracing initialized -> ${baseUrl}`);',
315
416
  ' if (userId) log("info", `LANGFUSE userId configured -> ${userId}`);',
417
+ ' if (knownSkillNames.length) log("info", `OpenCode skills discovered -> ${knownSkillNames.length}`);',
316
418
  "",
317
419
  " let shutdownStarted = false;",
318
420
  " const flush = async (reason) => {",
@@ -342,14 +444,51 @@ function getPatchedLangfuseDistIndexJs() {
342
444
  " void shutdown('process.exit').finally(() => originalExit(code));",
343
445
  " });",
344
446
  "",
447
+ " const rememberSkillNames = (messageId, names) => {",
448
+ " if (!messageId || !names.length) return;",
449
+ " let set = skillNamesByMessageId.get(messageId);",
450
+ " if (!set) {",
451
+ " set = new Set();",
452
+ " skillNamesByMessageId.set(messageId, set);",
453
+ " }",
454
+ " for (const name of names) set.add(name);",
455
+ " };",
456
+ " const rememberSessionSkillNames = (sessionId, names) => {",
457
+ " if (!sessionId || !names.length) return;",
458
+ " let set = skillNamesBySessionId.get(sessionId);",
459
+ " if (!set) {",
460
+ " set = new Set();",
461
+ " skillNamesBySessionId.set(sessionId, set);",
462
+ " }",
463
+ " for (const name of names) set.add(name);",
464
+ " };",
465
+ " const rememberToolActivity = (map, key, activity, kind) => {",
466
+ " if (!key || !activity?.[kind]) return;",
467
+ " let set = map.get(key);",
468
+ " if (!set) {",
469
+ " set = new Set();",
470
+ " map.set(key, set);",
471
+ " }",
472
+ " set.add(activity.toolCallId || `${kind}:${set.size + 1}`);",
473
+ " };",
474
+ "",
345
475
  " const recordInteractionMetric = (event) => {",
346
476
  " const payload = eventPayload(event);",
347
477
  " const part = eventPart(event);",
348
478
  " const partType = part?.type ?? '';",
349
479
  " const sessionId = pickEventString(part?.sessionID, part?.sessionId, payload?.sessionID, payload?.sessionId, payload?.session?.id);",
350
480
  " const messageId = pickEventString(part?.messageID, part?.messageId, payload?.messageID, payload?.messageId, payload?.message?.id);",
481
+ " const eventSkillNames = detectOpencodeSkillNames([event, payload, part, messageTextById.get(messageId)], knownSkillNames);",
482
+ " rememberSkillNames(messageId, eventSkillNames);",
483
+ " rememberSessionSkillNames(sessionId, eventSkillNames);",
484
+ " const toolActivity = countOpencodeToolActivity(event);",
485
+ " rememberToolActivity(toolCallIdsByMessageId, messageId, toolActivity, 'toolCallCount');",
486
+ " rememberToolActivity(toolCallIdsBySessionId, sessionId, toolActivity, 'toolCallCount');",
487
+ " rememberToolActivity(toolResultIdsByMessageId, messageId, toolActivity, 'toolResultCount');",
488
+ " rememberToolActivity(toolResultIdsBySessionId, sessionId, toolActivity, 'toolResultCount');",
351
489
  " if (partType === 'text' && messageId && typeof part.text === 'string') {",
352
490
  " messageTextById.set(messageId, part.text);",
491
+ " rememberSkillNames(messageId, detectOpencodeSkillNames(part.text, knownSkillNames));",
353
492
  " return;",
354
493
  " }",
355
494
  " if (partType !== 'step-finish' || !messageId || emittedMessageIds.has(messageId)) return;",
@@ -359,6 +498,9 @@ function getPatchedLangfuseDistIndexJs() {
359
498
  " const tokenAvailable = [tokenMetrics.input, tokenMetrics.output, total, tokenMetrics.cacheRead, tokenMetrics.reasoning].some((value) => value !== undefined);",
360
499
  " const span = metricsTracer.startSpan('AI Interaction');",
361
500
  " const text = messageTextById.get(messageId) || '';",
501
+ " const skillNames = [...new Set([...(skillNamesByMessageId.get(messageId) ?? []), ...(skillNamesBySessionId.get(sessionId) ?? [])])];",
502
+ " const toolCallCount = new Set([...(toolCallIdsByMessageId.get(messageId) ?? []), ...(toolCallIdsBySessionId.get(sessionId) ?? [])]).size;",
503
+ " const toolResultCount = new Set([...(toolResultIdsByMessageId.get(messageId) ?? []), ...(toolResultIdsBySessionId.get(sessionId) ?? [])]).size;",
362
504
  ' span.setAttribute("oh.langfuse.source", "opencode");',
363
505
  ' span.setAttribute("oh.langfuse.user_id", userId || "");',
364
506
  ' span.setAttribute("oh.langfuse.metrics_schema_version", "1.0");',
@@ -371,9 +513,12 @@ function getPatchedLangfuseDistIndexJs() {
371
513
  ' span.setAttribute("langfuse.observation.metadata.user_message_count", 1);',
372
514
  ' span.setAttribute("langfuse.observation.metadata.assistant_message_count", 1);',
373
515
  ' 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);',
516
+ ' span.setAttribute("langfuse.observation.metadata.tool_call_count", toolCallCount);',
517
+ ' span.setAttribute("langfuse.observation.metadata.tool_result_count", toolResultCount);',
518
+ ' span.setAttribute("langfuse.observation.metadata.skill_use_count", skillNames.length);',
519
+ ' if (skillNames.length) span.setAttribute("langfuse.observation.metadata.skill_names", skillNames);',
520
+ ' if (skillNames.length) span.setAttribute("langfuse.observation.metadata.skill_names_json", JSON.stringify(skillNames));',
521
+ ' if (skillNames.length) span.setAttribute("langfuse.observation.metadata.skill_names_csv", skillNames.join(","));',
377
522
  ' if (tokenMetrics.input !== undefined) span.setAttribute("langfuse.observation.metadata.input_tokens", tokenMetrics.input);',
378
523
  ' if (tokenMetrics.output !== undefined) span.setAttribute("langfuse.observation.metadata.output_tokens", tokenMetrics.output);',
379
524
  ' if (total !== undefined) span.setAttribute("langfuse.observation.metadata.total_tokens", total);',
@@ -381,6 +526,13 @@ function getPatchedLangfuseDistIndexJs() {
381
526
  ' if (tokenMetrics.reasoning !== undefined) span.setAttribute("langfuse.observation.metadata.reasoning_tokens", tokenMetrics.reasoning);',
382
527
  ' if (text) span.setAttribute("langfuse.observation.metadata.output_text_preview", text.slice(0, 512));',
383
528
  " span.end();",
529
+ " messageTextById.delete(messageId);",
530
+ " skillNamesByMessageId.delete(messageId);",
531
+ " skillNamesBySessionId.delete(sessionId);",
532
+ " toolCallIdsByMessageId.delete(messageId);",
533
+ " toolCallIdsBySessionId.delete(sessionId);",
534
+ " toolResultIdsByMessageId.delete(messageId);",
535
+ " toolResultIdsBySessionId.delete(sessionId);",
384
536
  " };",
385
537
  "",
386
538
  " return {",
@@ -391,7 +543,7 @@ function getPatchedLangfuseDistIndexJs() {
391
543
  " },",
392
544
  " event: async ({ event }) => {",
393
545
  " const eventType = event?.type ?? '';",
394
- ' if (eventType === "message.part.updated") recordInteractionMetric(event);',
546
+ " recordInteractionMetric(event);",
395
547
  ' if (eventType === "session.idle" || eventType === "message.updated" || eventType === "message.part.updated" || eventType === "session.updated") {',
396
548
  " await flush(eventType);",
397
549
  " }",