@getfoil/foil-js 0.4.0

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 (4) hide show
  1. package/README.md +1029 -0
  2. package/package.json +54 -0
  3. package/src/foil.js +2214 -0
  4. package/src/otel.js +610 -0
package/src/foil.js ADDED
@@ -0,0 +1,2214 @@
1
+ const axios = require('axios');
2
+ const crypto = require('crypto');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const FormData = require('form-data');
6
+
7
+ /**
8
+ * Foil JavaScript SDK
9
+ *
10
+ * This SDK can be used alongside OpenTelemetry (OTEL) instrumentation like OpenLLMetry/Traceloop.
11
+ * Below is a reference of which fields are auto-captured by OTEL vs which require manual setting.
12
+ *
13
+ * ═══════════════════════════════════════════════════════════════════════════════════════════════
14
+ * ATTRIBUTE REFERENCE: OTEL Auto-Captured vs Custom (Manual)
15
+ * ═══════════════════════════════════════════════════════════════════════════════════════════════
16
+ *
17
+ * LLM CONFIG (auto-captured by OpenLLMetry when passed to LLM call):
18
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
19
+ * | Field | OTEL Attribute | Auto-captured? | Notes |
20
+ * |------------------|-----------------------------------|----------------|----------------------|
21
+ * | temperature | gen_ai.request.temperature | ✅ Yes | Pass to LLM call |
22
+ * | maxTokens | gen_ai.request.max_tokens | ✅ Yes | Pass to LLM call |
23
+ * | topP | gen_ai.request.top_p | ✅ Yes | Pass to LLM call |
24
+ * | frequencyPenalty | llm.frequency_penalty | ✅ Yes | Pass to LLM call |
25
+ * | presencePenalty | llm.presence_penalty | ✅ Yes | Pass to LLM call |
26
+ * | stopSequences | gen_ai.request.stop_sequences | ❌ No | Not captured |
27
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
28
+ *
29
+ * SESSION & USER TRACKING (requires manual setting):
30
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
31
+ * | Field | OTEL Attribute | Auto-captured? | How to set |
32
+ * |--------------------|---------------------------- |----------------|------------------------|
33
+ * | sessionId | gen_ai.conversation.id | ❌ No | span.setAttribute() |
34
+ * | | session.id | | |
35
+ * | | foil.session_id | | |
36
+ * | endUserId | llm.user | ⚠️ Partial | OpenAI 'user' param |
37
+ * | | user.id | | or span.setAttribute() |
38
+ * | | foil.end_user_id | | |
39
+ * | endUserProperties | foil.end_user.* | ❌ No | span.setAttribute() |
40
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
41
+ *
42
+ * DEVICE CONTEXT (requires manual setting - useful for client-side apps):
43
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
44
+ * | Field | OTEL Attribute | Fallback OTEL Attr | Auto-captured? |
45
+ * |--------------|-------------------------|---------------------|---------------------------|
46
+ * | platform | foil.device.platform | - | ❌ No |
47
+ * | os | foil.device.os | os.type | ⚠️ os.type may be set |
48
+ * | browser | foil.device.browser | browser.name | ⚠️ browser.name may be set|
49
+ * | deviceType | foil.device.type | - | ❌ No |
50
+ * | appVersion | foil.device.app_version | service.version | ⚠️ service.version set |
51
+ * | locale | foil.device.locale | - | ❌ No |
52
+ * | timezone | foil.device.timezone | - | ❌ No |
53
+ * | userAgent | foil.device.user_agent | http.user_agent | ⚠️ http.user_agent may be|
54
+ * ─────────────────────────────────────────────────────────────────────────────────────────────────
55
+ *
56
+ * EXAMPLE: Setting custom attributes with OpenLLMetry/Traceloop:
57
+ * ```javascript
58
+ * import { trace } from "@opentelemetry/api";
59
+ *
60
+ * const span = trace.getActiveSpan();
61
+ * span?.setAttribute("gen_ai.conversation.id", sessionId); // Session tracking
62
+ * span?.setAttribute("llm.user", userId); // End user tracking
63
+ * span?.setAttribute("foil.end_user.plan", "premium"); // End user properties
64
+ * span?.setAttribute("foil.device.platform", "web"); // Device context
65
+ * ```
66
+ *
67
+ * ═══════════════════════════════════════════════════════════════════════════════════════════════
68
+ */
69
+
70
+ // Debug logging - enable with FOIL_DEBUG=true or pass debug: true to tracer
71
+ const isDebugEnabled = () => process.env.FOIL_DEBUG === 'true';
72
+
73
+ /**
74
+ * Span kinds supported by Foil tracing
75
+ */
76
+ const SpanKind = {
77
+ AGENT: 'agent',
78
+ LLM: 'llm',
79
+ TOOL: 'tool',
80
+ CHAIN: 'chain',
81
+ RETRIEVER: 'retriever',
82
+ EMBEDDING: 'embedding',
83
+ CUSTOM: 'custom',
84
+ };
85
+
86
+ /**
87
+ * Media categories for multimodal content
88
+ */
89
+ const MediaCategory = {
90
+ IMAGE: 'image',
91
+ DOCUMENT: 'document',
92
+ SPREADSHEET: 'spreadsheet',
93
+ CODE: 'code',
94
+ AUDIO: 'audio',
95
+ VIDEO: 'video',
96
+ ARCHIVE: 'archive',
97
+ NOTEBOOK: 'notebook',
98
+ OTHER: 'other',
99
+ };
100
+
101
+ /**
102
+ * Content block helpers for multimodal input/output
103
+ */
104
+ const ContentBlock = {
105
+ /**
106
+ * Create a text content block
107
+ * @param {string} text - The text content
108
+ * @returns {Object} Text content block
109
+ */
110
+ text(text) {
111
+ return { type: 'text', text };
112
+ },
113
+
114
+ /**
115
+ * Create a media content block
116
+ * @param {string} mediaId - The media ID from uploadMedia
117
+ * @param {Object} [options] - Additional options
118
+ * @param {string} [options.category] - Media category
119
+ * @param {string} [options.mimeType] - MIME type
120
+ * @param {string} [options.filename] - Original filename
121
+ * @returns {Object} Media content block
122
+ */
123
+ media(mediaId, options = {}) {
124
+ return {
125
+ type: 'media',
126
+ mediaId,
127
+ category: options.category,
128
+ mimeType: options.mimeType,
129
+ filename: options.filename,
130
+ };
131
+ },
132
+ };
133
+
134
+ /**
135
+ * Helper function to create content blocks array from mixed inputs
136
+ * @param {...(string|Object)} parts - Text strings or content block objects
137
+ * @returns {Array} Array of content blocks
138
+ */
139
+ function content(...parts) {
140
+ return parts.map((part) => {
141
+ if (typeof part === 'string') {
142
+ return ContentBlock.text(part);
143
+ }
144
+ return part;
145
+ });
146
+ }
147
+
148
+ // Default timeout for spans (5 minutes)
149
+ const DEFAULT_SPAN_TIMEOUT_MS = 300000;
150
+
151
+ /**
152
+ * TraceContext provides span management within a trace
153
+ * Passed to traced functions to allow creating child spans
154
+ */
155
+ class TraceContext {
156
+ constructor(tracer, traceId, rootSpanId, agentName, depth = 0) {
157
+ this.tracer = tracer;
158
+ this.traceId = traceId;
159
+ this.rootSpanId = rootSpanId;
160
+ this.agentName = agentName;
161
+ this.depth = depth;
162
+ this.activeSpans = new Map();
163
+ this.debug = tracer.debug || isDebugEnabled();
164
+ // Stack to track current active span for proper parent-child relationships
165
+ this._spanStack = [rootSpanId];
166
+ }
167
+
168
+ /**
169
+ * Get the current parent span ID (the most recent active span)
170
+ */
171
+ get currentParentSpanId() {
172
+ return this._spanStack[this._spanStack.length - 1];
173
+ }
174
+
175
+ /**
176
+ * Log debug message with indentation based on depth
177
+ */
178
+ _log(message, data = {}) {
179
+ if (!this.debug) return;
180
+ const indent = ' '.repeat(this.depth);
181
+ const dataStr = Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : '';
182
+ console.log(`[Foil] ${indent}${message}${dataStr}`);
183
+ }
184
+
185
+ /**
186
+ * Start a new child span
187
+ * @param {string} spanKind - Type of span (llm, tool, chain, etc.)
188
+ * @param {string} name - Name of the span (model name, tool name, etc.)
189
+ * @param {Object} [attributes] - Additional span attributes
190
+ * @param {string} [attributes.parentSpanId] - Explicit parent span ID (overrides automatic stack-based parent)
191
+ * @param {Array} [attributes.attachments] - Input attachments array
192
+ * @param {string} attributes.attachments[].type - Attachment type: 'image', 'code', 'text', 'iframe'
193
+ * @param {string} attributes.attachments[].value - URL for images, content for code/text
194
+ * @param {string} [attributes.attachments[].name] - Optional filename
195
+ * @param {string} [attributes.attachments[].language] - Optional language for code type
196
+ * @param {number} [attributes.timeout] - Timeout in ms (default: 5 minutes, 0 to disable)
197
+ * @returns {Object} Span object with spanId and methods to end the span
198
+ */
199
+ async startSpan(spanKind, name, attributes = {}) {
200
+ const spanId = crypto.randomUUID();
201
+ const startTime = Date.now();
202
+ // Use explicit parent if provided, otherwise use current parent from stack
203
+ const parentSpanId = attributes.parentSpanId || this.currentParentSpanId;
204
+
205
+ // Extract LLM config if provided
206
+ const llmConfig = attributes.llmConfig || {};
207
+
208
+ // Store all span data locally (no server call)
209
+ const localSpanData = {
210
+ spanId,
211
+ name,
212
+ agentName: this.agentName,
213
+ traceId: this.traceId,
214
+ parentSpanId,
215
+ spanKind,
216
+ model: spanKind === SpanKind.LLM ? name : attributes.model,
217
+ input: attributes.input,
218
+ properties: attributes.properties || {},
219
+ sessionId: attributes.sessionId,
220
+ experimentId: attributes.experimentId,
221
+ variantId: attributes.variantId,
222
+ endUserId: attributes.userId,
223
+ endUserProperties: attributes.userProperties,
224
+ device: attributes.device,
225
+ // Input attachments (images, code, text)
226
+ inputAttachments: attributes.attachments,
227
+ // LLM Configuration for replay/debugging
228
+ llmConfig: spanKind === SpanKind.LLM ? {
229
+ temperature: llmConfig.temperature,
230
+ maxTokens: llmConfig.maxTokens,
231
+ topP: llmConfig.topP,
232
+ frequencyPenalty: llmConfig.frequencyPenalty,
233
+ presencePenalty: llmConfig.presencePenalty,
234
+ systemPrompt: llmConfig.systemPrompt,
235
+ tools: llmConfig.tools,
236
+ responseFormat: llmConfig.responseFormat,
237
+ stopSequences: llmConfig.stopSequences,
238
+ seed: llmConfig.seed,
239
+ } : undefined,
240
+ startTime,
241
+ };
242
+
243
+ // Push this span onto the stack - it becomes the parent for subsequent spans
244
+ this._spanStack.push(spanId);
245
+
246
+ this._log(`▶ START [${spanKind}] ${name}`, { spanId: spanId.slice(0, 8), parentId: parentSpanId?.slice(0, 8) });
247
+
248
+ // Set up timeout for hung spans
249
+ const timeoutMs = attributes.timeout ?? DEFAULT_SPAN_TIMEOUT_MS;
250
+ let timeoutId = null;
251
+ let hasEnded = false;
252
+
253
+ const span = {
254
+ spanId,
255
+ spanKind,
256
+ name,
257
+ startTime,
258
+ depth: this.depth + 1,
259
+ attributes,
260
+ _localData: localSpanData,
261
+ _hasEnded: false,
262
+ /**
263
+ * Create a child context for this span
264
+ * @returns {TraceContext}
265
+ */
266
+ createChildContext: () => {
267
+ return new TraceContext(
268
+ this.tracer,
269
+ this.traceId,
270
+ spanId,
271
+ this.agentName,
272
+ this.depth + 1
273
+ );
274
+ },
275
+ /**
276
+ * End this span
277
+ * @param {Object} [result] - Span result
278
+ * @param {*} [result.output] - Output from the operation
279
+ * @param {Object} [result.tokens] - Token usage
280
+ * @param {Object} [result.error] - Error if operation failed
281
+ * @param {Array} [result.attachments] - Output attachments array
282
+ * @param {string} result.attachments[].type - Attachment type: 'image', 'code', 'text', 'iframe'
283
+ * @param {string} result.attachments[].value - URL for images, content for code/text
284
+ * @param {string} [result.attachments[].name] - Optional filename
285
+ * @param {string} [result.attachments[].language] - Optional language for code type
286
+ */
287
+ end: async (result = {}) => {
288
+ // Prevent double-ending
289
+ if (hasEnded) {
290
+ return { spanId, duration: 0 };
291
+ }
292
+ hasEnded = true;
293
+ span._hasEnded = true;
294
+
295
+ // Clear timeout
296
+ if (timeoutId) {
297
+ clearTimeout(timeoutId);
298
+ timeoutId = null;
299
+ }
300
+
301
+ const endTime = Date.now();
302
+ const duration = endTime - startTime;
303
+
304
+ // Build complete span data from local storage + end result
305
+ const completeSpanData = {
306
+ // All data from start (stored locally)
307
+ spanId: localSpanData.spanId,
308
+ traceId: localSpanData.traceId,
309
+ parentSpanId: localSpanData.parentSpanId,
310
+ name: localSpanData.name,
311
+ spanKind: localSpanData.spanKind,
312
+ agentName: localSpanData.agentName,
313
+ sessionId: localSpanData.sessionId,
314
+ model: localSpanData.model,
315
+ input: localSpanData.input,
316
+ properties: localSpanData.properties,
317
+ llmConfig: localSpanData.llmConfig,
318
+ device: localSpanData.device,
319
+ endUserId: localSpanData.endUserId,
320
+ endUserProperties: localSpanData.endUserProperties,
321
+ experimentId: localSpanData.experimentId,
322
+ variantId: localSpanData.variantId,
323
+ inputAttachments: localSpanData.inputAttachments,
324
+ // End data
325
+ output: result.output,
326
+ tokens: result.tokens,
327
+ timing: { totalDuration: duration, ttft: result.ttft },
328
+ error: result.error,
329
+ attachments: result.attachments,
330
+ // Timestamps
331
+ startTime: new Date(startTime).toISOString(),
332
+ endTime: new Date(endTime).toISOString(),
333
+ };
334
+
335
+ this.activeSpans.delete(spanId);
336
+
337
+ // Pop this span from the stack (restore parent as current)
338
+ const stackIdx = this._spanStack.indexOf(spanId);
339
+ if (stackIdx !== -1) {
340
+ this._spanStack.splice(stackIdx, 1);
341
+ }
342
+
343
+ const logData = { spanId: spanId.slice(0, 8), duration: `${duration}ms` };
344
+ if (result.tokens?.total) logData.tokens = result.tokens.total;
345
+ if (result.error) logData.error = result.error.message || result.error;
346
+ this._log(`■ END [${spanKind}] ${name}`, logData);
347
+
348
+ // Send complete span data to server (fire and forget)
349
+ this.tracer.foil.endSpan(completeSpanData).catch((err) => {
350
+ console.error('Foil span end failed:', err.message);
351
+ });
352
+
353
+ return { spanId, duration };
354
+ },
355
+ };
356
+
357
+ // Set up timeout if enabled
358
+ if (timeoutMs > 0) {
359
+ timeoutId = setTimeout(() => {
360
+ if (!hasEnded) {
361
+ this._log(`⚠ TIMEOUT [${spanKind}] ${name}`, { spanId: spanId.slice(0, 8), timeoutMs });
362
+ span.end({
363
+ error: { type: 'timeout', message: `Span timed out after ${timeoutMs}ms` },
364
+ });
365
+ }
366
+ }, timeoutMs);
367
+ }
368
+
369
+ this.activeSpans.set(spanId, span);
370
+ return span;
371
+ }
372
+
373
+ /**
374
+ * Wrap an async function in a span
375
+ * @param {string} spanKind - Type of span
376
+ * @param {string} name - Name of the span
377
+ * @param {Function} fn - Async function to execute
378
+ * @param {Object} [attributes] - Additional span attributes
379
+ * @returns {Promise<*>} Result of the function
380
+ */
381
+ async wrapInSpan(spanKind, name, fn, attributes = {}) {
382
+ const span = await this.startSpan(spanKind, name, attributes);
383
+
384
+ try {
385
+ // Pass the same context - span stack handles parent-child relationships
386
+ const result = await fn(this);
387
+ await span.end({ output: result });
388
+ return result;
389
+ } catch (error) {
390
+ await span.end({ error: { type: error.name, message: error.message } });
391
+ throw error;
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Create an LLM span and return wrapper functions
397
+ * @param {string} model - Model name
398
+ * @param {Object} [attributes] - Additional attributes
399
+ * @returns {Promise<Object>} LLM span with helper methods
400
+ */
401
+ async llm(model, attributes = {}) {
402
+ return this.startSpan(SpanKind.LLM, model, attributes);
403
+ }
404
+
405
+ /**
406
+ * Wrap a tool execution
407
+ * @param {string} toolName - Name of the tool
408
+ * @param {Function} fn - Tool function to execute
409
+ * @param {Object} [attributes] - Additional attributes (arguments, etc.)
410
+ * @returns {Promise<*>} Result of the tool
411
+ */
412
+ async tool(toolName, fn, attributes = {}) {
413
+ return this.wrapInSpan(SpanKind.TOOL, toolName, fn, attributes);
414
+ }
415
+
416
+ /**
417
+ * Wrap a retriever operation
418
+ * @param {string} retrieverName - Name of the retriever
419
+ * @param {Function} fn - Retriever function to execute
420
+ * @param {Object} [attributes] - Additional attributes
421
+ * @returns {Promise<*>} Retriever results
422
+ */
423
+ async retriever(retrieverName, fn, attributes = {}) {
424
+ return this.wrapInSpan(SpanKind.RETRIEVER, retrieverName, fn, attributes);
425
+ }
426
+
427
+ /**
428
+ * Wrap an embedding operation
429
+ * @param {string} model - Embedding model name
430
+ * @param {Function} fn - Embedding function to execute
431
+ * @param {Object} [attributes] - Additional attributes
432
+ * @returns {Promise<*>} Embedding results
433
+ */
434
+ async embedding(model, fn, attributes = {}) {
435
+ return this.wrapInSpan(SpanKind.EMBEDDING, model, fn, attributes);
436
+ }
437
+
438
+ /**
439
+ * Record a signal for this trace
440
+ * @param {string} signalName - Name of the signal (e.g., 'user_thumbs', 'sentiment')
441
+ * @param {boolean|number|string} value - Signal value
442
+ * @param {Object} [options] - Additional options
443
+ * @param {string} [options.spanId] - Specific span ID to associate with
444
+ * @param {string} [options.signalType] - Type of signal (feedback, sentiment, quality, etc.)
445
+ * @param {string} [options.source] - Signal source (user, llm, system)
446
+ * @param {Object} [options.metadata] - Additional metadata
447
+ * @returns {Promise<Object>} Recorded signal
448
+ */
449
+ async recordSignal(signalName, value, options = {}) {
450
+ const signalData = {
451
+ traceId: this.traceId,
452
+ spanId: options.spanId || '',
453
+ agentName: this.agentName,
454
+ signalName,
455
+ value,
456
+ signalType: options.signalType || 'feedback',
457
+ source: options.source || 'user',
458
+ metadata: options.metadata,
459
+ };
460
+
461
+ this._log(`📊 SIGNAL: ${signalName}`, { value, traceId: this.traceId.slice(0, 8) });
462
+
463
+ return this.tracer.foil.recordSignal(signalData).catch((err) => {
464
+ console.error('Foil signal recording failed:', err.message);
465
+ });
466
+ }
467
+
468
+ /**
469
+ * Record user feedback (thumbs up/down)
470
+ * @param {boolean} positive - True for thumbs up, false for thumbs down
471
+ * @param {Object} [options] - Additional options
472
+ * @returns {Promise<Object>} Recorded signal
473
+ */
474
+ async recordFeedback(positive, options = {}) {
475
+ return this.recordSignal('user_thumbs', positive, {
476
+ signalType: 'feedback',
477
+ source: 'user',
478
+ ...options,
479
+ });
480
+ }
481
+
482
+ /**
483
+ * Record a user rating
484
+ * @param {number} rating - Rating value (e.g., 1-5)
485
+ * @param {Object} [options] - Additional options
486
+ * @returns {Promise<Object>} Recorded signal
487
+ */
488
+ async recordRating(rating, options = {}) {
489
+ return this.recordSignal('user_rating', rating, {
490
+ signalType: 'feedback',
491
+ source: 'user',
492
+ ...options,
493
+ });
494
+ }
495
+ }
496
+
497
+ /**
498
+ * FoilTracer provides comprehensive tracing for AI applications
499
+ * Captures the full lifecycle: prompt → LLM → tools → response
500
+ */
501
+ class FoilTracer {
502
+ constructor(foil, options = {}) {
503
+ this.foil = foil;
504
+ this.agentName = options.agentName || 'default-agent';
505
+ this.defaultModel = options.defaultModel;
506
+ this.activeTraces = new Map();
507
+ this.debug = options.debug || isDebugEnabled();
508
+ this._cleanupRegistered = false;
509
+
510
+ // Register process cleanup handlers
511
+ this._registerCleanup();
512
+ }
513
+
514
+ /**
515
+ * Register process exit handlers to cleanup abandoned spans
516
+ * @private
517
+ */
518
+ _registerCleanup() {
519
+ if (this._cleanupRegistered) return;
520
+ this._cleanupRegistered = true;
521
+
522
+ const cleanup = () => {
523
+ if (this.activeTraces.size === 0) return;
524
+
525
+ this._log('Process exiting, cleaning up abandoned traces...');
526
+
527
+ for (const [traceId, traceInfo] of this.activeTraces) {
528
+ const { context, spanId, startTime } = traceInfo;
529
+ const duration = Date.now() - startTime;
530
+
531
+ // End the root span with abandoned error
532
+ const completeSpanData = {
533
+ spanId,
534
+ traceId,
535
+ agentName: this.agentName,
536
+ error: { type: 'abandoned', message: 'Process exited before span completed' },
537
+ timing: { totalDuration: duration },
538
+ startTime: new Date(startTime).toISOString(),
539
+ endTime: new Date().toISOString(),
540
+ };
541
+
542
+ // Synchronous - best effort cleanup
543
+ try {
544
+ this.foil.endSpan(completeSpanData).catch(() => {});
545
+ } catch {
546
+ // Ignore errors during cleanup
547
+ }
548
+ }
549
+ this.activeTraces.clear();
550
+ };
551
+
552
+ // Register for various exit signals
553
+ process.on('beforeExit', cleanup);
554
+ process.on('SIGINT', () => {
555
+ cleanup();
556
+ process.exit(130);
557
+ });
558
+ process.on('SIGTERM', () => {
559
+ cleanup();
560
+ process.exit(143);
561
+ });
562
+ }
563
+
564
+ /**
565
+ * Log debug message
566
+ */
567
+ _log(message, data = {}) {
568
+ if (!this.debug) return;
569
+ const dataStr = Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : '';
570
+ console.log(`[Foil] ${message}${dataStr}`);
571
+ }
572
+
573
+ /**
574
+ * Execute a function within a traced context
575
+ * @param {Function} fn - Async function to execute, receives TraceContext
576
+ * @param {Object} [options] - Trace options
577
+ * @param {string} [options.name] - Name for the root span
578
+ * @param {string} [options.traceId] - Custom trace ID (auto-generated if not provided)
579
+ * @param {string} [options.sessionId] - Session ID for conversation tracking
580
+ * @param {*} [options.input] - Input to record
581
+ * @param {Object} [options.properties] - Custom properties
582
+ * @param {string} [options.userId] - User identifier for tracking which user triggered this trace
583
+ * @param {Object} [options.userProperties] - Additional user attributes (plan, company, role, etc.)
584
+ * @param {Object} [options.device] - Device/platform information
585
+ * @param {string} [options.device.platform] - Platform type: 'web', 'ios', 'android', 'desktop', 'api'
586
+ * @param {string} [options.device.os] - Operating system (e.g., 'iOS 17.0', 'macOS 14.0', 'Windows 11')
587
+ * @param {string} [options.device.browser] - Browser name and version (e.g., 'Chrome 120', 'Safari 17')
588
+ * @param {string} [options.device.deviceType] - Device type: 'phone', 'tablet', 'desktop'
589
+ * @param {string} [options.device.appVersion] - Application version
590
+ * @param {string} [options.device.locale] - User locale (e.g., 'en-US')
591
+ * @param {string} [options.device.timezone] - User timezone (e.g., 'America/Los_Angeles')
592
+ * @param {string} [options.device.screenResolution] - Screen resolution (e.g., '1920x1080')
593
+ * @param {string} [options.device.userAgent] - Full user agent string
594
+ * @param {string} [options.device.ip] - IP address (for geo-location)
595
+ * @param {number} [options.timeout] - Timeout in ms for root span (default: 5 minutes, 0 to disable)
596
+ * @returns {Promise<*>} Result of the function
597
+ */
598
+ async trace(fn, options = {}) {
599
+ const traceId = options.traceId || crypto.randomUUID();
600
+ const spanId = crypto.randomUUID();
601
+ const startTime = Date.now();
602
+ const traceName = options.name || 'trace';
603
+
604
+ this._log(`━━━ TRACE START: ${traceName} ━━━`, { traceId: traceId.slice(0, 8), spanId: spanId.slice(0, 8) });
605
+
606
+ // Store root span data locally (no server call on start)
607
+ const localRootSpanData = {
608
+ spanId,
609
+ name: traceName,
610
+ agentName: this.agentName,
611
+ traceId,
612
+ parentSpanId: null,
613
+ spanKind: SpanKind.AGENT,
614
+ input: options.input,
615
+ sessionId: options.sessionId,
616
+ properties: options.properties || {},
617
+ // User tracking
618
+ endUserId: options.userId,
619
+ endUserProperties: options.userProperties,
620
+ // Device/platform context
621
+ device: options.device,
622
+ startTime,
623
+ };
624
+
625
+ const context = new TraceContext(this, traceId, spanId, this.agentName, 0);
626
+
627
+ // Track active trace with local data
628
+ this.activeTraces.set(traceId, { spanId, startTime, context, localData: localRootSpanData });
629
+
630
+ // Set up timeout for root span
631
+ const timeoutMs = options.timeout ?? DEFAULT_SPAN_TIMEOUT_MS;
632
+ let timeoutId = null;
633
+ let hasEnded = false;
634
+
635
+ if (timeoutMs > 0) {
636
+ timeoutId = setTimeout(() => {
637
+ if (!hasEnded) {
638
+ this._log(`⚠ TRACE TIMEOUT: ${traceName}`, { traceId: traceId.slice(0, 8), timeoutMs });
639
+ // Force end the trace with timeout error
640
+ const endTime = Date.now();
641
+ const duration = endTime - startTime;
642
+ const completeSpanData = {
643
+ ...localRootSpanData,
644
+ error: { type: 'timeout', message: `Trace timed out after ${timeoutMs}ms` },
645
+ timing: { totalDuration: duration },
646
+ startTime: new Date(startTime).toISOString(),
647
+ endTime: new Date(endTime).toISOString(),
648
+ };
649
+ this.foil.endSpan(completeSpanData).catch(() => {});
650
+ this.activeTraces.delete(traceId);
651
+ hasEnded = true;
652
+ }
653
+ }, timeoutMs);
654
+ }
655
+
656
+ try {
657
+ const result = await fn(context);
658
+
659
+ if (hasEnded) return result; // Already timed out
660
+ hasEnded = true;
661
+ if (timeoutId) clearTimeout(timeoutId);
662
+
663
+ const endTime = Date.now();
664
+ const duration = endTime - startTime;
665
+
666
+ this._log(`━━━ TRACE END: ${traceName} ━━━`, { traceId: traceId.slice(0, 8), duration: `${duration}ms` });
667
+
668
+ // Send complete span data to server
669
+ const completeSpanData = {
670
+ ...localRootSpanData,
671
+ output: result,
672
+ timing: { totalDuration: duration },
673
+ startTime: new Date(startTime).toISOString(),
674
+ endTime: new Date(endTime).toISOString(),
675
+ };
676
+
677
+ await this.foil.endSpan(completeSpanData).catch((err) => {
678
+ console.error('Foil trace end failed:', err.message);
679
+ });
680
+
681
+ this.activeTraces.delete(traceId);
682
+ return result;
683
+ } catch (error) {
684
+ if (hasEnded) throw error; // Already timed out
685
+ hasEnded = true;
686
+ if (timeoutId) clearTimeout(timeoutId);
687
+
688
+ const endTime = Date.now();
689
+ const duration = endTime - startTime;
690
+
691
+ this._log(`━━━ TRACE ERROR: ${traceName} ━━━`, { traceId: traceId.slice(0, 8), duration: `${duration}ms`, error: error.message });
692
+
693
+ // Send complete span data to server with error
694
+ const completeSpanData = {
695
+ ...localRootSpanData,
696
+ error: { type: error.name, message: error.message },
697
+ timing: { totalDuration: duration },
698
+ startTime: new Date(startTime).toISOString(),
699
+ endTime: new Date(endTime).toISOString(),
700
+ };
701
+
702
+ await this.foil.endSpan(completeSpanData).catch((err) => {
703
+ console.error('Foil trace end (error) failed:', err.message);
704
+ });
705
+
706
+ this.activeTraces.delete(traceId);
707
+ throw error;
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Get the trace data for a completed trace
713
+ * @param {string} traceId - The trace ID
714
+ * @returns {Promise<Object>} Trace data with all spans
715
+ */
716
+ async getTrace(traceId) {
717
+ return this.foil.getTrace(traceId);
718
+ }
719
+
720
+ /**
721
+ * Create an OpenAI client wrapper with automatic tracing
722
+ * @param {Object} openai - OpenAI client instance
723
+ * @param {Object} [options] - Wrapper options
724
+ * @returns {Object} Wrapped OpenAI client with tracing
725
+ */
726
+ wrapOpenAI(openai, options = {}) {
727
+ const tracer = this;
728
+ const originalCreate = openai.chat.completions.create.bind(openai.chat.completions);
729
+
730
+ openai.chat.completions.create = async function (params, reqOptions) {
731
+ // If we're in a trace context, create a child span
732
+ const context = options.context;
733
+ let span = null;
734
+
735
+ // Extract system prompt from messages
736
+ const systemMessage = params.messages?.find(m => m.role === 'system');
737
+ const systemPrompt = systemMessage?.content;
738
+
739
+ if (context) {
740
+ span = await context.startSpan(SpanKind.LLM, params.model || 'openai', {
741
+ input: params.messages,
742
+ properties: { tools: params.tools?.map((t) => t.function?.name) },
743
+ // LLM Configuration for replay/debugging
744
+ llmConfig: {
745
+ temperature: params.temperature,
746
+ maxTokens: params.max_tokens,
747
+ topP: params.top_p,
748
+ frequencyPenalty: params.frequency_penalty,
749
+ presencePenalty: params.presence_penalty,
750
+ systemPrompt,
751
+ tools: params.tools,
752
+ responseFormat: params.response_format,
753
+ stopSequences: params.stop,
754
+ seed: params.seed,
755
+ },
756
+ });
757
+ }
758
+
759
+ const startTime = Date.now();
760
+ let ttft = null;
761
+
762
+ try {
763
+ const response = await originalCreate(params, reqOptions);
764
+
765
+ // Handle streaming
766
+ if (params.stream) {
767
+ const originalIterator = response[Symbol.asyncIterator].bind(response);
768
+ let firstChunk = true;
769
+ let collectedContent = '';
770
+ let collectedToolCalls = [];
771
+ let usage = null;
772
+
773
+ const wrappedIterator = async function* () {
774
+ for await (const chunk of originalIterator()) {
775
+ if (firstChunk) {
776
+ ttft = Date.now() - startTime;
777
+ firstChunk = false;
778
+ }
779
+
780
+ const delta = chunk.choices?.[0]?.delta;
781
+ if (delta?.content) {
782
+ collectedContent += delta.content;
783
+ }
784
+
785
+ if (delta?.tool_calls) {
786
+ for (const tc of delta.tool_calls) {
787
+ if (!collectedToolCalls[tc.index]) {
788
+ collectedToolCalls[tc.index] = {
789
+ id: tc.id,
790
+ type: tc.type,
791
+ function: { name: '', arguments: '' },
792
+ };
793
+ }
794
+ if (tc.id) collectedToolCalls[tc.index].id = tc.id;
795
+ if (tc.function?.name) collectedToolCalls[tc.index].function.name += tc.function.name;
796
+ if (tc.function?.arguments) collectedToolCalls[tc.index].function.arguments += tc.function.arguments;
797
+ }
798
+ }
799
+
800
+ if (chunk.usage) {
801
+ usage = chunk.usage;
802
+ }
803
+
804
+ yield chunk;
805
+ }
806
+
807
+ // After stream completes, end the span
808
+ if (span) {
809
+ await span.end({
810
+ output: collectedContent || collectedToolCalls,
811
+ ttft,
812
+ tokens: usage
813
+ ? {
814
+ prompt: usage.prompt_tokens,
815
+ completion: usage.completion_tokens,
816
+ total: usage.total_tokens,
817
+ }
818
+ : undefined,
819
+ });
820
+ }
821
+ };
822
+
823
+ response[Symbol.asyncIterator] = wrappedIterator;
824
+ return response;
825
+ }
826
+
827
+ // Non-streaming response
828
+ const endTime = Date.now();
829
+ const choice = response.choices?.[0];
830
+ const output = choice?.message?.content;
831
+ const toolCalls = choice?.message?.tool_calls;
832
+
833
+ if (span) {
834
+ await span.end({
835
+ output: output || toolCalls,
836
+ ttft: endTime - startTime,
837
+ tokens: response.usage
838
+ ? {
839
+ prompt: response.usage.prompt_tokens,
840
+ completion: response.usage.completion_tokens,
841
+ total: response.usage.total_tokens,
842
+ }
843
+ : undefined,
844
+ });
845
+ }
846
+
847
+ return response;
848
+ } catch (error) {
849
+ if (span) {
850
+ await span.end({ error: { type: error.name, message: error.message } });
851
+ }
852
+ throw error;
853
+ }
854
+ };
855
+
856
+ return openai;
857
+ }
858
+
859
+ /**
860
+ * Create a tool executor that automatically traces tool calls
861
+ * @param {Object} tools - Map of tool name to function
862
+ * @param {TraceContext} context - Current trace context
863
+ * @returns {Function} Tool executor function
864
+ */
865
+ createToolExecutor(tools, context) {
866
+ return async (toolCall) => {
867
+ const toolName = toolCall.function?.name || toolCall.name;
868
+ const toolFn = tools[toolName];
869
+
870
+ if (!toolFn) {
871
+ throw new Error(`Tool not found: ${toolName}`);
872
+ }
873
+
874
+ let args = toolCall.function?.arguments || toolCall.arguments;
875
+ if (typeof args === 'string') {
876
+ try {
877
+ args = JSON.parse(args);
878
+ } catch {
879
+ // Keep as string if not valid JSON
880
+ }
881
+ }
882
+
883
+ return context.tool(toolName, async () => {
884
+ return toolFn(args);
885
+ }, { input: args });
886
+ };
887
+ }
888
+ }
889
+
890
+ /**
891
+ * Create LangChain callback handler for automatic tracing
892
+ * @param {FoilTracer} tracer - Foil tracer instance
893
+ * @param {Object} [options] - Callback options
894
+ * @returns {Object} LangChain callback handler
895
+ */
896
+ function createLangChainCallback(tracer, options = {}) {
897
+ const traceId = options.traceId || crypto.randomUUID();
898
+ // Store complete span data locally, keyed by runId
899
+ const spanDataMap = new Map();
900
+
901
+ return {
902
+ name: 'FoilTracingCallback',
903
+
904
+ async handleLLMStart(llm, prompts, runId, parentRunId, extraParams) {
905
+ const spanId = crypto.randomUUID();
906
+ const startTime = Date.now();
907
+
908
+ // Find parent span from stack
909
+ let parentSpanId = null;
910
+ for (const [, data] of spanDataMap) {
911
+ if (data.runId === parentRunId) {
912
+ parentSpanId = data.spanId;
913
+ break;
914
+ }
915
+ }
916
+
917
+ // Extract LLM config from extraParams or llm object
918
+ const invocationParams = extraParams?.invocation_params || {};
919
+ const llmConfig = {
920
+ temperature: invocationParams.temperature ?? llm.temperature,
921
+ maxTokens: invocationParams.max_tokens ?? llm.maxTokens,
922
+ topP: invocationParams.top_p ?? llm.topP,
923
+ frequencyPenalty: invocationParams.frequency_penalty,
924
+ presencePenalty: invocationParams.presence_penalty,
925
+ tools: invocationParams.tools || invocationParams.functions,
926
+ responseFormat: invocationParams.response_format,
927
+ stopSequences: invocationParams.stop,
928
+ seed: invocationParams.seed,
929
+ };
930
+
931
+ // Store complete span data locally
932
+ spanDataMap.set(runId, {
933
+ spanId,
934
+ runId,
935
+ startTime,
936
+ name: llm.name || 'unknown',
937
+ agentName: tracer.agentName,
938
+ traceId,
939
+ parentSpanId,
940
+ spanKind: SpanKind.LLM,
941
+ model: llm.name,
942
+ input: prompts,
943
+ llmConfig,
944
+ });
945
+ },
946
+
947
+ async handleLLMEnd(output, runId) {
948
+ const spanData = spanDataMap.get(runId);
949
+ if (!spanData) return;
950
+
951
+ const endTime = Date.now();
952
+ const completeSpanData = {
953
+ ...spanData,
954
+ output: output.generations?.[0]?.[0]?.text,
955
+ timing: { totalDuration: endTime - spanData.startTime },
956
+ tokens: output.llmOutput?.tokenUsage,
957
+ startTime: new Date(spanData.startTime).toISOString(),
958
+ endTime: new Date(endTime).toISOString(),
959
+ };
960
+
961
+ await tracer.foil.endSpan(completeSpanData).catch(() => {});
962
+ spanDataMap.delete(runId);
963
+ },
964
+
965
+ async handleLLMError(err, runId) {
966
+ const spanData = spanDataMap.get(runId);
967
+ if (!spanData) return;
968
+
969
+ const endTime = Date.now();
970
+ const completeSpanData = {
971
+ ...spanData,
972
+ error: { type: err.name, message: err.message },
973
+ timing: { totalDuration: endTime - spanData.startTime },
974
+ startTime: new Date(spanData.startTime).toISOString(),
975
+ endTime: new Date(endTime).toISOString(),
976
+ };
977
+
978
+ await tracer.foil.endSpan(completeSpanData).catch(() => {});
979
+ spanDataMap.delete(runId);
980
+ },
981
+
982
+ async handleToolStart(tool, input, runId, parentRunId) {
983
+ const spanId = crypto.randomUUID();
984
+ const startTime = Date.now();
985
+
986
+ // Find parent span
987
+ let parentSpanId = null;
988
+ for (const [, data] of spanDataMap) {
989
+ if (data.runId === parentRunId) {
990
+ parentSpanId = data.spanId;
991
+ break;
992
+ }
993
+ }
994
+
995
+ spanDataMap.set(runId, {
996
+ spanId,
997
+ runId,
998
+ startTime,
999
+ name: tool.name || 'unknown',
1000
+ agentName: tracer.agentName,
1001
+ traceId,
1002
+ parentSpanId,
1003
+ spanKind: SpanKind.TOOL,
1004
+ input,
1005
+ });
1006
+ },
1007
+
1008
+ async handleToolEnd(output, runId) {
1009
+ const spanData = spanDataMap.get(runId);
1010
+ if (!spanData) return;
1011
+
1012
+ const endTime = Date.now();
1013
+ const completeSpanData = {
1014
+ ...spanData,
1015
+ output,
1016
+ timing: { totalDuration: endTime - spanData.startTime },
1017
+ startTime: new Date(spanData.startTime).toISOString(),
1018
+ endTime: new Date(endTime).toISOString(),
1019
+ };
1020
+
1021
+ await tracer.foil.endSpan(completeSpanData).catch(() => {});
1022
+ spanDataMap.delete(runId);
1023
+ },
1024
+
1025
+ async handleToolError(err, runId) {
1026
+ const spanData = spanDataMap.get(runId);
1027
+ if (!spanData) return;
1028
+
1029
+ const endTime = Date.now();
1030
+ const completeSpanData = {
1031
+ ...spanData,
1032
+ error: { type: err.name, message: err.message },
1033
+ timing: { totalDuration: endTime - spanData.startTime },
1034
+ startTime: new Date(spanData.startTime).toISOString(),
1035
+ endTime: new Date(endTime).toISOString(),
1036
+ };
1037
+
1038
+ await tracer.foil.endSpan(completeSpanData).catch(() => {});
1039
+ spanDataMap.delete(runId);
1040
+ },
1041
+
1042
+ async handleChainStart(chain, inputs, runId, parentRunId) {
1043
+ const spanId = crypto.randomUUID();
1044
+ const startTime = Date.now();
1045
+
1046
+ // Find parent span
1047
+ let parentSpanId = null;
1048
+ for (const [, data] of spanDataMap) {
1049
+ if (data.runId === parentRunId) {
1050
+ parentSpanId = data.spanId;
1051
+ break;
1052
+ }
1053
+ }
1054
+
1055
+ spanDataMap.set(runId, {
1056
+ spanId,
1057
+ runId,
1058
+ startTime,
1059
+ name: chain.name || 'unknown',
1060
+ agentName: tracer.agentName,
1061
+ traceId,
1062
+ parentSpanId,
1063
+ spanKind: SpanKind.CHAIN,
1064
+ input: inputs,
1065
+ });
1066
+ },
1067
+
1068
+ async handleChainEnd(outputs, runId) {
1069
+ const spanData = spanDataMap.get(runId);
1070
+ if (!spanData) return;
1071
+
1072
+ const endTime = Date.now();
1073
+ const completeSpanData = {
1074
+ ...spanData,
1075
+ output: outputs,
1076
+ timing: { totalDuration: endTime - spanData.startTime },
1077
+ startTime: new Date(spanData.startTime).toISOString(),
1078
+ endTime: new Date(endTime).toISOString(),
1079
+ };
1080
+
1081
+ await tracer.foil.endSpan(completeSpanData).catch(() => {});
1082
+ spanDataMap.delete(runId);
1083
+ },
1084
+
1085
+ async handleChainError(err, runId) {
1086
+ const spanData = spanDataMap.get(runId);
1087
+ if (!spanData) return;
1088
+
1089
+ const endTime = Date.now();
1090
+ const completeSpanData = {
1091
+ ...spanData,
1092
+ error: { type: err.name, message: err.message },
1093
+ timing: { totalDuration: endTime - spanData.startTime },
1094
+ startTime: new Date(spanData.startTime).toISOString(),
1095
+ endTime: new Date(endTime).toISOString(),
1096
+ };
1097
+
1098
+ await tracer.foil.endSpan(completeSpanData).catch(() => {});
1099
+ spanDataMap.delete(runId);
1100
+ },
1101
+
1102
+ async handleRetrieverStart(retriever, query, runId, parentRunId) {
1103
+ const spanId = crypto.randomUUID();
1104
+ const startTime = Date.now();
1105
+
1106
+ // Find parent span
1107
+ let parentSpanId = null;
1108
+ for (const [, data] of spanDataMap) {
1109
+ if (data.runId === parentRunId) {
1110
+ parentSpanId = data.spanId;
1111
+ break;
1112
+ }
1113
+ }
1114
+
1115
+ spanDataMap.set(runId, {
1116
+ spanId,
1117
+ runId,
1118
+ startTime,
1119
+ name: retriever.name || 'unknown',
1120
+ agentName: tracer.agentName,
1121
+ traceId,
1122
+ parentSpanId,
1123
+ spanKind: SpanKind.RETRIEVER,
1124
+ input: query,
1125
+ });
1126
+ },
1127
+
1128
+ async handleRetrieverEnd(documents, runId) {
1129
+ const spanData = spanDataMap.get(runId);
1130
+ if (!spanData) return;
1131
+
1132
+ const endTime = Date.now();
1133
+ const completeSpanData = {
1134
+ ...spanData,
1135
+ output: documents,
1136
+ timing: { totalDuration: endTime - spanData.startTime },
1137
+ startTime: new Date(spanData.startTime).toISOString(),
1138
+ endTime: new Date(endTime).toISOString(),
1139
+ };
1140
+
1141
+ await tracer.foil.endSpan(completeSpanData).catch(() => {});
1142
+ spanDataMap.delete(runId);
1143
+ },
1144
+
1145
+ async handleRetrieverError(err, runId) {
1146
+ const spanData = spanDataMap.get(runId);
1147
+ if (!spanData) return;
1148
+
1149
+ const endTime = Date.now();
1150
+ const completeSpanData = {
1151
+ ...spanData,
1152
+ error: { type: err.name, message: err.message },
1153
+ timing: { totalDuration: endTime - spanData.startTime },
1154
+ startTime: new Date(spanData.startTime).toISOString(),
1155
+ endTime: new Date(endTime).toISOString(),
1156
+ };
1157
+
1158
+ await tracer.foil.endSpan(completeSpanData).catch(() => {});
1159
+ spanDataMap.delete(runId);
1160
+ },
1161
+ };
1162
+ }
1163
+
1164
+ /**
1165
+ * Create Vercel AI SDK callbacks for automatic tracing
1166
+ * @param {FoilTracer} tracer - Foil tracer instance
1167
+ * @param {Object} [options] - Callback options
1168
+ * @returns {Object} Vercel AI SDK callbacks
1169
+ */
1170
+ function createVercelAICallbacks(tracer, options = {}) {
1171
+ const traceId = options.traceId || crypto.randomUUID();
1172
+ // Store complete span data locally
1173
+ let currentSpanData = null;
1174
+ let toolSpanDataMap = new Map();
1175
+
1176
+ return {
1177
+ onStart: async ({ messages, model, ...params }) => {
1178
+ const spanId = crypto.randomUUID();
1179
+ const startTime = Date.now();
1180
+
1181
+ // Extract system prompt from messages
1182
+ const systemMessage = messages?.find(m => m.role === 'system');
1183
+ const systemPrompt = systemMessage?.content;
1184
+
1185
+ // Store complete span data locally (no server call)
1186
+ currentSpanData = {
1187
+ spanId,
1188
+ startTime,
1189
+ name: model || 'unknown',
1190
+ agentName: tracer.agentName,
1191
+ traceId,
1192
+ parentSpanId: options.parentSpanId,
1193
+ spanKind: SpanKind.LLM,
1194
+ model,
1195
+ input: messages,
1196
+ // LLM Configuration for replay/debugging
1197
+ llmConfig: {
1198
+ temperature: params.temperature,
1199
+ maxTokens: params.maxTokens || params.max_tokens,
1200
+ topP: params.topP || params.top_p,
1201
+ frequencyPenalty: params.frequencyPenalty || params.frequency_penalty,
1202
+ presencePenalty: params.presencePenalty || params.presence_penalty,
1203
+ systemPrompt,
1204
+ tools: params.tools,
1205
+ responseFormat: params.responseFormat || params.response_format,
1206
+ stopSequences: params.stop || params.stopSequences,
1207
+ seed: params.seed,
1208
+ },
1209
+ };
1210
+ },
1211
+
1212
+ onToken: async (token) => {
1213
+ // First token received - could track TTFT here
1214
+ },
1215
+
1216
+ onToolCall: async ({ toolCall }) => {
1217
+ const spanId = crypto.randomUUID();
1218
+ const startTime = Date.now();
1219
+
1220
+ // Store complete tool span data locally
1221
+ toolSpanDataMap.set(toolCall.toolCallId, {
1222
+ spanId,
1223
+ startTime,
1224
+ name: toolCall.toolName,
1225
+ agentName: tracer.agentName,
1226
+ traceId,
1227
+ parentSpanId: currentSpanData?.spanId,
1228
+ spanKind: SpanKind.TOOL,
1229
+ input: toolCall.args,
1230
+ });
1231
+ },
1232
+
1233
+ onToolResult: async ({ toolCallId, result }) => {
1234
+ const toolSpanData = toolSpanDataMap.get(toolCallId);
1235
+ if (!toolSpanData) return;
1236
+
1237
+ const endTime = Date.now();
1238
+ const completeSpanData = {
1239
+ ...toolSpanData,
1240
+ output: result,
1241
+ timing: { totalDuration: endTime - toolSpanData.startTime },
1242
+ startTime: new Date(toolSpanData.startTime).toISOString(),
1243
+ endTime: new Date(endTime).toISOString(),
1244
+ };
1245
+
1246
+ await tracer.foil.endSpan(completeSpanData).catch(() => {});
1247
+ toolSpanDataMap.delete(toolCallId);
1248
+ },
1249
+
1250
+ onFinish: async ({ text, toolCalls, usage, finishReason }) => {
1251
+ if (!currentSpanData) return;
1252
+
1253
+ const endTime = Date.now();
1254
+ const completeSpanData = {
1255
+ ...currentSpanData,
1256
+ output: text || toolCalls,
1257
+ timing: { totalDuration: endTime - currentSpanData.startTime },
1258
+ tokens: usage
1259
+ ? {
1260
+ prompt: usage.promptTokens,
1261
+ completion: usage.completionTokens,
1262
+ total: usage.totalTokens,
1263
+ }
1264
+ : undefined,
1265
+ startTime: new Date(currentSpanData.startTime).toISOString(),
1266
+ endTime: new Date(endTime).toISOString(),
1267
+ };
1268
+
1269
+ await tracer.foil.endSpan(completeSpanData).catch(() => {});
1270
+ currentSpanData = null;
1271
+ },
1272
+
1273
+ onError: async (error) => {
1274
+ if (!currentSpanData) return;
1275
+
1276
+ const endTime = Date.now();
1277
+ const completeSpanData = {
1278
+ ...currentSpanData,
1279
+ error: { type: error.name, message: error.message },
1280
+ timing: { totalDuration: endTime - currentSpanData.startTime },
1281
+ startTime: new Date(currentSpanData.startTime).toISOString(),
1282
+ endTime: new Date(endTime).toISOString(),
1283
+ };
1284
+
1285
+ await tracer.foil.endSpan(completeSpanData).catch(() => {});
1286
+ currentSpanData = null;
1287
+ },
1288
+ };
1289
+ }
1290
+
1291
+ /**
1292
+ * Factory function to create a FoilTracer
1293
+ * @param {Object} options - Tracer options
1294
+ * @param {string} options.apiKey - Foil API key
1295
+ * @param {string} options.agentName - Name of the agent
1296
+ * @param {string} [options.baseUrl] - Foil API base URL
1297
+ * @param {string} [options.defaultModel] - Default model name
1298
+ * @param {boolean} [options.debug] - Enable debug logging (also enabled via FOIL_DEBUG=true env var)
1299
+ * @returns {FoilTracer} Configured tracer instance
1300
+ */
1301
+ function createFoilTracer(options = {}) {
1302
+ if (!options.apiKey) {
1303
+ throw new Error('apiKey is required');
1304
+ }
1305
+ if (!options.agentName) {
1306
+ throw new Error('agentName is required');
1307
+ }
1308
+
1309
+ const foil = new Foil({
1310
+ apiKey: options.apiKey,
1311
+ baseUrl: options.baseUrl,
1312
+ });
1313
+
1314
+ return new FoilTracer(foil, {
1315
+ agentName: options.agentName,
1316
+ defaultModel: options.defaultModel,
1317
+ debug: options.debug,
1318
+ });
1319
+ }
1320
+
1321
+ class Foil {
1322
+ constructor(options = {}) {
1323
+ this.apiKey = options.apiKey;
1324
+ this.baseUrl = options.baseUrl || 'https://api.getfoil.ai/api';
1325
+ }
1326
+
1327
+ /**
1328
+ * Generate a unique trace ID
1329
+ * @returns {string} A unique trace ID
1330
+ */
1331
+ createTraceId() {
1332
+ return crypto.randomUUID();
1333
+ }
1334
+
1335
+ /**
1336
+ * Generate a unique span ID
1337
+ * @returns {string} A unique span ID
1338
+ */
1339
+ createSpanId() {
1340
+ return crypto.randomUUID();
1341
+ }
1342
+
1343
+ /**
1344
+ * Start a span (signals the beginning of an operation)
1345
+ * @param {Object} data - Span data
1346
+ * @param {string} data.spanId - Unique identifier for this span
1347
+ * @param {string} data.name - Name of the span (model name, tool name, etc.)
1348
+ * @param {string} data.agentName - Name of the agent (must be unique per user)
1349
+ * @param {string} [data.input] - Input to the operation
1350
+ * @param {string} [data.model] - Model name/identifier (for LLM spans)
1351
+ * @param {string} [data.sessionId] - Session/conversation identifier
1352
+ * @param {Object} [data.properties] - Additional custom properties
1353
+ * @param {string} [data.traceId] - Trace ID for grouping related spans
1354
+ * @param {string} [data.parentSpanId] - Parent span ID for nested spans
1355
+ * @param {string} [data.spanKind] - Span kind ('agent', 'llm', 'tool', 'chain', 'retriever', 'embedding', 'custom')
1356
+ * @returns {Promise<Object>} The created span object
1357
+ */
1358
+ async startSpan(data) {
1359
+ if (!data.agentName) {
1360
+ throw new Error('agentName is required');
1361
+ }
1362
+ try {
1363
+ const response = await axios.post(`${this.baseUrl}/traces/spans/start`, data, {
1364
+ headers: {
1365
+ Authorization: this.apiKey,
1366
+ },
1367
+ });
1368
+ return response.data;
1369
+ } catch (error) {
1370
+ const errDetails = error.response?.data || error.message;
1371
+ console.error('Foil Start Span Failed:', JSON.stringify(errDetails));
1372
+ throw error;
1373
+ }
1374
+ }
1375
+
1376
+ /**
1377
+ * End a span (signals the completion of an operation)
1378
+ * @param {Object} data - End span data
1379
+ * @param {string} data.spanId - The spanId of the span to end
1380
+ * @param {string} [data.agentName] - Agent name (fallback if start span not yet processed)
1381
+ * @param {*} [data.output] - Output from the operation
1382
+ * @param {Object} [data.tokens] - Token usage {prompt, completion, total}
1383
+ * @param {Object} [data.timing] - Timing metrics {ttft, totalDuration}
1384
+ * @param {Object} [data.error] - Error if operation failed {type, message}
1385
+ * @returns {Promise<Object>} The updated span object
1386
+ */
1387
+ async endSpan(data) {
1388
+ try {
1389
+ const response = await axios.post(`${this.baseUrl}/traces/spans/end`, data, {
1390
+ headers: {
1391
+ Authorization: this.apiKey,
1392
+ },
1393
+ });
1394
+ return response.data;
1395
+ } catch (error) {
1396
+ console.error('Foil End Span Failed:', error.message);
1397
+ throw error;
1398
+ }
1399
+ }
1400
+
1401
+ /**
1402
+ * Get a trace with all its spans
1403
+ * @param {string} traceId - The trace ID
1404
+ * @returns {Promise<Object>} The trace data with spans and tree structure
1405
+ */
1406
+ async getTrace(traceId) {
1407
+ try {
1408
+ const response = await axios.get(
1409
+ `${this.baseUrl}/traces/${traceId}`,
1410
+ {
1411
+ headers: {
1412
+ Authorization: this.apiKey,
1413
+ },
1414
+ }
1415
+ );
1416
+ return response.data;
1417
+ } catch (error) {
1418
+ console.error('Foil Get Trace Failed:', error.message);
1419
+ throw error;
1420
+ }
1421
+ }
1422
+
1423
+ /**
1424
+ * List traces with optional filtering
1425
+ * @param {Object} [options] - Query options
1426
+ * @param {number} [options.page] - Page number
1427
+ * @param {number} [options.limit] - Results per page
1428
+ * @param {string} [options.agentName] - Filter by agent name
1429
+ * @param {string} [options.status] - Filter by status
1430
+ * @param {string} [options.from] - Start date (ISO string)
1431
+ * @param {string} [options.to] - End date (ISO string)
1432
+ * @returns {Promise<Object>} Paginated trace results
1433
+ */
1434
+ async listTraces(options = {}) {
1435
+ try {
1436
+ const response = await axios.get(
1437
+ `${this.baseUrl}/traces`,
1438
+ {
1439
+ params: options,
1440
+ headers: {
1441
+ Authorization: this.apiKey,
1442
+ },
1443
+ }
1444
+ );
1445
+ return response.data;
1446
+ } catch (error) {
1447
+ console.error('Foil List Traces Failed:', error.message);
1448
+ throw error;
1449
+ }
1450
+ }
1451
+
1452
+ /**
1453
+ * Record a signal (user feedback, LLM analysis, etc.)
1454
+ * @param {Object} data - Signal data
1455
+ * @param {string} data.signalName - Name of the signal (e.g., 'user_thumbs', 'sentiment')
1456
+ * @param {boolean|number|string} data.value - Signal value
1457
+ * @param {string} [data.traceId] - Associated trace ID
1458
+ * @param {string} [data.spanId] - Associated span ID
1459
+ * @param {string} [data.sessionId] - Associated session ID
1460
+ * @param {string} [data.agentId] - Agent ID
1461
+ * @param {string} [data.agentName] - Agent name
1462
+ * @param {string} [data.signalType] - Type (feedback, sentiment, quality, completion, safety, effort, engagement, custom)
1463
+ * @param {string} [data.source] - Source (user, llm, system)
1464
+ * @param {string} [data.valueType] - Explicit value type (boolean, number, category, text)
1465
+ * @param {Object} [data.metadata] - Additional metadata
1466
+ * @param {number} [data.confidence] - Confidence score 0-1 (for LLM signals)
1467
+ * @param {string} [data.reasoning] - LLM reasoning (for LLM signals)
1468
+ * @param {string} [data.modelUsed] - Model used (for LLM signals)
1469
+ * @returns {Promise<Object>} The recorded signal
1470
+ */
1471
+ async recordSignal(data) {
1472
+ try {
1473
+ const response = await axios.post(
1474
+ `${this.baseUrl}/signals`,
1475
+ data,
1476
+ {
1477
+ headers: {
1478
+ Authorization: this.apiKey,
1479
+ },
1480
+ }
1481
+ );
1482
+ return response.data;
1483
+ } catch (error) {
1484
+ console.error('Foil Record Signal Failed:', error.message);
1485
+ throw error;
1486
+ }
1487
+ }
1488
+
1489
+ /**
1490
+ * Record multiple signals in batch
1491
+ * @param {Array<Object>} signals - Array of signal objects (same format as recordSignal)
1492
+ * @returns {Promise<Object>} The recorded signals
1493
+ */
1494
+ async recordSignalBatch(signals) {
1495
+ try {
1496
+ const response = await axios.post(
1497
+ `${this.baseUrl}/signals/batch`,
1498
+ { signals },
1499
+ {
1500
+ headers: {
1501
+ Authorization: this.apiKey,
1502
+ },
1503
+ }
1504
+ );
1505
+ return response.data;
1506
+ } catch (error) {
1507
+ console.error('Foil Record Signal Batch Failed:', error.message);
1508
+ throw error;
1509
+ }
1510
+ }
1511
+
1512
+ /**
1513
+ * Get signals for a trace
1514
+ * @param {string} traceId - The trace ID
1515
+ * @returns {Promise<Object>} Signals associated with the trace
1516
+ */
1517
+ async getTraceSignals(traceId) {
1518
+ try {
1519
+ const response = await axios.get(
1520
+ `${this.baseUrl}/signals/trace/${traceId}`,
1521
+ {
1522
+ headers: {
1523
+ Authorization: this.apiKey,
1524
+ },
1525
+ }
1526
+ );
1527
+ return response.data;
1528
+ } catch (error) {
1529
+ console.error('Foil Get Trace Signals Failed:', error.message);
1530
+ throw error;
1531
+ }
1532
+ }
1533
+
1534
+ /**
1535
+ * Get variant assignment for an experiment
1536
+ * Uses deterministic hashing so the same identifier always gets the same variant
1537
+ * @param {string} experimentId - The experiment ID
1538
+ * @param {string} identifier - Unique identifier (userId, sessionId, etc.) for consistent assignment
1539
+ * @returns {Promise<Object>} Assignment result with variant details
1540
+ * @returns {boolean} result.inExperiment - Whether the identifier is in the experiment
1541
+ * @returns {string} [result.experimentId] - The experiment ID
1542
+ * @returns {string} [result.experimentName] - The experiment name
1543
+ * @returns {string} [result.variantId] - Assigned variant ID
1544
+ * @returns {string} [result.variantName] - Assigned variant name
1545
+ * @returns {Object} [result.config] - Variant configuration (model, systemPrompt, temperature, etc.)
1546
+ * @returns {string} [result.message] - Message if excluded from experiment
1547
+ */
1548
+ async getExperimentVariant(experimentId, identifier) {
1549
+ if (!experimentId) {
1550
+ throw new Error('experimentId is required');
1551
+ }
1552
+ if (!identifier) {
1553
+ throw new Error('identifier is required');
1554
+ }
1555
+ try {
1556
+ const response = await axios.get(
1557
+ `${this.baseUrl}/experiments/${experimentId}/assign`,
1558
+ {
1559
+ params: { identifier },
1560
+ headers: {
1561
+ Authorization: this.apiKey,
1562
+ },
1563
+ }
1564
+ );
1565
+ return response.data;
1566
+ } catch (error) {
1567
+ console.error('Foil Get Experiment Variant Failed:', error.message);
1568
+ throw error;
1569
+ }
1570
+ }
1571
+
1572
+ /**
1573
+ * Upload a media file for multimodal spans
1574
+ * @param {string|Buffer|ReadStream} file - File path, Buffer, or ReadStream
1575
+ * @param {Object} [options] - Upload options
1576
+ * @param {string} [options.filename] - Override filename (required for Buffer)
1577
+ * @param {string} [options.mimeType] - Override MIME type
1578
+ * @param {string} [options.spanId] - Associate with a span
1579
+ * @param {string} [options.traceId] - Associate with a trace
1580
+ * @param {string} [options.direction] - 'input' or 'output'
1581
+ * @returns {Promise<Object>} Upload result with mediaId and category
1582
+ */
1583
+ async uploadMedia(file, options = {}) {
1584
+ try {
1585
+ const form = new FormData();
1586
+
1587
+ // Handle different input types
1588
+ if (typeof file === 'string') {
1589
+ // File path
1590
+ const filename = options.filename || path.basename(file);
1591
+ form.append('file', fs.createReadStream(file), {
1592
+ filename,
1593
+ contentType: options.mimeType,
1594
+ });
1595
+ } else if (Buffer.isBuffer(file)) {
1596
+ // Buffer - filename is required
1597
+ if (!options.filename) {
1598
+ throw new Error('filename is required when uploading a Buffer');
1599
+ }
1600
+ form.append('file', file, {
1601
+ filename: options.filename,
1602
+ contentType: options.mimeType,
1603
+ });
1604
+ } else if (file && typeof file.pipe === 'function') {
1605
+ // ReadStream
1606
+ const filename = options.filename || (file.path ? path.basename(file.path) : 'upload');
1607
+ form.append('file', file, {
1608
+ filename,
1609
+ contentType: options.mimeType,
1610
+ });
1611
+ } else {
1612
+ throw new Error('file must be a path string, Buffer, or ReadStream');
1613
+ }
1614
+
1615
+ // Add optional fields
1616
+ if (options.spanId) form.append('spanId', options.spanId);
1617
+ if (options.traceId) form.append('traceId', options.traceId);
1618
+ if (options.direction) form.append('direction', options.direction);
1619
+
1620
+ const response = await axios.post(`${this.baseUrl}/media/upload`, form, {
1621
+ headers: {
1622
+ ...form.getHeaders(),
1623
+ Authorization: this.apiKey,
1624
+ },
1625
+ maxContentLength: Infinity,
1626
+ maxBodyLength: Infinity,
1627
+ });
1628
+
1629
+ return response.data;
1630
+ } catch (error) {
1631
+ const errDetails = error.response?.data || error.message;
1632
+ console.error('Foil Upload Media Failed:', JSON.stringify(errDetails));
1633
+ throw error;
1634
+ }
1635
+ }
1636
+
1637
+ /**
1638
+ * Get media information by ID
1639
+ * @param {string} mediaId - The media ID
1640
+ * @param {Object} [options] - Options
1641
+ * @param {string} [options.content] - Content type to get URL for ('original', 'extracted', 'preview', 'structured')
1642
+ * @returns {Promise<Object>} Media information
1643
+ */
1644
+ async getMedia(mediaId, options = {}) {
1645
+ try {
1646
+ const response = await axios.get(`${this.baseUrl}/media/${mediaId}`, {
1647
+ params: { content: options.content },
1648
+ headers: {
1649
+ Authorization: this.apiKey,
1650
+ },
1651
+ });
1652
+ return response.data;
1653
+ } catch (error) {
1654
+ console.error('Foil Get Media Failed:', error.message);
1655
+ throw error;
1656
+ }
1657
+ }
1658
+
1659
+ /**
1660
+ * Get presigned download URL for media content
1661
+ * @param {string} mediaId - The media ID
1662
+ * @param {string} [content='original'] - Content type ('original', 'extracted', 'preview', 'structured')
1663
+ * @returns {Promise<Object>} Presigned URL info
1664
+ */
1665
+ async getMediaUrl(mediaId, content = 'original') {
1666
+ try {
1667
+ const response = await axios.get(`${this.baseUrl}/media/${mediaId}/url`, {
1668
+ params: { content },
1669
+ headers: {
1670
+ Authorization: this.apiKey,
1671
+ },
1672
+ });
1673
+ return response.data;
1674
+ } catch (error) {
1675
+ console.error('Foil Get Media URL Failed:', error.message);
1676
+ throw error;
1677
+ }
1678
+ }
1679
+
1680
+ /**
1681
+ * Get information for multiple media IDs
1682
+ * @param {string[]} mediaIds - Array of media IDs
1683
+ * @returns {Promise<Object>} Batch media info
1684
+ */
1685
+ async batchMediaInfo(mediaIds) {
1686
+ try {
1687
+ const response = await axios.post(
1688
+ `${this.baseUrl}/media/batch`,
1689
+ { mediaIds },
1690
+ {
1691
+ headers: {
1692
+ Authorization: this.apiKey,
1693
+ },
1694
+ }
1695
+ );
1696
+ return response.data;
1697
+ } catch (error) {
1698
+ console.error('Foil Batch Media Info Failed:', error.message);
1699
+ throw error;
1700
+ }
1701
+ }
1702
+
1703
+ // ============================================
1704
+ // Semantic Search Methods
1705
+ // ============================================
1706
+
1707
+ /**
1708
+ * Search spans using natural language queries
1709
+ * Uses AI embeddings to find semantically similar content
1710
+ * @param {string} query - Natural language search query (e.g., "conversations about refunds")
1711
+ * @param {Object} [options] - Search options
1712
+ * @param {string} [options.agentId] - Filter by specific agent
1713
+ * @param {string} [options.agentName] - Filter by agent name
1714
+ * @param {string} [options.from] - Start date (ISO string)
1715
+ * @param {string} [options.to] - End date (ISO string)
1716
+ * @param {number} [options.limit=20] - Maximum results to return
1717
+ * @param {number} [options.offset=0] - Offset for pagination
1718
+ * @param {number} [options.threshold=0.3] - Minimum similarity threshold (0-1)
1719
+ * @returns {Promise<Object>} Search results with similarity scores
1720
+ * @returns {Array} result.results - Array of matching spans with similarity scores
1721
+ * @returns {Object} result.pagination - Pagination info (total, limit, offset, hasMore)
1722
+ * @returns {Object} result.query - Query info (text, threshold, embedding status)
1723
+ * @example
1724
+ * const results = await foil.semanticSearch('conversations about refunds', {
1725
+ * agentId: 'agent-123',
1726
+ * limit: 10,
1727
+ * threshold: 0.4
1728
+ * });
1729
+ */
1730
+ async semanticSearch(query, options = {}) {
1731
+ if (!query || typeof query !== 'string') {
1732
+ throw new Error('query is required and must be a string');
1733
+ }
1734
+ try {
1735
+ const response = await axios.post(
1736
+ `${this.baseUrl}/semantic-search/query`,
1737
+ {
1738
+ query,
1739
+ filters: {
1740
+ agentId: options.agentId,
1741
+ agentName: options.agentName,
1742
+ from: options.from,
1743
+ to: options.to,
1744
+ },
1745
+ limit: options.limit,
1746
+ offset: options.offset,
1747
+ threshold: options.threshold,
1748
+ },
1749
+ {
1750
+ headers: {
1751
+ Authorization: this.apiKey,
1752
+ },
1753
+ }
1754
+ );
1755
+ return response.data;
1756
+ } catch (error) {
1757
+ console.error('Foil Semantic Search Failed:', error.message);
1758
+ throw error;
1759
+ }
1760
+ }
1761
+
1762
+ /**
1763
+ * Find traces similar to a given trace
1764
+ * Uses the trace's content embeddings to find related conversations
1765
+ * @param {string} traceId - The trace ID to find similar traces for
1766
+ * @param {Object} [options] - Search options
1767
+ * @param {number} [options.limit=10] - Maximum results to return
1768
+ * @param {number} [options.threshold=0.3] - Minimum similarity threshold (0-1)
1769
+ * @returns {Promise<Object>} Similar traces with similarity scores
1770
+ * @returns {Array} result.results - Array of similar traces
1771
+ * @returns {Object} result.sourceTrace - Info about the source trace
1772
+ * @example
1773
+ * const similar = await foil.findSimilarTraces('trace-abc-123', {
1774
+ * limit: 5,
1775
+ * threshold: 0.5
1776
+ * });
1777
+ */
1778
+ async findSimilarTraces(traceId, options = {}) {
1779
+ if (!traceId) {
1780
+ throw new Error('traceId is required');
1781
+ }
1782
+ try {
1783
+ const response = await axios.get(
1784
+ `${this.baseUrl}/semantic-search/similar/${traceId}`,
1785
+ {
1786
+ params: {
1787
+ limit: options.limit,
1788
+ threshold: options.threshold,
1789
+ },
1790
+ headers: {
1791
+ Authorization: this.apiKey,
1792
+ },
1793
+ }
1794
+ );
1795
+ return response.data;
1796
+ } catch (error) {
1797
+ console.error('Foil Find Similar Traces Failed:', error.message);
1798
+ throw error;
1799
+ }
1800
+ }
1801
+
1802
+ /**
1803
+ * Get semantic search status and embedding statistics
1804
+ * Shows how many spans have been embedded and are searchable
1805
+ * @param {string} [agentId] - Optional agent ID to get stats for specific agent
1806
+ * @returns {Promise<Object>} Embedding status and statistics
1807
+ * @returns {number} result.embeddedSpans - Number of spans with embeddings
1808
+ * @returns {number} result.totalSpans - Total eligible spans
1809
+ * @returns {number} result.coveragePercent - Percentage of spans embedded
1810
+ * @returns {boolean} result.ready - Whether semantic search is ready
1811
+ */
1812
+ async getSemanticSearchStatus(agentId) {
1813
+ try {
1814
+ const response = await axios.get(
1815
+ `${this.baseUrl}/semantic-search/status`,
1816
+ {
1817
+ params: agentId ? { agentId } : {},
1818
+ headers: {
1819
+ Authorization: this.apiKey,
1820
+ },
1821
+ }
1822
+ );
1823
+ return response.data;
1824
+ } catch (error) {
1825
+ console.error('Foil Get Semantic Search Status Failed:', error.message);
1826
+ throw error;
1827
+ }
1828
+ }
1829
+
1830
+ // ============================================
1831
+ // Custom Evaluation Methods
1832
+ // ============================================
1833
+
1834
+ /**
1835
+ * Get available evaluation templates
1836
+ * Templates are pre-built evaluation criteria that can be cloned and customized
1837
+ * @returns {Promise<Object>} List of available evaluation templates
1838
+ * @returns {Array} result.templates - Array of template objects
1839
+ * @returns {Array} result.builtin - Built-in evaluation types
1840
+ */
1841
+ async getEvaluationTemplates() {
1842
+ try {
1843
+ const response = await axios.get(
1844
+ `${this.baseUrl}/evaluations/templates`,
1845
+ {
1846
+ headers: {
1847
+ Authorization: this.apiKey,
1848
+ },
1849
+ }
1850
+ );
1851
+ return response.data;
1852
+ } catch (error) {
1853
+ console.error('Foil Get Evaluation Templates Failed:', error.message);
1854
+ throw error;
1855
+ }
1856
+ }
1857
+
1858
+ /**
1859
+ * Get evaluations configured for an agent
1860
+ * @param {string} agentId - The agent ID
1861
+ * @returns {Promise<Object>} List of evaluations for the agent
1862
+ * @returns {Array} result.evaluations - Array of evaluation configurations
1863
+ */
1864
+ async getAgentEvaluations(agentId) {
1865
+ if (!agentId) {
1866
+ throw new Error('agentId is required');
1867
+ }
1868
+ try {
1869
+ const response = await axios.get(
1870
+ `${this.baseUrl}/agents/${agentId}/evaluations`,
1871
+ {
1872
+ headers: {
1873
+ Authorization: this.apiKey,
1874
+ },
1875
+ }
1876
+ );
1877
+ return response.data;
1878
+ } catch (error) {
1879
+ console.error('Foil Get Agent Evaluations Failed:', error.message);
1880
+ throw error;
1881
+ }
1882
+ }
1883
+
1884
+ /**
1885
+ * Get a specific evaluation by ID
1886
+ * @param {string} agentId - The agent ID
1887
+ * @param {string} evaluationId - The evaluation ID
1888
+ * @returns {Promise<Object>} Evaluation details
1889
+ */
1890
+ async getEvaluation(agentId, evaluationId) {
1891
+ if (!agentId) {
1892
+ throw new Error('agentId is required');
1893
+ }
1894
+ if (!evaluationId) {
1895
+ throw new Error('evaluationId is required');
1896
+ }
1897
+ try {
1898
+ const response = await axios.get(
1899
+ `${this.baseUrl}/agents/${agentId}/evaluations/${evaluationId}`,
1900
+ {
1901
+ headers: {
1902
+ Authorization: this.apiKey,
1903
+ },
1904
+ }
1905
+ );
1906
+ return response.data;
1907
+ } catch (error) {
1908
+ console.error('Foil Get Evaluation Failed:', error.message);
1909
+ throw error;
1910
+ }
1911
+ }
1912
+
1913
+ /**
1914
+ * Create a custom evaluation for an agent
1915
+ * Custom evaluations define criteria for analyzing agent responses
1916
+ * @param {string} agentId - The agent ID to create evaluation for
1917
+ * @param {Object} data - Evaluation configuration
1918
+ * @param {string} data.name - Unique name for the evaluation
1919
+ * @param {string} data.description - Description of what this evaluation checks
1920
+ * @param {string} data.prompt - The evaluation prompt/criteria
1921
+ * @param {string} [data.evaluationType='boolean'] - Result type: 'boolean', 'score', 'category'
1922
+ * @param {Array} [data.categories] - Categories if evaluationType is 'category'
1923
+ * @param {number} [data.scoreMin] - Minimum score if evaluationType is 'score'
1924
+ * @param {number} [data.scoreMax] - Maximum score if evaluationType is 'score'
1925
+ * @param {boolean} [data.enabled=true] - Whether evaluation is active
1926
+ * @param {Array} [data.examples] - Example inputs/outputs for few-shot learning
1927
+ * @returns {Promise<Object>} Created evaluation
1928
+ * @example
1929
+ * const evaluation = await foil.createEvaluation('agent-123', {
1930
+ * name: 'tone_check',
1931
+ * description: 'Checks if response maintains professional tone',
1932
+ * prompt: 'Evaluate if the assistant response maintains a professional and helpful tone. Return true if professional, false otherwise.',
1933
+ * evaluationType: 'boolean',
1934
+ * enabled: true
1935
+ * });
1936
+ */
1937
+ async createEvaluation(agentId, data) {
1938
+ if (!agentId) {
1939
+ throw new Error('agentId is required');
1940
+ }
1941
+ if (!data.name) {
1942
+ throw new Error('evaluation name is required');
1943
+ }
1944
+ if (!data.prompt) {
1945
+ throw new Error('evaluation prompt is required');
1946
+ }
1947
+ try {
1948
+ const response = await axios.post(
1949
+ `${this.baseUrl}/agents/${agentId}/evaluations`,
1950
+ data,
1951
+ {
1952
+ headers: {
1953
+ Authorization: this.apiKey,
1954
+ },
1955
+ }
1956
+ );
1957
+ return response.data;
1958
+ } catch (error) {
1959
+ const errDetails = error.response?.data || error.message;
1960
+ console.error('Foil Create Evaluation Failed:', JSON.stringify(errDetails));
1961
+ throw error;
1962
+ }
1963
+ }
1964
+
1965
+ /**
1966
+ * Update an existing evaluation
1967
+ * @param {string} agentId - The agent ID
1968
+ * @param {string} evaluationId - The evaluation ID to update
1969
+ * @param {Object} data - Fields to update
1970
+ * @param {string} [data.name] - New name
1971
+ * @param {string} [data.description] - New description
1972
+ * @param {string} [data.prompt] - New evaluation prompt
1973
+ * @param {boolean} [data.enabled] - Enable/disable the evaluation
1974
+ * @returns {Promise<Object>} Updated evaluation
1975
+ */
1976
+ async updateEvaluation(agentId, evaluationId, data) {
1977
+ if (!agentId) {
1978
+ throw new Error('agentId is required');
1979
+ }
1980
+ if (!evaluationId) {
1981
+ throw new Error('evaluationId is required');
1982
+ }
1983
+ try {
1984
+ const response = await axios.put(
1985
+ `${this.baseUrl}/agents/${agentId}/evaluations/${evaluationId}`,
1986
+ data,
1987
+ {
1988
+ headers: {
1989
+ Authorization: this.apiKey,
1990
+ },
1991
+ }
1992
+ );
1993
+ return response.data;
1994
+ } catch (error) {
1995
+ console.error('Foil Update Evaluation Failed:', error.message);
1996
+ throw error;
1997
+ }
1998
+ }
1999
+
2000
+ /**
2001
+ * Delete an evaluation
2002
+ * @param {string} agentId - The agent ID
2003
+ * @param {string} evaluationId - The evaluation ID to delete
2004
+ * @returns {Promise<Object>} Deletion confirmation
2005
+ */
2006
+ async deleteEvaluation(agentId, evaluationId) {
2007
+ if (!agentId) {
2008
+ throw new Error('agentId is required');
2009
+ }
2010
+ if (!evaluationId) {
2011
+ throw new Error('evaluationId is required');
2012
+ }
2013
+ try {
2014
+ const response = await axios.delete(
2015
+ `${this.baseUrl}/agents/${agentId}/evaluations/${evaluationId}`,
2016
+ {
2017
+ headers: {
2018
+ Authorization: this.apiKey,
2019
+ },
2020
+ }
2021
+ );
2022
+ return response.data;
2023
+ } catch (error) {
2024
+ console.error('Foil Delete Evaluation Failed:', error.message);
2025
+ throw error;
2026
+ }
2027
+ }
2028
+
2029
+ /**
2030
+ * Test an evaluation with sample input/output
2031
+ * Useful for validating evaluation prompts before enabling
2032
+ * @param {string} agentId - The agent ID
2033
+ * @param {string} evaluationId - The evaluation ID to test
2034
+ * @param {Object} data - Test data
2035
+ * @param {string} data.input - Sample user input
2036
+ * @param {string} data.output - Sample assistant output
2037
+ * @returns {Promise<Object>} Test results with evaluation outcome
2038
+ * @returns {boolean|number|string} result.result - Evaluation result
2039
+ * @returns {string} result.reasoning - Explanation for the result
2040
+ */
2041
+ async testEvaluation(agentId, evaluationId, data) {
2042
+ if (!agentId) {
2043
+ throw new Error('agentId is required');
2044
+ }
2045
+ if (!evaluationId) {
2046
+ throw new Error('evaluationId is required');
2047
+ }
2048
+ try {
2049
+ const response = await axios.post(
2050
+ `${this.baseUrl}/agents/${agentId}/evaluations/${evaluationId}/test`,
2051
+ data,
2052
+ {
2053
+ headers: {
2054
+ Authorization: this.apiKey,
2055
+ },
2056
+ }
2057
+ );
2058
+ return response.data;
2059
+ } catch (error) {
2060
+ console.error('Foil Test Evaluation Failed:', error.message);
2061
+ throw error;
2062
+ }
2063
+ }
2064
+
2065
+ /**
2066
+ * Clone an evaluation template to an agent
2067
+ * Creates a customizable copy of a pre-built evaluation template
2068
+ * @param {string} agentId - The agent ID to clone the template to
2069
+ * @param {string} templateId - The template ID to clone
2070
+ * @returns {Promise<Object>} Cloned evaluation
2071
+ */
2072
+ async cloneEvaluationTemplate(agentId, templateId) {
2073
+ if (!agentId) {
2074
+ throw new Error('agentId is required');
2075
+ }
2076
+ if (!templateId) {
2077
+ throw new Error('templateId is required');
2078
+ }
2079
+ try {
2080
+ const response = await axios.post(
2081
+ `${this.baseUrl}/agents/${agentId}/evaluations/templates/${templateId}/clone`,
2082
+ {},
2083
+ {
2084
+ headers: {
2085
+ Authorization: this.apiKey,
2086
+ },
2087
+ }
2088
+ );
2089
+ return response.data;
2090
+ } catch (error) {
2091
+ console.error('Foil Clone Evaluation Template Failed:', error.message);
2092
+ throw error;
2093
+ }
2094
+ }
2095
+
2096
+ /**
2097
+ * Add an example to an evaluation for few-shot learning
2098
+ * Examples help improve evaluation accuracy
2099
+ * @param {string} agentId - The agent ID
2100
+ * @param {string} evaluationId - The evaluation ID
2101
+ * @param {Object} data - Example data
2102
+ * @param {string} data.input - Example user input
2103
+ * @param {string} data.output - Example assistant output
2104
+ * @param {boolean|number|string} data.expectedResult - Expected evaluation result
2105
+ * @param {string} [data.reasoning] - Explanation for the expected result
2106
+ * @returns {Promise<Object>} Updated evaluation with new example
2107
+ */
2108
+ async addEvaluationExample(agentId, evaluationId, data) {
2109
+ if (!agentId) {
2110
+ throw new Error('agentId is required');
2111
+ }
2112
+ if (!evaluationId) {
2113
+ throw new Error('evaluationId is required');
2114
+ }
2115
+ try {
2116
+ const response = await axios.post(
2117
+ `${this.baseUrl}/agents/${agentId}/evaluations/${evaluationId}/examples`,
2118
+ data,
2119
+ {
2120
+ headers: {
2121
+ Authorization: this.apiKey,
2122
+ },
2123
+ }
2124
+ );
2125
+ return response.data;
2126
+ } catch (error) {
2127
+ console.error('Foil Add Evaluation Example Failed:', error.message);
2128
+ throw error;
2129
+ }
2130
+ }
2131
+
2132
+ /**
2133
+ * Remove an example from an evaluation
2134
+ * @param {string} agentId - The agent ID
2135
+ * @param {string} evaluationId - The evaluation ID
2136
+ * @param {string} exampleId - The example ID to remove
2137
+ * @returns {Promise<Object>} Updated evaluation
2138
+ */
2139
+ async removeEvaluationExample(agentId, evaluationId, exampleId) {
2140
+ if (!agentId) {
2141
+ throw new Error('agentId is required');
2142
+ }
2143
+ if (!evaluationId) {
2144
+ throw new Error('evaluationId is required');
2145
+ }
2146
+ if (!exampleId) {
2147
+ throw new Error('exampleId is required');
2148
+ }
2149
+ try {
2150
+ const response = await axios.delete(
2151
+ `${this.baseUrl}/agents/${agentId}/evaluations/${evaluationId}/examples/${exampleId}`,
2152
+ {
2153
+ headers: {
2154
+ Authorization: this.apiKey,
2155
+ },
2156
+ }
2157
+ );
2158
+ return response.data;
2159
+ } catch (error) {
2160
+ console.error('Foil Remove Evaluation Example Failed:', error.message);
2161
+ throw error;
2162
+ }
2163
+ }
2164
+
2165
+ /**
2166
+ * Get analytics for an evaluation
2167
+ * Shows how the evaluation has performed over time
2168
+ * @param {string} agentId - The agent ID
2169
+ * @param {string} evaluationId - The evaluation ID
2170
+ * @returns {Promise<Object>} Evaluation analytics
2171
+ * @returns {number} result.totalEvaluations - Total times evaluated
2172
+ * @returns {Object} result.distribution - Result distribution
2173
+ * @returns {Array} result.trend - Results over time
2174
+ */
2175
+ async getEvaluationAnalytics(agentId, evaluationId) {
2176
+ if (!agentId) {
2177
+ throw new Error('agentId is required');
2178
+ }
2179
+ if (!evaluationId) {
2180
+ throw new Error('evaluationId is required');
2181
+ }
2182
+ try {
2183
+ const response = await axios.get(
2184
+ `${this.baseUrl}/agents/${agentId}/evaluations/${evaluationId}/analytics`,
2185
+ {
2186
+ headers: {
2187
+ Authorization: this.apiKey,
2188
+ },
2189
+ }
2190
+ );
2191
+ return response.data;
2192
+ } catch (error) {
2193
+ console.error('Foil Get Evaluation Analytics Failed:', error.message);
2194
+ throw error;
2195
+ }
2196
+ }
2197
+ }
2198
+
2199
+ // Main export for backwards compatibility
2200
+ module.exports = Foil;
2201
+
2202
+ // Named exports for modern usage
2203
+ module.exports.Foil = Foil;
2204
+ module.exports.FoilTracer = FoilTracer;
2205
+ module.exports.TraceContext = TraceContext;
2206
+ module.exports.SpanKind = SpanKind;
2207
+ module.exports.createFoilTracer = createFoilTracer;
2208
+ module.exports.createLangChainCallback = createLangChainCallback;
2209
+ module.exports.createVercelAICallbacks = createVercelAICallbacks;
2210
+
2211
+ // Multimodal support exports
2212
+ module.exports.MediaCategory = MediaCategory;
2213
+ module.exports.ContentBlock = ContentBlock;
2214
+ module.exports.content = content;