@getfoil/foil-js 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +1029 -0
  2. package/package.json +54 -0
  3. package/src/foil.js +2214 -0
  4. package/src/otel.js +610 -0
package/src/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
+ };