0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
294 lines
9.3 KiB
TypeScript
294 lines
9.3 KiB
TypeScript
/**
|
|
* Integration tests for OpenTelemetry tracing infrastructure.
|
|
*
|
|
* These tests verify that the OTEL SDK can be initialized and that
|
|
* provider calls correctly create spans with GenAI semantic conventions.
|
|
*/
|
|
|
|
import { SpanStatusCode } from '@opentelemetry/api';
|
|
import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
|
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
GenAIAttributes,
|
|
getGenAITracer,
|
|
PromptfooAttributes,
|
|
withGenAISpan,
|
|
} from '../../src/tracing/genaiTracer';
|
|
|
|
import type { GenAISpanContext, GenAISpanResult } from '../../src/tracing/genaiTracer';
|
|
|
|
describe('OpenTelemetry Tracing Integration', () => {
|
|
let tracerProvider: NodeTracerProvider;
|
|
let memoryExporter: InMemorySpanExporter;
|
|
|
|
beforeAll(() => {
|
|
// Set up an in-memory exporter for testing
|
|
memoryExporter = new InMemorySpanExporter();
|
|
tracerProvider = new NodeTracerProvider({
|
|
spanProcessors: [new SimpleSpanProcessor(memoryExporter)],
|
|
});
|
|
tracerProvider.register();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tracerProvider.shutdown();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
memoryExporter.reset();
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
describe('withGenAISpan', () => {
|
|
it('should create a span with correct GenAI attributes', async () => {
|
|
const spanContext: GenAISpanContext = {
|
|
system: 'openai',
|
|
operationName: 'chat',
|
|
model: 'gpt-4',
|
|
providerId: 'openai:gpt-4',
|
|
maxTokens: 1000,
|
|
temperature: 0.7,
|
|
testIndex: 5,
|
|
promptLabel: 'test-prompt',
|
|
};
|
|
|
|
const mockResult = {
|
|
output: 'Hello, world!',
|
|
tokenUsage: { prompt: 10, completion: 5, total: 15 },
|
|
};
|
|
|
|
const resultExtractor = (): GenAISpanResult => ({
|
|
tokenUsage: { prompt: 10, completion: 5, total: 15 },
|
|
finishReasons: ['stop'],
|
|
});
|
|
|
|
const result = await withGenAISpan(spanContext, async () => mockResult, resultExtractor);
|
|
|
|
expect(result).toEqual(mockResult);
|
|
|
|
// Get the exported spans
|
|
const spans = memoryExporter.getFinishedSpans();
|
|
expect(spans.length).toBe(1);
|
|
|
|
const span = spans[0];
|
|
|
|
// Verify span name follows GenAI convention
|
|
expect(span.name).toBe('chat gpt-4');
|
|
|
|
// Verify GenAI attributes
|
|
expect(span.attributes[GenAIAttributes.SYSTEM]).toBe('openai');
|
|
expect(span.attributes[GenAIAttributes.OPERATION_NAME]).toBe('chat');
|
|
expect(span.attributes[GenAIAttributes.REQUEST_MODEL]).toBe('gpt-4');
|
|
expect(span.attributes[GenAIAttributes.REQUEST_MAX_TOKENS]).toBe(1000);
|
|
expect(span.attributes[GenAIAttributes.REQUEST_TEMPERATURE]).toBe(0.7);
|
|
|
|
// Verify Promptfoo attributes
|
|
expect(span.attributes[PromptfooAttributes.PROVIDER_ID]).toBe('openai:gpt-4');
|
|
expect(span.attributes[PromptfooAttributes.TEST_INDEX]).toBe(5);
|
|
expect(span.attributes[PromptfooAttributes.PROMPT_LABEL]).toBe('test-prompt');
|
|
|
|
// Verify response attributes
|
|
expect(span.attributes[GenAIAttributes.USAGE_INPUT_TOKENS]).toBe(10);
|
|
expect(span.attributes[GenAIAttributes.USAGE_OUTPUT_TOKENS]).toBe(5);
|
|
expect(span.attributes[GenAIAttributes.USAGE_TOTAL_TOKENS]).toBe(15);
|
|
expect(span.attributes[GenAIAttributes.RESPONSE_FINISH_REASONS]).toEqual(['stop']);
|
|
|
|
// Verify span status
|
|
expect(span.status.code).toBe(SpanStatusCode.OK);
|
|
});
|
|
|
|
it('should handle errors and set span status to ERROR', async () => {
|
|
const spanContext: GenAISpanContext = {
|
|
system: 'anthropic',
|
|
operationName: 'chat',
|
|
model: 'claude-3-opus',
|
|
providerId: 'anthropic:claude-3-opus',
|
|
};
|
|
|
|
const error = new Error('API rate limit exceeded');
|
|
|
|
await expect(
|
|
withGenAISpan(spanContext, async () => {
|
|
throw error;
|
|
}),
|
|
).rejects.toThrow('API rate limit exceeded');
|
|
|
|
const spans = memoryExporter.getFinishedSpans();
|
|
expect(spans.length).toBe(1);
|
|
|
|
const span = spans[0];
|
|
|
|
// Verify span status is ERROR
|
|
expect(span.status.code).toBe(SpanStatusCode.ERROR);
|
|
expect(span.status.message).toBe('API rate limit exceeded');
|
|
|
|
// Verify exception was recorded
|
|
expect(span.events.length).toBeGreaterThan(0);
|
|
const exceptionEvent = span.events.find((e) => e.name === 'exception');
|
|
expect(exceptionEvent).toBeDefined();
|
|
});
|
|
|
|
it('should work without result extractor', async () => {
|
|
const spanContext: GenAISpanContext = {
|
|
system: 'bedrock',
|
|
operationName: 'chat',
|
|
model: 'anthropic.claude-3-sonnet',
|
|
providerId: 'bedrock:claude-3-sonnet',
|
|
};
|
|
|
|
const result = await withGenAISpan(spanContext, async () => ({
|
|
output: 'response',
|
|
}));
|
|
|
|
expect(result).toEqual({ output: 'response' });
|
|
|
|
const spans = memoryExporter.getFinishedSpans();
|
|
expect(spans.length).toBe(1);
|
|
|
|
// Should still have basic attributes
|
|
expect(spans[0].attributes[GenAIAttributes.SYSTEM]).toBe('bedrock');
|
|
expect(spans[0].status.code).toBe(SpanStatusCode.OK);
|
|
});
|
|
|
|
it('should capture multiple nested spans correctly', async () => {
|
|
const outerContext: GenAISpanContext = {
|
|
system: 'azure',
|
|
operationName: 'chat',
|
|
model: 'gpt-4-deployment',
|
|
providerId: 'azure:gpt-4',
|
|
};
|
|
|
|
const innerContext: GenAISpanContext = {
|
|
system: 'openai',
|
|
operationName: 'embedding',
|
|
model: 'text-embedding-ada-002',
|
|
providerId: 'openai:embedding',
|
|
};
|
|
|
|
await withGenAISpan(outerContext, async () => {
|
|
// Nested span for embedding
|
|
await withGenAISpan(innerContext, async () => {
|
|
return { embedding: [0.1, 0.2, 0.3] };
|
|
});
|
|
return { output: 'response' };
|
|
});
|
|
|
|
const spans = memoryExporter.getFinishedSpans();
|
|
expect(spans.length).toBe(2);
|
|
|
|
// Inner span should finish first
|
|
const embeddingSpan = spans.find((s) => s.name.includes('embedding'));
|
|
const chatSpan = spans.find((s) => s.name.includes('chat'));
|
|
|
|
expect(embeddingSpan).toBeDefined();
|
|
expect(chatSpan).toBeDefined();
|
|
expect(embeddingSpan!.attributes[GenAIAttributes.SYSTEM]).toBe('openai');
|
|
expect(chatSpan!.attributes[GenAIAttributes.SYSTEM]).toBe('azure');
|
|
});
|
|
});
|
|
|
|
describe('getGenAITracer', () => {
|
|
it('should return a tracer with correct name', () => {
|
|
const tracer = getGenAITracer();
|
|
expect(tracer).toBeDefined();
|
|
// Tracer should be usable
|
|
const span = tracer.startSpan('test-span');
|
|
span.end();
|
|
|
|
const spans = memoryExporter.getFinishedSpans();
|
|
expect(spans.some((s) => s.name === 'test-span')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Token usage with completion details', () => {
|
|
it('should capture reasoning tokens in completion details', async () => {
|
|
const spanContext: GenAISpanContext = {
|
|
system: 'openai',
|
|
operationName: 'chat',
|
|
model: 'o1-preview',
|
|
providerId: 'openai:o1-preview',
|
|
};
|
|
|
|
const resultExtractor = (): GenAISpanResult => ({
|
|
tokenUsage: {
|
|
prompt: 100,
|
|
completion: 500,
|
|
total: 600,
|
|
completionDetails: {
|
|
reasoning: 450,
|
|
},
|
|
},
|
|
});
|
|
|
|
await withGenAISpan(spanContext, async () => ({ output: 'response' }), resultExtractor);
|
|
|
|
const spans = memoryExporter.getFinishedSpans();
|
|
expect(spans.length).toBe(1);
|
|
|
|
const span = spans[0];
|
|
expect(span.attributes[GenAIAttributes.USAGE_INPUT_TOKENS]).toBe(100);
|
|
expect(span.attributes[GenAIAttributes.USAGE_OUTPUT_TOKENS]).toBe(500);
|
|
expect(span.attributes[GenAIAttributes.USAGE_REASONING_TOKENS]).toBe(450);
|
|
});
|
|
|
|
it('should capture predicted token details', async () => {
|
|
const spanContext: GenAISpanContext = {
|
|
system: 'openai',
|
|
operationName: 'chat',
|
|
model: 'gpt-4-turbo',
|
|
providerId: 'openai:gpt-4-turbo',
|
|
};
|
|
|
|
const resultExtractor = (): GenAISpanResult => ({
|
|
tokenUsage: {
|
|
prompt: 50,
|
|
completion: 30,
|
|
total: 80,
|
|
completionDetails: {
|
|
acceptedPrediction: 25,
|
|
rejectedPrediction: 5,
|
|
},
|
|
},
|
|
});
|
|
|
|
await withGenAISpan(spanContext, async () => ({ output: 'response' }), resultExtractor);
|
|
|
|
const spans = memoryExporter.getFinishedSpans();
|
|
const span = spans[0];
|
|
|
|
expect(span.attributes[GenAIAttributes.USAGE_ACCEPTED_PREDICTION_TOKENS]).toBe(25);
|
|
expect(span.attributes[GenAIAttributes.USAGE_REJECTED_PREDICTION_TOKENS]).toBe(5);
|
|
});
|
|
|
|
it('should capture cached tokens', async () => {
|
|
const spanContext: GenAISpanContext = {
|
|
system: 'anthropic',
|
|
operationName: 'chat',
|
|
model: 'claude-3-sonnet',
|
|
providerId: 'anthropic:claude-3-sonnet',
|
|
};
|
|
|
|
const resultExtractor = (): GenAISpanResult => ({
|
|
tokenUsage: {
|
|
prompt: 200,
|
|
completion: 100,
|
|
total: 300,
|
|
cached: 150,
|
|
},
|
|
});
|
|
|
|
await withGenAISpan(
|
|
spanContext,
|
|
async () => ({ output: 'cached response' }),
|
|
resultExtractor,
|
|
);
|
|
|
|
const spans = memoryExporter.getFinishedSpans();
|
|
const span = spans[0];
|
|
|
|
expect(span.attributes[GenAIAttributes.USAGE_CACHED_TOKENS]).toBe(150);
|
|
});
|
|
});
|
|
});
|