explorbot 0.1.10 → 0.1.11

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.
Files changed (84) hide show
  1. package/README.md +27 -1
  2. package/bin/explorbot-cli.ts +27 -18
  3. package/dist/bin/explorbot-cli.js +26 -18
  4. package/dist/package.json +2 -2
  5. package/dist/rules/navigator/output.md +9 -0
  6. package/dist/rules/navigator/verification-actions.md +2 -0
  7. package/dist/src/action-result.js +23 -1
  8. package/dist/src/action.js +46 -38
  9. package/dist/src/ai/bosun.js +11 -1
  10. package/dist/src/ai/conversation.js +39 -0
  11. package/dist/src/ai/historian/codeceptjs.js +109 -0
  12. package/dist/src/ai/historian/experience.js +320 -0
  13. package/dist/src/ai/historian/mixin.js +2 -0
  14. package/dist/src/ai/historian/playwright.js +145 -0
  15. package/dist/src/ai/historian/utils.js +18 -0
  16. package/dist/src/ai/historian.js +19 -405
  17. package/dist/src/ai/navigator.js +82 -29
  18. package/dist/src/ai/pilot.js +232 -13
  19. package/dist/src/ai/planner.js +29 -9
  20. package/dist/src/ai/provider.js +54 -17
  21. package/dist/src/ai/researcher.js +41 -32
  22. package/dist/src/ai/rules.js +26 -14
  23. package/dist/src/ai/tester.js +90 -26
  24. package/dist/src/ai/tools.js +13 -7
  25. package/dist/src/browser-server.js +16 -3
  26. package/dist/src/commands/add-rule-command.js +11 -8
  27. package/dist/src/commands/clean-command.js +2 -1
  28. package/dist/src/commands/explore-command.js +27 -15
  29. package/dist/src/commands/init-command.js +9 -8
  30. package/dist/src/commands/plan-command.js +32 -0
  31. package/dist/src/commands/plan-save-command.js +19 -7
  32. package/dist/src/commands/rerun-command.js +4 -0
  33. package/dist/src/components/App.js +15 -5
  34. package/dist/src/execution-controller.js +13 -2
  35. package/dist/src/experience-tracker.js +20 -64
  36. package/dist/src/explorbot.js +5 -8
  37. package/dist/src/explorer.js +9 -2
  38. package/dist/src/observability.js +50 -99
  39. package/dist/src/playwright-recorder.js +309 -0
  40. package/dist/src/test-plan.js +12 -0
  41. package/dist/src/utils/aria.js +37 -1
  42. package/dist/src/utils/error-page.js +20 -7
  43. package/dist/src/utils/next-steps.js +37 -0
  44. package/package.json +2 -2
  45. package/rules/navigator/output.md +9 -0
  46. package/rules/navigator/verification-actions.md +2 -0
  47. package/src/action-result.ts +26 -1
  48. package/src/action.ts +44 -37
  49. package/src/ai/bosun.ts +11 -1
  50. package/src/ai/conversation.ts +37 -0
  51. package/src/ai/historian/codeceptjs.ts +130 -0
  52. package/src/ai/historian/experience.ts +383 -0
  53. package/src/ai/historian/mixin.ts +4 -0
  54. package/src/ai/historian/playwright.ts +169 -0
  55. package/src/ai/historian/utils.ts +23 -0
  56. package/src/ai/historian.ts +35 -473
  57. package/src/ai/navigator.ts +82 -29
  58. package/src/ai/pilot.ts +237 -14
  59. package/src/ai/planner.ts +29 -9
  60. package/src/ai/provider.ts +51 -17
  61. package/src/ai/researcher.ts +45 -33
  62. package/src/ai/rules.ts +27 -14
  63. package/src/ai/tester.ts +94 -26
  64. package/src/ai/tools.ts +47 -25
  65. package/src/browser-server.ts +17 -3
  66. package/src/commands/add-rule-command.ts +11 -7
  67. package/src/commands/clean-command.ts +2 -1
  68. package/src/commands/explore-command.ts +29 -15
  69. package/src/commands/init-command.ts +9 -8
  70. package/src/commands/plan-command.ts +35 -0
  71. package/src/commands/plan-save-command.ts +18 -7
  72. package/src/commands/rerun-command.ts +5 -0
  73. package/src/components/App.tsx +16 -5
  74. package/src/config.ts +6 -1
  75. package/src/execution-controller.ts +14 -3
  76. package/src/experience-tracker.ts +21 -72
  77. package/src/explorbot.ts +5 -8
  78. package/src/explorer.ts +11 -2
  79. package/src/observability.ts +50 -109
  80. package/src/playwright-recorder.ts +305 -0
  81. package/src/test-plan.ts +12 -0
  82. package/src/utils/aria.ts +38 -1
  83. package/src/utils/error-page.ts +22 -7
  84. package/src/utils/next-steps.ts +51 -0
@@ -1,5 +1,5 @@
1
- import path from 'node:path';
2
- import { tag } from '../utils/logger.js';
1
+ import { getCliName } from "../utils/cli-name.js";
2
+ import { printNextSteps, relativeToCwd } from "../utils/next-steps.js";
3
3
  import { BaseCommand } from './base-command.js';
4
4
  export class PlanSaveCommand extends BaseCommand {
5
5
  name = 'plan:save';
@@ -12,10 +12,22 @@ export class PlanSaveCommand extends BaseCommand {
12
12
  }
13
13
  const filename = args.trim() || undefined;
14
14
  const savedPath = this.explorBot.savePlan(filename);
15
- if (savedPath) {
16
- const relativePath = path.relative(process.cwd(), savedPath);
17
- tag('success').log(`Plan saved to: ${relativePath}`);
18
- tag('info').log(`Run /plan:load ${relativePath} to reload it`);
19
- }
15
+ if (!savedPath)
16
+ return;
17
+ const cli = getCliName();
18
+ const relPlan = relativeToCwd(savedPath);
19
+ const sections = [
20
+ {
21
+ label: 'Plan',
22
+ path: savedPath,
23
+ commands: [
24
+ { label: 'Re-run', command: `${cli} test ${relPlan} 1` },
25
+ { label: 'Run all', command: `${cli} test ${relPlan} *` },
26
+ { label: 'Run range', command: `${cli} test ${relPlan} 1-3` },
27
+ { label: 'Reload', command: `/plan:load ${relPlan}` },
28
+ ],
29
+ },
30
+ ];
31
+ printNextSteps(sections);
20
32
  }
21
33
  }
@@ -19,6 +19,10 @@ export class RerunCommand extends BaseCommand {
19
19
  if (!existsSync(filePath)) {
20
20
  filePath = resolve(ConfigParser.getInstance().getTestsDir(), filename);
21
21
  }
22
+ if (filePath.endsWith('.spec.ts') || filePath.endsWith('.spec.js')) {
23
+ tag('error').log(`Rerun does not support Playwright tests. Run them with: npx playwright test ${filePath}`);
24
+ return;
25
+ }
22
26
  const testIndices = indexArg ? parseTestIndices(indexArg) : undefined;
23
27
  await this.explorBot.agentRerunner().rerun(filePath, { testIndices });
24
28
  }
@@ -72,16 +72,27 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
72
72
  setInterruptPrompt(prompt);
73
73
  setShowInput(true);
74
74
  return new Promise((resolve) => {
75
- interruptResolveRef.current = resolve;
75
+ interruptResolveRef.current = (value) => {
76
+ interruptResolveRef.current = null;
77
+ setInterruptPrompt(null);
78
+ resolve(value);
79
+ };
76
80
  });
77
81
  });
78
82
  const handleIdle = () => {
79
83
  setShowInput(true);
80
84
  };
85
+ const handleInterrupt = () => {
86
+ if (interruptResolveRef.current) {
87
+ interruptResolveRef.current(null);
88
+ }
89
+ };
81
90
  executionController.on('idle', handleIdle);
91
+ executionController.on('interrupt', handleInterrupt);
82
92
  setInputCallbackReady(true);
83
93
  return () => {
84
94
  executionController.off('idle', handleIdle);
95
+ executionController.off('interrupt', handleInterrupt);
85
96
  executionController.reset();
86
97
  };
87
98
  }, []);
@@ -244,9 +255,10 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
244
255
  return;
245
256
  }
246
257
  if (isCommand) {
247
- setInterruptPrompt(null);
258
+ if (interruptResolveRef.current) {
259
+ interruptResolveRef.current(null);
260
+ }
248
261
  setShowInput(false);
249
- interruptResolveRef.current = null;
250
262
  executionController.startExecution();
251
263
  try {
252
264
  await commandHandler.executeCommand(trimmed);
@@ -264,8 +276,6 @@ export function App({ explorBot, initialShowInput = false, exitOnEmptyInput = fa
264
276
  }
265
277
  if (interruptResolveRef.current) {
266
278
  interruptResolveRef.current(input);
267
- interruptResolveRef.current = null;
268
- setInterruptPrompt(null);
269
279
  setShowInput(false);
270
280
  return;
271
281
  }
@@ -7,6 +7,7 @@ export class ExecutionController extends EventEmitter {
7
7
  inputCallback = null;
8
8
  interruptResolvers = [];
9
9
  abortController = null;
10
+ awaitingInput = false;
10
11
  constructor() {
11
12
  super();
12
13
  }
@@ -39,6 +40,9 @@ export class ExecutionController extends EventEmitter {
39
40
  this.interruptResolvers = [];
40
41
  this.emit('idle');
41
42
  }
43
+ isAwaitingInput() {
44
+ return this.awaitingInput;
45
+ }
42
46
  isInterrupted() {
43
47
  return this.interrupted;
44
48
  }
@@ -64,10 +68,16 @@ export class ExecutionController extends EventEmitter {
64
68
  return userInput;
65
69
  }
66
70
  async requestInput(prompt) {
67
- if (this.inputCallback) {
71
+ if (!this.inputCallback) {
72
+ return await this.readlineInput(prompt);
73
+ }
74
+ this.awaitingInput = true;
75
+ try {
68
76
  return await this.inputCallback(prompt);
69
77
  }
70
- return await this.readlineInput(prompt);
78
+ finally {
79
+ this.awaitingInput = false;
80
+ }
71
81
  }
72
82
  async readlineInput(prompt) {
73
83
  const rl = readline.createInterface({
@@ -86,6 +96,7 @@ export class ExecutionController extends EventEmitter {
86
96
  this.interrupted = false;
87
97
  this.interruptResolvers = [];
88
98
  this.abortController = null;
99
+ this.awaitingInput = false;
89
100
  }
90
101
  }
91
102
  export const executionController = ExecutionController.getInstance();
@@ -13,15 +13,17 @@ export const RECENT_WINDOW_DAYS = 30;
13
13
  /**
14
14
  * Stores and reads per-page experience files (`./experience/<stateHash>.md`).
15
15
  *
16
- * Format rules (enforced by writeFlow/writeAction — the only supported writers):
16
+ * Two writers, two contracts:
17
17
  *
18
- * ## FLOW: <imperative title> multi-step, `*` bullets + optional ```js``` + `>` discovery, ends with `---`
19
- * ## ACTION: <imperative title> single-step, optional `Solution:` line + one ```js``` code block
18
+ * writeFlow(state, body, relatedUrls?) — caller hands in a fully-formatted
19
+ * `## FLOW: <imperative title>` block (multi-step,
20
+ * `*` bullets + optional ```js``` + `>` discovery,
21
+ * ends with `---`). Tracker dedups + prepends.
22
+ * writeAction(state, ActionInput) — `## ACTION: <imperative title>`, single-step,
23
+ * optional `Solution:` line + one ```js``` code block.
24
+ * Title normalized via normalizeTitle().
20
25
  *
21
26
  * - Always h2. Never h3 for FLOW/ACTION.
22
- * - Title is an imperative verb phrase, lowercase-first, no trailing punctuation.
23
- * Writers normalize automatically (strip own `FLOW:` / `ACTION:` prefix if the caller included it,
24
- * lowercase first char, trim trailing `.!?,;:`).
25
27
  * - On read (getSuccessfulExperience), headings are rendered as
26
28
  * `## HOW to <title> (multi-step|single-step)` so prompts get natural phrasing.
27
29
  */
@@ -159,27 +161,25 @@ export class ExperienceTracker {
159
161
  this.writeExperienceFile(stateHash, updatedContent, data);
160
162
  tag('substep').log(` Added ACTION to: ${stateHash}.md`);
161
163
  }
162
- writeFlow(state, flow) {
164
+ writeFlow(state, body, relatedUrls) {
163
165
  if (this.disabled || this.isWritingDisabled(state))
164
166
  return;
165
- if (!flow.steps?.length)
167
+ if (!body?.trim())
166
168
  return;
167
169
  this.ensureExperienceFile(state);
168
170
  const stateHash = state.getStateHash();
169
171
  const { content, data } = this.readExperienceFile(stateHash);
170
- if (flow.relatedUrls?.length) {
172
+ if (content.includes(body)) {
173
+ debugLog('Skipping duplicate flow body');
174
+ return;
175
+ }
176
+ if (relatedUrls?.length) {
171
177
  const currentPath = extractStatePath(state.url || '');
172
178
  const existingRelated = Array.isArray(data.related) ? data.related : [];
173
- const allRelated = [...new Set([...existingRelated, ...flow.relatedUrls])];
179
+ const allRelated = [...new Set([...existingRelated, ...relatedUrls])];
174
180
  data.related = allRelated.filter((url) => url !== currentPath);
175
181
  }
176
- const title = normalizeTitle(flow.scenario);
177
- if (!title)
178
- return;
179
- const sessionContent = this.trimSessionContent(generateFlowContent(title, flow.steps));
180
- if (!sessionContent)
181
- return;
182
- const updatedContent = `${sessionContent}\n${content}`;
182
+ const updatedContent = `${body}\n${content}`;
183
183
  this.writeExperienceFile(stateHash, updatedContent, data);
184
184
  tag('substep').log(`Added FLOW to: ${stateHash}.md`);
185
185
  }
@@ -245,33 +245,6 @@ export class ExperienceTracker {
245
245
  // Clear any in-memory state if needed
246
246
  // The actual files will be cleaned up by test cleanup
247
247
  }
248
- trimSessionContent(content) {
249
- const q = mdq(content);
250
- if (q.query('heading').count() === 0)
251
- return null;
252
- if (q.query('code').count() === 0)
253
- return null;
254
- let result = content;
255
- const codeBlocks = q.query('code').each();
256
- if (codeBlocks.length > 2) {
257
- for (const block of codeBlocks.slice(2)) {
258
- result = result.replace(block.text(), '');
259
- }
260
- }
261
- const blockquotes = mdq(result).query('blockquote').each();
262
- if (blockquotes.length > 5) {
263
- for (const bq of blockquotes.slice(5)) {
264
- result = result.replace(bq.text(), '');
265
- }
266
- }
267
- const lines = result.split('\n');
268
- if (lines.length > 40) {
269
- result = lines.slice(0, 40).join('\n');
270
- }
271
- if (!result.trim())
272
- return null;
273
- return result;
274
- }
275
248
  getSuccessfulExperience(state, options) {
276
249
  const records = this.getRelevantExperience(state, {
277
250
  includeDescendantExperience: options?.includeDescendants,
@@ -469,7 +442,9 @@ export function renderExperienceToc(toc) {
469
442
  return '';
470
443
  const lines = [];
471
444
  lines.push('<experience>');
472
- lines.push('Past experience for this page — reusable recipes recorded from prior successful runs.');
445
+ lines.push('Past experience for this page — recipes recorded from prior successful runs.');
446
+ lines.push('Locators and step ordering worked then; the page may have changed since.');
447
+ lines.push('Treat as a starting hypothesis, not ground truth. If a step fails, fall back to ARIA/UI-map.');
473
448
  lines.push('FLOW: = multi-step recipe (bullets + code + discovery). ACTION: = single-step snippet (one code block).');
474
449
  lines.push('Call learn_experience({ fileTag, sectionIndex }) to read a section when it looks relevant to the current step.');
475
450
  lines.push('');
@@ -513,25 +488,6 @@ function generateActionContent(title, code, explanation) {
513
488
  lines.push('');
514
489
  return lines.join('\n');
515
490
  }
516
- function generateFlowContent(title, steps) {
517
- let content = `## FLOW: ${title}\n\n`;
518
- for (const step of steps) {
519
- content += `* ${step.message}\n\n`;
520
- if (step.code) {
521
- content += '```js\n';
522
- content += `${step.code}\n`;
523
- content += '```\n\n';
524
- }
525
- if (step.discovery) {
526
- const discoveries = step.discovery.split('\n').filter((d) => d.trim());
527
- for (const discovery of discoveries) {
528
- content += `> ${discovery.trim()}\n\n`;
529
- }
530
- }
531
- }
532
- content += '---\n';
533
- return content;
534
- }
535
491
  function renderAsHowTo(content) {
536
492
  const tokens = marked.lexer(content);
537
493
  let result = '';
@@ -36,6 +36,7 @@ export class ExplorBot {
36
36
  currentPlan;
37
37
  planFeature;
38
38
  lastPlanError = null;
39
+ lastSavedPlanPath = null;
39
40
  agents = {};
40
41
  constructor(options = {}) {
41
42
  this.options = options;
@@ -214,10 +215,10 @@ export class ExplorBot {
214
215
  return this.agents.quartermaster;
215
216
  }
216
217
  agentHistorian() {
217
- return (this.agents.historian ||= this.createAgent(({ ai, explorer }) => {
218
+ return (this.agents.historian ||= this.createAgent(({ ai, explorer, config }) => {
218
219
  const experienceTracker = explorer.getStateManager().getExperienceTracker();
219
220
  const reporter = explorer.getReporter();
220
- return new Historian(ai, experienceTracker, reporter, explorer.getStateManager());
221
+ return new Historian(ai, experienceTracker, reporter, explorer.getStateManager(), config, explorer.getPlaywrightRecorder());
221
222
  }));
222
223
  }
223
224
  agentRerunner() {
@@ -314,12 +315,7 @@ export class ExplorBot {
314
315
  return undefined;
315
316
  return this.currentPlan;
316
317
  }
317
- const savedPath = this.savePlan();
318
- if (savedPath) {
319
- const relativePath = path.relative(process.cwd(), savedPath);
320
- tag('info').log(`Plan saved to: ${relativePath}`);
321
- tag('info').log(`Edit the plan file and run /plan:load ${relativePath} to reload it`);
322
- }
318
+ this.savePlan();
323
319
  return this.currentPlan;
324
320
  }
325
321
  getPlansDir() {
@@ -341,6 +337,7 @@ export class ExplorBot {
341
337
  const planFilename = filename || this.generatePlanFilename();
342
338
  const planPath = path.join(plansDir, planFilename);
343
339
  Plan.saveMultipleToMarkdown(plans, planPath);
340
+ this.lastSavedPlanPath = planPath;
344
341
  return planPath;
345
342
  }
346
343
  generatePlanFilename() {
@@ -12,6 +12,7 @@ import { RequestStore } from "./api/request-store.js";
12
12
  import { XhrCapture } from "./api/xhr-capture.js";
13
13
  import { ConfigParser, outputPath } from './config.js';
14
14
  import { KnowledgeTracker } from './knowledge-tracker.js';
15
+ import { PlaywrightRecorder } from "./playwright-recorder.js";
15
16
  import { Reporter } from "./reporter.js";
16
17
  import { StateManager } from './state-manager.js';
17
18
  import { createDebug, log, tag } from './utils/logger.js';
@@ -35,6 +36,7 @@ class Explorer {
35
36
  _activeTest = null;
36
37
  xhrCapture = null;
37
38
  requestStore = null;
39
+ playwrightRecorder = new PlaywrightRecorder();
38
40
  constructor(config, aiProvider, options) {
39
41
  this.config = config;
40
42
  this.aiProvider = aiProvider;
@@ -89,7 +91,7 @@ class Explorer {
89
91
  tag('substep').log(debugInfo);
90
92
  }
91
93
  const PlaywrightConfig = {
92
- timeout: 1000,
94
+ timeout: 3000,
93
95
  highlightElement: true,
94
96
  waitForAction: 500,
95
97
  ...playwrightConfig,
@@ -188,6 +190,7 @@ class Explorer {
188
190
  const hasSession = this.options?.session && existsSync(this.options.session);
189
191
  const contextOptions = hasSession ? { storageState: this.options.session } : undefined;
190
192
  await this.playwrightHelper._createContextPage(contextOptions);
193
+ await this.playwrightRecorder.start(this.playwrightHelper.browserContext);
191
194
  this.setupXhrCapture();
192
195
  if (hasSession) {
193
196
  tag('info').log(`Session restored from ${path.relative(process.cwd(), this.options.session)}`);
@@ -216,7 +219,10 @@ class Explorer {
216
219
  await this.playwrightHelper._startBrowser();
217
220
  }
218
221
  createAction() {
219
- return new Action(this.actor, this.stateManager);
222
+ return new Action(this.actor, this.stateManager, this.playwrightRecorder);
223
+ }
224
+ getPlaywrightRecorder() {
225
+ return this.playwrightRecorder;
220
226
  }
221
227
  async visit(url) {
222
228
  await this.closeOtherTabs();
@@ -411,6 +417,7 @@ class Explorer {
411
417
  if (this.xhrCapture && this.playwrightHelper?.page) {
412
418
  this.xhrCapture.detach(this.playwrightHelper.page);
413
419
  }
420
+ await this.playwrightRecorder.stop();
414
421
  if (this.options?.session && this.playwrightHelper?.browserContext) {
415
422
  const dir = path.dirname(this.options.session);
416
423
  if (!existsSync(dir))
@@ -1,125 +1,76 @@
1
- import { randomBytes } from 'node:crypto';
2
- import { context, trace } from '@opentelemetry/api';
1
+ import { trace } from '@opentelemetry/api';
3
2
  let current = null;
4
- let depth = 0;
5
3
  export const Observability = {
6
4
  async run(name, metadata, fn) {
7
- const started = Observability.startTrace(name, metadata);
8
- try {
9
- if (!started) {
10
- const parentSpan = current?.span;
11
- if (!parentSpan || !current)
12
- return await fn();
13
- const tracer = trace.getTracer('ai');
14
- const childSpan = tracer.startSpan(name, undefined, trace.setSpan(context.active(), parentSpan));
15
- const savedSpan = current.span;
16
- const savedName = current.name;
17
- current.span = childSpan;
18
- current.name = name;
19
- return await context.with(trace.setSpan(context.active(), childSpan), async () => {
20
- try {
21
- return await fn();
22
- }
23
- finally {
24
- childSpan.end();
25
- current.span = savedSpan;
26
- current.name = savedName;
27
- }
28
- });
29
- }
30
- const tracer = trace.getTracer('ai');
31
- const spanContext = {
32
- traceId: current?.traceId || randomBytes(16).toString('hex'),
33
- spanId: randomBytes(8).toString('hex'),
34
- traceFlags: 1,
35
- };
36
- const rootContext = trace.setSpanContext(context.active(), spanContext);
37
- const initSpan = tracer.startSpan(name, undefined, rootContext);
38
- initSpan.setAttribute('langfuse.trace.name', name);
39
- initSpan.setAttribute('langfuse.trace.id', current?.traceId || '');
40
- if (current?.metadata?.sessionId) {
41
- initSpan.setAttribute('langfuse.trace.session_id', String(current.metadata.sessionId));
42
- }
43
- if (current?.metadata?.userId) {
44
- initSpan.setAttribute('langfuse.trace.user_id', String(current.metadata.userId));
45
- }
46
- if (current?.metadata?.tags && Array.isArray(current.metadata.tags)) {
47
- initSpan.setAttribute('langfuse.trace.tags', current.metadata.tags);
48
- }
49
- if (current?.metadata?.input) {
50
- initSpan.setAttribute('langfuse.trace.input', JSON.stringify(current.metadata.input));
51
- }
52
- initSpan.end();
53
- const span = tracer.startSpan(name, undefined, rootContext);
54
- current.span = span;
55
- return await context.with(trace.setSpan(rootContext, span), async () => {
5
+ const tracer = trace.getTracer('ai');
6
+ if (current) {
7
+ return await tracer.startActiveSpan(name, {}, async (span) => {
8
+ const saved = current;
9
+ current = {
10
+ metadata: { ...saved.metadata, ...metadata },
11
+ name,
12
+ span,
13
+ };
56
14
  try {
57
15
  return await fn();
58
16
  }
59
17
  finally {
60
18
  span.end();
61
- current.span = undefined;
19
+ current = saved;
62
20
  }
63
21
  });
64
22
  }
65
- finally {
66
- Observability.endTrace(started);
67
- }
68
- },
69
- startTrace(name, metadata) {
70
- if (current) {
71
- depth += 1;
72
- return false;
73
- }
74
- const langfuseTraceId = metadata.langfuseTraceId || randomBytes(16).toString('hex');
75
- current = {
76
- metadata: {
77
- ...metadata,
78
- langfuseTraceId,
79
- },
80
- traceId: langfuseTraceId,
81
- updateParent: true,
82
- name,
83
- };
84
- depth = 1;
85
- return true;
86
- },
87
- endTrace(started) {
88
- if (!current) {
89
- return;
90
- }
91
- if (!started) {
92
- depth -= 1;
93
- return;
94
- }
95
- depth -= 1;
96
- if (depth <= 0) {
97
- current = null;
98
- depth = 0;
99
- }
23
+ const attributes = buildRootSpanAttributes(name, metadata);
24
+ return await tracer.startActiveSpan(name, { attributes }, async (span) => {
25
+ current = { metadata, name, span };
26
+ try {
27
+ return await fn();
28
+ }
29
+ finally {
30
+ span.end();
31
+ current = null;
32
+ }
33
+ });
100
34
  },
101
35
  getTelemetry() {
102
36
  if (!current) {
103
37
  return undefined;
104
38
  }
105
- const telemetry = {
39
+ const metadata = {};
40
+ if (current.metadata.sessionId)
41
+ metadata.sessionId = current.metadata.sessionId;
42
+ if (current.metadata.userId)
43
+ metadata.userId = current.metadata.userId;
44
+ if (Array.isArray(current.metadata.tags))
45
+ metadata.tags = current.metadata.tags;
46
+ return {
106
47
  isEnabled: true,
107
48
  functionId: current.name,
108
- metadata: {
109
- ...current.metadata,
110
- langfuseTraceId: current.traceId,
111
- langfuseUpdateParent: current.updateParent,
112
- },
49
+ metadata,
113
50
  };
114
- if (current.updateParent) {
115
- current.updateParent = false;
116
- }
117
- return telemetry;
118
51
  },
119
52
  isTracing() {
120
53
  return Boolean(current);
121
54
  },
122
55
  getSpan() {
123
- return current?.span;
56
+ return current?.span ?? trace.getActiveSpan();
124
57
  },
125
58
  };
59
+ function buildRootSpanAttributes(name, metadata) {
60
+ const attributes = {
61
+ 'langfuse.trace.name': name,
62
+ };
63
+ if (metadata.sessionId) {
64
+ attributes['session.id'] = String(metadata.sessionId);
65
+ }
66
+ if (metadata.userId) {
67
+ attributes['user.id'] = String(metadata.userId);
68
+ }
69
+ if (Array.isArray(metadata.tags)) {
70
+ attributes['langfuse.trace.tags'] = metadata.tags;
71
+ }
72
+ if (metadata.input !== undefined) {
73
+ attributes['langfuse.trace.input'] = JSON.stringify(metadata.input);
74
+ }
75
+ return attributes;
76
+ }