@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.
- package/README.md +1029 -0
- package/package.json +54 -0
- package/src/foil.js +2214 -0
- 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;
|