@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/otel.js
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foil OpenTelemetry Integration
|
|
3
|
+
*
|
|
4
|
+
* Provides a SpanProcessor that exports OpenTelemetry spans to Foil's OTLP endpoint.
|
|
5
|
+
* This enables automatic instrumentation of LLM calls using OpenLLMetry or other
|
|
6
|
+
* OpenTelemetry instrumentations.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ```javascript
|
|
10
|
+
* const { FoilSpanProcessor, Foil } = require('@foil/foil-js/otel');
|
|
11
|
+
* const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
|
|
12
|
+
*
|
|
13
|
+
* // Option 1: Use FoilSpanProcessor directly
|
|
14
|
+
* const provider = new NodeTracerProvider();
|
|
15
|
+
* provider.addSpanProcessor(new FoilSpanProcessor({ apiKey: 'sk_live_...' }));
|
|
16
|
+
* provider.register();
|
|
17
|
+
*
|
|
18
|
+
* // Option 2: Use Foil.init() convenience method
|
|
19
|
+
* Foil.init({ apiKey: 'sk_live_...' });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const axios = require('axios');
|
|
24
|
+
|
|
25
|
+
// Debug logging
|
|
26
|
+
const isDebugEnabled = () => process.env.FOIL_DEBUG === 'true';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default Foil OTLP endpoint
|
|
30
|
+
*/
|
|
31
|
+
const DEFAULT_ENDPOINT = 'https://api.getfoil.ai/api/otlp/v1/traces';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* FoilSpanProcessor implements the OpenTelemetry SpanProcessor interface.
|
|
35
|
+
* It batches completed spans and exports them to Foil's OTLP endpoint.
|
|
36
|
+
*
|
|
37
|
+
* @implements {SpanProcessor}
|
|
38
|
+
*/
|
|
39
|
+
class FoilSpanProcessor {
|
|
40
|
+
/**
|
|
41
|
+
* Create a new FoilSpanProcessor
|
|
42
|
+
* @param {Object} options - Configuration options
|
|
43
|
+
* @param {string} options.apiKey - Foil API key (required)
|
|
44
|
+
* @param {string} [options.endpoint] - Custom OTLP endpoint URL
|
|
45
|
+
* @param {number} [options.maxBatchSize=100] - Maximum spans per batch
|
|
46
|
+
* @param {number} [options.scheduledDelayMs=5000] - Batch export interval in milliseconds
|
|
47
|
+
* @param {number} [options.exportTimeoutMs=30000] - Export request timeout in milliseconds
|
|
48
|
+
* @param {boolean} [options.debug] - Enable debug logging
|
|
49
|
+
*/
|
|
50
|
+
constructor(options = {}) {
|
|
51
|
+
if (!options.apiKey) {
|
|
52
|
+
throw new Error('Foil API key is required. Pass { apiKey: "sk_live_..." }');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.apiKey = options.apiKey;
|
|
56
|
+
this.endpoint = options.endpoint || process.env.FOIL_OTLP_ENDPOINT || DEFAULT_ENDPOINT;
|
|
57
|
+
this.maxBatchSize = options.maxBatchSize || 100;
|
|
58
|
+
this.scheduledDelayMs = options.scheduledDelayMs || 5000;
|
|
59
|
+
this.exportTimeoutMs = options.exportTimeoutMs || 30000;
|
|
60
|
+
this.debug = options.debug || isDebugEnabled();
|
|
61
|
+
|
|
62
|
+
// Pending spans waiting to be exported
|
|
63
|
+
this._pendingSpans = [];
|
|
64
|
+
|
|
65
|
+
// Export timer
|
|
66
|
+
this._timer = null;
|
|
67
|
+
|
|
68
|
+
// Track if we've been shutdown
|
|
69
|
+
this._shutdown = false;
|
|
70
|
+
|
|
71
|
+
// Start the export timer
|
|
72
|
+
this._startTimer();
|
|
73
|
+
|
|
74
|
+
if (this.debug) {
|
|
75
|
+
console.log('[Foil] SpanProcessor initialized', {
|
|
76
|
+
endpoint: this.endpoint,
|
|
77
|
+
maxBatchSize: this.maxBatchSize,
|
|
78
|
+
scheduledDelayMs: this.scheduledDelayMs,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Called when a span is started. We don't need to do anything here
|
|
85
|
+
* since we only export completed spans.
|
|
86
|
+
* @param {Span} span - The span that was started
|
|
87
|
+
* @param {Context} parentContext - The parent context
|
|
88
|
+
*/
|
|
89
|
+
onStart(span, parentContext) {
|
|
90
|
+
// No-op: we only export on span end
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Called when a span is ended. We collect the span for batch export.
|
|
95
|
+
* @param {ReadableSpan} span - The completed span
|
|
96
|
+
*/
|
|
97
|
+
onEnd(span) {
|
|
98
|
+
if (this._shutdown) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Convert OTEL span to OTLP format
|
|
103
|
+
const otlpSpan = this._convertToOTLP(span);
|
|
104
|
+
this._pendingSpans.push(otlpSpan);
|
|
105
|
+
|
|
106
|
+
if (this.debug) {
|
|
107
|
+
console.log('[Foil] Span ended', {
|
|
108
|
+
name: span.name,
|
|
109
|
+
traceId: span.spanContext().traceId,
|
|
110
|
+
spanId: span.spanContext().spanId,
|
|
111
|
+
pendingCount: this._pendingSpans.length,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Export immediately if batch is full
|
|
116
|
+
if (this._pendingSpans.length >= this.maxBatchSize) {
|
|
117
|
+
this._export();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Force flush any pending spans
|
|
123
|
+
* @returns {Promise<void>}
|
|
124
|
+
*/
|
|
125
|
+
async forceFlush() {
|
|
126
|
+
if (this._pendingSpans.length > 0) {
|
|
127
|
+
await this._export();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Shutdown the processor and flush any remaining spans
|
|
133
|
+
* @returns {Promise<void>}
|
|
134
|
+
*/
|
|
135
|
+
async shutdown() {
|
|
136
|
+
this._shutdown = true;
|
|
137
|
+
|
|
138
|
+
// Stop the timer
|
|
139
|
+
if (this._timer) {
|
|
140
|
+
clearInterval(this._timer);
|
|
141
|
+
this._timer = null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Flush remaining spans
|
|
145
|
+
await this.forceFlush();
|
|
146
|
+
|
|
147
|
+
if (this.debug) {
|
|
148
|
+
console.log('[Foil] SpanProcessor shutdown complete');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Start the batch export timer
|
|
154
|
+
* @private
|
|
155
|
+
*/
|
|
156
|
+
_startTimer() {
|
|
157
|
+
this._timer = setInterval(() => {
|
|
158
|
+
if (this._pendingSpans.length > 0) {
|
|
159
|
+
this._export();
|
|
160
|
+
}
|
|
161
|
+
}, this.scheduledDelayMs);
|
|
162
|
+
|
|
163
|
+
// Don't prevent process from exiting
|
|
164
|
+
if (this._timer.unref) {
|
|
165
|
+
this._timer.unref();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Convert an OpenTelemetry ReadableSpan to OTLP JSON format
|
|
171
|
+
* @param {ReadableSpan} span - The OTEL span
|
|
172
|
+
* @returns {Object} OTLP span format
|
|
173
|
+
* @private
|
|
174
|
+
*/
|
|
175
|
+
_convertToOTLP(span) {
|
|
176
|
+
const spanContext = span.spanContext();
|
|
177
|
+
|
|
178
|
+
// Convert attributes to OTLP format
|
|
179
|
+
const attributes = [];
|
|
180
|
+
if (span.attributes) {
|
|
181
|
+
for (const [key, value] of Object.entries(span.attributes)) {
|
|
182
|
+
attributes.push({
|
|
183
|
+
key,
|
|
184
|
+
value: this._convertAttributeValue(value),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Convert events to OTLP format
|
|
190
|
+
const events = (span.events || []).map((event) => ({
|
|
191
|
+
timeUnixNano: String(this._hrTimeToNanos(event.time)),
|
|
192
|
+
name: event.name,
|
|
193
|
+
attributes: (event.attributes ? Object.entries(event.attributes) : []).map(([key, value]) => ({
|
|
194
|
+
key,
|
|
195
|
+
value: this._convertAttributeValue(value),
|
|
196
|
+
})),
|
|
197
|
+
}));
|
|
198
|
+
|
|
199
|
+
// Convert links to OTLP format
|
|
200
|
+
const links = (span.links || []).map((link) => ({
|
|
201
|
+
traceId: link.context.traceId,
|
|
202
|
+
spanId: link.context.spanId,
|
|
203
|
+
traceState: link.context.traceState?.serialize() || '',
|
|
204
|
+
attributes: (link.attributes ? Object.entries(link.attributes) : []).map(([key, value]) => ({
|
|
205
|
+
key,
|
|
206
|
+
value: this._convertAttributeValue(value),
|
|
207
|
+
})),
|
|
208
|
+
}));
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
traceId: spanContext.traceId,
|
|
212
|
+
spanId: spanContext.spanId,
|
|
213
|
+
parentSpanId: span.parentSpanId || '',
|
|
214
|
+
traceState: spanContext.traceState?.serialize() || '',
|
|
215
|
+
name: span.name,
|
|
216
|
+
kind: span.kind || 0,
|
|
217
|
+
startTimeUnixNano: String(this._hrTimeToNanos(span.startTime)),
|
|
218
|
+
endTimeUnixNano: String(this._hrTimeToNanos(span.endTime)),
|
|
219
|
+
attributes,
|
|
220
|
+
droppedAttributesCount: span.droppedAttributesCount || 0,
|
|
221
|
+
events,
|
|
222
|
+
droppedEventsCount: span.droppedEventsCount || 0,
|
|
223
|
+
links,
|
|
224
|
+
droppedLinksCount: span.droppedLinksCount || 0,
|
|
225
|
+
status: {
|
|
226
|
+
code: span.status?.code || 0,
|
|
227
|
+
message: span.status?.message || '',
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Convert an attribute value to OTLP format
|
|
234
|
+
* @param {any} value - The attribute value
|
|
235
|
+
* @returns {Object} OTLP attribute value
|
|
236
|
+
* @private
|
|
237
|
+
*/
|
|
238
|
+
_convertAttributeValue(value) {
|
|
239
|
+
if (typeof value === 'string') {
|
|
240
|
+
return { stringValue: value };
|
|
241
|
+
}
|
|
242
|
+
if (typeof value === 'boolean') {
|
|
243
|
+
return { boolValue: value };
|
|
244
|
+
}
|
|
245
|
+
if (typeof value === 'number') {
|
|
246
|
+
if (Number.isInteger(value)) {
|
|
247
|
+
return { intValue: String(value) };
|
|
248
|
+
}
|
|
249
|
+
return { doubleValue: value };
|
|
250
|
+
}
|
|
251
|
+
if (Array.isArray(value)) {
|
|
252
|
+
return {
|
|
253
|
+
arrayValue: {
|
|
254
|
+
values: value.map((v) => this._convertAttributeValue(v)),
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
// Fallback: convert to string
|
|
259
|
+
return { stringValue: String(value) };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Convert OTEL HrTime [seconds, nanoseconds] to nanoseconds BigInt
|
|
264
|
+
* @param {[number, number]} hrTime - High resolution time
|
|
265
|
+
* @returns {bigint} Nanoseconds since epoch
|
|
266
|
+
* @private
|
|
267
|
+
*/
|
|
268
|
+
_hrTimeToNanos(hrTime) {
|
|
269
|
+
if (!hrTime || !Array.isArray(hrTime)) {
|
|
270
|
+
return BigInt(Date.now()) * BigInt(1000000);
|
|
271
|
+
}
|
|
272
|
+
const [seconds, nanos] = hrTime;
|
|
273
|
+
return BigInt(seconds) * BigInt(1000000000) + BigInt(nanos);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Export pending spans to Foil
|
|
278
|
+
* @returns {Promise<void>}
|
|
279
|
+
* @private
|
|
280
|
+
*/
|
|
281
|
+
async _export() {
|
|
282
|
+
if (this._pendingSpans.length === 0) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Take the current batch and clear pending
|
|
287
|
+
const batch = this._pendingSpans.splice(0, this.maxBatchSize);
|
|
288
|
+
|
|
289
|
+
if (this.debug) {
|
|
290
|
+
console.log('[Foil] Exporting spans', { count: batch.length });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Build OTLP request
|
|
294
|
+
const request = {
|
|
295
|
+
resourceSpans: [
|
|
296
|
+
{
|
|
297
|
+
resource: {
|
|
298
|
+
attributes: [
|
|
299
|
+
{
|
|
300
|
+
key: 'service.name',
|
|
301
|
+
value: { stringValue: process.env.OTEL_SERVICE_NAME || 'foil-instrumented-app' },
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
key: 'telemetry.sdk.name',
|
|
305
|
+
value: { stringValue: '@foil/foil-js' },
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
key: 'telemetry.sdk.language',
|
|
309
|
+
value: { stringValue: 'nodejs' },
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
scopeSpans: [
|
|
314
|
+
{
|
|
315
|
+
scope: {
|
|
316
|
+
name: '@foil/foil-js',
|
|
317
|
+
version: '0.4.0',
|
|
318
|
+
},
|
|
319
|
+
spans: batch,
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const response = await axios.post(this.endpoint, request, {
|
|
328
|
+
headers: {
|
|
329
|
+
'Content-Type': 'application/json',
|
|
330
|
+
Authorization: this.apiKey,
|
|
331
|
+
},
|
|
332
|
+
timeout: this.exportTimeoutMs,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
if (this.debug) {
|
|
336
|
+
console.log('[Foil] Export successful', {
|
|
337
|
+
count: batch.length,
|
|
338
|
+
status: response.status,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Check for partial success
|
|
343
|
+
if (response.data?.partialSuccess?.rejectedSpans > 0) {
|
|
344
|
+
console.warn('[Foil] Some spans were rejected', response.data.partialSuccess);
|
|
345
|
+
}
|
|
346
|
+
} catch (error) {
|
|
347
|
+
console.error('[Foil] Export failed', {
|
|
348
|
+
error: error.message,
|
|
349
|
+
count: batch.length,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Re-queue spans on failure (up to a limit to prevent memory issues)
|
|
353
|
+
if (this._pendingSpans.length < this.maxBatchSize * 3) {
|
|
354
|
+
this._pendingSpans.unshift(...batch);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Foil convenience class for initializing OpenTelemetry tracing.
|
|
362
|
+
* Provides a simple way to set up Foil as the trace exporter.
|
|
363
|
+
*/
|
|
364
|
+
class Foil {
|
|
365
|
+
/**
|
|
366
|
+
* The global TracerProvider instance
|
|
367
|
+
* @type {TracerProvider|null}
|
|
368
|
+
*/
|
|
369
|
+
static _provider = null;
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The FoilSpanProcessor instance
|
|
373
|
+
* @type {FoilSpanProcessor|null}
|
|
374
|
+
*/
|
|
375
|
+
static _processor = null;
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Initialize Foil OpenTelemetry integration.
|
|
379
|
+
* This sets up a TracerProvider with FoilSpanProcessor and registers it globally.
|
|
380
|
+
*
|
|
381
|
+
* Note: This requires @opentelemetry/sdk-trace-node to be installed.
|
|
382
|
+
*
|
|
383
|
+
* @param {Object} options - Configuration options
|
|
384
|
+
* @param {string} options.apiKey - Foil API key (required)
|
|
385
|
+
* @param {string} [options.agentName] - Agent name for grouping traces in Foil
|
|
386
|
+
* @param {string} [options.endpoint] - Custom OTLP endpoint URL
|
|
387
|
+
* @param {boolean} [options.debug] - Enable debug logging
|
|
388
|
+
* @returns {Object} Object with provider and processor
|
|
389
|
+
*
|
|
390
|
+
* @example
|
|
391
|
+
* ```javascript
|
|
392
|
+
* const { Foil } = require('@foil/foil-js/otel');
|
|
393
|
+
*
|
|
394
|
+
* // Initialize with API key
|
|
395
|
+
* Foil.init({ apiKey: process.env.FOIL_API_KEY });
|
|
396
|
+
*
|
|
397
|
+
* // Now any OpenTelemetry instrumentation will export to Foil
|
|
398
|
+
* // e.g., OpenLLMetry for LLM calls
|
|
399
|
+
* ```
|
|
400
|
+
*/
|
|
401
|
+
static init(options = {}) {
|
|
402
|
+
if (!options.apiKey) {
|
|
403
|
+
throw new Error('Foil API key is required. Pass { apiKey: "sk_live_..." }');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const debug = options.debug || isDebugEnabled();
|
|
407
|
+
|
|
408
|
+
// Load OpenTelemetry SDK
|
|
409
|
+
const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
|
|
410
|
+
|
|
411
|
+
// Set agent name as OTEL service name (used by transformer to create/lookup agent)
|
|
412
|
+
if (options.agentName) {
|
|
413
|
+
process.env.OTEL_SERVICE_NAME = options.agentName;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Create the processor
|
|
417
|
+
Foil._processor = new FoilSpanProcessor({
|
|
418
|
+
apiKey: options.apiKey,
|
|
419
|
+
endpoint: options.endpoint,
|
|
420
|
+
debug,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Create and configure the provider
|
|
424
|
+
Foil._provider = new NodeTracerProvider();
|
|
425
|
+
Foil._provider.addSpanProcessor(Foil._processor);
|
|
426
|
+
Foil._provider.register();
|
|
427
|
+
|
|
428
|
+
if (debug) {
|
|
429
|
+
console.log('[Foil] Initialized OpenTelemetry provider');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Initialize OpenLLMetry (Traceloop) for automatic LLM instrumentation
|
|
433
|
+
// We disable their exporter since we use FoilSpanProcessor instead
|
|
434
|
+
try {
|
|
435
|
+
const Traceloop = require('@traceloop/node-server-sdk');
|
|
436
|
+
Traceloop.initialize({
|
|
437
|
+
disableBatch: true,
|
|
438
|
+
exporter: null, // Disable Traceloop's exporter, we use FoilSpanProcessor
|
|
439
|
+
silenceInitializationMessage: true,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
if (debug) {
|
|
443
|
+
console.log('[Foil] Auto-instrumentation enabled for: OpenAI, Anthropic, Cohere, Bedrock, LangChain, etc.');
|
|
444
|
+
}
|
|
445
|
+
} catch (error) {
|
|
446
|
+
if (debug) {
|
|
447
|
+
console.log('[Foil] Note: Install @traceloop/node-server-sdk for auto-instrumentation');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (debug) {
|
|
452
|
+
console.log('[Foil] Ready! All LLM calls will be automatically traced.', {
|
|
453
|
+
agentName: options.agentName || process.env.OTEL_SERVICE_NAME || 'default',
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
provider: Foil._provider,
|
|
459
|
+
processor: Foil._processor,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Shutdown Foil and flush any pending spans
|
|
465
|
+
* @returns {Promise<void>}
|
|
466
|
+
*/
|
|
467
|
+
static async shutdown() {
|
|
468
|
+
if (Foil._processor) {
|
|
469
|
+
await Foil._processor.shutdown();
|
|
470
|
+
Foil._processor = null;
|
|
471
|
+
}
|
|
472
|
+
if (Foil._provider) {
|
|
473
|
+
await Foil._provider.shutdown();
|
|
474
|
+
Foil._provider = null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Force flush any pending spans
|
|
480
|
+
* @returns {Promise<void>}
|
|
481
|
+
*/
|
|
482
|
+
static async flush() {
|
|
483
|
+
if (Foil._processor) {
|
|
484
|
+
await Foil._processor.forceFlush();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Attachment counter for unique indexing within a span
|
|
491
|
+
* Uses WeakMap to track per-span attachment counts
|
|
492
|
+
*/
|
|
493
|
+
const spanAttachmentCounts = new WeakMap();
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Add an attachment to the current active span.
|
|
497
|
+
* Attachments allow you to include additional context like images, code, text, or embedded content.
|
|
498
|
+
*
|
|
499
|
+
* @param {Object} attachment - The attachment to add
|
|
500
|
+
* @param {string} attachment.type - Type of attachment: 'image', 'code', 'text', or 'iframe'
|
|
501
|
+
* @param {string} attachment.value - The content or URL of the attachment
|
|
502
|
+
* @param {string} attachment.role - Whether this is 'input' or 'output'
|
|
503
|
+
* @param {string} [attachment.name] - Optional display name for the attachment
|
|
504
|
+
* @param {string} [attachment.language] - Language for code attachments (e.g., 'javascript', 'python')
|
|
505
|
+
*
|
|
506
|
+
* @example
|
|
507
|
+
* // Attach an input image
|
|
508
|
+
* addAttachment({ type: 'image', value: 'https://example.com/image.png', role: 'input' });
|
|
509
|
+
*
|
|
510
|
+
* // Attach generated code
|
|
511
|
+
* addAttachment({ type: 'code', value: 'console.log("hello")', role: 'output', language: 'javascript' });
|
|
512
|
+
*
|
|
513
|
+
* // Attach a document
|
|
514
|
+
* addAttachment({ type: 'text', value: 'Long document content...', role: 'input', name: 'context.txt' });
|
|
515
|
+
*/
|
|
516
|
+
function addAttachment(attachment) {
|
|
517
|
+
const api = require('@opentelemetry/api');
|
|
518
|
+
const span = api.trace.getActiveSpan();
|
|
519
|
+
|
|
520
|
+
if (!span) {
|
|
521
|
+
if (isDebugEnabled()) {
|
|
522
|
+
console.warn('[Foil] addAttachment called but no active span found');
|
|
523
|
+
}
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Validate attachment
|
|
528
|
+
const validTypes = ['image', 'code', 'text', 'iframe'];
|
|
529
|
+
if (!validTypes.includes(attachment.type)) {
|
|
530
|
+
console.warn(`[Foil] Invalid attachment type: ${attachment.type}. Must be one of: ${validTypes.join(', ')}`);
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (!attachment.value) {
|
|
535
|
+
console.warn('[Foil] Attachment value is required');
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (!['input', 'output'].includes(attachment.role)) {
|
|
540
|
+
console.warn('[Foil] Attachment role must be "input" or "output"');
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Get or initialize attachment count for this span
|
|
545
|
+
let count = spanAttachmentCounts.get(span) || 0;
|
|
546
|
+
|
|
547
|
+
// Set attributes on the span using Foil's attachment convention
|
|
548
|
+
span.setAttribute(`foil.attachment.${count}.type`, attachment.type);
|
|
549
|
+
span.setAttribute(`foil.attachment.${count}.value`, attachment.value);
|
|
550
|
+
span.setAttribute(`foil.attachment.${count}.role`, attachment.role);
|
|
551
|
+
|
|
552
|
+
if (attachment.name) {
|
|
553
|
+
span.setAttribute(`foil.attachment.${count}.name`, attachment.name);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (attachment.language && attachment.type === 'code') {
|
|
557
|
+
span.setAttribute(`foil.attachment.${count}.language`, attachment.language);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Increment counter
|
|
561
|
+
spanAttachmentCounts.set(span, count + 1);
|
|
562
|
+
|
|
563
|
+
if (isDebugEnabled()) {
|
|
564
|
+
console.log('[Foil] Attachment added', { index: count, type: attachment.type, role: attachment.role });
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Convenience function to attach an image
|
|
572
|
+
* @param {string} url - Image URL
|
|
573
|
+
* @param {string} role - 'input' or 'output'
|
|
574
|
+
* @param {string} [name] - Optional display name
|
|
575
|
+
*/
|
|
576
|
+
function attachImage(url, role, name) {
|
|
577
|
+
return addAttachment({ type: 'image', value: url, role, name });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Convenience function to attach code
|
|
582
|
+
* @param {string} code - Code content
|
|
583
|
+
* @param {string} role - 'input' or 'output'
|
|
584
|
+
* @param {string} [language] - Programming language
|
|
585
|
+
* @param {string} [name] - Optional display name
|
|
586
|
+
*/
|
|
587
|
+
function attachCode(code, role, language, name) {
|
|
588
|
+
return addAttachment({ type: 'code', value: code, role, language, name });
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Convenience function to attach text/document
|
|
593
|
+
* @param {string} text - Text content
|
|
594
|
+
* @param {string} role - 'input' or 'output'
|
|
595
|
+
* @param {string} [name] - Optional display name
|
|
596
|
+
*/
|
|
597
|
+
function attachText(text, role, name) {
|
|
598
|
+
return addAttachment({ type: 'text', value: text, role, name });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
module.exports = {
|
|
602
|
+
FoilSpanProcessor,
|
|
603
|
+
Foil,
|
|
604
|
+
DEFAULT_ENDPOINT,
|
|
605
|
+
// Attachment helpers
|
|
606
|
+
addAttachment,
|
|
607
|
+
attachImage,
|
|
608
|
+
attachCode,
|
|
609
|
+
attachText,
|
|
610
|
+
};
|