scorecard-ai 2.6.0 → 3.0.0-beta.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.
@@ -0,0 +1,485 @@
1
+ import { trace, context, Span, Context } from '@opentelemetry/api';
2
+ import {
3
+ NodeTracerProvider,
4
+ BatchSpanProcessor,
5
+ ReadableSpan,
6
+ Span as SdkSpan,
7
+ SpanProcessor,
8
+ } from '@opentelemetry/sdk-trace-node';
9
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
10
+ import { resourceFromAttributes } from '@opentelemetry/resources';
11
+ import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
12
+ import { readEnv } from '../internal/utils';
13
+ import { ScorecardError } from '../error';
14
+
15
+ /**
16
+ * Configuration for wrapping LLM SDKs
17
+ */
18
+ interface WrapConfig {
19
+ /**
20
+ * ID of the Scorecard project that traces should be associated with.
21
+ * Defaults to SCORECARD_PROJECT_ID environment variable.
22
+ */
23
+ projectId?: string | undefined;
24
+
25
+ /**
26
+ * Scorecard API key for authentication.
27
+ * Defaults to SCORECARD_API_KEY environment variable.
28
+ */
29
+ apiKey?: string | undefined;
30
+
31
+ /**
32
+ * Service name for telemetry.
33
+ * Defaults to "llm-app".
34
+ */
35
+ serviceName?: string | undefined;
36
+
37
+ /**
38
+ * OTLP endpoint for trace export.
39
+ * Defaults to "https://tracing.scorecard.io/otel/v1/traces".
40
+ */
41
+ endpoint?: string | undefined;
42
+
43
+ /**
44
+ * Maximum batch size of spans to be exported in a single request.
45
+ * Lower values provide faster feedback but more network requests.
46
+ * Higher values are more efficient but delay span visibility.
47
+ * @default 1
48
+ */
49
+ maxExportBatchSize?: number | undefined;
50
+ }
51
+
52
+ type LLMProvider = 'openai' | 'anthropic';
53
+
54
+ /**
55
+ * Custom exporter that wraps OTLP exporter and injects projectId from span attributes
56
+ * into the resource before export. This allows per-span projectId while keeping
57
+ * ResourceAttributes where the backend expects them.
58
+ */
59
+ class ScorecardExporter extends OTLPTraceExporter {
60
+ override export(spans: ReadableSpan[], resultCallback: (result: any) => void): void {
61
+ // For each span, inject all scorecard.* attributes into the resource
62
+ spans.forEach((span) => {
63
+ // Collect all scorecard.* attributes from span attributes
64
+ const scorecardAttrs = Object.entries(span.attributes).reduce(
65
+ (acc, [key, value]) => {
66
+ if (key.startsWith('scorecard.')) {
67
+ acc[key] = value;
68
+ }
69
+ return acc;
70
+ },
71
+ {} as Record<string, any>,
72
+ );
73
+
74
+ if (Object.keys(scorecardAttrs).length > 0) {
75
+ // Merge all scorecard.* attributes into the resource
76
+ const newResource = span.resource.merge(resourceFromAttributes(scorecardAttrs));
77
+
78
+ // Directly assign the new resource (cast to any to bypass readonly)
79
+ (span as any).resource = newResource;
80
+ }
81
+ });
82
+
83
+ // Call the parent exporter with modified spans
84
+ super.export(spans, resultCallback);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Composite processor that forwards span events to all registered processors.
90
+ * Allows dynamic addition of exporters after provider registration.
91
+ */
92
+ class CompositeProcessor implements SpanProcessor {
93
+ private processors = new Map<string, BatchSpanProcessor>();
94
+
95
+ addProcessor(apiKey: string, endpoint: string, maxExportBatchSize: number): void {
96
+ const key = `${apiKey}:${endpoint}`;
97
+ if (this.processors.has(key)) return;
98
+
99
+ const exporter = new ScorecardExporter({
100
+ url: endpoint,
101
+ headers: { Authorization: `Bearer ${apiKey}` },
102
+ });
103
+
104
+ const processor = new BatchSpanProcessor(exporter, {
105
+ maxExportBatchSize,
106
+ });
107
+
108
+ this.processors.set(key, processor);
109
+ }
110
+
111
+ onStart(span: SdkSpan, parentContext: Context): void {
112
+ for (const processor of this.processors.values()) {
113
+ processor.onStart(span, parentContext);
114
+ }
115
+ }
116
+
117
+ onEnd(span: ReadableSpan): void {
118
+ for (const processor of this.processors.values()) {
119
+ processor.onEnd(span);
120
+ }
121
+ }
122
+
123
+ async forceFlush(): Promise<void> {
124
+ await Promise.all(Array.from(this.processors.values()).map((p) => p.forceFlush()));
125
+ }
126
+
127
+ async shutdown(): Promise<void> {
128
+ await Promise.all(Array.from(this.processors.values()).map((p) => p.shutdown()));
129
+ }
130
+ }
131
+
132
+ let globalProvider: NodeTracerProvider | null = null;
133
+ let globalTracer: ReturnType<typeof trace.getTracer> | null = null;
134
+ let compositeProcessor: CompositeProcessor | null = null;
135
+
136
+ /**
137
+ * Initialize OpenTelemetry provider for LLM SDK wrappers.
138
+ * Creates a single global provider for nesting support, with exporters
139
+ * added dynamically for each unique apiKey+endpoint combination.
140
+ */
141
+ function initProvider(config: WrapConfig): string | undefined {
142
+ const apiKey = config.apiKey || readEnv('SCORECARD_API_KEY');
143
+ if (!apiKey) {
144
+ throw new ScorecardError(
145
+ 'Scorecard API key is required. Set SCORECARD_API_KEY environment variable or pass apiKey in config.',
146
+ );
147
+ }
148
+
149
+ const endpoint = config.endpoint || 'https://tracing.scorecard.io/otel/v1/traces';
150
+ const serviceName = config.serviceName || 'llm-app';
151
+ const projectId = config.projectId || readEnv('SCORECARD_PROJECT_ID');
152
+ const maxExportBatchSize = config.maxExportBatchSize ?? 1;
153
+
154
+ // Create the global provider once (enables span nesting)
155
+ if (!globalProvider) {
156
+ compositeProcessor = new CompositeProcessor();
157
+
158
+ const resource = resourceFromAttributes({
159
+ [ATTR_SERVICE_NAME]: serviceName,
160
+ });
161
+
162
+ globalProvider = new NodeTracerProvider({
163
+ resource,
164
+ spanProcessors: [compositeProcessor as any],
165
+ });
166
+
167
+ globalProvider.register();
168
+ globalTracer = trace.getTracer('scorecard-llm');
169
+ }
170
+
171
+ // Add an exporter for this specific apiKey+endpoint (if not already added)
172
+ compositeProcessor?.addProcessor(apiKey, endpoint, maxExportBatchSize);
173
+
174
+ return projectId;
175
+ }
176
+
177
+ /**
178
+ * Detect which LLM provider the client is for
179
+ */
180
+ function detectProvider(client: any): LLMProvider {
181
+ // Check for OpenAI SDK structure
182
+ if (client.chat?.completions) {
183
+ return 'openai';
184
+ }
185
+
186
+ // Check for Anthropic SDK structure
187
+ if (client.messages) {
188
+ return 'anthropic';
189
+ }
190
+
191
+ throw new ScorecardError(
192
+ 'Unable to detect LLM provider. Client must be an OpenAI or Anthropic SDK instance.',
193
+ );
194
+ }
195
+
196
+ /**
197
+ * Handle OpenAI-specific response parsing
198
+ */
199
+ function handleOpenAIResponse(span: Span, result: any, params: any) {
200
+ span.setAttributes({
201
+ 'gen_ai.response.id': result.id || 'unknown',
202
+ 'gen_ai.response.model': result.model || params.model || 'unknown',
203
+ 'gen_ai.response.finish_reason': result.choices?.[0]?.finish_reason || 'unknown',
204
+ 'gen_ai.usage.prompt_tokens': result.usage?.prompt_tokens || 0,
205
+ 'gen_ai.usage.completion_tokens': result.usage?.completion_tokens || 0,
206
+ 'gen_ai.usage.total_tokens': result.usage?.total_tokens || 0,
207
+ });
208
+
209
+ if (result.choices?.[0]?.message) {
210
+ span.setAttribute('gen_ai.completion.choices', JSON.stringify([result.choices[0].message]));
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Handle Anthropic-specific response parsing
216
+ */
217
+ function handleAnthropicResponse(span: Span, result: any, params: any) {
218
+ span.setAttributes({
219
+ 'gen_ai.response.id': result.id || 'unknown',
220
+ 'gen_ai.response.model': result.model || params.model || 'unknown',
221
+ 'gen_ai.response.finish_reason': result.stop_reason || 'unknown',
222
+ 'gen_ai.usage.prompt_tokens': result.usage?.input_tokens || 0,
223
+ 'gen_ai.usage.completion_tokens': result.usage?.output_tokens || 0,
224
+ 'gen_ai.usage.total_tokens': (result.usage?.input_tokens || 0) + (result.usage?.output_tokens || 0),
225
+ });
226
+
227
+ // Collect text from all text blocks (Anthropic can return multiple content blocks)
228
+ if (result.content) {
229
+ const completionTexts = result.content.filter((c: any) => c.text).map((c: any) => c.text);
230
+ if (completionTexts.length > 0) {
231
+ span.setAttribute(
232
+ 'gen_ai.completion.choices',
233
+ JSON.stringify([{ message: { role: 'assistant', content: completionTexts.join('\n') } }]),
234
+ );
235
+ }
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Wrapper for async streams that collects metadata and ends span when consumed
241
+ */
242
+ class StreamWrapper {
243
+ private contentParts: string[] = [];
244
+ private finishReason: string | null = null;
245
+ private usageData: Record<string, number> = {};
246
+ private responseId: string | null = null;
247
+ private model: string | null = null;
248
+
249
+ constructor(
250
+ private stream: AsyncIterable<any>,
251
+ private span: Span,
252
+ private provider: LLMProvider,
253
+ private params: any,
254
+ ) {}
255
+
256
+ async *[Symbol.asyncIterator](): AsyncIterator<any> {
257
+ try {
258
+ for await (const chunk of this.stream) {
259
+ this.processChunk(chunk);
260
+ yield chunk;
261
+ }
262
+ } finally {
263
+ this.finalizeSpan();
264
+ }
265
+ }
266
+
267
+ private processChunk(chunk: any): void {
268
+ // OpenAI streaming format
269
+ if (this.provider === 'openai') {
270
+ if (!this.responseId && chunk.id) {
271
+ this.responseId = chunk.id;
272
+ }
273
+ if (!this.model && chunk.model) {
274
+ this.model = chunk.model;
275
+ }
276
+
277
+ if (chunk.choices?.[0]) {
278
+ const choice = chunk.choices[0];
279
+ if (choice.delta?.content) {
280
+ this.contentParts.push(choice.delta.content);
281
+ }
282
+ if (choice.finish_reason) {
283
+ this.finishReason = choice.finish_reason;
284
+ }
285
+ }
286
+
287
+ // OpenAI includes usage in the last chunk with stream_options
288
+ if (chunk.usage) {
289
+ this.usageData = {
290
+ prompt_tokens: chunk.usage.prompt_tokens || 0,
291
+ completion_tokens: chunk.usage.completion_tokens || 0,
292
+ total_tokens: chunk.usage.total_tokens || 0,
293
+ };
294
+ }
295
+ }
296
+ // Anthropic streaming format
297
+ else if (this.provider === 'anthropic') {
298
+ if (chunk.type === 'message_start' && chunk.message) {
299
+ this.responseId = chunk.message.id;
300
+ this.model = chunk.message.model;
301
+ if (chunk.message.usage) {
302
+ this.usageData['input_tokens'] = chunk.message.usage.input_tokens || 0;
303
+ }
304
+ } else if (chunk.type === 'content_block_delta' && chunk.delta?.text) {
305
+ this.contentParts.push(chunk.delta.text);
306
+ } else if (chunk.type === 'message_delta') {
307
+ if (chunk.delta?.stop_reason) {
308
+ this.finishReason = chunk.delta.stop_reason;
309
+ }
310
+ if (chunk.usage?.output_tokens) {
311
+ this.usageData['output_tokens'] = chunk.usage.output_tokens;
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ private finalizeSpan(): void {
318
+ // Set response attributes
319
+ this.span.setAttributes({
320
+ 'gen_ai.response.id': this.responseId || 'unknown',
321
+ 'gen_ai.response.model': this.model || this.params.model || 'unknown',
322
+ 'gen_ai.response.finish_reason': this.finishReason || 'unknown',
323
+ });
324
+
325
+ // Set usage data
326
+ if (Object.keys(this.usageData).length > 0) {
327
+ if (this.provider === 'openai') {
328
+ this.span.setAttributes({
329
+ 'gen_ai.usage.prompt_tokens': this.usageData['prompt_tokens'] || 0,
330
+ 'gen_ai.usage.completion_tokens': this.usageData['completion_tokens'] || 0,
331
+ 'gen_ai.usage.total_tokens': this.usageData['total_tokens'] || 0,
332
+ });
333
+ } else if (this.provider === 'anthropic') {
334
+ const inputTokens = this.usageData['input_tokens'] || 0;
335
+ const outputTokens = this.usageData['output_tokens'] || 0;
336
+ this.span.setAttributes({
337
+ 'gen_ai.usage.prompt_tokens': inputTokens,
338
+ 'gen_ai.usage.completion_tokens': outputTokens,
339
+ 'gen_ai.usage.total_tokens': inputTokens + outputTokens,
340
+ });
341
+ }
342
+ }
343
+
344
+ // Set completion content if any was collected
345
+ if (this.contentParts.length > 0) {
346
+ const content = this.contentParts.join('');
347
+ this.span.setAttribute(
348
+ 'gen_ai.completion.choices',
349
+ JSON.stringify([{ message: { role: 'assistant', content } }]),
350
+ );
351
+ }
352
+
353
+ this.span.end();
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Wrap any LLM SDK (OpenAI or Anthropic) to automatically trace all API calls
359
+ *
360
+ * @example
361
+ * ```typescript
362
+ * import { wrap } from '@scorecard/node';
363
+ * import OpenAI from 'openai';
364
+ * import Anthropic from '@anthropic-ai/sdk';
365
+ *
366
+ * // Works with OpenAI
367
+ * const openai = wrap(new OpenAI({ apiKey: '...' }), {
368
+ * apiKey: process.env.SCORECARD_API_KEY,
369
+ * projectId: '123'
370
+ * });
371
+ *
372
+ * // Works with Anthropic
373
+ * const claude = wrap(new Anthropic({ apiKey: '...' }), {
374
+ * apiKey: process.env.SCORECARD_API_KEY,
375
+ * projectId: '123'
376
+ * });
377
+ *
378
+ * // Use normally - traces are automatically sent to Scorecard
379
+ * const response = await openai.chat.completions.create({...});
380
+ * const response2 = await claude.messages.create({...});
381
+ * ```
382
+ */
383
+ export function wrap<T>(client: T, config: WrapConfig = {}): T {
384
+ const projectId = initProvider(config);
385
+
386
+ if (!globalTracer) {
387
+ throw new ScorecardError('Failed to initialize tracer');
388
+ }
389
+
390
+ const tracer = globalTracer;
391
+ const provider = detectProvider(client);
392
+
393
+ // Track the path to determine if we should wrap this method
394
+ const createHandler = (target: any, path: string[] = []): ProxyHandler<any> => ({
395
+ get(target, prop: string | symbol) {
396
+ const value = target[prop];
397
+
398
+ // Check if this is a method we should wrap based on the path
399
+ const currentPath = [...path, prop.toString()];
400
+ const shouldWrap =
401
+ (provider === 'openai' && currentPath.join('.') === 'chat.completions.create') ||
402
+ (provider === 'anthropic' &&
403
+ (currentPath.join('.') === 'messages.create' || currentPath.join('.') === 'messages.stream'));
404
+
405
+ // Intercept specific LLM methods
406
+ if (shouldWrap && typeof value === 'function') {
407
+ return async function (this: any, ...args: any[]) {
408
+ const params = args[0] || {};
409
+ // Streaming if: 1) stream param is true, or 2) using the 'stream' method
410
+ const isStreaming = params.stream === true || prop === 'stream';
411
+
412
+ // Start span in the current active context (enables nesting)
413
+ const span = tracer.startSpan(`${provider}.request`, {}, context.active());
414
+
415
+ // Set request attributes (common to both providers)
416
+ span.setAttributes({
417
+ 'gen_ai.system': provider,
418
+ 'gen_ai.request.model': params.model || 'unknown',
419
+ 'gen_ai.operation.name': 'chat',
420
+ ...(params.temperature !== undefined && { 'gen_ai.request.temperature': params.temperature }),
421
+ ...(params.max_tokens !== undefined && { 'gen_ai.request.max_tokens': params.max_tokens }),
422
+ ...(params.top_p !== undefined && { 'gen_ai.request.top_p': params.top_p }),
423
+ });
424
+
425
+ // Store projectId as span attribute - our custom exporter will inject it
426
+ // into ResourceAttributes before export (where the backend expects it)
427
+ if (projectId) {
428
+ span.setAttribute('scorecard.project_id', projectId);
429
+ }
430
+
431
+ // Set prompt messages
432
+ if (params.messages) {
433
+ span.setAttribute('gen_ai.prompt.messages', JSON.stringify(params.messages));
434
+ }
435
+
436
+ // Execute within the span's context (enables nested spans to be children)
437
+ return context.with(trace.setSpan(context.active(), span), async () => {
438
+ try {
439
+ const result = await value.apply(target, args);
440
+
441
+ if (isStreaming) {
442
+ // For streaming, wrap the stream to collect metadata and end span when consumed
443
+ return new StreamWrapper(result, span, provider, params);
444
+ } else {
445
+ // For non-streaming, set response attributes immediately
446
+ if (provider === 'openai') {
447
+ handleOpenAIResponse(span, result, params);
448
+ } else if (provider === 'anthropic') {
449
+ handleAnthropicResponse(span, result, params);
450
+ }
451
+ return result;
452
+ }
453
+ } catch (error: any) {
454
+ span.recordException(error);
455
+ throw error;
456
+ } finally {
457
+ // Only end span for non-streaming (streaming ends in StreamWrapper)
458
+ if (!isStreaming) {
459
+ span.end();
460
+ }
461
+ }
462
+ });
463
+ };
464
+ }
465
+
466
+ // Recursively proxy nested objects, passing the path along
467
+ if (value && typeof value === 'object') {
468
+ return new Proxy(value, createHandler(value, currentPath));
469
+ }
470
+
471
+ // Return functions and primitives as-is
472
+ if (typeof value === 'function') {
473
+ return value.bind(target);
474
+ }
475
+
476
+ return value;
477
+ },
478
+ });
479
+
480
+ return new Proxy(client, createHandler(client, [])) as T;
481
+ }
482
+
483
+ // Backwards compatibility aliases
484
+ export const wrapOpenAI = wrap;
485
+ export const wrapAnthropic = wrap;
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '2.6.0'; // x-release-please-version
1
+ export const VERSION = '3.0.0-beta.0'; // x-release-please-version
package/version.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "2.6.0";
1
+ export declare const VERSION = "3.0.0-beta.0";
2
2
  //# sourceMappingURL=version.d.mts.map
package/version.d.mts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"version.d.mts","sourceRoot":"","sources":["src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,UAAU,CAAC"}
1
+ {"version":3,"file":"version.d.mts","sourceRoot":"","sources":["src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,iBAAiB,CAAC"}
package/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "2.6.0";
1
+ export declare const VERSION = "3.0.0-beta.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/version.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,UAAU,CAAC"}
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,iBAAiB,CAAC"}
package/version.js CHANGED
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
- exports.VERSION = '2.6.0'; // x-release-please-version
4
+ exports.VERSION = '3.0.0-beta.0'; // x-release-please-version
5
5
  //# sourceMappingURL=version.js.map
package/version.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"version.js","sourceRoot":"","sources":["src/version.ts"],"names":[],"mappings":";;;AAAa,QAAA,OAAO,GAAG,OAAO,CAAC,CAAC,2BAA2B"}
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["src/version.ts"],"names":[],"mappings":";;;AAAa,QAAA,OAAO,GAAG,cAAc,CAAC,CAAC,2BAA2B"}
package/version.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '2.6.0'; // x-release-please-version
1
+ export const VERSION = '3.0.0-beta.0'; // x-release-please-version
2
2
  //# sourceMappingURL=version.mjs.map
package/version.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"version.mjs","sourceRoot":"","sources":["src/version.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,OAAO,GAAG,OAAO,CAAC,CAAC,2BAA2B"}
1
+ {"version":3,"file":"version.mjs","sourceRoot":"","sources":["src/version.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,OAAO,GAAG,cAAc,CAAC,CAAC,2BAA2B"}