Files
promptfoo--promptfoo/test/tracing/genaiTracer.test.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

454 lines
15 KiB
TypeScript

import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
GenAIAttributes,
type GenAISpanContext,
type GenAISpanResult,
getCurrentSpanId,
getCurrentTraceId,
getTraceparent,
PromptfooAttributes,
setGenAIResponseAttributes,
withGenAISpan,
} from '../../src/tracing/genaiTracer';
// Mock @opentelemetry/api
const mockSpan = {
setAttribute: vi.fn(),
setStatus: vi.fn(),
end: vi.fn(),
recordException: vi.fn(),
spanContext: vi.fn(() => ({
traceId: 'mock-trace-id-1234567890abcdef',
spanId: 'mock-span-id-12345678',
traceFlags: 1,
})),
};
const mockTracer = {
// Handle both 3-param (name, options, fn) and 4-param (name, options, parentContext, fn) signatures
startActiveSpan: vi.fn((_name, _options, arg3, arg4) => {
const fn = typeof arg4 === 'function' ? arg4 : arg3;
return fn(mockSpan);
}),
};
vi.mock('@opentelemetry/api', async () => {
const actual = await vi.importActual('@opentelemetry/api');
return {
...actual,
trace: {
getTracer: vi.fn(() => mockTracer),
getActiveSpan: vi.fn(() => mockSpan),
},
SpanKind: {
CLIENT: 2,
},
SpanStatusCode: {
OK: 1,
ERROR: 2,
},
};
});
describe('genaiTracer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
describe('GenAIAttributes', () => {
it('should have correct attribute names for GenAI semantic conventions', () => {
expect(GenAIAttributes.SYSTEM).toBe('gen_ai.system');
expect(GenAIAttributes.OPERATION_NAME).toBe('gen_ai.operation.name');
expect(GenAIAttributes.REQUEST_MODEL).toBe('gen_ai.request.model');
expect(GenAIAttributes.USAGE_INPUT_TOKENS).toBe('gen_ai.usage.input_tokens');
expect(GenAIAttributes.USAGE_OUTPUT_TOKENS).toBe('gen_ai.usage.output_tokens');
});
});
describe('PromptfooAttributes', () => {
it('should have correct attribute names for promptfoo-specific attributes', () => {
expect(PromptfooAttributes.PROVIDER_ID).toBe('promptfoo.provider.id');
expect(PromptfooAttributes.EVAL_ID).toBe('promptfoo.eval.id');
expect(PromptfooAttributes.TEST_INDEX).toBe('promptfoo.test.index');
expect(PromptfooAttributes.PROMPT_LABEL).toBe('promptfoo.prompt.label');
});
});
describe('withGenAISpan', () => {
const baseContext: GenAISpanContext = {
system: 'openai',
operationName: 'chat',
model: 'gpt-4',
providerId: 'openai:gpt-4',
};
it('should create span with correct name', async () => {
await withGenAISpan(baseContext, async () => ({ output: 'test' }));
expect(mockTracer.startActiveSpan).toHaveBeenCalledWith(
'chat gpt-4',
expect.any(Object),
expect.anything(), // parentContext
expect.any(Function),
);
});
it('should set span kind to CLIENT', async () => {
await withGenAISpan(baseContext, async () => ({ output: 'test' }));
expect(mockTracer.startActiveSpan).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ kind: SpanKind.CLIENT }),
expect.anything(), // parentContext
expect.any(Function),
);
});
it('should set required GenAI attributes', async () => {
await withGenAISpan(baseContext, async () => ({ output: 'test' }));
const callArgs = mockTracer.startActiveSpan.mock.calls[0];
const options = callArgs[1];
expect(options.attributes).toMatchObject({
[GenAIAttributes.SYSTEM]: 'openai',
[GenAIAttributes.OPERATION_NAME]: 'chat',
[GenAIAttributes.REQUEST_MODEL]: 'gpt-4',
[PromptfooAttributes.PROVIDER_ID]: 'openai:gpt-4',
});
});
it('should set optional request attributes when provided', async () => {
const contextWithOptions: GenAISpanContext = {
...baseContext,
maxTokens: 1000,
temperature: 0.7,
topP: 0.9,
stopSequences: ['END'],
};
await withGenAISpan(contextWithOptions, async () => ({ output: 'test' }));
const callArgs = mockTracer.startActiveSpan.mock.calls[0];
const options = callArgs[1];
expect(options.attributes).toMatchObject({
[GenAIAttributes.REQUEST_MAX_TOKENS]: 1000,
[GenAIAttributes.REQUEST_TEMPERATURE]: 0.7,
[GenAIAttributes.REQUEST_TOP_P]: 0.9,
[GenAIAttributes.REQUEST_STOP_SEQUENCES]: ['END'],
});
});
it('should set promptfoo context attributes when provided', async () => {
const contextWithPromptfoo: GenAISpanContext = {
...baseContext,
evalId: 'eval-123',
testIndex: 5,
promptLabel: 'test-prompt',
};
await withGenAISpan(contextWithPromptfoo, async () => ({ output: 'test' }));
const callArgs = mockTracer.startActiveSpan.mock.calls[0];
const options = callArgs[1];
expect(options.attributes).toMatchObject({
[PromptfooAttributes.EVAL_ID]: 'eval-123',
[PromptfooAttributes.TEST_INDEX]: 5,
[PromptfooAttributes.PROMPT_LABEL]: 'test-prompt',
});
});
it('should return the result from the wrapped function', async () => {
const expectedResult = { output: 'Hello, world!' };
const result = await withGenAISpan(baseContext, async () => expectedResult);
expect(result).toEqual(expectedResult);
});
it('should set OK status on success', async () => {
await withGenAISpan(baseContext, async () => ({ output: 'test' }));
expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.OK });
});
it('should end the span after execution', async () => {
await withGenAISpan(baseContext, async () => ({ output: 'test' }));
expect(mockSpan.end).toHaveBeenCalled();
});
it('should set ERROR status and record exception on failure', async () => {
const error = new Error('API call failed');
await expect(
withGenAISpan(baseContext, async () => {
throw error;
}),
).rejects.toThrow('API call failed');
expect(mockSpan.setStatus).toHaveBeenCalledWith({
code: SpanStatusCode.ERROR,
message: 'API call failed',
});
expect(mockSpan.recordException).toHaveBeenCalledWith(error);
});
it('should end span even on failure', async () => {
try {
await withGenAISpan(baseContext, async () => {
throw new Error('fail');
});
} catch {
// Expected
}
expect(mockSpan.end).toHaveBeenCalled();
});
it('should call result extractor and set response attributes', async () => {
const resultExtractor = vi.fn((_value: { output: string }) => ({
tokenUsage: { prompt: 100, completion: 50 },
responseId: 'resp-123',
}));
await withGenAISpan(baseContext, async () => ({ output: 'test' }), resultExtractor);
expect(resultExtractor).toHaveBeenCalledWith({ output: 'test' });
expect(mockSpan.setAttribute).toHaveBeenCalledWith(GenAIAttributes.USAGE_INPUT_TOKENS, 100);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(GenAIAttributes.USAGE_OUTPUT_TOKENS, 50);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(GenAIAttributes.RESPONSE_ID, 'resp-123');
});
});
describe('setGenAIResponseAttributes', () => {
it('should set token usage attributes', () => {
const result: GenAISpanResult = {
tokenUsage: {
prompt: 100,
completion: 50,
total: 150,
cached: 20,
},
};
setGenAIResponseAttributes(mockSpan as any, result);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(GenAIAttributes.USAGE_INPUT_TOKENS, 100);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(GenAIAttributes.USAGE_OUTPUT_TOKENS, 50);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(GenAIAttributes.USAGE_TOTAL_TOKENS, 150);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(GenAIAttributes.USAGE_CACHED_TOKENS, 20);
});
it('should set completion details attributes', () => {
const result: GenAISpanResult = {
tokenUsage: {
completionDetails: {
reasoning: 25,
acceptedPrediction: 10,
rejectedPrediction: 5,
},
},
};
setGenAIResponseAttributes(mockSpan as any, result);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
GenAIAttributes.USAGE_REASONING_TOKENS,
25,
);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
GenAIAttributes.USAGE_ACCEPTED_PREDICTION_TOKENS,
10,
);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
GenAIAttributes.USAGE_REJECTED_PREDICTION_TOKENS,
5,
);
});
it('should set cache token completion details attributes', () => {
const result: GenAISpanResult = {
tokenUsage: {
completionDetails: {
cacheReadInputTokens: 150,
cacheCreationInputTokens: 40,
},
},
};
setGenAIResponseAttributes(mockSpan as any, result);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
GenAIAttributes.USAGE_CACHE_READ_INPUT_TOKENS,
150,
);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
GenAIAttributes.USAGE_CACHE_CREATION_INPUT_TOKENS,
40,
);
});
it('should set response metadata attributes', () => {
const result: GenAISpanResult = {
responseModel: 'gpt-4-0613',
responseId: 'chatcmpl-123',
finishReasons: ['stop'],
};
setGenAIResponseAttributes(mockSpan as any, result);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
GenAIAttributes.RESPONSE_MODEL,
'gpt-4-0613',
);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(
GenAIAttributes.RESPONSE_ID,
'chatcmpl-123',
);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(GenAIAttributes.RESPONSE_FINISH_REASONS, [
'stop',
]);
});
it('should not set attributes for undefined values', () => {
const result: GenAISpanResult = {
tokenUsage: {
prompt: 100,
// completion, total, cached not set
},
};
setGenAIResponseAttributes(mockSpan as any, result);
expect(mockSpan.setAttribute).toHaveBeenCalledWith(GenAIAttributes.USAGE_INPUT_TOKENS, 100);
expect(mockSpan.setAttribute).not.toHaveBeenCalledWith(
GenAIAttributes.USAGE_OUTPUT_TOKENS,
expect.anything(),
);
});
});
describe('getTraceparent', () => {
it('should return W3C traceparent format', () => {
const traceparent = getTraceparent();
// Format: 00-traceId-spanId-traceFlags
expect(traceparent).toBe('00-mock-trace-id-1234567890abcdef-mock-span-id-12345678-01');
});
});
describe('getCurrentTraceId', () => {
it('should return trace ID from active span', () => {
const traceId = getCurrentTraceId();
expect(traceId).toBe('mock-trace-id-1234567890abcdef');
});
});
describe('getCurrentSpanId', () => {
it('should return span ID from active span', () => {
const spanId = getCurrentSpanId();
expect(spanId).toBe('mock-span-id-12345678');
});
});
describe('body sanitization', () => {
const baseContext: GenAISpanContext = {
system: 'openai',
operationName: 'chat',
model: 'gpt-4',
providerId: 'openai:gpt-4',
sanitizeBodies: true, // Enable sanitization for these tests
};
it('should redact OpenAI API keys from request body', async () => {
const contextWithBody = {
...baseContext,
requestBody: '{"api_key": "sk-proj-abcdefghij1234567890abcdefghij1234567890"}',
};
await withGenAISpan(contextWithBody, async () => 'result');
// Check the attributes passed to startActiveSpan
const call = mockTracer.startActiveSpan.mock.calls[0];
const options = call[1];
const requestBodyAttr = options.attributes[PromptfooAttributes.REQUEST_BODY];
expect(requestBodyAttr).toBeDefined();
expect(requestBodyAttr).toContain('<REDACTED_API_KEY>');
expect(requestBodyAttr).not.toContain('sk-proj-');
});
it('should redact Authorization headers from request body', async () => {
const contextWithBody = {
...baseContext,
requestBody: '{"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"}',
};
await withGenAISpan(contextWithBody, async () => 'result');
const call = mockTracer.startActiveSpan.mock.calls[0];
const options = call[1];
const requestBodyAttr = options.attributes[PromptfooAttributes.REQUEST_BODY];
expect(requestBodyAttr).toBeDefined();
expect(requestBodyAttr).toContain('<REDACTED>');
expect(requestBodyAttr).not.toContain('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9');
});
it('should redact AWS access keys from request body', async () => {
const contextWithBody = {
...baseContext,
requestBody: '{"credentials": "AKIAIOSFODNN7EXAMPLE"}',
};
await withGenAISpan(contextWithBody, async () => 'result');
const call = mockTracer.startActiveSpan.mock.calls[0];
const options = call[1];
const requestBodyAttr = options.attributes[PromptfooAttributes.REQUEST_BODY];
expect(requestBodyAttr).toBeDefined();
expect(requestBodyAttr).toContain('<REDACTED_AWS_KEY>');
expect(requestBodyAttr).not.toContain('AKIAIOSFODNN7EXAMPLE');
});
it('should redact password fields from request body', async () => {
const contextWithBody = {
...baseContext,
requestBody: '{"password": "supersecret123"}',
};
await withGenAISpan(contextWithBody, async () => 'result');
const call = mockTracer.startActiveSpan.mock.calls[0];
const options = call[1];
const requestBodyAttr = options.attributes[PromptfooAttributes.REQUEST_BODY];
expect(requestBodyAttr).toBeDefined();
expect(requestBodyAttr).toContain('<REDACTED>');
expect(requestBodyAttr).not.toContain('supersecret123');
});
it('should redact response body sensitive data', async () => {
const resultExtractor = vi.fn(() => ({
responseBody: '{"token": "secret-token-value-12345678901234567890"}',
}));
await withGenAISpan(baseContext, async () => 'result', resultExtractor);
const responseBodyCall = mockSpan.setAttribute.mock.calls.find(
(call) => call[0] === PromptfooAttributes.RESPONSE_BODY,
);
expect(responseBodyCall).toBeDefined();
expect(responseBodyCall![1]).toContain('<REDACTED>');
expect(responseBodyCall![1]).not.toContain('secret-token-value-12345678901234567890');
});
});
});