moda-ai 0.1.1

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/dist/index.mjs ADDED
@@ -0,0 +1,1256 @@
1
+ import { trace, SpanStatusCode } from '@opentelemetry/api';
2
+ import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
3
+ import { SimpleSpanProcessor, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
4
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
5
+ import { Resource } from '@opentelemetry/resources';
6
+ import { ATTR_SERVICE_VERSION, ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
7
+ import { createHash, randomUUID } from 'crypto';
8
+ import { AsyncLocalStorage } from 'async_hooks';
9
+
10
+ /**
11
+ * Default configuration values
12
+ */
13
+ const DEFAULT_OPTIONS = {
14
+ baseUrl: 'https://ingest.moda.so/v1/traces',
15
+ environment: 'production',
16
+ enabled: true,
17
+ debug: false,
18
+ batchSize: 100,
19
+ flushInterval: 5000,
20
+ };
21
+
22
+ /**
23
+ * Internal SDK state (shared module to avoid circular dependencies)
24
+ */
25
+ const state = {
26
+ initialized: false,
27
+ apiKey: null,
28
+ options: { ...DEFAULT_OPTIONS },
29
+ };
30
+ /**
31
+ * Check if debug mode is enabled
32
+ */
33
+ function isDebugEnabled() {
34
+ return state.options.debug;
35
+ }
36
+ /**
37
+ * Update state options
38
+ */
39
+ function setStateOptions(options) {
40
+ state.options = options;
41
+ }
42
+ /**
43
+ * Reset state to initial values
44
+ */
45
+ function resetState() {
46
+ state.initialized = false;
47
+ state.apiKey = null;
48
+ state.options = { ...DEFAULT_OPTIONS };
49
+ }
50
+
51
+ /**
52
+ * Compute SHA256 hash of input string
53
+ * @param input - String to hash
54
+ * @returns Hexadecimal hash string
55
+ */
56
+ function sha256(input) {
57
+ return createHash('sha256').update(input).digest('hex');
58
+ }
59
+ /**
60
+ * Compute truncated SHA256 hash
61
+ * @param input - String to hash
62
+ * @param length - Number of characters to return (default: 16)
63
+ * @returns Truncated hexadecimal hash string
64
+ */
65
+ function sha256Short(input, length = 16) {
66
+ return sha256(input).slice(0, length);
67
+ }
68
+
69
+ /**
70
+ * Extract text content from various message content formats
71
+ */
72
+ function extractTextContent(content) {
73
+ if (content === null || content === undefined) {
74
+ return '';
75
+ }
76
+ if (typeof content === 'string') {
77
+ return content;
78
+ }
79
+ if (Array.isArray(content)) {
80
+ return content
81
+ .map((part) => {
82
+ if (typeof part === 'string') {
83
+ return part;
84
+ }
85
+ if (part.type === 'text' && part.text) {
86
+ return part.text;
87
+ }
88
+ return '';
89
+ })
90
+ .filter(Boolean)
91
+ .join('\n');
92
+ }
93
+ return '';
94
+ }
95
+ /**
96
+ * Normalize OpenAI messages to generic Message format
97
+ */
98
+ function normalizeOpenAIMessages(messages) {
99
+ return messages.map((msg) => ({
100
+ role: msg.role,
101
+ content: normalizeOpenAIContent(msg.content),
102
+ name: msg.name,
103
+ tool_call_id: msg.tool_call_id,
104
+ }));
105
+ }
106
+ /**
107
+ * Normalize OpenAI content to string or ContentPart array
108
+ */
109
+ function normalizeOpenAIContent(content) {
110
+ if (content === null) {
111
+ return '';
112
+ }
113
+ if (typeof content === 'string') {
114
+ return content;
115
+ }
116
+ return content.map((part) => {
117
+ if (part.type === 'text') {
118
+ return { type: 'text', text: part.text };
119
+ }
120
+ if (part.type === 'image_url') {
121
+ return { type: 'image_url', image_url: part.image_url };
122
+ }
123
+ return { type: 'text', text: '' };
124
+ });
125
+ }
126
+ /**
127
+ * Normalize Anthropic messages to generic Message format
128
+ */
129
+ function normalizeAnthropicMessages(messages, systemPrompt) {
130
+ const normalized = [];
131
+ if (systemPrompt) {
132
+ normalized.push({
133
+ role: 'system',
134
+ content: systemPrompt,
135
+ });
136
+ }
137
+ for (const msg of messages) {
138
+ normalized.push({
139
+ role: msg.role,
140
+ content: normalizeAnthropicContent(msg.content),
141
+ });
142
+ }
143
+ return normalized;
144
+ }
145
+ /**
146
+ * Normalize Anthropic content to string or ContentPart array
147
+ */
148
+ function normalizeAnthropicContent(content) {
149
+ if (typeof content === 'string') {
150
+ return content;
151
+ }
152
+ return content.map((block) => {
153
+ switch (block.type) {
154
+ case 'text':
155
+ return { type: 'text', text: block.text };
156
+ case 'image':
157
+ return { type: 'image', source: block.source };
158
+ case 'tool_use':
159
+ return {
160
+ type: 'tool_use',
161
+ text: JSON.stringify({ id: block.id, name: block.name, input: block.input }),
162
+ };
163
+ case 'tool_result':
164
+ return {
165
+ type: 'tool_result',
166
+ text: typeof block.content === 'string'
167
+ ? block.content
168
+ : JSON.stringify(block.content),
169
+ };
170
+ case 'thinking':
171
+ return { type: 'thinking', text: block.thinking };
172
+ default:
173
+ return { type: 'text', text: '' };
174
+ }
175
+ });
176
+ }
177
+ /**
178
+ * Find the first user message in a message array
179
+ */
180
+ function findFirstUserMessage(messages) {
181
+ return messages.find((m) => m.role === 'user');
182
+ }
183
+ /**
184
+ * Extract system prompt from messages
185
+ */
186
+ function extractSystemPrompt(messages) {
187
+ const systemMessage = messages.find((m) => m.role === 'system');
188
+ if (!systemMessage)
189
+ return undefined;
190
+ return extractTextContent(systemMessage.content);
191
+ }
192
+ /**
193
+ * Format messages for span attributes (OpenLLMetry compatible)
194
+ */
195
+ function formatMessagesForSpan(messages) {
196
+ const attributes = {};
197
+ messages.forEach((msg, index) => {
198
+ attributes[`llm.prompts.${index}.role`] = msg.role;
199
+ attributes[`llm.prompts.${index}.content`] = extractTextContent(msg.content);
200
+ });
201
+ return attributes;
202
+ }
203
+ /**
204
+ * Format completion for span attributes
205
+ */
206
+ function formatCompletionForSpan(role, content, index = 0) {
207
+ return {
208
+ [`llm.completions.${index}.role`]: role,
209
+ [`llm.completions.${index}.content`]: content,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * AsyncLocalStorage instance for managing Moda context across async operations
215
+ */
216
+ const storage = new AsyncLocalStorage();
217
+ /**
218
+ * Get the current Moda context from AsyncLocalStorage
219
+ * @returns Current context or empty object if not set
220
+ */
221
+ function getContext() {
222
+ return storage.getStore() ?? {};
223
+ }
224
+ /**
225
+ * Set the conversation ID in the current context.
226
+ * Note: This creates a new context with only the conversationId.
227
+ * For nested contexts, use withConversationId instead.
228
+ */
229
+ let globalContext = {};
230
+ /**
231
+ * Set a global conversation ID that persists across async boundaries.
232
+ * This is useful when you want to track a conversation across multiple API calls
233
+ * without wrapping them in a callback.
234
+ *
235
+ * @param id - The conversation ID to set
236
+ */
237
+ function setConversationId(id) {
238
+ globalContext = { ...globalContext, conversationId: id };
239
+ }
240
+ /**
241
+ * Clear the global conversation ID.
242
+ */
243
+ function clearConversationId() {
244
+ const { conversationId: _, ...rest } = globalContext;
245
+ globalContext = rest;
246
+ }
247
+ /**
248
+ * Set a global user ID that persists across async boundaries.
249
+ *
250
+ * @param id - The user ID to set
251
+ */
252
+ function setUserId(id) {
253
+ globalContext = { ...globalContext, userId: id };
254
+ }
255
+ /**
256
+ * Clear the global user ID.
257
+ */
258
+ function clearUserId() {
259
+ const { userId: _, ...rest } = globalContext;
260
+ globalContext = rest;
261
+ }
262
+ /**
263
+ * Get the combined context (AsyncLocalStorage + global)
264
+ * AsyncLocalStorage takes precedence over global context
265
+ */
266
+ function getEffectiveContext() {
267
+ const localContext = getContext();
268
+ return {
269
+ ...globalContext,
270
+ ...localContext,
271
+ };
272
+ }
273
+ /**
274
+ * Run a callback with a specific conversation ID in the AsyncLocalStorage context.
275
+ * The conversation ID will be available to all async operations within the callback.
276
+ *
277
+ * @param id - The conversation ID to set
278
+ * @param callback - The function to run with the context
279
+ * @returns The return value of the callback
280
+ *
281
+ * @example
282
+ * ```typescript
283
+ * await withConversationId('my-conv-123', async () => {
284
+ * await openai.chat.completions.create({ ... });
285
+ * await openai.chat.completions.create({ ... });
286
+ * // Both calls will have the same conversation ID
287
+ * });
288
+ * ```
289
+ */
290
+ function withConversationId(id, callback) {
291
+ const currentContext = getEffectiveContext();
292
+ return storage.run({ ...currentContext, conversationId: id }, callback);
293
+ }
294
+ /**
295
+ * Run a callback with a specific user ID in the AsyncLocalStorage context.
296
+ *
297
+ * @param id - The user ID to set
298
+ * @param callback - The function to run with the context
299
+ * @returns The return value of the callback
300
+ */
301
+ function withUserId(id, callback) {
302
+ const currentContext = getEffectiveContext();
303
+ return storage.run({ ...currentContext, userId: id }, callback);
304
+ }
305
+ /**
306
+ * Run a callback with both conversation ID and user ID in context.
307
+ *
308
+ * @param conversationId - The conversation ID to set
309
+ * @param userId - The user ID to set
310
+ * @param callback - The function to run with the context
311
+ * @returns The return value of the callback
312
+ */
313
+ function withContext(conversationId, userId, callback) {
314
+ return storage.run({ conversationId, userId }, callback);
315
+ }
316
+ /**
317
+ * Get the global context (without AsyncLocalStorage context).
318
+ * Useful for accessing the explicitly set conversationId and userId.
319
+ */
320
+ function getGlobalContext() {
321
+ return globalContext;
322
+ }
323
+
324
+ /**
325
+ * Compute a stable conversation ID from message history.
326
+ *
327
+ * The algorithm:
328
+ * 1. If an explicit ID is provided (via context or parameter), use it
329
+ * 2. Find the first user message in the conversation
330
+ * 3. Compute SHA256 hash of: system prompt + first user message
331
+ * 4. Return conv_[hash[:16]]
332
+ *
333
+ * This ensures that multi-turn conversations with the same starting point
334
+ * always get the same conversation ID, enabling proper thread tracking.
335
+ *
336
+ * @param messages - Array of messages in the conversation
337
+ * @param systemPrompt - Optional system prompt (separate from messages, e.g., Anthropic style)
338
+ * @param explicitId - Optional explicit conversation ID to override computation
339
+ * @returns Conversation ID in format conv_[16-char-hash] or the explicit ID
340
+ */
341
+ function computeConversationId(messages, systemPrompt, explicitId) {
342
+ // Priority 1: Explicit ID provided as parameter
343
+ if (explicitId) {
344
+ return explicitId;
345
+ }
346
+ // Priority 2: ID set in context (global or AsyncLocalStorage)
347
+ const context = getEffectiveContext();
348
+ if (context.conversationId) {
349
+ return context.conversationId;
350
+ }
351
+ // Priority 3: Compute from messages
352
+ const firstUserMessage = findFirstUserMessage(messages);
353
+ if (!firstUserMessage) {
354
+ // No user message found, generate random ID
355
+ return `conv_${randomUUID().replace(/-/g, '').slice(0, 16)}`;
356
+ }
357
+ // Get system prompt from messages if not provided separately
358
+ const effectiveSystemPrompt = systemPrompt ?? extractSystemPrompt(messages);
359
+ const firstUserContent = extractTextContent(firstUserMessage.content);
360
+ // Create seed for hashing
361
+ const seed = JSON.stringify({
362
+ system: effectiveSystemPrompt ?? null,
363
+ first_user: firstUserContent,
364
+ });
365
+ const hash = sha256Short(seed, 16);
366
+ return `conv_${hash}`;
367
+ }
368
+ /**
369
+ * Generate a random conversation ID
370
+ * Useful for cases where automatic computation is not desired
371
+ */
372
+ function generateRandomConversationId() {
373
+ return `conv_${randomUUID().replace(/-/g, '').slice(0, 16)}`;
374
+ }
375
+ /**
376
+ * Validate conversation ID format
377
+ * @param id - Conversation ID to validate
378
+ * @returns true if the ID matches expected format
379
+ */
380
+ function isValidConversationId(id) {
381
+ // Accept any string, but prefer conv_* format
382
+ return typeof id === 'string' && id.length > 0;
383
+ }
384
+
385
+ /**
386
+ * Base class for LLM client instrumentations.
387
+ * Provides common functionality for creating spans and setting attributes.
388
+ */
389
+ class BaseInstrumentation {
390
+ vendor;
391
+ tracer = trace.getTracer('moda-sdk', '0.1.0');
392
+ constructor(vendor) {
393
+ this.vendor = vendor;
394
+ }
395
+ /**
396
+ * Create span attributes for an LLM call
397
+ */
398
+ createSpanAttributes(messages, model, systemPrompt) {
399
+ const context = getEffectiveContext();
400
+ const conversationId = computeConversationId(messages, systemPrompt);
401
+ const attributes = {
402
+ 'llm.vendor': this.vendor,
403
+ 'llm.request.type': 'chat',
404
+ 'llm.request.model': model,
405
+ 'moda.conversation_id': conversationId,
406
+ };
407
+ if (context.userId) {
408
+ attributes['moda.user_id'] = context.userId;
409
+ }
410
+ // Add message attributes
411
+ const messageAttrs = formatMessagesForSpan(messages);
412
+ Object.assign(attributes, messageAttrs);
413
+ return attributes;
414
+ }
415
+ /**
416
+ * Add completion attributes to a span
417
+ */
418
+ addCompletionAttributes(span, role, content, index = 0) {
419
+ const attrs = formatCompletionForSpan(role, content, index);
420
+ for (const [key, value] of Object.entries(attrs)) {
421
+ span.setAttribute(key, value);
422
+ }
423
+ }
424
+ /**
425
+ * Add usage metrics to a span
426
+ */
427
+ addUsageAttributes(span, usage) {
428
+ if (!usage)
429
+ return;
430
+ const promptTokens = usage.prompt_tokens ?? usage.input_tokens;
431
+ const completionTokens = usage.completion_tokens ?? usage.output_tokens;
432
+ const totalTokens = usage.total_tokens ?? (promptTokens && completionTokens ? promptTokens + completionTokens : undefined);
433
+ if (promptTokens !== undefined) {
434
+ span.setAttribute('llm.usage.prompt_tokens', promptTokens);
435
+ }
436
+ if (completionTokens !== undefined) {
437
+ span.setAttribute('llm.usage.completion_tokens', completionTokens);
438
+ }
439
+ if (totalTokens !== undefined) {
440
+ span.setAttribute('llm.usage.total_tokens', totalTokens);
441
+ }
442
+ }
443
+ /**
444
+ * Set span error status
445
+ */
446
+ setSpanError(span, error) {
447
+ span.setStatus({
448
+ code: SpanStatusCode.ERROR,
449
+ message: error.message,
450
+ });
451
+ span.recordException(error);
452
+ }
453
+ /**
454
+ * Set span success status
455
+ */
456
+ setSpanSuccess(span) {
457
+ span.setStatus({ code: SpanStatusCode.OK });
458
+ }
459
+ /**
460
+ * Log debug message if debug mode is enabled
461
+ */
462
+ debug(message, ...args) {
463
+ if (isDebugEnabled()) {
464
+ console.log(`[Moda:${this.vendor}] ${message}`, ...args);
465
+ }
466
+ }
467
+ }
468
+
469
+ // Store original methods for unpatching
470
+ let originalChatCompletionsCreate = null;
471
+ let patched$1 = false;
472
+ /**
473
+ * OpenAI client instrumentation.
474
+ * Automatically tracks all chat completion calls with conversation threading.
475
+ */
476
+ class OpenAIInstrumentation extends BaseInstrumentation {
477
+ constructor() {
478
+ super('openai');
479
+ }
480
+ /**
481
+ * Patch the OpenAI client to add instrumentation
482
+ */
483
+ patch() {
484
+ if (patched$1) {
485
+ this.debug('Already patched');
486
+ return;
487
+ }
488
+ try {
489
+ // Dynamic import to handle optional dependency
490
+ const openaiModule = require('openai');
491
+ const OpenAI = openaiModule.default || openaiModule;
492
+ if (!OpenAI || !OpenAI.prototype) {
493
+ this.debug('OpenAI module not found or invalid');
494
+ return;
495
+ }
496
+ const instrumentation = this;
497
+ // Patch the Chat.Completions.create method
498
+ // We need to patch at the prototype level of the internal class
499
+ const originalConstructor = OpenAI;
500
+ // Override the constructor to patch instances
501
+ const patchedOpenAI = function (...args) {
502
+ const instance = new originalConstructor(...args);
503
+ instrumentation.patchInstance(instance);
504
+ return instance;
505
+ };
506
+ // Copy static properties
507
+ Object.setPrototypeOf(patchedOpenAI, originalConstructor);
508
+ patchedOpenAI.prototype = originalConstructor.prototype;
509
+ // Replace in module cache
510
+ if (openaiModule.default) {
511
+ openaiModule.default = patchedOpenAI;
512
+ }
513
+ // Also patch existing instances by patching the prototype
514
+ this.patchPrototype(OpenAI);
515
+ patched$1 = true;
516
+ this.debug('Patched successfully');
517
+ }
518
+ catch (error) {
519
+ // OpenAI not installed, skip silently
520
+ this.debug('Could not patch OpenAI:', error);
521
+ }
522
+ }
523
+ /**
524
+ * Patch an OpenAI instance
525
+ */
526
+ patchInstance(instance) {
527
+ if (!instance.chat?.completions?.create) {
528
+ return;
529
+ }
530
+ const instrumentation = this;
531
+ const original = instance.chat.completions.create.bind(instance.chat.completions);
532
+ instance.chat.completions.create = async function (params, options) {
533
+ return instrumentation.tracedCreate(original, params, options);
534
+ };
535
+ }
536
+ /**
537
+ * Patch the OpenAI prototype to catch all instances
538
+ */
539
+ patchPrototype(OpenAI) {
540
+ const instrumentation = this;
541
+ // Store original Chat class create method
542
+ try {
543
+ const chatProto = OpenAI.Chat?.Completions?.prototype;
544
+ if (chatProto && chatProto.create) {
545
+ originalChatCompletionsCreate = chatProto.create;
546
+ chatProto.create = async function (params, options) {
547
+ const original = originalChatCompletionsCreate.bind(this);
548
+ return instrumentation.tracedCreate(original, params, options);
549
+ };
550
+ }
551
+ }
552
+ catch {
553
+ // Prototype structure may vary
554
+ }
555
+ }
556
+ /**
557
+ * Wrap a create call with tracing
558
+ */
559
+ async tracedCreate(original, params, options) {
560
+ const messages = params.messages;
561
+ const model = params.model;
562
+ const isStreaming = params.stream === true;
563
+ // Normalize messages
564
+ const normalizedMessages = normalizeOpenAIMessages(messages);
565
+ // Create span attributes
566
+ const attributes = this.createSpanAttributes(normalizedMessages, model);
567
+ // Add streaming flag
568
+ attributes['llm.request.streaming'] = isStreaming;
569
+ // Create span
570
+ const span = this.tracer.startSpan('openai.chat.completions.create', {
571
+ attributes,
572
+ });
573
+ try {
574
+ if (isStreaming) {
575
+ return await this.handleStreamingResponse(original, params, options, span);
576
+ }
577
+ else {
578
+ return await this.handleNonStreamingResponse(original, params, options, span);
579
+ }
580
+ }
581
+ catch (error) {
582
+ this.setSpanError(span, error);
583
+ throw error;
584
+ }
585
+ finally {
586
+ span.end();
587
+ }
588
+ }
589
+ /**
590
+ * Handle non-streaming response
591
+ */
592
+ async handleNonStreamingResponse(original, params, options, span) {
593
+ const response = await original(params, options);
594
+ // Extract completion
595
+ const choice = response.choices?.[0];
596
+ if (choice?.message) {
597
+ const content = choice.message.content || '';
598
+ const role = choice.message.role || 'assistant';
599
+ this.addCompletionAttributes(span, role, content);
600
+ // Add finish reason
601
+ if (choice.finish_reason) {
602
+ span.setAttribute('llm.response.finish_reason', choice.finish_reason);
603
+ }
604
+ }
605
+ // Add model from response
606
+ if (response.model) {
607
+ span.setAttribute('llm.response.model', response.model);
608
+ }
609
+ // Add usage metrics
610
+ this.addUsageAttributes(span, response.usage);
611
+ this.setSpanSuccess(span);
612
+ return response;
613
+ }
614
+ /**
615
+ * Handle streaming response
616
+ */
617
+ async handleStreamingResponse(original, params, options, span) {
618
+ const stream = await original(params, options);
619
+ const instrumentation = this;
620
+ // Wrap the stream to capture content
621
+ let fullContent = '';
622
+ let finishReason = null;
623
+ let model = null;
624
+ let usage = null;
625
+ // Create async iterator wrapper
626
+ const wrappedStream = {
627
+ [Symbol.asyncIterator]: async function* () {
628
+ try {
629
+ for await (const chunk of stream) {
630
+ // Capture content
631
+ const delta = chunk.choices?.[0]?.delta;
632
+ if (delta?.content) {
633
+ fullContent += delta.content;
634
+ }
635
+ // Capture finish reason
636
+ if (chunk.choices?.[0]?.finish_reason) {
637
+ finishReason = chunk.choices[0].finish_reason;
638
+ }
639
+ // Capture model
640
+ if (chunk.model) {
641
+ model = chunk.model;
642
+ }
643
+ // Capture usage (OpenAI includes it in the last chunk with stream_options)
644
+ if (chunk.usage) {
645
+ usage = chunk.usage;
646
+ }
647
+ yield chunk;
648
+ }
649
+ // After stream completes, add attributes
650
+ instrumentation.addCompletionAttributes(span, 'assistant', fullContent);
651
+ if (finishReason) {
652
+ span.setAttribute('llm.response.finish_reason', finishReason);
653
+ }
654
+ if (model) {
655
+ span.setAttribute('llm.response.model', model);
656
+ }
657
+ if (usage) {
658
+ instrumentation.addUsageAttributes(span, usage);
659
+ }
660
+ instrumentation.setSpanSuccess(span);
661
+ }
662
+ catch (error) {
663
+ instrumentation.setSpanError(span, error);
664
+ throw error;
665
+ }
666
+ },
667
+ // Forward other stream methods
668
+ ...stream,
669
+ };
670
+ // If the stream has a controller/abort method, forward it
671
+ if (stream.controller) {
672
+ wrappedStream.controller = stream.controller;
673
+ }
674
+ return wrappedStream;
675
+ }
676
+ /**
677
+ * Unpatch the OpenAI client
678
+ */
679
+ unpatch() {
680
+ if (!patched$1) {
681
+ return;
682
+ }
683
+ try {
684
+ const openaiModule = require('openai');
685
+ const OpenAI = openaiModule.default || openaiModule;
686
+ if (originalChatCompletionsCreate && OpenAI.Chat?.Completions?.prototype) {
687
+ OpenAI.Chat.Completions.prototype.create = originalChatCompletionsCreate;
688
+ }
689
+ patched$1 = false;
690
+ originalChatCompletionsCreate = null;
691
+ this.debug('Unpatched successfully');
692
+ }
693
+ catch {
694
+ // Ignore
695
+ }
696
+ }
697
+ }
698
+ // Export singleton instance
699
+ const openAIInstrumentation = new OpenAIInstrumentation();
700
+
701
+ // Store original methods for unpatching
702
+ let originalMessagesCreate = null;
703
+ let patched = false;
704
+ /**
705
+ * Anthropic client instrumentation.
706
+ * Automatically tracks all message creation calls with conversation threading.
707
+ */
708
+ class AnthropicInstrumentation extends BaseInstrumentation {
709
+ constructor() {
710
+ super('anthropic');
711
+ }
712
+ /**
713
+ * Patch the Anthropic client to add instrumentation
714
+ */
715
+ patch() {
716
+ if (patched) {
717
+ this.debug('Already patched');
718
+ return;
719
+ }
720
+ try {
721
+ // Dynamic import to handle optional dependency
722
+ const anthropicModule = require('@anthropic-ai/sdk');
723
+ const Anthropic = anthropicModule.default || anthropicModule;
724
+ if (!Anthropic || !Anthropic.prototype) {
725
+ this.debug('Anthropic module not found or invalid');
726
+ return;
727
+ }
728
+ // Patch the Messages.create method at prototype level
729
+ this.patchPrototype(Anthropic);
730
+ patched = true;
731
+ this.debug('Patched successfully');
732
+ }
733
+ catch (error) {
734
+ // Anthropic not installed, skip silently
735
+ this.debug('Could not patch Anthropic:', error);
736
+ }
737
+ }
738
+ /**
739
+ * Patch the Anthropic prototype to catch all instances
740
+ */
741
+ patchPrototype(Anthropic) {
742
+ const instrumentation = this;
743
+ try {
744
+ const messagesProto = Anthropic.Messages?.prototype;
745
+ if (messagesProto && messagesProto.create) {
746
+ originalMessagesCreate = messagesProto.create;
747
+ messagesProto.create = async function (params, options) {
748
+ const original = originalMessagesCreate.bind(this);
749
+ return instrumentation.tracedCreate(original, params, options);
750
+ };
751
+ }
752
+ }
753
+ catch {
754
+ // Try alternative approach - patch on client construction
755
+ const originalConstructor = Anthropic;
756
+ // We'll wrap the constructor
757
+ const patchedAnthropic = function (...args) {
758
+ const instance = new originalConstructor(...args);
759
+ instrumentation.patchInstance(instance);
760
+ return instance;
761
+ };
762
+ Object.setPrototypeOf(patchedAnthropic, originalConstructor);
763
+ patchedAnthropic.prototype = originalConstructor.prototype;
764
+ }
765
+ }
766
+ /**
767
+ * Patch an Anthropic instance
768
+ */
769
+ patchInstance(instance) {
770
+ if (!instance.messages?.create) {
771
+ return;
772
+ }
773
+ const instrumentation = this;
774
+ const original = instance.messages.create.bind(instance.messages);
775
+ instance.messages.create = async function (params, options) {
776
+ return instrumentation.tracedCreate(original, params, options);
777
+ };
778
+ }
779
+ /**
780
+ * Wrap a create call with tracing
781
+ */
782
+ async tracedCreate(original, params, options) {
783
+ const messages = params.messages;
784
+ const model = params.model;
785
+ const systemPrompt = this.extractSystemPrompt(params.system);
786
+ const isStreaming = params.stream === true;
787
+ // Normalize messages
788
+ const normalizedMessages = normalizeAnthropicMessages(messages, systemPrompt);
789
+ // Create span attributes
790
+ const attributes = this.createSpanAttributes(normalizedMessages, model, systemPrompt);
791
+ // Add streaming flag
792
+ attributes['llm.request.streaming'] = isStreaming;
793
+ // Add max tokens if specified
794
+ if (params.max_tokens) {
795
+ attributes['llm.request.max_tokens'] = params.max_tokens;
796
+ }
797
+ // Create span
798
+ const span = this.tracer.startSpan('anthropic.messages.create', {
799
+ attributes,
800
+ });
801
+ try {
802
+ if (isStreaming) {
803
+ return await this.handleStreamingResponse(original, params, options, span);
804
+ }
805
+ else {
806
+ return await this.handleNonStreamingResponse(original, params, options, span);
807
+ }
808
+ }
809
+ catch (error) {
810
+ this.setSpanError(span, error);
811
+ throw error;
812
+ }
813
+ finally {
814
+ span.end();
815
+ }
816
+ }
817
+ /**
818
+ * Extract system prompt from Anthropic's system parameter
819
+ * Can be a string or array of content blocks
820
+ */
821
+ extractSystemPrompt(system) {
822
+ if (!system)
823
+ return undefined;
824
+ if (typeof system === 'string') {
825
+ return system;
826
+ }
827
+ if (Array.isArray(system)) {
828
+ return system
829
+ .filter((block) => block.type === 'text')
830
+ .map((block) => block.text)
831
+ .join('\n');
832
+ }
833
+ return undefined;
834
+ }
835
+ /**
836
+ * Handle non-streaming response
837
+ */
838
+ async handleNonStreamingResponse(original, params, options, span) {
839
+ const response = await original(params, options);
840
+ // Extract completion content
841
+ const content = this.extractResponseContent(response.content);
842
+ this.addCompletionAttributes(span, 'assistant', content);
843
+ // Add stop reason
844
+ if (response.stop_reason) {
845
+ span.setAttribute('llm.response.finish_reason', response.stop_reason);
846
+ }
847
+ // Add model from response
848
+ if (response.model) {
849
+ span.setAttribute('llm.response.model', response.model);
850
+ }
851
+ // Add usage metrics (Anthropic uses input_tokens/output_tokens)
852
+ this.addUsageAttributes(span, response.usage);
853
+ this.setSpanSuccess(span);
854
+ return response;
855
+ }
856
+ /**
857
+ * Handle streaming response
858
+ */
859
+ async handleStreamingResponse(original, params, options, span) {
860
+ const stream = await original(params, options);
861
+ const instrumentation = this;
862
+ // Wrap the stream to capture content
863
+ let fullContent = '';
864
+ let stopReason = null;
865
+ let model = null;
866
+ let usage = null;
867
+ // Create async iterator wrapper
868
+ const wrappedStream = {
869
+ [Symbol.asyncIterator]: async function* () {
870
+ try {
871
+ for await (const event of stream) {
872
+ // Handle different event types
873
+ switch (event.type) {
874
+ case 'message_start':
875
+ if (event.message?.model) {
876
+ model = event.message.model;
877
+ }
878
+ if (event.message?.usage) {
879
+ usage = { ...usage, ...event.message.usage };
880
+ }
881
+ break;
882
+ case 'content_block_delta':
883
+ if (event.delta?.type === 'text_delta' && event.delta?.text) {
884
+ fullContent += event.delta.text;
885
+ }
886
+ break;
887
+ case 'message_delta':
888
+ if (event.delta?.stop_reason) {
889
+ stopReason = event.delta.stop_reason;
890
+ }
891
+ if (event.usage) {
892
+ usage = { ...usage, ...event.usage };
893
+ }
894
+ break;
895
+ }
896
+ yield event;
897
+ }
898
+ // After stream completes, add attributes
899
+ instrumentation.addCompletionAttributes(span, 'assistant', fullContent);
900
+ if (stopReason) {
901
+ span.setAttribute('llm.response.finish_reason', stopReason);
902
+ }
903
+ if (model) {
904
+ span.setAttribute('llm.response.model', model);
905
+ }
906
+ if (usage) {
907
+ instrumentation.addUsageAttributes(span, usage);
908
+ }
909
+ instrumentation.setSpanSuccess(span);
910
+ }
911
+ catch (error) {
912
+ instrumentation.setSpanError(span, error);
913
+ throw error;
914
+ }
915
+ },
916
+ // Forward other stream methods
917
+ ...stream,
918
+ };
919
+ return wrappedStream;
920
+ }
921
+ /**
922
+ * Extract text content from Anthropic response content blocks
923
+ */
924
+ extractResponseContent(content) {
925
+ if (!content || !Array.isArray(content)) {
926
+ return '';
927
+ }
928
+ return content
929
+ .filter((block) => block.type === 'text')
930
+ .map((block) => block.text || '')
931
+ .join('\n');
932
+ }
933
+ /**
934
+ * Unpatch the Anthropic client
935
+ */
936
+ unpatch() {
937
+ if (!patched) {
938
+ return;
939
+ }
940
+ try {
941
+ const anthropicModule = require('@anthropic-ai/sdk');
942
+ const Anthropic = anthropicModule.default || anthropicModule;
943
+ if (originalMessagesCreate && Anthropic.Messages?.prototype) {
944
+ Anthropic.Messages.prototype.create = originalMessagesCreate;
945
+ }
946
+ patched = false;
947
+ originalMessagesCreate = null;
948
+ this.debug('Unpatched successfully');
949
+ }
950
+ catch {
951
+ // Ignore
952
+ }
953
+ }
954
+ }
955
+ // Export singleton instance
956
+ const anthropicInstrumentation = new AnthropicInstrumentation();
957
+
958
+ /**
959
+ * List of all available instrumentations
960
+ */
961
+ const instrumentations = [
962
+ openAIInstrumentation,
963
+ anthropicInstrumentation,
964
+ ];
965
+ /**
966
+ * Register all LLM client instrumentations.
967
+ * This is called automatically by Moda.init()
968
+ */
969
+ function registerInstrumentations() {
970
+ for (const instrumentation of instrumentations) {
971
+ try {
972
+ instrumentation.patch();
973
+ }
974
+ catch (error) {
975
+ // Individual instrumentation failures should not break the SDK
976
+ console.warn(`[Moda] Failed to register instrumentation:`, error);
977
+ }
978
+ }
979
+ }
980
+
981
+ let provider = null;
982
+ let exporter = null;
983
+ /**
984
+ * Check if the SDK is initialized
985
+ */
986
+ function isInitialized() {
987
+ return state.initialized;
988
+ }
989
+ /**
990
+ * Initialize the Moda SDK.
991
+ *
992
+ * This sets up OpenTelemetry tracing with an OTLP exporter pointed at Moda's
993
+ * ingestion endpoint and registers instrumentations for OpenAI and Anthropic.
994
+ *
995
+ * @param apiKey - Your Moda API key (format: moda_xxx)
996
+ * @param options - Configuration options
997
+ *
998
+ * @example
999
+ * ```typescript
1000
+ * import { Moda } from '@moda/sdk';
1001
+ *
1002
+ * Moda.init('moda_your_api_key', {
1003
+ * environment: 'production',
1004
+ * debug: false,
1005
+ * });
1006
+ * ```
1007
+ */
1008
+ function init(apiKey, options = {}) {
1009
+ if (state.initialized) {
1010
+ if (state.options.debug) {
1011
+ console.warn('[Moda] SDK already initialized. Call shutdown() before re-initializing.');
1012
+ }
1013
+ return;
1014
+ }
1015
+ if (!apiKey || typeof apiKey !== 'string') {
1016
+ throw new Error('[Moda] API key is required');
1017
+ }
1018
+ // Merge options with defaults
1019
+ const mergedOptions = {
1020
+ ...DEFAULT_OPTIONS,
1021
+ ...options,
1022
+ };
1023
+ state.apiKey = apiKey;
1024
+ setStateOptions(mergedOptions);
1025
+ if (!mergedOptions.enabled) {
1026
+ if (mergedOptions.debug) {
1027
+ console.log('[Moda] SDK disabled via options');
1028
+ }
1029
+ state.initialized = true;
1030
+ return;
1031
+ }
1032
+ // Create resource with service info
1033
+ const resource = new Resource({
1034
+ [ATTR_SERVICE_NAME]: 'moda-sdk',
1035
+ [ATTR_SERVICE_VERSION]: '0.1.0',
1036
+ 'moda.environment': mergedOptions.environment,
1037
+ });
1038
+ // Create OTLP exporter with Moda API key in headers
1039
+ exporter = new OTLPTraceExporter({
1040
+ url: mergedOptions.baseUrl,
1041
+ headers: {
1042
+ 'Authorization': `Bearer ${apiKey}`,
1043
+ 'Content-Type': 'application/x-protobuf',
1044
+ },
1045
+ });
1046
+ // Create tracer provider
1047
+ provider = new NodeTracerProvider({
1048
+ resource,
1049
+ });
1050
+ // Use BatchSpanProcessor for production, SimpleSpanProcessor for debug
1051
+ const processor = mergedOptions.debug
1052
+ ? new SimpleSpanProcessor(exporter)
1053
+ : new BatchSpanProcessor(exporter, {
1054
+ maxQueueSize: mergedOptions.batchSize * 2,
1055
+ maxExportBatchSize: mergedOptions.batchSize,
1056
+ scheduledDelayMillis: mergedOptions.flushInterval,
1057
+ });
1058
+ provider.addSpanProcessor(processor);
1059
+ provider.register();
1060
+ // Register LLM instrumentations
1061
+ registerInstrumentations();
1062
+ state.initialized = true;
1063
+ if (mergedOptions.debug) {
1064
+ console.log('[Moda] SDK initialized successfully');
1065
+ console.log(`[Moda] Endpoint: ${mergedOptions.baseUrl}`);
1066
+ console.log(`[Moda] Environment: ${mergedOptions.environment}`);
1067
+ }
1068
+ }
1069
+ /**
1070
+ * Force flush all pending spans to the Moda backend.
1071
+ * Call this before your application exits to ensure all telemetry is sent.
1072
+ *
1073
+ * @example
1074
+ * ```typescript
1075
+ * // Before shutting down
1076
+ * await Moda.flush();
1077
+ * process.exit(0);
1078
+ * ```
1079
+ */
1080
+ async function flush() {
1081
+ if (!state.initialized || !provider) {
1082
+ return;
1083
+ }
1084
+ try {
1085
+ await provider.forceFlush();
1086
+ if (state.options.debug) {
1087
+ console.log('[Moda] Flushed all pending spans');
1088
+ }
1089
+ }
1090
+ catch (error) {
1091
+ if (state.options.debug) {
1092
+ console.error('[Moda] Error flushing spans:', error);
1093
+ }
1094
+ throw error;
1095
+ }
1096
+ }
1097
+ /**
1098
+ * Shutdown the SDK and release all resources.
1099
+ * This flushes any pending spans and stops the tracer provider.
1100
+ *
1101
+ * @example
1102
+ * ```typescript
1103
+ * process.on('SIGTERM', async () => {
1104
+ * await Moda.shutdown();
1105
+ * process.exit(0);
1106
+ * });
1107
+ * ```
1108
+ */
1109
+ async function shutdown() {
1110
+ if (!state.initialized) {
1111
+ return;
1112
+ }
1113
+ try {
1114
+ if (provider) {
1115
+ await provider.shutdown();
1116
+ }
1117
+ if (state.options.debug) {
1118
+ console.log('[Moda] SDK shutdown complete');
1119
+ }
1120
+ }
1121
+ catch (error) {
1122
+ if (state.options.debug) {
1123
+ console.error('[Moda] Error during shutdown:', error);
1124
+ }
1125
+ throw error;
1126
+ }
1127
+ finally {
1128
+ resetState();
1129
+ provider = null;
1130
+ exporter = null;
1131
+ }
1132
+ }
1133
+ /**
1134
+ * Get the OpenTelemetry tracer for creating custom spans.
1135
+ * Returns a no-op tracer if the SDK is not initialized.
1136
+ */
1137
+ function getTracer() {
1138
+ return trace.getTracer('moda-sdk', '0.1.0');
1139
+ }
1140
+
1141
+ /**
1142
+ * @moda/sdk - Official TypeScript/Node.js SDK for Moda LLM observability
1143
+ *
1144
+ * @example
1145
+ * ```typescript
1146
+ * import { Moda } from '@moda/sdk';
1147
+ *
1148
+ * // Initialize the SDK
1149
+ * Moda.init('moda_your_api_key');
1150
+ *
1151
+ * // All OpenAI/Anthropic calls are now automatically tracked
1152
+ * const openai = new OpenAI();
1153
+ * await openai.chat.completions.create({ ... });
1154
+ *
1155
+ * // Flush before exit
1156
+ * await Moda.flush();
1157
+ * ```
1158
+ */
1159
+ // Core SDK functions
1160
+ /**
1161
+ * Main Moda SDK object with all public methods
1162
+ */
1163
+ const Moda = {
1164
+ /**
1165
+ * Initialize the Moda SDK with your API key
1166
+ * @see {@link init}
1167
+ */
1168
+ init,
1169
+ /**
1170
+ * Force flush all pending spans to the Moda backend
1171
+ * @see {@link flush}
1172
+ */
1173
+ flush,
1174
+ /**
1175
+ * Shutdown the SDK and release all resources
1176
+ * @see {@link shutdown}
1177
+ */
1178
+ shutdown,
1179
+ /**
1180
+ * Check if the SDK is initialized
1181
+ * @see {@link isInitialized}
1182
+ */
1183
+ isInitialized,
1184
+ /**
1185
+ * Set a global conversation ID for subsequent LLM calls
1186
+ * @see {@link setConversationId}
1187
+ */
1188
+ setConversationId,
1189
+ /**
1190
+ * Clear the global conversation ID
1191
+ * @see {@link clearConversationId}
1192
+ */
1193
+ clearConversationId,
1194
+ /**
1195
+ * Set a global user ID for subsequent LLM calls
1196
+ * @see {@link setUserId}
1197
+ */
1198
+ setUserId,
1199
+ /**
1200
+ * Clear the global user ID
1201
+ * @see {@link clearUserId}
1202
+ */
1203
+ clearUserId,
1204
+ /**
1205
+ * Get the OpenTelemetry tracer for custom spans
1206
+ * @see {@link getTracer}
1207
+ */
1208
+ getTracer,
1209
+ /**
1210
+ * Get or set the global conversation ID.
1211
+ * Setting to null clears the conversation ID.
1212
+ *
1213
+ * @example
1214
+ * ```typescript
1215
+ * Moda.conversationId = 'session_123';
1216
+ * await client.chat.completions.create({...});
1217
+ * Moda.conversationId = null; // clear
1218
+ * ```
1219
+ */
1220
+ get conversationId() {
1221
+ return getGlobalContext().conversationId ?? null;
1222
+ },
1223
+ set conversationId(id) {
1224
+ if (id) {
1225
+ setConversationId(id);
1226
+ }
1227
+ else {
1228
+ clearConversationId();
1229
+ }
1230
+ },
1231
+ /**
1232
+ * Get or set the global user ID.
1233
+ * Setting to null clears the user ID.
1234
+ *
1235
+ * @example
1236
+ * ```typescript
1237
+ * Moda.userId = 'user_456';
1238
+ * await client.chat.completions.create({...});
1239
+ * Moda.userId = null; // clear
1240
+ * ```
1241
+ */
1242
+ get userId() {
1243
+ return getGlobalContext().userId ?? null;
1244
+ },
1245
+ set userId(id) {
1246
+ if (id) {
1247
+ setUserId(id);
1248
+ }
1249
+ else {
1250
+ clearUserId();
1251
+ }
1252
+ },
1253
+ };
1254
+
1255
+ export { DEFAULT_OPTIONS, Moda, clearConversationId, clearUserId, computeConversationId, Moda as default, flush, generateRandomConversationId, getContext, getEffectiveContext, getGlobalContext, getTracer, init, isInitialized, isValidConversationId, setConversationId, setUserId, shutdown, withContext, withConversationId, withUserId };
1256
+ //# sourceMappingURL=index.mjs.map