chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,828 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import logger from '../../src/logger';
|
||||
import {
|
||||
generateSpanId,
|
||||
generateTraceContextIfNeeded,
|
||||
generateTraceId,
|
||||
generateTraceparent,
|
||||
isOtlpReceiverStarted,
|
||||
isTracingEnabled,
|
||||
resetTracingState,
|
||||
startOtlpReceiverIfNeeded,
|
||||
stopOtlpReceiverIfNeeded,
|
||||
} from '../../src/tracing/evaluatorTracing';
|
||||
import { getTraceStore } from '../../src/tracing/store';
|
||||
import { mockProcessEnv } from '../util/utils';
|
||||
|
||||
import type { TestCase, TestSuite } from '../../src/types/index';
|
||||
|
||||
const mockStartOTLPReceiver = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const mockStopOTLPReceiver = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const mockUpdateOTLPReceiverOptions = vi.hoisted(() => vi.fn());
|
||||
const mockRegisterOTLPReceiverTracePolicy = vi.hoisted(() => vi.fn());
|
||||
const mockCreateTrace = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the trace store
|
||||
vi.mock('../../src/tracing/store', () => ({
|
||||
getTraceStore: vi.fn(() => ({
|
||||
createTrace: mockCreateTrace,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/tracing/otlpReceiver', () => ({
|
||||
startOTLPReceiver: mockStartOTLPReceiver,
|
||||
stopOTLPReceiver: mockStopOTLPReceiver,
|
||||
updateOTLPReceiverOptions: mockUpdateOTLPReceiverOptions,
|
||||
registerOTLPReceiverTracePolicy: mockRegisterOTLPReceiverTracePolicy,
|
||||
}));
|
||||
|
||||
describe('evaluatorTracing', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockStartOTLPReceiver.mockReset();
|
||||
mockStartOTLPReceiver.mockResolvedValue(undefined);
|
||||
mockStopOTLPReceiver.mockReset();
|
||||
mockStopOTLPReceiver.mockResolvedValue(undefined);
|
||||
mockUpdateOTLPReceiverOptions.mockReset();
|
||||
mockRegisterOTLPReceiverTracePolicy.mockReset();
|
||||
mockCreateTrace.mockReset();
|
||||
mockCreateTrace.mockResolvedValue(undefined);
|
||||
vi.mocked(getTraceStore).mockReturnValue({
|
||||
createTrace: mockCreateTrace,
|
||||
} as unknown as ReturnType<typeof getTraceStore>);
|
||||
resetTracingState();
|
||||
// Reset environment variables
|
||||
mockProcessEnv({ PROMPTFOO_TRACING_ENABLED: undefined });
|
||||
});
|
||||
|
||||
describe('generateTraceId', () => {
|
||||
it('should generate a 32-character hex string', () => {
|
||||
const traceId = generateTraceId();
|
||||
expect(traceId).toMatch(/^[a-f0-9]{32}$/);
|
||||
expect(traceId).toHaveLength(32);
|
||||
});
|
||||
|
||||
it('should generate unique IDs', () => {
|
||||
const id1 = generateTraceId();
|
||||
const id2 = generateTraceId();
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSpanId', () => {
|
||||
it('should generate a 16-character hex string', () => {
|
||||
const spanId = generateSpanId();
|
||||
expect(spanId).toMatch(/^[a-f0-9]{16}$/);
|
||||
expect(spanId).toHaveLength(16);
|
||||
});
|
||||
|
||||
it('should generate unique IDs', () => {
|
||||
const id1 = generateSpanId();
|
||||
const id2 = generateSpanId();
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateTraceparent', () => {
|
||||
it('should generate valid W3C Trace Context format with sampled flag', () => {
|
||||
const traceId = 'a'.repeat(32);
|
||||
const spanId = 'b'.repeat(16);
|
||||
const traceparent = generateTraceparent(traceId, spanId, true);
|
||||
expect(traceparent).toBe(`00-${traceId}-${spanId}-01`);
|
||||
});
|
||||
|
||||
it('should generate valid W3C Trace Context format without sampled flag', () => {
|
||||
const traceId = 'a'.repeat(32);
|
||||
const spanId = 'b'.repeat(16);
|
||||
const traceparent = generateTraceparent(traceId, spanId, false);
|
||||
expect(traceparent).toBe(`00-${traceId}-${spanId}-00`);
|
||||
});
|
||||
|
||||
it('should default to sampled=true', () => {
|
||||
const traceId = 'a'.repeat(32);
|
||||
const spanId = 'b'.repeat(16);
|
||||
const traceparent = generateTraceparent(traceId, spanId);
|
||||
expect(traceparent).toBe(`00-${traceId}-${spanId}-01`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateTraceContextIfNeeded', () => {
|
||||
it('should return null when tracing is not enabled', async () => {
|
||||
const test: TestCase = {
|
||||
vars: { foo: 'bar' },
|
||||
};
|
||||
const result = await generateTraceContextIfNeeded(test, {}, 0, 0);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should generate trace context when tracing is enabled via metadata', async () => {
|
||||
const test: TestCase = {
|
||||
vars: { foo: 'bar' },
|
||||
metadata: {
|
||||
tracingEnabled: true,
|
||||
evaluationId: 'eval-123',
|
||||
testCaseId: 'test-456',
|
||||
},
|
||||
};
|
||||
const result = await generateTraceContextIfNeeded(test, {}, 0, 0);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.traceparent).toMatch(/^00-[a-f0-9]{32}-[a-f0-9]{16}-01$/);
|
||||
expect(result!.evaluationId).toBe('eval-123');
|
||||
expect(result!.testCaseId).toBe('test-456');
|
||||
});
|
||||
|
||||
it('should generate trace context when tracing is enabled via environment', async () => {
|
||||
mockProcessEnv({ PROMPTFOO_TRACING_ENABLED: 'true' });
|
||||
const test: TestCase = {
|
||||
vars: { foo: 'bar' },
|
||||
};
|
||||
const result = await generateTraceContextIfNeeded(test, {}, 5, 10);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.traceparent).toMatch(/^00-[a-f0-9]{32}-[a-f0-9]{16}-01$/);
|
||||
expect(result!.evaluationId).toMatch(/^eval-/);
|
||||
expect(result!.testCaseId).toBe('5-10');
|
||||
});
|
||||
|
||||
it('should generate trace context when tracing is enabled via YAML config', async () => {
|
||||
const test: TestCase = {
|
||||
vars: { foo: 'bar' },
|
||||
};
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
|
||||
const result = await generateTraceContextIfNeeded(test, {}, 3, 7, testSuite);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.traceparent).toMatch(/^00-[a-f0-9]{32}-[a-f0-9]{16}-01$/);
|
||||
expect(result!.evaluationId).toMatch(/^eval-/);
|
||||
expect(result!.testCaseId).toBe('3-7');
|
||||
});
|
||||
|
||||
it('should persist command tool-name overrides in trace metadata', async () => {
|
||||
const test: TestCase = {
|
||||
vars: { foo: 'bar' },
|
||||
};
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
commandToolNames: ['bash'],
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
const traceStoreModule = await import('../../src/tracing/store');
|
||||
const getTraceStoreSpy = vi.spyOn(traceStoreModule, 'getTraceStore').mockReturnValue({
|
||||
createTrace: mockCreateTrace,
|
||||
} as unknown as ReturnType<typeof traceStoreModule.getTraceStore>);
|
||||
|
||||
try {
|
||||
await generateTraceContextIfNeeded(test, {}, 2, 4, testSuite);
|
||||
|
||||
expect(mockCreateTrace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
commandToolNames: ['bash'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
getTraceStoreSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should persist OTLP redaction config in trace metadata', async () => {
|
||||
const test: TestCase = {
|
||||
vars: { foo: 'bar' },
|
||||
};
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: {
|
||||
http: {
|
||||
enabled: true,
|
||||
redactAttributes: ['authorization'],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
|
||||
await generateTraceContextIfNeeded(test, {}, 2, 4, testSuite);
|
||||
|
||||
expect(mockCreateTrace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
otlpHttpRedactAttributes: ['authorization'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTracingEnabled', () => {
|
||||
it('should return false when no tracing is configured', () => {
|
||||
const test: TestCase = { vars: {} };
|
||||
expect(isTracingEnabled(test)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when test metadata has tracingEnabled', () => {
|
||||
const test: TestCase = {
|
||||
vars: {},
|
||||
metadata: { tracingEnabled: true },
|
||||
};
|
||||
expect(isTracingEnabled(test)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when environment variable is set', () => {
|
||||
mockProcessEnv({ PROMPTFOO_TRACING_ENABLED: 'true' });
|
||||
const test: TestCase = { vars: {} };
|
||||
expect(isTracingEnabled(test)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when testSuite.tracing.enabled is true', () => {
|
||||
const test: TestCase = { vars: {} };
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: { enabled: true },
|
||||
} as unknown as TestSuite;
|
||||
expect(isTracingEnabled(test, testSuite)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when testSuite.tracing.enabled is false', () => {
|
||||
const test: TestCase = { vars: {} };
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: { enabled: false },
|
||||
} as unknown as TestSuite;
|
||||
expect(isTracingEnabled(test, testSuite)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when any source enables tracing', () => {
|
||||
const test: TestCase = {
|
||||
vars: {},
|
||||
metadata: { tracingEnabled: false },
|
||||
};
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: { enabled: true },
|
||||
} as unknown as TestSuite;
|
||||
// testSuite enables tracing even though metadata doesn't
|
||||
expect(isTracingEnabled(test, testSuite)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOtlpReceiverStarted', () => {
|
||||
it('should return false initially', () => {
|
||||
expect(isOtlpReceiverStarted()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startOtlpReceiverIfNeeded', () => {
|
||||
it('should pass tracing configuration to the logger as sanitizable context', async () => {
|
||||
const secret = 'Bearer secret-token';
|
||||
const tracing = {
|
||||
enabled: true,
|
||||
forwarding: {
|
||||
enabled: true,
|
||||
endpoint: 'https://example.com/v1/traces',
|
||||
headers: { authorization: secret },
|
||||
},
|
||||
};
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing,
|
||||
} as unknown as TestSuite;
|
||||
|
||||
await startOtlpReceiverIfNeeded(testSuite);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'[EvaluatorTracing] Checking tracing configuration',
|
||||
{
|
||||
tracing,
|
||||
testSuiteKeys: ['providers', 'prompts', 'tracing'],
|
||||
},
|
||||
);
|
||||
expect(
|
||||
vi.mocked(logger.debug).mock.calls.some(([message]) => String(message).includes(secret)),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass configured acceptFormats to the OTLP receiver', async () => {
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: {
|
||||
http: {
|
||||
enabled: true,
|
||||
port: 4318,
|
||||
host: '0.0.0.0',
|
||||
acceptFormats: ['protobuf'],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
|
||||
await startOtlpReceiverIfNeeded(testSuite);
|
||||
|
||||
expect(mockStartOTLPReceiver).toHaveBeenCalledWith(4318, '0.0.0.0', ['protobuf'], {
|
||||
commandToolNames: undefined,
|
||||
redactAttributes: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should swallow receiver start errors by default', async () => {
|
||||
mockStartOTLPReceiver.mockRejectedValueOnce(new Error('EADDRINUSE: port 4318'));
|
||||
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: { http: { enabled: true, port: 4318, host: '127.0.0.1' } },
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
|
||||
await expect(startOtlpReceiverIfNeeded(testSuite)).resolves.toBe(false);
|
||||
expect(isOtlpReceiverStarted()).toBe(false);
|
||||
});
|
||||
|
||||
it('should rethrow receiver start errors when failOnReceiverStartFailure is true', async () => {
|
||||
mockStartOTLPReceiver.mockRejectedValueOnce(new Error('EADDRINUSE: port 4318'));
|
||||
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
failOnReceiverStartFailure: true,
|
||||
otlp: { http: { enabled: true, port: 4318, host: '127.0.0.1' } },
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
|
||||
await expect(startOtlpReceiverIfNeeded(testSuite)).rejects.toThrow(
|
||||
/Failed to start OTLP tracing receiver: EADDRINUSE/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prune the trace store when storage.retentionDays is set', async () => {
|
||||
const deleteOldTraces = vi.fn().mockResolvedValue(undefined);
|
||||
const { getTraceStore } = await import('../../src/tracing/store');
|
||||
vi.mocked(getTraceStore).mockReturnValue({
|
||||
deleteOldTraces,
|
||||
} as unknown as ReturnType<typeof getTraceStore>);
|
||||
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: { http: { enabled: true, port: 4318, host: '127.0.0.1' } },
|
||||
storage: { type: 'sqlite', retentionDays: 7 },
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
|
||||
await startOtlpReceiverIfNeeded(testSuite);
|
||||
|
||||
expect(deleteOldTraces).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('should prune the trace store without requiring an OTLP HTTP receiver', async () => {
|
||||
const deleteOldTraces = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(getTraceStore).mockReturnValue({
|
||||
deleteOldTraces,
|
||||
} as unknown as ReturnType<typeof getTraceStore>);
|
||||
|
||||
await startOtlpReceiverIfNeeded({
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
storage: { type: 'sqlite', retentionDays: 7 },
|
||||
},
|
||||
} as unknown as TestSuite);
|
||||
|
||||
expect(deleteOldTraces).toHaveBeenCalledWith(7);
|
||||
expect(mockStartOTLPReceiver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prune the trace store when tracing is enabled by environment', async () => {
|
||||
const deleteOldTraces = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(getTraceStore).mockReturnValue({
|
||||
deleteOldTraces,
|
||||
} as unknown as ReturnType<typeof getTraceStore>);
|
||||
mockProcessEnv({ PROMPTFOO_TRACING_ENABLED: 'true' });
|
||||
|
||||
await startOtlpReceiverIfNeeded({
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
storage: { type: 'sqlite', retentionDays: 7 },
|
||||
},
|
||||
} as unknown as TestSuite);
|
||||
|
||||
expect(deleteOldTraces).toHaveBeenCalledWith(7);
|
||||
expect(mockStartOTLPReceiver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not prune when retentionDays is omitted', async () => {
|
||||
const deleteOldTraces = vi.fn().mockResolvedValue(undefined);
|
||||
const { getTraceStore } = await import('../../src/tracing/store');
|
||||
vi.mocked(getTraceStore).mockReturnValue({
|
||||
deleteOldTraces,
|
||||
} as unknown as ReturnType<typeof getTraceStore>);
|
||||
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: { http: { enabled: true, port: 4318, host: '127.0.0.1' } },
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
|
||||
await startOtlpReceiverIfNeeded(testSuite);
|
||||
|
||||
expect(deleteOldTraces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass redactAttributes to the OTLP receiver', async () => {
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: {
|
||||
http: {
|
||||
enabled: true,
|
||||
port: 4318,
|
||||
host: '127.0.0.1',
|
||||
redactAttributes: ['tool.arguments', 'authorization'],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
|
||||
await startOtlpReceiverIfNeeded(testSuite);
|
||||
|
||||
expect(mockStartOTLPReceiver).toHaveBeenCalledWith(4318, '127.0.0.1', ['json', 'protobuf'], {
|
||||
commandToolNames: undefined,
|
||||
redactAttributes: ['tool.arguments', 'authorization'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass commandToolNames to the OTLP receiver', async () => {
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
commandToolNames: ['bash', 'shell'],
|
||||
otlp: {
|
||||
http: {
|
||||
enabled: true,
|
||||
port: 4318,
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
|
||||
await startOtlpReceiverIfNeeded(testSuite);
|
||||
|
||||
expect(mockStartOTLPReceiver).toHaveBeenCalledWith(4318, '127.0.0.1', ['json', 'protobuf'], {
|
||||
commandToolNames: ['bash', 'shell'],
|
||||
redactAttributes: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass the current evaluation policy when starting the OTLP receiver', async () => {
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
commandToolNames: ['bash'],
|
||||
otlp: {
|
||||
http: {
|
||||
enabled: true,
|
||||
port: 4318,
|
||||
host: '127.0.0.1',
|
||||
redactAttributes: ['authorization'],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
|
||||
await startOtlpReceiverIfNeeded(testSuite, 'eval-current');
|
||||
|
||||
expect(mockStartOTLPReceiver).toHaveBeenCalledWith(4318, '127.0.0.1', ['json', 'protobuf'], {
|
||||
commandToolNames: ['bash'],
|
||||
redactAttributes: ['authorization'],
|
||||
tracePolicy: {
|
||||
evaluationId: 'eval-current',
|
||||
commandToolNames: ['bash'],
|
||||
redactAttributes: ['authorization'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should default acceptFormats to both when omitted', async () => {
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: {
|
||||
http: {
|
||||
enabled: true,
|
||||
port: 4318,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
|
||||
await startOtlpReceiverIfNeeded(testSuite);
|
||||
|
||||
expect(mockStartOTLPReceiver).toHaveBeenCalledWith(4318, '0.0.0.0', ['json', 'protobuf'], {
|
||||
commandToolNames: undefined,
|
||||
redactAttributes: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should prune the trace store when tracing is already running', async () => {
|
||||
const deleteOldTraces = vi.fn().mockResolvedValue(undefined);
|
||||
const { getTraceStore } = await import('../../src/tracing/store');
|
||||
vi.mocked(getTraceStore).mockReturnValue({
|
||||
deleteOldTraces,
|
||||
} as unknown as ReturnType<typeof getTraceStore>);
|
||||
|
||||
await startOtlpReceiverIfNeeded({
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: { http: { enabled: true, port: 4318, host: '127.0.0.1' } },
|
||||
},
|
||||
} as unknown as TestSuite);
|
||||
|
||||
await startOtlpReceiverIfNeeded({
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: {
|
||||
http: {
|
||||
enabled: true,
|
||||
port: 4318,
|
||||
host: '127.0.0.1',
|
||||
},
|
||||
},
|
||||
storage: { type: 'sqlite', retentionDays: 7 },
|
||||
},
|
||||
} as unknown as TestSuite);
|
||||
|
||||
expect(mockStartOTLPReceiver).toHaveBeenCalledTimes(1);
|
||||
expect(deleteOldTraces).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('should not mutate receiver defaults when another traced evaluation is already running', async () => {
|
||||
await startOtlpReceiverIfNeeded({
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: { http: { enabled: true, port: 4318, host: '127.0.0.1' } },
|
||||
},
|
||||
} as unknown as TestSuite);
|
||||
|
||||
await startOtlpReceiverIfNeeded({
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
commandToolNames: ['bash'],
|
||||
otlp: {
|
||||
http: {
|
||||
enabled: true,
|
||||
port: 4318,
|
||||
host: '127.0.0.1',
|
||||
acceptFormats: ['json'],
|
||||
redactAttributes: ['authorization'],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as TestSuite);
|
||||
|
||||
expect(mockStartOTLPReceiver).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateOTLPReceiverOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register the current evaluation policy when reusing an active receiver', async () => {
|
||||
await startOtlpReceiverIfNeeded(
|
||||
{
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: { http: { enabled: true, port: 4318, host: '127.0.0.1' } },
|
||||
},
|
||||
} as unknown as TestSuite,
|
||||
'eval-a',
|
||||
);
|
||||
|
||||
await startOtlpReceiverIfNeeded(
|
||||
{
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
commandToolNames: ['bash'],
|
||||
otlp: {
|
||||
http: {
|
||||
enabled: true,
|
||||
port: 4318,
|
||||
host: '127.0.0.1',
|
||||
redactAttributes: ['authorization'],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as TestSuite,
|
||||
'eval-b',
|
||||
);
|
||||
|
||||
expect(mockStartOTLPReceiver).toHaveBeenCalledTimes(1);
|
||||
expect(mockRegisterOTLPReceiverTracePolicy).toHaveBeenCalledWith({
|
||||
evaluationId: 'eval-b',
|
||||
commandToolNames: ['bash'],
|
||||
redactAttributes: ['authorization'],
|
||||
});
|
||||
expect(mockUpdateOTLPReceiverOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should preserve the first receiver defaults during overlapping startup', async () => {
|
||||
let resolveStart!: () => void;
|
||||
mockStartOTLPReceiver.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveStart = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const firstStart = startOtlpReceiverIfNeeded({
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
commandToolNames: ['bash'],
|
||||
otlp: {
|
||||
http: {
|
||||
enabled: true,
|
||||
port: 4318,
|
||||
host: '127.0.0.1',
|
||||
acceptFormats: ['json'],
|
||||
redactAttributes: ['authorization'],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as TestSuite);
|
||||
const secondStart = startOtlpReceiverIfNeeded({
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
commandToolNames: ['shell'],
|
||||
otlp: {
|
||||
http: {
|
||||
enabled: true,
|
||||
port: 4318,
|
||||
host: '127.0.0.1',
|
||||
acceptFormats: ['protobuf'],
|
||||
redactAttributes: ['cookie'],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as TestSuite);
|
||||
|
||||
await vi.waitFor(() => expect(mockStartOTLPReceiver).toHaveBeenCalledTimes(1));
|
||||
resolveStart();
|
||||
await Promise.all([firstStart, secondStart]);
|
||||
|
||||
expect(mockStartOTLPReceiver).toHaveBeenCalledWith(4318, '127.0.0.1', ['json'], {
|
||||
commandToolNames: ['bash'],
|
||||
redactAttributes: ['authorization'],
|
||||
});
|
||||
expect(mockStartOTLPReceiver).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateOTLPReceiverOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should keep the receiver live until every overlapping evaluation releases its lease', async () => {
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: { http: { enabled: true, port: 4318, host: '127.0.0.1' } },
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
|
||||
const firstLease = await startOtlpReceiverIfNeeded(testSuite);
|
||||
const secondLease = await startOtlpReceiverIfNeeded(testSuite);
|
||||
|
||||
await stopOtlpReceiverIfNeeded(firstLease);
|
||||
expect(mockStopOTLPReceiver).not.toHaveBeenCalled();
|
||||
expect(isOtlpReceiverStarted()).toBe(true);
|
||||
|
||||
await stopOtlpReceiverIfNeeded(secondLease);
|
||||
expect(mockStopOTLPReceiver).toHaveBeenCalledTimes(1);
|
||||
expect(isOtlpReceiverStarted()).toBe(false);
|
||||
});
|
||||
|
||||
it('should wait for an in-flight shutdown before starting a new receiver', async () => {
|
||||
let resolveStop!: () => void;
|
||||
mockStopOTLPReceiver.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveStop = resolve;
|
||||
}),
|
||||
);
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: { http: { enabled: true, port: 4318, host: '127.0.0.1' } },
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
|
||||
const lease = await startOtlpReceiverIfNeeded(testSuite);
|
||||
const stopping = stopOtlpReceiverIfNeeded(lease);
|
||||
const restarting = startOtlpReceiverIfNeeded(testSuite);
|
||||
|
||||
await vi.waitFor(() => expect(mockStopOTLPReceiver).toHaveBeenCalledTimes(1));
|
||||
expect(mockStartOTLPReceiver).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveStop();
|
||||
await Promise.all([stopping, restarting]);
|
||||
expect(mockStartOTLPReceiver).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should preserve a receiver lease when an overlapping shutdown fails', async () => {
|
||||
let rejectStop!: (error: Error) => void;
|
||||
mockStopOTLPReceiver.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectStop = reject;
|
||||
}),
|
||||
);
|
||||
const testSuite = {
|
||||
providers: [],
|
||||
prompts: [],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: { http: { enabled: true, port: 4318, host: '127.0.0.1' } },
|
||||
},
|
||||
} as unknown as TestSuite;
|
||||
|
||||
const lease = await startOtlpReceiverIfNeeded(testSuite);
|
||||
const stopping = stopOtlpReceiverIfNeeded(lease);
|
||||
const restarting = startOtlpReceiverIfNeeded(testSuite);
|
||||
|
||||
await vi.waitFor(() => expect(mockStopOTLPReceiver).toHaveBeenCalledTimes(1));
|
||||
rejectStop(new Error('shutdown failed'));
|
||||
|
||||
await expect(stopping).resolves.toBeUndefined();
|
||||
await expect(restarting).resolves.toBe(true);
|
||||
expect(mockStartOTLPReceiver).toHaveBeenCalledTimes(1);
|
||||
expect(isOtlpReceiverStarted()).toBe(true);
|
||||
|
||||
await stopOtlpReceiverIfNeeded(true);
|
||||
expect(mockStopOTLPReceiver).toHaveBeenCalledTimes(2);
|
||||
expect(isOtlpReceiverStarted()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { sleep } from '../../src/util/time';
|
||||
import { mockProcessEnv } from '../util/utils';
|
||||
|
||||
import type { EvaluateTestSuite } from '../../src/types/index';
|
||||
|
||||
// Mock the trace store to avoid database dependency in tests
|
||||
vi.mock('../../src/tracing/store', () => ({
|
||||
getTraceStore: vi.fn(() => ({
|
||||
createTrace: vi.fn().mockResolvedValue(undefined),
|
||||
addSpans: vi.fn().mockResolvedValue(undefined),
|
||||
getTracesByEvaluation: vi.fn().mockResolvedValue([]),
|
||||
getTrace: vi.fn().mockResolvedValue(null),
|
||||
})),
|
||||
TraceStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/util/time', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../src/util/time')>()),
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Define the mock provider class
|
||||
class MockTracedProviderInstance {
|
||||
id() {
|
||||
return 'mock-traced-provider';
|
||||
}
|
||||
|
||||
async callApi(prompt: string, context: any) {
|
||||
if (context.traceparent) {
|
||||
return {
|
||||
output: `Traced response for: ${prompt}`,
|
||||
metadata: {
|
||||
traceparent: context.traceparent,
|
||||
evaluationId: context.evaluationId,
|
||||
testCaseId: context.testCaseId,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
output: `Untraced response for: ${prompt}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const mockProvider = new MockTracedProviderInstance();
|
||||
|
||||
// Mock providers using vi.mock with async importActual
|
||||
vi.mock('../../src/providers', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/providers')>('../../src/providers');
|
||||
return {
|
||||
...actual,
|
||||
loadApiProvider: vi.fn(async (providerPath: string) => {
|
||||
if (providerPath === 'mock-traced-provider') {
|
||||
return mockProvider;
|
||||
}
|
||||
// Fall back to original for other providers
|
||||
return actual.loadApiProvider(providerPath);
|
||||
}),
|
||||
loadApiProviders: vi.fn(async (providers: any) => {
|
||||
if (Array.isArray(providers) && providers.includes('mock-traced-provider')) {
|
||||
return [mockProvider];
|
||||
}
|
||||
if (providers === 'mock-traced-provider') {
|
||||
return [mockProvider];
|
||||
}
|
||||
return [mockProvider]; // Default to mock for tests
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Dynamic import after mocking - initialized in beforeAll
|
||||
let evaluate: typeof import('../../src/index').evaluate;
|
||||
|
||||
describe('OpenTelemetry Tracing Integration', () => {
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../../src/index');
|
||||
evaluate = mod.evaluate;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(sleep).mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should pass trace context to providers when tracing is enabled', async () => {
|
||||
const config: Partial<EvaluateTestSuite> = {
|
||||
providers: ['mock-traced-provider'],
|
||||
prompts: ['Test prompt'],
|
||||
tests: [
|
||||
{
|
||||
vars: { topic: 'testing' },
|
||||
},
|
||||
],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
otlp: {
|
||||
http: {
|
||||
enabled: true,
|
||||
port: 4318,
|
||||
host: '127.0.0.1',
|
||||
acceptFormats: ['json'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Run evaluation
|
||||
const results = await evaluate(config as EvaluateTestSuite, {
|
||||
cache: false,
|
||||
maxConcurrency: 1,
|
||||
});
|
||||
|
||||
// Check that results contain trace context
|
||||
expect(results.results).toBeDefined();
|
||||
expect(results.results.length).toBeGreaterThan(0);
|
||||
|
||||
const firstResult = results.results[0];
|
||||
expect(firstResult.response).toBeDefined();
|
||||
expect(firstResult.response?.metadata).toBeDefined();
|
||||
expect(firstResult.response?.metadata?.traceparent).toBeDefined();
|
||||
expect(firstResult.response?.metadata?.traceparent).toMatch(
|
||||
/^00-[a-f0-9]{32}-[a-f0-9]{16}-01$/,
|
||||
);
|
||||
expect(firstResult.response?.metadata?.evaluationId).toBeDefined();
|
||||
expect(firstResult.response?.metadata?.testCaseId).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not pass trace context when tracing is disabled', async () => {
|
||||
const config: Partial<EvaluateTestSuite> = {
|
||||
providers: ['mock-traced-provider'],
|
||||
prompts: ['Test prompt'],
|
||||
tests: [
|
||||
{
|
||||
vars: { topic: 'testing' },
|
||||
},
|
||||
],
|
||||
tracing: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Run evaluation
|
||||
const results = await evaluate(config as EvaluateTestSuite, {
|
||||
cache: false,
|
||||
maxConcurrency: 1,
|
||||
});
|
||||
|
||||
// Check that results do not contain trace context
|
||||
expect(results.results).toBeDefined();
|
||||
expect(results.results.length).toBeGreaterThan(0);
|
||||
|
||||
const firstResult = results.results[0];
|
||||
expect(firstResult.response).toBeDefined();
|
||||
expect(firstResult.response?.metadata?.traceparent).toBeUndefined();
|
||||
expect(firstResult.response?.output).toContain('Untraced response');
|
||||
});
|
||||
|
||||
it('should generate unique trace IDs for each test case', async () => {
|
||||
const config: Partial<EvaluateTestSuite> = {
|
||||
providers: ['mock-traced-provider'],
|
||||
prompts: ['Prompt 1', 'Prompt 2'],
|
||||
tests: [{ vars: { topic: 'test1' } }, { vars: { topic: 'test2' } }],
|
||||
tracing: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Run evaluation
|
||||
const results = await evaluate(config as EvaluateTestSuite, {
|
||||
cache: false,
|
||||
maxConcurrency: 1,
|
||||
});
|
||||
|
||||
// Collect all trace IDs
|
||||
const traceIds = results.results
|
||||
.map((r: any) => r.response?.metadata?.traceparent)
|
||||
.filter(Boolean)
|
||||
.map((tp: string) => tp!.split('-')[1]); // Extract trace ID from traceparent
|
||||
|
||||
// All trace IDs should be unique
|
||||
const uniqueTraceIds = new Set(traceIds);
|
||||
expect(uniqueTraceIds.size).toBe(traceIds.length);
|
||||
});
|
||||
|
||||
it('should respect environment variable for enabling tracing', async () => {
|
||||
const restoreEnv = mockProcessEnv({ PROMPTFOO_TRACING_ENABLED: 'true' });
|
||||
try {
|
||||
const config: Partial<EvaluateTestSuite> = {
|
||||
providers: ['mock-traced-provider'],
|
||||
prompts: ['Test prompt'],
|
||||
tests: [{ vars: { topic: 'testing' } }],
|
||||
// No tracing config in YAML
|
||||
};
|
||||
|
||||
// Run evaluation
|
||||
const results = await evaluate(config as EvaluateTestSuite, {
|
||||
cache: false,
|
||||
maxConcurrency: 1,
|
||||
});
|
||||
|
||||
// Should have trace context from environment variable
|
||||
expect(results.results[0].response).toBeDefined();
|
||||
expect(results.results[0].response?.metadata?.traceparent).toBeDefined();
|
||||
} finally {
|
||||
restoreEnv();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { ExportResultCode } from '@opentelemetry/core';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { LocalSpanExporter } from '../../src/tracing/localSpanExporter';
|
||||
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
||||
|
||||
// Mock the store module
|
||||
const mockAddSpans = vi.fn();
|
||||
const mockTraceStore = {
|
||||
addSpans: mockAddSpans,
|
||||
};
|
||||
|
||||
vi.mock('../../src/tracing/store', () => ({
|
||||
getTraceStore: vi.fn(() => mockTraceStore),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('LocalSpanExporter', () => {
|
||||
let exporter: LocalSpanExporter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAddSpans.mockResolvedValue({ stored: true });
|
||||
exporter = new LocalSpanExporter();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
function createMockSpan(
|
||||
overrides: Partial<{
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId: string;
|
||||
name: string;
|
||||
startTime: [number, number];
|
||||
endTime: [number, number];
|
||||
attributes: Record<string, unknown>;
|
||||
status: { code: number; message?: string };
|
||||
}> = {},
|
||||
): ReadableSpan {
|
||||
const traceId = overrides.traceId ?? 'trace-id-123';
|
||||
return {
|
||||
spanContext: () => ({
|
||||
traceId,
|
||||
spanId: overrides.spanId ?? 'span-id-456',
|
||||
traceFlags: 1,
|
||||
isRemote: false,
|
||||
}),
|
||||
// OpenTelemetry SDK 2.x uses parentSpanContext instead of parentSpanId
|
||||
parentSpanContext: overrides.parentSpanId
|
||||
? { traceId, spanId: overrides.parentSpanId, traceFlags: 1, isRemote: false }
|
||||
: undefined,
|
||||
name: overrides.name ?? 'test-span',
|
||||
startTime: overrides.startTime ?? [1000, 500000000], // 1000.5 seconds
|
||||
endTime: overrides.endTime ?? [1001, 200000000], // 1001.2 seconds
|
||||
attributes: overrides.attributes ?? { 'test.attr': 'value' },
|
||||
status: overrides.status ?? { code: 1 },
|
||||
kind: 2, // CLIENT
|
||||
links: [],
|
||||
events: [],
|
||||
resource: { attributes: {} },
|
||||
instrumentationLibrary: { name: 'test' },
|
||||
duration: [0, 700000000],
|
||||
ended: true,
|
||||
droppedAttributesCount: 0,
|
||||
droppedEventsCount: 0,
|
||||
droppedLinksCount: 0,
|
||||
} as unknown as ReadableSpan;
|
||||
}
|
||||
|
||||
// Helper to wrap callback-based export in a promise
|
||||
function exportSpans(spans: ReadableSpan[]): Promise<{ code: number; error?: Error }> {
|
||||
return new Promise((resolve) => {
|
||||
exporter.export(spans, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
describe('export', () => {
|
||||
it('should export empty span array successfully', async () => {
|
||||
const result = await exportSpans([]);
|
||||
|
||||
expect(result.code).toBe(ExportResultCode.SUCCESS);
|
||||
expect(mockAddSpans).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should export single span to trace store', async () => {
|
||||
const span = createMockSpan();
|
||||
|
||||
const result = await exportSpans([span]);
|
||||
|
||||
expect(result.code).toBe(ExportResultCode.SUCCESS);
|
||||
expect(mockAddSpans).toHaveBeenCalledWith(
|
||||
'trace-id-123',
|
||||
[
|
||||
expect.objectContaining({
|
||||
spanId: 'span-id-456',
|
||||
name: 'test-span',
|
||||
}),
|
||||
],
|
||||
{ skipTraceCheck: false, warnIfMissingTrace: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('should group spans by trace ID', async () => {
|
||||
const span1 = createMockSpan({
|
||||
traceId: 'trace-1',
|
||||
spanId: 'span-1',
|
||||
name: 'span-one',
|
||||
});
|
||||
const span2 = createMockSpan({
|
||||
traceId: 'trace-1',
|
||||
spanId: 'span-2',
|
||||
name: 'span-two',
|
||||
});
|
||||
const span3 = createMockSpan({
|
||||
traceId: 'trace-2',
|
||||
spanId: 'span-3',
|
||||
name: 'span-three',
|
||||
});
|
||||
|
||||
const result = await exportSpans([span1, span2, span3]);
|
||||
|
||||
expect(result.code).toBe(ExportResultCode.SUCCESS);
|
||||
expect(mockAddSpans).toHaveBeenCalledTimes(2);
|
||||
|
||||
// First call for trace-1 with 2 spans
|
||||
expect(mockAddSpans).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'trace-1',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ spanId: 'span-1' }),
|
||||
expect.objectContaining({ spanId: 'span-2' }),
|
||||
]),
|
||||
{ skipTraceCheck: false, warnIfMissingTrace: false },
|
||||
);
|
||||
|
||||
// Second call for trace-2 with 1 span
|
||||
expect(mockAddSpans).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'trace-2',
|
||||
[expect.objectContaining({ spanId: 'span-3' })],
|
||||
{ skipTraceCheck: false, warnIfMissingTrace: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('retries once when the trace record is not visible yet', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const span = createMockSpan();
|
||||
|
||||
mockAddSpans
|
||||
.mockResolvedValueOnce({ stored: false, reason: 'Trace trace-id-123 not found' })
|
||||
.mockResolvedValueOnce({ stored: true });
|
||||
|
||||
const resultPromise = exportSpans([span]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.code).toBe(ExportResultCode.SUCCESS);
|
||||
expect(mockAddSpans).toHaveBeenCalledTimes(2);
|
||||
expect(mockAddSpans).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'trace-id-123',
|
||||
[expect.objectContaining({ spanId: 'span-id-456' })],
|
||||
{ skipTraceCheck: false, warnIfMissingTrace: false },
|
||||
);
|
||||
expect(mockAddSpans).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'trace-id-123',
|
||||
[expect.objectContaining({ spanId: 'span-id-456' })],
|
||||
{ skipTraceCheck: false, warnIfMissingTrace: false },
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('should convert span times to milliseconds', async () => {
|
||||
const span = createMockSpan({
|
||||
startTime: [100, 500000000], // 100.5 seconds
|
||||
endTime: [101, 750000000], // 101.75 seconds
|
||||
});
|
||||
|
||||
const result = await exportSpans([span]);
|
||||
|
||||
expect(result.code).toBe(ExportResultCode.SUCCESS);
|
||||
expect(mockAddSpans).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
[
|
||||
expect.objectContaining({
|
||||
// Milliseconds: seconds * 1000 + nanoseconds / 1_000_000
|
||||
startTime: 100 * 1e3 + 500000000 / 1e6, // 100500 ms
|
||||
endTime: 101 * 1e3 + 750000000 / 1e6, // 101750 ms
|
||||
}),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include parent span ID when present', async () => {
|
||||
const span = createMockSpan({
|
||||
parentSpanId: 'parent-span-id',
|
||||
});
|
||||
|
||||
const result = await exportSpans([span]);
|
||||
|
||||
expect(result.code).toBe(ExportResultCode.SUCCESS);
|
||||
expect(mockAddSpans).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
[
|
||||
expect.objectContaining({
|
||||
parentSpanId: 'parent-span-id',
|
||||
}),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include span attributes', async () => {
|
||||
const span = createMockSpan({
|
||||
attributes: {
|
||||
'gen_ai.system': 'openai',
|
||||
'gen_ai.request.model': 'gpt-4',
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await exportSpans([span]);
|
||||
|
||||
expect(result.code).toBe(ExportResultCode.SUCCESS);
|
||||
expect(mockAddSpans).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
[
|
||||
expect.objectContaining({
|
||||
attributes: {
|
||||
'gen_ai.system': 'openai',
|
||||
'gen_ai.request.model': 'gpt-4',
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
},
|
||||
}),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include status code and message', async () => {
|
||||
const span = createMockSpan({
|
||||
status: { code: 2, message: 'Error occurred' },
|
||||
});
|
||||
|
||||
const result = await exportSpans([span]);
|
||||
|
||||
expect(result.code).toBe(ExportResultCode.SUCCESS);
|
||||
expect(mockAddSpans).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
[
|
||||
expect.objectContaining({
|
||||
statusCode: 2,
|
||||
statusMessage: 'Error occurred',
|
||||
}),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return FAILED on export error', async () => {
|
||||
const span = createMockSpan();
|
||||
const error = new Error('Database error');
|
||||
|
||||
mockAddSpans.mockRejectedValue(error);
|
||||
|
||||
const result = await exportSpans([span]);
|
||||
|
||||
expect(result.code).toBe(ExportResultCode.FAILED);
|
||||
expect(result.error).toBe(error);
|
||||
});
|
||||
|
||||
it('should continue exporting other traces if one fails', async () => {
|
||||
const span1 = createMockSpan({ traceId: 'trace-1', spanId: 'span-1' });
|
||||
const span2 = createMockSpan({ traceId: 'trace-2', spanId: 'span-2' });
|
||||
|
||||
mockAddSpans
|
||||
.mockRejectedValueOnce(new Error('First trace failed'))
|
||||
.mockResolvedValueOnce({ stored: true });
|
||||
|
||||
// Even with one failure, we still attempt all exports
|
||||
const _result = await exportSpans([span1, span2]);
|
||||
|
||||
// Both traces were attempted
|
||||
expect(mockAddSpans).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('should resolve successfully', async () => {
|
||||
await expect(exporter.shutdown()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceFlush', () => {
|
||||
it('should resolve successfully', async () => {
|
||||
await expect(exporter.forceFlush()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getOtelConfigFromEnv,
|
||||
getOtelConfigFromYaml,
|
||||
mergeOtelConfigs,
|
||||
type OtelConfig,
|
||||
} from '../../src/tracing/otelConfig';
|
||||
|
||||
// Mock envars module
|
||||
vi.mock('../../src/envars', () => ({
|
||||
getEnvBool: vi.fn(),
|
||||
getEnvString: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getEnvBool, getEnvString } from '../../src/envars';
|
||||
|
||||
const mockedGetEnvBool = vi.mocked(getEnvBool);
|
||||
const mockedGetEnvString = vi.mocked(getEnvString);
|
||||
|
||||
describe('otelConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getOtelConfigFromEnv', () => {
|
||||
it('should return default config when no env vars are set', () => {
|
||||
mockedGetEnvBool.mockImplementation((_key, defaultVal) => defaultVal ?? false);
|
||||
mockedGetEnvString.mockImplementation((_key, defaultVal) => defaultVal);
|
||||
|
||||
const config = getOtelConfigFromEnv();
|
||||
|
||||
expect(config).toEqual({
|
||||
enabled: false,
|
||||
serviceName: 'promptfoo',
|
||||
endpoint: undefined,
|
||||
localExport: true,
|
||||
debug: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should read PROMPTFOO_OTEL_ENABLED', () => {
|
||||
mockedGetEnvBool.mockImplementation((key, defaultVal) => {
|
||||
if (key === 'PROMPTFOO_OTEL_ENABLED') {
|
||||
return true;
|
||||
}
|
||||
return defaultVal ?? false;
|
||||
});
|
||||
mockedGetEnvString.mockImplementation((_key, defaultVal) => defaultVal);
|
||||
|
||||
const config = getOtelConfigFromEnv();
|
||||
|
||||
expect(config.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should read PROMPTFOO_OTEL_SERVICE_NAME', () => {
|
||||
mockedGetEnvBool.mockImplementation((_key, defaultVal) => defaultVal ?? false);
|
||||
mockedGetEnvString.mockImplementation((key, defaultVal) => {
|
||||
if (key === 'PROMPTFOO_OTEL_SERVICE_NAME') {
|
||||
return 'my-service';
|
||||
}
|
||||
return defaultVal;
|
||||
});
|
||||
|
||||
const config = getOtelConfigFromEnv();
|
||||
|
||||
expect(config.serviceName).toBe('my-service');
|
||||
});
|
||||
|
||||
it('should prefer PROMPTFOO_OTEL_ENDPOINT over OTEL_EXPORTER_OTLP_ENDPOINT', () => {
|
||||
mockedGetEnvBool.mockImplementation((_key, defaultVal) => defaultVal ?? false);
|
||||
mockedGetEnvString.mockImplementation((key, defaultVal) => {
|
||||
if (key === 'PROMPTFOO_OTEL_ENDPOINT') {
|
||||
return 'http://custom:4318';
|
||||
}
|
||||
if (key === 'OTEL_EXPORTER_OTLP_ENDPOINT') {
|
||||
return 'http://standard:4318';
|
||||
}
|
||||
return defaultVal;
|
||||
});
|
||||
|
||||
const config = getOtelConfigFromEnv();
|
||||
|
||||
expect(config.endpoint).toBe('http://custom:4318');
|
||||
});
|
||||
|
||||
it('should fall back to OTEL_EXPORTER_OTLP_ENDPOINT', () => {
|
||||
mockedGetEnvBool.mockImplementation((_key, defaultVal) => defaultVal ?? false);
|
||||
mockedGetEnvString.mockImplementation(((key: string, defaultVal?: string) => {
|
||||
if (key === 'PROMPTFOO_OTEL_ENDPOINT') {
|
||||
return defaultVal;
|
||||
}
|
||||
if (key === 'OTEL_EXPORTER_OTLP_ENDPOINT') {
|
||||
return 'http://standard:4318';
|
||||
}
|
||||
return defaultVal;
|
||||
}) as typeof getEnvString);
|
||||
|
||||
const config = getOtelConfigFromEnv();
|
||||
|
||||
expect(config.endpoint).toBe('http://standard:4318');
|
||||
});
|
||||
|
||||
it('should read PROMPTFOO_OTEL_LOCAL_EXPORT', () => {
|
||||
mockedGetEnvBool.mockImplementation((key, defaultVal) => {
|
||||
if (key === 'PROMPTFOO_OTEL_LOCAL_EXPORT') {
|
||||
return false;
|
||||
}
|
||||
return defaultVal ?? false;
|
||||
});
|
||||
mockedGetEnvString.mockImplementation((_key, defaultVal) => defaultVal);
|
||||
|
||||
const config = getOtelConfigFromEnv();
|
||||
|
||||
expect(config.localExport).toBe(false);
|
||||
});
|
||||
|
||||
it('should read PROMPTFOO_OTEL_DEBUG', () => {
|
||||
mockedGetEnvBool.mockImplementation((key, defaultVal) => {
|
||||
if (key === 'PROMPTFOO_OTEL_DEBUG') {
|
||||
return true;
|
||||
}
|
||||
return defaultVal ?? false;
|
||||
});
|
||||
mockedGetEnvString.mockImplementation((_key, defaultVal) => defaultVal);
|
||||
|
||||
const config = getOtelConfigFromEnv();
|
||||
|
||||
expect(config.debug).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOtelConfigFromYaml', () => {
|
||||
it('should return empty object when no tracing config', () => {
|
||||
const config = getOtelConfigFromYaml({});
|
||||
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when tracing is undefined', () => {
|
||||
const config = getOtelConfigFromYaml({ tracing: undefined });
|
||||
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it('should parse all tracing config options', () => {
|
||||
const config = getOtelConfigFromYaml({
|
||||
tracing: {
|
||||
enabled: true,
|
||||
serviceName: 'yaml-service',
|
||||
endpoint: 'http://yaml:4318',
|
||||
localExport: false,
|
||||
debug: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
enabled: true,
|
||||
serviceName: 'yaml-service',
|
||||
endpoint: 'http://yaml:4318',
|
||||
localExport: false,
|
||||
debug: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should only include valid boolean/string values', () => {
|
||||
const config = getOtelConfigFromYaml({
|
||||
tracing: {
|
||||
enabled: 'true', // string, not boolean - should be ignored
|
||||
serviceName: 123, // number, not string - should be ignored
|
||||
endpoint: 'http://valid:4318',
|
||||
},
|
||||
});
|
||||
|
||||
expect(config).toEqual({
|
||||
endpoint: 'http://valid:4318',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeOtelConfigs', () => {
|
||||
const defaultEnvConfig: OtelConfig = {
|
||||
enabled: false,
|
||||
serviceName: 'promptfoo',
|
||||
endpoint: undefined,
|
||||
localExport: true,
|
||||
debug: false,
|
||||
};
|
||||
|
||||
it('should use env config when yaml config is empty', () => {
|
||||
const merged = mergeOtelConfigs(defaultEnvConfig, {});
|
||||
|
||||
expect(merged).toEqual(defaultEnvConfig);
|
||||
});
|
||||
|
||||
it('should prefer yaml config over env config', () => {
|
||||
const envConfig: OtelConfig = {
|
||||
enabled: false,
|
||||
serviceName: 'env-service',
|
||||
endpoint: 'http://env:4318',
|
||||
localExport: true,
|
||||
debug: false,
|
||||
};
|
||||
|
||||
const yamlConfig = {
|
||||
enabled: true,
|
||||
serviceName: 'yaml-service',
|
||||
endpoint: 'http://yaml:4318',
|
||||
};
|
||||
|
||||
const merged = mergeOtelConfigs(envConfig, yamlConfig);
|
||||
|
||||
expect(merged).toEqual({
|
||||
enabled: true,
|
||||
serviceName: 'yaml-service',
|
||||
endpoint: 'http://yaml:4318',
|
||||
localExport: true, // from env
|
||||
debug: false, // from env
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial yaml overrides', () => {
|
||||
const envConfig: OtelConfig = {
|
||||
enabled: true,
|
||||
serviceName: 'env-service',
|
||||
endpoint: 'http://env:4318',
|
||||
localExport: true,
|
||||
debug: true,
|
||||
};
|
||||
|
||||
const yamlConfig = {
|
||||
serviceName: 'yaml-service',
|
||||
};
|
||||
|
||||
const merged = mergeOtelConfigs(envConfig, yamlConfig);
|
||||
|
||||
expect(merged.serviceName).toBe('yaml-service');
|
||||
expect(merged.enabled).toBe(true); // from env
|
||||
expect(merged.endpoint).toBe('http://env:4318'); // from env
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { OtelConfig } from '../../src/tracing/otelConfig';
|
||||
|
||||
// Create mock functions that will be used across tests
|
||||
const mockRegister = vi.fn();
|
||||
const mockShutdown = vi.fn().mockResolvedValue(undefined);
|
||||
const mockForceFlush = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAddSpanProcessor = vi.fn();
|
||||
const mockSetLogger = vi.fn();
|
||||
|
||||
// Track constructor calls
|
||||
let nodeTracerProviderCalls: unknown[] = [];
|
||||
let otlpExporterCalls: unknown[] = [];
|
||||
let localExporterCalls: unknown[] = [];
|
||||
let batchProcessorCalls: unknown[] = [];
|
||||
let resourceCalls: unknown[] = [];
|
||||
|
||||
vi.mock('@opentelemetry/sdk-trace-node', () => {
|
||||
// Use a class-like constructor function
|
||||
return {
|
||||
NodeTracerProvider: class MockNodeTracerProvider {
|
||||
constructor(options: unknown) {
|
||||
nodeTracerProviderCalls.push(options);
|
||||
}
|
||||
register = mockRegister;
|
||||
shutdown = mockShutdown;
|
||||
forceFlush = mockForceFlush;
|
||||
addSpanProcessor = mockAddSpanProcessor;
|
||||
},
|
||||
BatchSpanProcessor: class MockBatchSpanProcessor {
|
||||
exporter: unknown;
|
||||
constructor(exporter: unknown) {
|
||||
this.exporter = exporter;
|
||||
batchProcessorCalls.push(exporter);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@opentelemetry/exporter-trace-otlp-http', () => ({
|
||||
OTLPTraceExporter: class MockOTLPTraceExporter {
|
||||
url: string | undefined;
|
||||
constructor(config: { url?: string } = {}) {
|
||||
this.url = config.url;
|
||||
otlpExporterCalls.push(config);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@opentelemetry/core', () => ({
|
||||
W3CTraceContextPropagator: class MockW3CTraceContextPropagator {},
|
||||
}));
|
||||
|
||||
vi.mock('@opentelemetry/resources', () => ({
|
||||
resourceFromAttributes: (attrs: Record<string, unknown>) => {
|
||||
resourceCalls.push(attrs);
|
||||
return { attributes: attrs };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@opentelemetry/semantic-conventions', () => ({
|
||||
ATTR_SERVICE_NAME: 'service.name',
|
||||
ATTR_SERVICE_VERSION: 'service.version',
|
||||
}));
|
||||
|
||||
vi.mock('@opentelemetry/api', () => ({
|
||||
diag: {
|
||||
setLogger: mockSetLogger,
|
||||
},
|
||||
DiagConsoleLogger: class MockDiagConsoleLogger {},
|
||||
DiagLogLevel: {
|
||||
DEBUG: 0,
|
||||
},
|
||||
propagation: {
|
||||
setGlobalPropagator: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/version', () => ({
|
||||
VERSION: '1.0.0-test',
|
||||
}));
|
||||
|
||||
vi.mock('../../src/tracing/localSpanExporter', () => ({
|
||||
LocalSpanExporter: class MockLocalSpanExporter {
|
||||
constructor() {
|
||||
localExporterCalls.push({});
|
||||
}
|
||||
export = vi.fn();
|
||||
shutdown = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
describe('otelSdk', () => {
|
||||
// Module functions - will be re-imported in beforeEach
|
||||
let initializeOtel: typeof import('../../src/tracing/otelSdk').initializeOtel;
|
||||
let shutdownOtel: typeof import('../../src/tracing/otelSdk').shutdownOtel;
|
||||
let flushOtel: typeof import('../../src/tracing/otelSdk').flushOtel;
|
||||
let isOtelInitialized: typeof import('../../src/tracing/otelSdk').isOtelInitialized;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear all mocks and call tracking
|
||||
vi.clearAllMocks();
|
||||
nodeTracerProviderCalls = [];
|
||||
otlpExporterCalls = [];
|
||||
localExporterCalls = [];
|
||||
batchProcessorCalls = [];
|
||||
resourceCalls = [];
|
||||
|
||||
// Reset mock implementations
|
||||
mockShutdown.mockResolvedValue(undefined);
|
||||
mockForceFlush.mockResolvedValue(undefined);
|
||||
|
||||
// Reset modules to clear singleton state
|
||||
vi.resetModules();
|
||||
|
||||
// Re-import the module
|
||||
const module = await import('../../src/tracing/otelSdk');
|
||||
initializeOtel = module.initializeOtel;
|
||||
shutdownOtel = module.shutdownOtel;
|
||||
flushOtel = module.flushOtel;
|
||||
isOtelInitialized = module.isOtelInitialized;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const defaultConfig: OtelConfig = {
|
||||
enabled: true,
|
||||
serviceName: 'test-service',
|
||||
endpoint: undefined,
|
||||
localExport: true,
|
||||
debug: false,
|
||||
};
|
||||
|
||||
describe('initializeOtel', () => {
|
||||
it('should not initialize when disabled', () => {
|
||||
initializeOtel({ ...defaultConfig, enabled: false });
|
||||
|
||||
expect(isOtelInitialized()).toBe(false);
|
||||
expect(mockRegister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should initialize and register provider', () => {
|
||||
initializeOtel(defaultConfig);
|
||||
|
||||
expect(isOtelInitialized()).toBe(true);
|
||||
expect(nodeTracerProviderCalls.length).toBe(1);
|
||||
expect(mockRegister).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add local span processor when localExport is true', () => {
|
||||
initializeOtel(defaultConfig);
|
||||
|
||||
expect(localExporterCalls.length).toBe(1);
|
||||
// Span processors are now passed via constructor, so we check the constructor args
|
||||
expect(nodeTracerProviderCalls.length).toBe(1);
|
||||
const constructorArg = nodeTracerProviderCalls[0] as { spanProcessors?: unknown[] };
|
||||
expect(constructorArg.spanProcessors).toBeDefined();
|
||||
expect(constructorArg.spanProcessors?.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should add OTLP exporter when endpoint is configured', () => {
|
||||
initializeOtel({
|
||||
...defaultConfig,
|
||||
endpoint: 'http://localhost:4318/v1/traces',
|
||||
});
|
||||
|
||||
expect(otlpExporterCalls.length).toBe(1);
|
||||
expect(otlpExporterCalls[0]).toEqual({ url: 'http://localhost:4318/v1/traces' });
|
||||
// Both local and OTLP exporters - now passed via constructor
|
||||
const constructorArg = nodeTracerProviderCalls[0] as { spanProcessors?: unknown[] };
|
||||
expect(constructorArg.spanProcessors?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should skip local export when localExport is false', () => {
|
||||
initializeOtel({
|
||||
...defaultConfig,
|
||||
localExport: false,
|
||||
endpoint: 'http://localhost:4318',
|
||||
});
|
||||
|
||||
expect(localExporterCalls.length).toBe(0);
|
||||
// Only OTLP exporter - now passed via constructor
|
||||
const constructorArg = nodeTracerProviderCalls[0] as { spanProcessors?: unknown[] };
|
||||
expect(constructorArg.spanProcessors?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should enable debug logging when debug is true', () => {
|
||||
initializeOtel({
|
||||
...defaultConfig,
|
||||
debug: true,
|
||||
});
|
||||
|
||||
expect(mockSetLogger).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not reinitialize when already initialized', () => {
|
||||
initializeOtel(defaultConfig);
|
||||
const firstCallCount = mockRegister.mock.calls.length;
|
||||
|
||||
initializeOtel(defaultConfig);
|
||||
|
||||
expect(mockRegister.mock.calls.length).toBe(firstCallCount);
|
||||
});
|
||||
|
||||
it('should create resource with service name and version', () => {
|
||||
initializeOtel(defaultConfig);
|
||||
|
||||
expect(resourceCalls.length).toBe(1);
|
||||
expect(resourceCalls[0]).toEqual({
|
||||
'service.name': 'test-service',
|
||||
'service.version': '1.0.0-test',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdownOtel', () => {
|
||||
it('should not fail when not initialized', async () => {
|
||||
await expect(shutdownOtel()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should call provider shutdown when initialized', async () => {
|
||||
initializeOtel(defaultConfig);
|
||||
await shutdownOtel();
|
||||
|
||||
expect(mockShutdown).toHaveBeenCalled();
|
||||
expect(isOtelInitialized()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle shutdown errors gracefully', async () => {
|
||||
mockShutdown.mockRejectedValue(new Error('Shutdown failed'));
|
||||
|
||||
initializeOtel(defaultConfig);
|
||||
await expect(shutdownOtel()).resolves.toBeUndefined();
|
||||
expect(isOtelInitialized()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flushOtel', () => {
|
||||
it('should not fail when not initialized', async () => {
|
||||
await expect(flushOtel()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should call provider forceFlush when initialized', async () => {
|
||||
initializeOtel(defaultConfig);
|
||||
await flushOtel();
|
||||
|
||||
expect(mockForceFlush).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle flush errors gracefully', async () => {
|
||||
mockForceFlush.mockRejectedValue(new Error('Flush failed'));
|
||||
|
||||
initializeOtel(defaultConfig);
|
||||
await expect(flushOtel()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOtelInitialized', () => {
|
||||
it('should return false before initialization', () => {
|
||||
expect(isOtelInitialized()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true after initialization', () => {
|
||||
initializeOtel(defaultConfig);
|
||||
expect(isOtelInitialized()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false after shutdown', async () => {
|
||||
initializeOtel(defaultConfig);
|
||||
await shutdownOtel();
|
||||
expect(isOtelInitialized()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Smoke tests for OTEL instrumentation.
|
||||
*
|
||||
* These tests verify that tracing works correctly under various conditions
|
||||
* (concurrency, errors, many attributes, etc.) without asserting on timing.
|
||||
* Performance benchmarking should be done with dedicated tooling, not in CI.
|
||||
*/
|
||||
|
||||
import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
||||
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { withGenAISpan } from '../../src/tracing/genaiTracer';
|
||||
|
||||
import type { GenAISpanContext, GenAISpanResult } from '../../src/tracing/genaiTracer';
|
||||
|
||||
// Mock logger to reduce noise
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('OTEL Tracing Smoke Tests', () => {
|
||||
let tracerProvider: NodeTracerProvider;
|
||||
let memoryExporter: InMemorySpanExporter;
|
||||
|
||||
beforeAll(() => {
|
||||
memoryExporter = new InMemorySpanExporter();
|
||||
tracerProvider = new NodeTracerProvider({
|
||||
spanProcessors: [new SimpleSpanProcessor(memoryExporter)],
|
||||
});
|
||||
tracerProvider.register();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tracerProvider.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
memoryExporter.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const baseContext: GenAISpanContext = {
|
||||
system: 'openai',
|
||||
operationName: 'chat',
|
||||
model: 'gpt-4',
|
||||
providerId: 'openai:gpt-4',
|
||||
maxTokens: 1000,
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
||||
const resultExtractor = (): GenAISpanResult => ({
|
||||
tokenUsage: { prompt: 100, completion: 50, total: 150 },
|
||||
responseId: 'chatcmpl-123',
|
||||
finishReasons: ['stop'],
|
||||
});
|
||||
|
||||
describe('Basic Span Creation', () => {
|
||||
it('should create spans for traced operations', async () => {
|
||||
const iterations = 10;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
await withGenAISpan(baseContext, async () => ({ output: 'test' }));
|
||||
}
|
||||
|
||||
const spans = memoryExporter.getFinishedSpans();
|
||||
expect(spans.length).toBe(iterations);
|
||||
});
|
||||
|
||||
it('should create spans with result extraction', async () => {
|
||||
const iterations = 10;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
await withGenAISpan(baseContext, async () => ({ output: 'test' }), resultExtractor);
|
||||
}
|
||||
|
||||
const spans = memoryExporter.getFinishedSpans();
|
||||
expect(spans.length).toBe(iterations);
|
||||
|
||||
// Verify token usage attributes are set
|
||||
const span = spans[0];
|
||||
expect(span.attributes['gen_ai.usage.input_tokens']).toBe(100);
|
||||
expect(span.attributes['gen_ai.usage.output_tokens']).toBe(50);
|
||||
});
|
||||
|
||||
it('should handle high concurrency without errors', async () => {
|
||||
const concurrency = 100;
|
||||
const batches = 3;
|
||||
|
||||
for (let batch = 0; batch < batches; batch++) {
|
||||
await Promise.all(
|
||||
Array.from({ length: concurrency }, (_, i) =>
|
||||
withGenAISpan(
|
||||
{ ...baseContext, testIndex: batch * concurrency + i },
|
||||
async () => ({ output: `Response ${i}` }),
|
||||
resultExtractor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const spans = memoryExporter.getFinishedSpans();
|
||||
expect(spans.length).toBe(concurrency * batches);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Attribute Handling', () => {
|
||||
it('should handle contexts with all optional attributes', async () => {
|
||||
const fullContext: GenAISpanContext = {
|
||||
system: 'openai',
|
||||
operationName: 'chat',
|
||||
model: 'gpt-4-turbo-preview',
|
||||
providerId: 'openai:gpt-4-turbo-preview',
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
stopSequences: ['END', 'STOP', '###'],
|
||||
frequencyPenalty: 0.5,
|
||||
presencePenalty: 0.5,
|
||||
evalId: 'eval-benchmark-test-123',
|
||||
testIndex: 42,
|
||||
promptLabel: 'benchmark-prompt-label',
|
||||
};
|
||||
|
||||
const fullResultExtractor = (): GenAISpanResult => ({
|
||||
tokenUsage: {
|
||||
prompt: 1000,
|
||||
completion: 500,
|
||||
total: 1500,
|
||||
cached: 200,
|
||||
completionDetails: {
|
||||
reasoning: 300,
|
||||
acceptedPrediction: 150,
|
||||
rejectedPrediction: 50,
|
||||
},
|
||||
},
|
||||
responseModel: 'gpt-4-turbo-preview-2024-01-01',
|
||||
responseId: 'chatcmpl-benchmark123456789',
|
||||
finishReasons: ['stop'],
|
||||
});
|
||||
|
||||
const iterations = 10;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
await withGenAISpan(fullContext, async () => ({ output: 'test' }), fullResultExtractor);
|
||||
}
|
||||
|
||||
const spans = memoryExporter.getFinishedSpans();
|
||||
expect(spans.length).toBe(iterations);
|
||||
|
||||
// Verify key attributes are set
|
||||
const span = spans[0];
|
||||
expect(span.attributes['gen_ai.request.model']).toBe('gpt-4-turbo-preview');
|
||||
expect(span.attributes['gen_ai.request.max_tokens']).toBe(4096);
|
||||
expect(span.attributes['gen_ai.usage.input_tokens']).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should properly record errors in spans', async () => {
|
||||
const iterations = 10;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
try {
|
||||
await withGenAISpan(baseContext, async () => {
|
||||
throw new Error(`Test error ${i}`);
|
||||
});
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
const spans = memoryExporter.getFinishedSpans();
|
||||
expect(spans.length).toBe(iterations);
|
||||
|
||||
// Verify error is recorded
|
||||
const span = spans[0];
|
||||
expect(span.status.code).toBe(2); // SpanStatusCode.ERROR
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import path from 'path';
|
||||
|
||||
import protobuf from 'protobufjs';
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
bytesToHex,
|
||||
decodeExportTraceServiceRequest,
|
||||
initializeProtobuf,
|
||||
longToNumber,
|
||||
} from '../../src/tracing/protobuf';
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper to create protobuf-encoded OTLP data
|
||||
let protoRoot: protobuf.Root | null = null;
|
||||
let ExportTraceServiceRequest: protobuf.Type | null = null;
|
||||
|
||||
async function getProtoRoot(): Promise<protobuf.Root> {
|
||||
if (protoRoot) {
|
||||
return protoRoot;
|
||||
}
|
||||
const protoDir = path.join(__dirname, '../../src/tracing/proto');
|
||||
|
||||
// Create a new Root with the proto directory as the include path
|
||||
const root = new protobuf.Root();
|
||||
root.resolvePath = (_origin: string, target: string) => {
|
||||
return path.join(protoDir, target);
|
||||
};
|
||||
await root.load('opentelemetry/proto/collector/trace/v1/trace_service.proto');
|
||||
|
||||
protoRoot = root;
|
||||
return protoRoot;
|
||||
}
|
||||
|
||||
async function encodeOTLPRequest(data: any): Promise<Buffer> {
|
||||
const root = await getProtoRoot();
|
||||
if (!ExportTraceServiceRequest) {
|
||||
ExportTraceServiceRequest = root.lookupType(
|
||||
'opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest',
|
||||
);
|
||||
}
|
||||
const message = ExportTraceServiceRequest.create(data);
|
||||
const encoded = ExportTraceServiceRequest.encode(message).finish();
|
||||
return Buffer.from(encoded);
|
||||
}
|
||||
|
||||
describe('Protobuf decoding', () => {
|
||||
beforeAll(async () => {
|
||||
// Initialize proto definitions for faster subsequent tests
|
||||
await initializeProtobuf();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('bytesToHex', () => {
|
||||
it('should convert Uint8Array to hex string', () => {
|
||||
const bytes = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
|
||||
expect(bytesToHex(bytes, 8)).toBe('deadbeef');
|
||||
});
|
||||
|
||||
it('should pad short hex strings to expected length', () => {
|
||||
const bytes = new Uint8Array([0x00, 0x01]);
|
||||
expect(bytesToHex(bytes, 8)).toBe('00000001');
|
||||
});
|
||||
|
||||
it('should return zeros for undefined input', () => {
|
||||
expect(bytesToHex(undefined, 8)).toBe('00000000');
|
||||
});
|
||||
|
||||
it('should return zeros for empty array', () => {
|
||||
expect(bytesToHex(new Uint8Array([]), 8)).toBe('00000000');
|
||||
});
|
||||
|
||||
it('should handle full trace ID length', () => {
|
||||
const bytes = new Uint8Array([
|
||||
0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56, 0x78, 0x90, 0x12, 0x34, 0x56, 0x78, 0x90,
|
||||
0x12,
|
||||
]);
|
||||
expect(bytesToHex(bytes, 32)).toBe('12345678901234567890123456789012');
|
||||
});
|
||||
});
|
||||
|
||||
describe('longToNumber', () => {
|
||||
it('should return 0 for undefined', () => {
|
||||
expect(longToNumber(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return number as-is', () => {
|
||||
expect(longToNumber(12345)).toBe(12345);
|
||||
});
|
||||
|
||||
it('should convert Long-like object to number', () => {
|
||||
const longValue = {
|
||||
low: 1000,
|
||||
high: 0,
|
||||
unsigned: false,
|
||||
toNumber: () => 1000,
|
||||
toString: () => '1000',
|
||||
};
|
||||
expect(longToNumber(longValue)).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeExportTraceServiceRequest', () => {
|
||||
it('should decode a simple protobuf trace request', async () => {
|
||||
const traceIdBytes = new Uint8Array([
|
||||
0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
|
||||
0x99,
|
||||
]);
|
||||
const spanIdBytes = new Uint8Array([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]);
|
||||
|
||||
const testData = {
|
||||
resourceSpans: [
|
||||
{
|
||||
resource: {
|
||||
attributes: [{ key: 'service.name', value: { stringValue: 'test-service' } }],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
scope: {
|
||||
name: 'test-tracer',
|
||||
version: '1.0.0',
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
traceId: traceIdBytes,
|
||||
spanId: spanIdBytes,
|
||||
name: 'test-span',
|
||||
kind: 1,
|
||||
startTimeUnixNano: 1700000000000000000n,
|
||||
endTimeUnixNano: 1700000001000000000n,
|
||||
status: {
|
||||
code: 0,
|
||||
message: 'OK',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const encoded = await encodeOTLPRequest(testData);
|
||||
const decoded = await decodeExportTraceServiceRequest(encoded);
|
||||
|
||||
expect(decoded.resourceSpans).toHaveLength(1);
|
||||
expect(decoded.resourceSpans[0].scopeSpans).toHaveLength(1);
|
||||
expect(decoded.resourceSpans[0].scopeSpans[0].spans).toHaveLength(1);
|
||||
|
||||
const span = decoded.resourceSpans[0].scopeSpans[0].spans[0];
|
||||
expect(span.name).toBe('test-span');
|
||||
expect(span.kind).toBe(1);
|
||||
});
|
||||
|
||||
it('should decode multiple spans', async () => {
|
||||
const traceIdBytes = new Uint8Array([
|
||||
0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd,
|
||||
0xef,
|
||||
]);
|
||||
|
||||
const testData = {
|
||||
resourceSpans: [
|
||||
{
|
||||
scopeSpans: [
|
||||
{
|
||||
spans: [
|
||||
{
|
||||
traceId: traceIdBytes,
|
||||
spanId: new Uint8Array([0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11]),
|
||||
name: 'span-1',
|
||||
startTimeUnixNano: 1000000000n,
|
||||
},
|
||||
{
|
||||
traceId: traceIdBytes,
|
||||
spanId: new Uint8Array([0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22]),
|
||||
parentSpanId: new Uint8Array([0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11]),
|
||||
name: 'span-2',
|
||||
startTimeUnixNano: 2000000000n,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const encoded = await encodeOTLPRequest(testData);
|
||||
const decoded = await decodeExportTraceServiceRequest(encoded);
|
||||
|
||||
const spans = decoded.resourceSpans[0].scopeSpans[0].spans;
|
||||
expect(spans).toHaveLength(2);
|
||||
expect(spans[0].name).toBe('span-1');
|
||||
expect(spans[1].name).toBe('span-2');
|
||||
});
|
||||
|
||||
it('should decode different attribute types', async () => {
|
||||
const traceIdBytes = new Uint8Array([
|
||||
0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
|
||||
0x99,
|
||||
]);
|
||||
const spanIdBytes = new Uint8Array([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]);
|
||||
|
||||
const testData = {
|
||||
resourceSpans: [
|
||||
{
|
||||
scopeSpans: [
|
||||
{
|
||||
spans: [
|
||||
{
|
||||
traceId: traceIdBytes,
|
||||
spanId: spanIdBytes,
|
||||
name: 'test-attributes',
|
||||
startTimeUnixNano: 1000000000n,
|
||||
attributes: [
|
||||
{ key: 'string.attr', value: { stringValue: 'hello' } },
|
||||
{ key: 'int.attr', value: { intValue: 123 } },
|
||||
{ key: 'double.attr', value: { doubleValue: 3.14 } },
|
||||
{ key: 'bool.attr', value: { boolValue: true } },
|
||||
{
|
||||
key: 'array.attr',
|
||||
value: {
|
||||
arrayValue: {
|
||||
values: [{ stringValue: 'a' }, { stringValue: 'b' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const encoded = await encodeOTLPRequest(testData);
|
||||
const decoded = await decodeExportTraceServiceRequest(encoded);
|
||||
|
||||
const span = decoded.resourceSpans[0].scopeSpans[0].spans[0];
|
||||
expect(span.attributes).toBeDefined();
|
||||
|
||||
// Check that attributes are present
|
||||
const attrs = span.attributes!;
|
||||
expect(attrs.find((a) => a.key === 'string.attr')?.value?.stringValue).toBe('hello');
|
||||
expect(attrs.find((a) => a.key === 'int.attr')?.value?.intValue).toBe(123);
|
||||
expect(attrs.find((a) => a.key === 'double.attr')?.value?.doubleValue).toBeCloseTo(3.14);
|
||||
expect(attrs.find((a) => a.key === 'bool.attr')?.value?.boolValue).toBe(true);
|
||||
expect(attrs.find((a) => a.key === 'array.attr')?.value?.arrayValue?.values).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should decode resource attributes', async () => {
|
||||
const traceIdBytes = new Uint8Array([
|
||||
0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd,
|
||||
0xef,
|
||||
]);
|
||||
const spanIdBytes = new Uint8Array([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]);
|
||||
|
||||
const testData = {
|
||||
resourceSpans: [
|
||||
{
|
||||
resource: {
|
||||
attributes: [
|
||||
{ key: 'service.name', value: { stringValue: 'my-service' } },
|
||||
{ key: 'service.version', value: { stringValue: '2.0.0' } },
|
||||
{ key: 'telemetry.sdk.name', value: { stringValue: 'opentelemetry' } },
|
||||
],
|
||||
},
|
||||
scopeSpans: [
|
||||
{
|
||||
spans: [
|
||||
{
|
||||
traceId: traceIdBytes,
|
||||
spanId: spanIdBytes,
|
||||
name: 'test-span',
|
||||
startTimeUnixNano: 1000000000n,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const encoded = await encodeOTLPRequest(testData);
|
||||
const decoded = await decodeExportTraceServiceRequest(encoded);
|
||||
|
||||
const resourceAttrs = decoded.resourceSpans[0].resource?.attributes;
|
||||
expect(resourceAttrs).toBeDefined();
|
||||
expect(resourceAttrs!.find((a) => a.key === 'service.name')?.value?.stringValue).toBe(
|
||||
'my-service',
|
||||
);
|
||||
expect(resourceAttrs!.find((a) => a.key === 'service.version')?.value?.stringValue).toBe(
|
||||
'2.0.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid protobuf data', async () => {
|
||||
const invalidData = Buffer.from('not valid protobuf data');
|
||||
|
||||
await expect(decodeExportTraceServiceRequest(invalidData)).rejects.toThrow(
|
||||
/invalid protobuf/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty resourceSpans', async () => {
|
||||
const testData = {
|
||||
resourceSpans: [],
|
||||
};
|
||||
|
||||
const encoded = await encodeOTLPRequest(testData);
|
||||
const decoded = await decodeExportTraceServiceRequest(encoded);
|
||||
|
||||
expect(decoded.resourceSpans).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle Buffer input', async () => {
|
||||
const traceIdBytes = new Uint8Array([
|
||||
0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd,
|
||||
0xef,
|
||||
]);
|
||||
const spanIdBytes = new Uint8Array([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]);
|
||||
|
||||
const testData = {
|
||||
resourceSpans: [
|
||||
{
|
||||
scopeSpans: [
|
||||
{
|
||||
spans: [
|
||||
{
|
||||
traceId: traceIdBytes,
|
||||
spanId: spanIdBytes,
|
||||
name: 'test-span',
|
||||
startTimeUnixNano: 1000000000n,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const encoded = await encodeOTLPRequest(testData);
|
||||
// Pass as Buffer instead of Uint8Array
|
||||
const decoded = await decodeExportTraceServiceRequest(Buffer.from(encoded));
|
||||
|
||||
expect(decoded.resourceSpans).toHaveLength(1);
|
||||
expect(decoded.resourceSpans[0].scopeSpans[0].spans[0].name).toBe('test-span');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* Phase 5: Comprehensive provider instrumentation validation tests.
|
||||
*
|
||||
* These tests verify that OTEL tracing is correctly implemented across
|
||||
* all instrumented providers, covering:
|
||||
* - GenAI semantic conventions compliance
|
||||
* - Token usage capture
|
||||
* - Trace context propagation
|
||||
* - Error handling
|
||||
* - Concurrent calls
|
||||
* - Provider inheritance
|
||||
*/
|
||||
|
||||
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
|
||||
import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
||||
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
GenAIAttributes,
|
||||
getCurrentTraceId,
|
||||
getTraceparent,
|
||||
PromptfooAttributes,
|
||||
withGenAISpan,
|
||||
} from '../../src/tracing/genaiTracer';
|
||||
|
||||
import type { GenAISpanContext, GenAISpanResult } from '../../src/tracing/genaiTracer';
|
||||
|
||||
// Mock external dependencies for provider tests
|
||||
vi.mock('../../src/cache', () => ({
|
||||
fetchWithCache: vi.fn(),
|
||||
getCache: vi.fn(() => ({ get: vi.fn(), set: vi.fn() })),
|
||||
isCacheEnabled: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Phase 5: Provider Instrumentation Validation', () => {
|
||||
let tracerProvider: NodeTracerProvider;
|
||||
let memoryExporter: InMemorySpanExporter;
|
||||
|
||||
beforeAll(() => {
|
||||
memoryExporter = new InMemorySpanExporter();
|
||||
tracerProvider = new NodeTracerProvider({
|
||||
spanProcessors: [new SimpleSpanProcessor(memoryExporter)],
|
||||
});
|
||||
tracerProvider.register();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tracerProvider.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
memoryExporter.reset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('GenAI Semantic Conventions Compliance', () => {
|
||||
it('should set all required GenAI attributes on spans', async () => {
|
||||
const spanContext: GenAISpanContext = {
|
||||
system: 'openai',
|
||||
operationName: 'chat',
|
||||
model: 'gpt-4',
|
||||
providerId: 'openai:gpt-4',
|
||||
maxTokens: 1000,
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
stopSequences: ['END'],
|
||||
};
|
||||
|
||||
await withGenAISpan(spanContext, async () => ({ output: 'test' }));
|
||||
|
||||
const spans = memoryExporter.getFinishedSpans();
|
||||
expect(spans).toHaveLength(1);
|
||||
|
||||
const span = spans[0];
|
||||
|
||||
// Required 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');
|
||||
|
||||
// Optional request attributes
|
||||
expect(span.attributes[GenAIAttributes.REQUEST_MAX_TOKENS]).toBe(1000);
|
||||
expect(span.attributes[GenAIAttributes.REQUEST_TEMPERATURE]).toBe(0.7);
|
||||
expect(span.attributes[GenAIAttributes.REQUEST_TOP_P]).toBe(0.9);
|
||||
expect(span.attributes[GenAIAttributes.REQUEST_STOP_SEQUENCES]).toEqual(['END']);
|
||||
});
|
||||
|
||||
it('should follow span naming convention: "{operation} {model}"', async () => {
|
||||
const testCases = [
|
||||
{ operationName: 'chat' as const, model: 'gpt-4', expected: 'chat gpt-4' },
|
||||
{
|
||||
operationName: 'completion' as const,
|
||||
model: 'text-davinci-003',
|
||||
expected: 'completion text-davinci-003',
|
||||
},
|
||||
{
|
||||
operationName: 'embedding' as const,
|
||||
model: 'text-embedding-ada-002',
|
||||
expected: 'embedding text-embedding-ada-002',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { operationName, model, expected } of testCases) {
|
||||
memoryExporter.reset();
|
||||
|
||||
await withGenAISpan(
|
||||
{ system: 'openai', operationName, model, providerId: `openai:${model}` },
|
||||
async () => ({ output: 'test' }),
|
||||
);
|
||||
|
||||
const spans = memoryExporter.getFinishedSpans();
|
||||
expect(spans[0].name).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('should set span kind to CLIENT for all provider calls', async () => {
|
||||
await withGenAISpan(
|
||||
{
|
||||
system: 'anthropic',
|
||||
operationName: 'chat',
|
||||
model: 'claude-3-opus',
|
||||
providerId: 'anthropic:claude-3-opus',
|
||||
},
|
||||
async () => ({ output: 'test' }),
|
||||
);
|
||||
|
||||
const spans = memoryExporter.getFinishedSpans();
|
||||
expect(spans[0].kind).toBe(SpanKind.CLIENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Usage Capture', () => {
|
||||
it('should capture basic token usage (prompt, completion, total)', async () => {
|
||||
const resultExtractor = (): GenAISpanResult => ({
|
||||
tokenUsage: {
|
||||
prompt: 100,
|
||||
completion: 50,
|
||||
total: 150,
|
||||
},
|
||||
});
|
||||
|
||||
await withGenAISpan(
|
||||
{ system: 'openai', operationName: 'chat', model: 'gpt-4', providerId: 'openai:gpt-4' },
|
||||
async () => ({ output: 'test' }),
|
||||
resultExtractor,
|
||||
);
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
expect(span.attributes[GenAIAttributes.USAGE_INPUT_TOKENS]).toBe(100);
|
||||
expect(span.attributes[GenAIAttributes.USAGE_OUTPUT_TOKENS]).toBe(50);
|
||||
expect(span.attributes[GenAIAttributes.USAGE_TOTAL_TOKENS]).toBe(150);
|
||||
});
|
||||
|
||||
it('should capture cached tokens (Anthropic prompt caching)', async () => {
|
||||
const resultExtractor = (): GenAISpanResult => ({
|
||||
tokenUsage: {
|
||||
prompt: 200,
|
||||
completion: 100,
|
||||
total: 300,
|
||||
cached: 150,
|
||||
},
|
||||
});
|
||||
|
||||
await withGenAISpan(
|
||||
{
|
||||
system: 'anthropic',
|
||||
operationName: 'chat',
|
||||
model: 'claude-3-sonnet',
|
||||
providerId: 'anthropic:claude-3-sonnet',
|
||||
},
|
||||
async () => ({ output: 'test' }),
|
||||
resultExtractor,
|
||||
);
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
expect(span.attributes[GenAIAttributes.USAGE_CACHED_TOKENS]).toBe(150);
|
||||
});
|
||||
|
||||
it('should capture reasoning tokens (OpenAI o1 models)', async () => {
|
||||
const resultExtractor = (): GenAISpanResult => ({
|
||||
tokenUsage: {
|
||||
prompt: 100,
|
||||
completion: 500,
|
||||
total: 600,
|
||||
completionDetails: {
|
||||
reasoning: 450,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await withGenAISpan(
|
||||
{
|
||||
system: 'openai',
|
||||
operationName: 'chat',
|
||||
model: 'o1-preview',
|
||||
providerId: 'openai:o1-preview',
|
||||
},
|
||||
async () => ({ output: 'test' }),
|
||||
resultExtractor,
|
||||
);
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
expect(span.attributes[GenAIAttributes.USAGE_REASONING_TOKENS]).toBe(450);
|
||||
});
|
||||
|
||||
it('should capture speculative decoding tokens', async () => {
|
||||
const resultExtractor = (): GenAISpanResult => ({
|
||||
tokenUsage: {
|
||||
prompt: 50,
|
||||
completion: 30,
|
||||
total: 80,
|
||||
completionDetails: {
|
||||
acceptedPrediction: 25,
|
||||
rejectedPrediction: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await withGenAISpan(
|
||||
{
|
||||
system: 'openai',
|
||||
operationName: 'chat',
|
||||
model: 'gpt-4-turbo',
|
||||
providerId: 'openai:gpt-4-turbo',
|
||||
},
|
||||
async () => ({ output: 'test' }),
|
||||
resultExtractor,
|
||||
);
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
expect(span.attributes[GenAIAttributes.USAGE_ACCEPTED_PREDICTION_TOKENS]).toBe(25);
|
||||
expect(span.attributes[GenAIAttributes.USAGE_REJECTED_PREDICTION_TOKENS]).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trace Context Propagation', () => {
|
||||
it('should generate valid W3C traceparent header', async () => {
|
||||
let capturedTraceparent: string | undefined;
|
||||
|
||||
await withGenAISpan(
|
||||
{ system: 'openai', operationName: 'chat', model: 'gpt-4', providerId: 'openai:gpt-4' },
|
||||
async () => {
|
||||
capturedTraceparent = getTraceparent();
|
||||
return { output: 'test' };
|
||||
},
|
||||
);
|
||||
|
||||
expect(capturedTraceparent).toBeDefined();
|
||||
// Format: 00-traceId(32 hex)-spanId(16 hex)-flags(2 hex)
|
||||
expect(capturedTraceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/);
|
||||
});
|
||||
|
||||
it('should provide trace ID within active span', async () => {
|
||||
let capturedTraceId: string | undefined;
|
||||
|
||||
await withGenAISpan(
|
||||
{ system: 'openai', operationName: 'chat', model: 'gpt-4', providerId: 'openai:gpt-4' },
|
||||
async () => {
|
||||
capturedTraceId = getCurrentTraceId();
|
||||
return { output: 'test' };
|
||||
},
|
||||
);
|
||||
|
||||
expect(capturedTraceId).toBeDefined();
|
||||
expect(capturedTraceId).toHaveLength(32);
|
||||
expect(capturedTraceId).toMatch(/^[0-9a-f]+$/);
|
||||
});
|
||||
|
||||
it('should maintain parent-child relationship for nested spans', async () => {
|
||||
await withGenAISpan(
|
||||
{ system: 'openai', operationName: 'chat', model: 'gpt-4', providerId: 'openai:gpt-4' },
|
||||
async () => {
|
||||
// Nested call (e.g., embedding for RAG)
|
||||
await withGenAISpan(
|
||||
{
|
||||
system: 'openai',
|
||||
operationName: 'embedding',
|
||||
model: 'text-embedding-ada-002',
|
||||
providerId: 'openai:embedding',
|
||||
},
|
||||
async () => ({ embedding: [0.1, 0.2] }),
|
||||
);
|
||||
return { output: 'test' };
|
||||
},
|
||||
);
|
||||
|
||||
const spans = memoryExporter.getFinishedSpans();
|
||||
expect(spans).toHaveLength(2);
|
||||
|
||||
// Find parent and child spans
|
||||
const embeddingSpan = spans.find((s) => s.name.includes('embedding'));
|
||||
const chatSpan = spans.find((s) => s.name.includes('chat'));
|
||||
|
||||
expect(embeddingSpan).toBeDefined();
|
||||
expect(chatSpan).toBeDefined();
|
||||
|
||||
// Verify parent-child relationship
|
||||
expect(embeddingSpan!.parentSpanContext?.spanId).toBe(chatSpan!.spanContext().spanId);
|
||||
expect(embeddingSpan!.spanContext().traceId).toBe(chatSpan!.spanContext().traceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should set ERROR status on provider failure', async () => {
|
||||
const error = new Error('API rate limit exceeded');
|
||||
|
||||
await expect(
|
||||
withGenAISpan(
|
||||
{ system: 'openai', operationName: 'chat', model: 'gpt-4', providerId: 'openai:gpt-4' },
|
||||
async () => {
|
||||
throw error;
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('API rate limit exceeded');
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
expect(span.status.code).toBe(SpanStatusCode.ERROR);
|
||||
expect(span.status.message).toBe('API rate limit exceeded');
|
||||
});
|
||||
|
||||
it('should record exception events for errors', async () => {
|
||||
await expect(
|
||||
withGenAISpan(
|
||||
{
|
||||
system: 'anthropic',
|
||||
operationName: 'chat',
|
||||
model: 'claude-3-opus',
|
||||
providerId: 'anthropic:claude-3-opus',
|
||||
},
|
||||
async () => {
|
||||
throw new Error('Service unavailable');
|
||||
},
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
const exceptionEvent = span.events.find((e) => e.name === 'exception');
|
||||
|
||||
expect(exceptionEvent).toBeDefined();
|
||||
expect(exceptionEvent!.attributes).toHaveProperty('exception.message', 'Service unavailable');
|
||||
});
|
||||
|
||||
it('should still end span even when error occurs', async () => {
|
||||
try {
|
||||
await withGenAISpan(
|
||||
{ system: 'openai', operationName: 'chat', model: 'gpt-4', providerId: 'openai:gpt-4' },
|
||||
async () => {
|
||||
throw new Error('Network error');
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
|
||||
const spans = memoryExporter.getFinishedSpans();
|
||||
expect(spans).toHaveLength(1);
|
||||
// If span is in finished spans, it was ended
|
||||
});
|
||||
|
||||
it('should handle non-Error thrown values', async () => {
|
||||
await expect(
|
||||
withGenAISpan(
|
||||
{ system: 'openai', operationName: 'chat', model: 'gpt-4', providerId: 'openai:gpt-4' },
|
||||
async () => {
|
||||
throw 'String error'; // Non-Error thrown
|
||||
},
|
||||
),
|
||||
).rejects.toBe('String error');
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
expect(span.status.code).toBe(SpanStatusCode.ERROR);
|
||||
expect(span.status.message).toBe('String error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Concurrent Provider Calls', () => {
|
||||
it('should handle multiple concurrent provider calls', async () => {
|
||||
const providers = [
|
||||
{ system: 'openai', model: 'gpt-4' },
|
||||
{ system: 'anthropic', model: 'claude-3-opus' },
|
||||
{ system: 'bedrock', model: 'anthropic.claude-3-sonnet' },
|
||||
{ system: 'azure', model: 'gpt-4-deployment' },
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
providers.map(({ system, model }) =>
|
||||
withGenAISpan(
|
||||
{ system, operationName: 'chat', model, providerId: `${system}:${model}` },
|
||||
async () => {
|
||||
await Promise.resolve();
|
||||
return { output: `Response from ${system}` };
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const spans = memoryExporter.getFinishedSpans();
|
||||
expect(spans).toHaveLength(4);
|
||||
|
||||
// Verify all systems are represented
|
||||
const systems = spans.map((s) => s.attributes[GenAIAttributes.SYSTEM]);
|
||||
expect(systems).toContain('openai');
|
||||
expect(systems).toContain('anthropic');
|
||||
expect(systems).toContain('bedrock');
|
||||
expect(systems).toContain('azure');
|
||||
|
||||
// All spans should be successful
|
||||
spans.forEach((span) => {
|
||||
expect(span.status.code).toBe(SpanStatusCode.OK);
|
||||
});
|
||||
});
|
||||
|
||||
it('should maintain correct token usage across concurrent calls', async () => {
|
||||
const results = await Promise.all([
|
||||
withGenAISpan(
|
||||
{ system: 'openai', operationName: 'chat', model: 'gpt-4', providerId: 'openai:gpt-4' },
|
||||
async () => ({ output: 'a' }),
|
||||
() => ({ tokenUsage: { prompt: 100, completion: 50, total: 150 } }),
|
||||
),
|
||||
withGenAISpan(
|
||||
{
|
||||
system: 'anthropic',
|
||||
operationName: 'chat',
|
||||
model: 'claude-3',
|
||||
providerId: 'anthropic:claude-3',
|
||||
},
|
||||
async () => ({ output: 'b' }),
|
||||
() => ({ tokenUsage: { prompt: 200, completion: 100, total: 300 } }),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
|
||||
const spans = memoryExporter.getFinishedSpans();
|
||||
const openaiSpan = spans.find((s) => s.attributes[GenAIAttributes.SYSTEM] === 'openai');
|
||||
const anthropicSpan = spans.find((s) => s.attributes[GenAIAttributes.SYSTEM] === 'anthropic');
|
||||
|
||||
expect(openaiSpan!.attributes[GenAIAttributes.USAGE_INPUT_TOKENS]).toBe(100);
|
||||
expect(anthropicSpan!.attributes[GenAIAttributes.USAGE_INPUT_TOKENS]).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Provider Systems Coverage', () => {
|
||||
// Test all Category A providers (directly instrumented)
|
||||
const categoryAProviders = [
|
||||
{ system: 'openai', model: 'gpt-4' },
|
||||
{ system: 'anthropic', model: 'claude-3-opus' },
|
||||
{ system: 'azure', model: 'gpt-4-deployment' },
|
||||
{ system: 'bedrock', model: 'anthropic.claude-3-sonnet' },
|
||||
{ system: 'vertex', model: 'gemini-1.5-pro' },
|
||||
{ system: 'vertex:anthropic', model: 'claude-3-sonnet@anthropic' },
|
||||
{ system: 'vertex:gemini', model: 'gemini-1.5-flash' },
|
||||
{ system: 'ollama', model: 'llama2' },
|
||||
{ system: 'mistral', model: 'mistral-large-latest' },
|
||||
{ system: 'cohere', model: 'command-r-plus' },
|
||||
{ system: 'huggingface', model: 'meta-llama/Llama-2-7b' },
|
||||
{ system: 'watsonx', model: 'ibm/granite-13b-chat-v2' },
|
||||
{ system: 'http', model: 'custom-endpoint' },
|
||||
{ system: 'replicate', model: 'meta/llama-2-70b-chat' },
|
||||
{ system: 'openrouter', model: 'openai/gpt-4' },
|
||||
];
|
||||
|
||||
it.each(categoryAProviders)('should correctly instrument $system provider', async ({
|
||||
system,
|
||||
model,
|
||||
}) => {
|
||||
await withGenAISpan(
|
||||
{ system, operationName: 'chat', model, providerId: `${system}:${model}` },
|
||||
async () => ({ output: 'test' }),
|
||||
() => ({ tokenUsage: { prompt: 10, completion: 5, total: 15 } }),
|
||||
);
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
|
||||
expect(span.attributes[GenAIAttributes.SYSTEM]).toBe(system);
|
||||
expect(span.attributes[GenAIAttributes.REQUEST_MODEL]).toBe(model);
|
||||
expect(span.attributes[PromptfooAttributes.PROVIDER_ID]).toBe(`${system}:${model}`);
|
||||
expect(span.status.code).toBe(SpanStatusCode.OK);
|
||||
|
||||
memoryExporter.reset();
|
||||
});
|
||||
|
||||
// Test Category B providers (inherit from OpenAI)
|
||||
const categoryBProviders = [
|
||||
'groq',
|
||||
'together',
|
||||
'cerebras',
|
||||
'fireworks',
|
||||
'deepinfra',
|
||||
'xai',
|
||||
'sambanova',
|
||||
'perplexity',
|
||||
];
|
||||
|
||||
it.each(
|
||||
categoryBProviders,
|
||||
)('should support inherited instrumentation for %s (via OpenAI base)', async (system) => {
|
||||
// Category B providers inherit from OpenAI and should work with the same pattern
|
||||
await withGenAISpan(
|
||||
{ system, operationName: 'chat', model: 'model-name', providerId: `${system}:model-name` },
|
||||
async () => ({ output: 'test' }),
|
||||
);
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
expect(span.attributes[GenAIAttributes.SYSTEM]).toBe(system);
|
||||
expect(span.status.code).toBe(SpanStatusCode.OK);
|
||||
|
||||
memoryExporter.reset();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Promptfoo Context Attributes', () => {
|
||||
it('should capture eval ID', async () => {
|
||||
await withGenAISpan(
|
||||
{
|
||||
system: 'openai',
|
||||
operationName: 'chat',
|
||||
model: 'gpt-4',
|
||||
providerId: 'openai:gpt-4',
|
||||
evalId: 'eval-abc123',
|
||||
},
|
||||
async () => ({ output: 'test' }),
|
||||
);
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
expect(span.attributes[PromptfooAttributes.EVAL_ID]).toBe('eval-abc123');
|
||||
});
|
||||
|
||||
it('should capture test index', async () => {
|
||||
await withGenAISpan(
|
||||
{
|
||||
system: 'openai',
|
||||
operationName: 'chat',
|
||||
model: 'gpt-4',
|
||||
providerId: 'openai:gpt-4',
|
||||
testIndex: 42,
|
||||
},
|
||||
async () => ({ output: 'test' }),
|
||||
);
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
expect(span.attributes[PromptfooAttributes.TEST_INDEX]).toBe(42);
|
||||
});
|
||||
|
||||
it('should capture prompt label', async () => {
|
||||
await withGenAISpan(
|
||||
{
|
||||
system: 'openai',
|
||||
operationName: 'chat',
|
||||
model: 'gpt-4',
|
||||
providerId: 'openai:gpt-4',
|
||||
promptLabel: 'summarization-v2',
|
||||
},
|
||||
async () => ({ output: 'test' }),
|
||||
);
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
expect(span.attributes[PromptfooAttributes.PROMPT_LABEL]).toBe('summarization-v2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Metadata', () => {
|
||||
it('should capture response model (may differ from requested)', async () => {
|
||||
await withGenAISpan(
|
||||
{ system: 'openai', operationName: 'chat', model: 'gpt-4', providerId: 'openai:gpt-4' },
|
||||
async () => ({ output: 'test' }),
|
||||
() => ({ responseModel: 'gpt-4-0613' }),
|
||||
);
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
expect(span.attributes[GenAIAttributes.RESPONSE_MODEL]).toBe('gpt-4-0613');
|
||||
});
|
||||
|
||||
it('should capture response ID', async () => {
|
||||
await withGenAISpan(
|
||||
{ system: 'openai', operationName: 'chat', model: 'gpt-4', providerId: 'openai:gpt-4' },
|
||||
async () => ({ output: 'test' }),
|
||||
() => ({ responseId: 'chatcmpl-abc123' }),
|
||||
);
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
expect(span.attributes[GenAIAttributes.RESPONSE_ID]).toBe('chatcmpl-abc123');
|
||||
});
|
||||
|
||||
it('should capture finish reasons', async () => {
|
||||
await withGenAISpan(
|
||||
{ system: 'openai', operationName: 'chat', model: 'gpt-4', providerId: 'openai:gpt-4' },
|
||||
async () => ({ output: 'test' }),
|
||||
() => ({ finishReasons: ['stop', 'length'] }),
|
||||
);
|
||||
|
||||
const span = memoryExporter.getFinishedSpans()[0];
|
||||
expect(span.attributes[GenAIAttributes.RESPONSE_FINISH_REASONS]).toEqual(['stop', 'length']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,711 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mockGlobal } from '../util/utils';
|
||||
|
||||
const mockRandomUUID = vi.fn(() => 'test-uuid');
|
||||
const restoreCrypto = mockGlobal('crypto', {
|
||||
...crypto,
|
||||
randomUUID: mockRandomUUID,
|
||||
} as Crypto);
|
||||
|
||||
afterAll(() => {
|
||||
restoreCrypto();
|
||||
});
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Dynamic import after mocking - initialized in beforeAll
|
||||
let TraceStore: typeof import('../../src/tracing/store').TraceStore;
|
||||
|
||||
describe('TraceStore', () => {
|
||||
let traceStore: InstanceType<typeof TraceStore>;
|
||||
let mockDb: any;
|
||||
let mockDeleteChain: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../../src/tracing/store');
|
||||
TraceStore = mod.TraceStore;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset the UUID mock
|
||||
mockRandomUUID.mockReturnValue('test-uuid');
|
||||
|
||||
// Create mock database methods that properly chain
|
||||
const mockInsertChain = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
onConflictDoNothing: vi.fn().mockReturnThis(),
|
||||
run: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const mockSelectChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn(() => Promise.resolve([])),
|
||||
};
|
||||
mockDeleteChain = {
|
||||
where: vi.fn().mockReturnThis(),
|
||||
run: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockDb = {
|
||||
insert: vi.fn(() => mockInsertChain),
|
||||
select: vi.fn(() => mockSelectChain),
|
||||
delete: vi.fn(() => mockDeleteChain),
|
||||
transaction: vi.fn(async (callback) => callback(mockDb)),
|
||||
};
|
||||
|
||||
// Create trace store and inject mock DB
|
||||
traceStore = new TraceStore();
|
||||
// Use private property access to inject the mock
|
||||
(traceStore as any).db = mockDb;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createTrace', () => {
|
||||
it('should create a new trace record', async () => {
|
||||
const traceData = {
|
||||
traceId: 'test-trace-id',
|
||||
evaluationId: 'test-eval-id',
|
||||
testCaseId: 'test-case-id',
|
||||
metadata: { test: 'data' },
|
||||
};
|
||||
|
||||
await traceStore.createTrace(traceData);
|
||||
|
||||
expect(mockDb.insert).toHaveBeenCalledWith(expect.anything());
|
||||
expect(mockDb.insert().values).toHaveBeenCalledWith({
|
||||
id: 'test-uuid',
|
||||
traceId: 'test-trace-id',
|
||||
evaluationId: 'test-eval-id',
|
||||
testCaseId: 'test-case-id',
|
||||
createdAt: expect.any(Number),
|
||||
metadata: { test: 'data' },
|
||||
});
|
||||
expect(mockDb.insert().values().onConflictDoNothing).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors when creating trace', async () => {
|
||||
const error = new Error('Database error');
|
||||
mockDb.insert().values().onConflictDoNothing().run.mockRejectedValueOnce(error);
|
||||
|
||||
const traceData = {
|
||||
traceId: 'test-trace-id',
|
||||
evaluationId: 'test-eval-id',
|
||||
testCaseId: 'test-case-id',
|
||||
};
|
||||
|
||||
await expect(traceStore.createTrace(traceData)).rejects.toThrow('Database error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTraceMetadata', () => {
|
||||
// This three-way contract is the single source of truth for ingest-time redaction
|
||||
// (otlpReceiver.getRedactAttributePatterns): object => use stored policy, {} => no
|
||||
// policy (no redaction), undefined => no trace row (fall back to receiver default).
|
||||
it('returns the stored metadata object when the trace row has metadata', async () => {
|
||||
mockDb
|
||||
.select()
|
||||
.from()
|
||||
.where()
|
||||
.limit.mockResolvedValueOnce([
|
||||
{ metadata: { otlpHttpRedactAttributes: ['authorization'] } },
|
||||
]);
|
||||
|
||||
await expect(traceStore.getTraceMetadata('trace-1')).resolves.toEqual({
|
||||
otlpHttpRedactAttributes: ['authorization'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty object (not undefined) when the row exists but metadata is null', async () => {
|
||||
mockDb
|
||||
.select()
|
||||
.from()
|
||||
.where()
|
||||
.limit.mockResolvedValueOnce([{ metadata: null }]);
|
||||
|
||||
await expect(traceStore.getTraceMetadata('trace-2')).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('returns undefined when no trace row exists', async () => {
|
||||
mockDb.select().from().where().limit.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(traceStore.getTraceMetadata('trace-3')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addSpans', () => {
|
||||
it('should add spans to an existing trace', async () => {
|
||||
// Mock trace exists check
|
||||
mockDb
|
||||
.select()
|
||||
.from()
|
||||
.where()
|
||||
.limit.mockResolvedValueOnce([{ traceId: 'test-trace-id' }]);
|
||||
|
||||
const spans = [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
name: 'operation-1',
|
||||
startTime: 1000,
|
||||
endTime: 2000,
|
||||
attributes: { key: 'value' },
|
||||
},
|
||||
{
|
||||
spanId: 'span-2',
|
||||
parentSpanId: 'span-1',
|
||||
name: 'operation-2',
|
||||
startTime: 1100,
|
||||
endTime: 1900,
|
||||
statusCode: 0,
|
||||
statusMessage: 'OK',
|
||||
},
|
||||
];
|
||||
|
||||
await traceStore.addSpans('test-trace-id', spans);
|
||||
|
||||
expect(mockDb.select).toHaveBeenCalledWith();
|
||||
expect(mockDb.select().from).toHaveBeenCalledWith(expect.anything());
|
||||
expect(mockDb.select().from().where).toHaveBeenCalledWith(expect.anything());
|
||||
|
||||
expect(mockDb.insert).toHaveBeenCalledWith(expect.anything());
|
||||
expect(mockDb.insert().values).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'test-uuid',
|
||||
traceId: 'test-trace-id',
|
||||
spanId: 'span-1',
|
||||
parentSpanId: undefined,
|
||||
name: 'operation-1',
|
||||
startTime: 1000,
|
||||
endTime: 2000,
|
||||
attributes: { key: 'value' },
|
||||
statusCode: undefined,
|
||||
statusMessage: undefined,
|
||||
},
|
||||
{
|
||||
id: 'test-uuid',
|
||||
traceId: 'test-trace-id',
|
||||
spanId: 'span-2',
|
||||
parentSpanId: 'span-1',
|
||||
name: 'operation-2',
|
||||
startTime: 1100,
|
||||
endTime: 1900,
|
||||
attributes: undefined,
|
||||
statusCode: 0,
|
||||
statusMessage: 'OK',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip spans if trace does not exist', async () => {
|
||||
// Mock trace does not exist
|
||||
mockDb.select().from().where().limit.mockResolvedValueOnce([]);
|
||||
|
||||
const spans = [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
name: 'operation-1',
|
||||
startTime: 1000,
|
||||
},
|
||||
];
|
||||
|
||||
await traceStore.addSpans('non-existent-trace', spans);
|
||||
|
||||
// Should check for trace existence
|
||||
expect(mockDb.select).toHaveBeenCalledWith();
|
||||
|
||||
// Should not insert spans
|
||||
expect(mockDb.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors when adding spans', async () => {
|
||||
// Mock trace exists
|
||||
mockDb
|
||||
.select()
|
||||
.from()
|
||||
.where()
|
||||
.limit.mockResolvedValueOnce([{ traceId: 'test-trace-id' }]);
|
||||
|
||||
// Mock insert error
|
||||
const error = new Error('Insert failed');
|
||||
mockDb.insert().values().run.mockRejectedValueOnce(error);
|
||||
|
||||
const spans = [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
name: 'operation-1',
|
||||
startTime: 1000,
|
||||
},
|
||||
];
|
||||
|
||||
await expect(traceStore.addSpans('test-trace-id', spans)).rejects.toThrow('Insert failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTracesByEvaluation', () => {
|
||||
it('should retrieve all traces for an evaluation', async () => {
|
||||
const mockTraces = [
|
||||
{ id: '1', traceId: 'trace-1', evaluationId: 'eval-1', testCaseId: 'test-case-1' },
|
||||
{ id: '2', traceId: 'trace-2', evaluationId: 'eval-1', testCaseId: 'test-case-2' },
|
||||
];
|
||||
|
||||
const mockSpans = {
|
||||
'trace-1': [
|
||||
{
|
||||
id: '1',
|
||||
traceId: 'trace-1',
|
||||
spanId: 'span-1-1',
|
||||
parentSpanId: null,
|
||||
name: 'span one',
|
||||
startTime: 1000,
|
||||
endTime: null,
|
||||
attributes: null,
|
||||
statusCode: null,
|
||||
statusMessage: null,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
traceId: 'trace-1',
|
||||
spanId: 'span-1-2',
|
||||
parentSpanId: 'span-1-1',
|
||||
name: 'span two',
|
||||
startTime: 2000,
|
||||
endTime: 2500,
|
||||
attributes: { foo: 'bar' },
|
||||
statusCode: 1,
|
||||
statusMessage: 'ok',
|
||||
},
|
||||
],
|
||||
'trace-2': [
|
||||
{
|
||||
id: '3',
|
||||
traceId: 'trace-2',
|
||||
spanId: 'span-2-1',
|
||||
parentSpanId: null,
|
||||
name: 'span three',
|
||||
startTime: 3000,
|
||||
endTime: null,
|
||||
attributes: null,
|
||||
statusCode: null,
|
||||
statusMessage: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Set up the main traces query
|
||||
const tracesSelectChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn(() => Promise.resolve(mockTraces)),
|
||||
};
|
||||
|
||||
// Set up span queries for each trace
|
||||
const spanQuery1 = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn(() => Promise.resolve(mockSpans['trace-1'])),
|
||||
};
|
||||
const spanQuery2 = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn(() => Promise.resolve(mockSpans['trace-2'])),
|
||||
};
|
||||
|
||||
// Mock the select calls in sequence: first for traces, then for each trace's spans
|
||||
vi.spyOn(mockDb, 'select')
|
||||
.mockImplementation(() => ({}))
|
||||
.mockReturnValueOnce(tracesSelectChain)
|
||||
.mockReturnValueOnce(spanQuery1)
|
||||
.mockReturnValueOnce(spanQuery2);
|
||||
|
||||
const result = await traceStore.getTracesByEvaluation('eval-1');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
traceId: 'trace-1',
|
||||
evaluationId: 'eval-1',
|
||||
testCaseId: 'test-case-1',
|
||||
metadata: undefined,
|
||||
spans: [
|
||||
{
|
||||
spanId: 'span-1-1',
|
||||
parentSpanId: undefined,
|
||||
name: 'span one',
|
||||
startTime: 1000,
|
||||
endTime: undefined,
|
||||
attributes: undefined,
|
||||
statusCode: undefined,
|
||||
statusMessage: undefined,
|
||||
},
|
||||
{
|
||||
spanId: 'span-1-2',
|
||||
parentSpanId: 'span-1-1',
|
||||
name: 'span two',
|
||||
startTime: 2000,
|
||||
endTime: 2500,
|
||||
attributes: { foo: 'bar' },
|
||||
statusCode: 1,
|
||||
statusMessage: 'ok',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
traceId: 'trace-2',
|
||||
evaluationId: 'eval-1',
|
||||
testCaseId: 'test-case-2',
|
||||
metadata: undefined,
|
||||
spans: [
|
||||
{
|
||||
spanId: 'span-2-1',
|
||||
parentSpanId: undefined,
|
||||
name: 'span three',
|
||||
startTime: 3000,
|
||||
endTime: undefined,
|
||||
attributes: undefined,
|
||||
statusCode: undefined,
|
||||
statusMessage: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should redact sensitive span attributes for an evaluation', async () => {
|
||||
const mockTrace = {
|
||||
id: '1',
|
||||
traceId: 'trace-1',
|
||||
evaluationId: 'eval-1',
|
||||
testCaseId: 'test-case-1',
|
||||
metadata: null,
|
||||
};
|
||||
const mockSpans = [
|
||||
{
|
||||
id: '1',
|
||||
traceId: 'trace-1',
|
||||
spanId: 'span-1',
|
||||
parentSpanId: null,
|
||||
name: 'http request',
|
||||
startTime: 1000,
|
||||
endTime: null,
|
||||
attributes: {
|
||||
authorization: 'Bearer trace-secret',
|
||||
'x-api-key': 'gateway-secret',
|
||||
headers: {
|
||||
'api-key': 'header-secret',
|
||||
set_cookie: 'session=trace-secret',
|
||||
accept: 'application/json',
|
||||
},
|
||||
nested: {
|
||||
api_token: 'nested-secret',
|
||||
safe: 'ok',
|
||||
},
|
||||
'gen_ai.request.max_tokens': 4096,
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
},
|
||||
statusCode: null,
|
||||
statusMessage: null,
|
||||
},
|
||||
];
|
||||
|
||||
const tracesSelectChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn(() => Promise.resolve([mockTrace])),
|
||||
};
|
||||
const spanQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn(() => Promise.resolve(mockSpans)),
|
||||
};
|
||||
|
||||
vi.spyOn(mockDb, 'select')
|
||||
.mockImplementation(() => ({}))
|
||||
.mockReturnValueOnce(tracesSelectChain)
|
||||
.mockReturnValueOnce(spanQuery);
|
||||
|
||||
const result = await traceStore.getTracesByEvaluation('eval-1');
|
||||
|
||||
expect(result[0].spans[0].attributes).toEqual({
|
||||
authorization: '<redacted>',
|
||||
'x-api-key': '<redacted>',
|
||||
headers: {
|
||||
'api-key': '<redacted>',
|
||||
set_cookie: '<redacted>',
|
||||
accept: 'application/json',
|
||||
},
|
||||
nested: {
|
||||
api_token: '<redacted>',
|
||||
safe: 'ok',
|
||||
},
|
||||
'gen_ai.request.max_tokens': 4096,
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow callers to retrieve raw span attributes for an evaluation', async () => {
|
||||
const mockTrace = {
|
||||
id: '1',
|
||||
traceId: 'trace-1',
|
||||
evaluationId: 'eval-1',
|
||||
testCaseId: 'test-case-1',
|
||||
metadata: null,
|
||||
};
|
||||
const mockSpans = [
|
||||
{
|
||||
id: '1',
|
||||
traceId: 'trace-1',
|
||||
spanId: 'span-1',
|
||||
parentSpanId: null,
|
||||
name: 'http request',
|
||||
startTime: 1000,
|
||||
endTime: null,
|
||||
attributes: {
|
||||
authorization: 'Bearer trace-secret',
|
||||
'x-api-key': 'gateway-secret',
|
||||
headers: {
|
||||
'api-key': 'header-secret',
|
||||
set_cookie: 'session=trace-secret',
|
||||
accept: 'application/json',
|
||||
},
|
||||
},
|
||||
statusCode: null,
|
||||
statusMessage: null,
|
||||
},
|
||||
];
|
||||
|
||||
const tracesSelectChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn(() => Promise.resolve([mockTrace])),
|
||||
};
|
||||
const spanQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn(() => Promise.resolve(mockSpans)),
|
||||
};
|
||||
|
||||
vi.spyOn(mockDb, 'select')
|
||||
.mockImplementation(() => ({}))
|
||||
.mockReturnValueOnce(tracesSelectChain)
|
||||
.mockReturnValueOnce(spanQuery);
|
||||
|
||||
const result = await traceStore.getTracesByEvaluation('eval-1', {
|
||||
sanitizeAttributes: false,
|
||||
});
|
||||
|
||||
expect(result[0].spans[0].attributes).toEqual(mockSpans[0].attributes);
|
||||
});
|
||||
|
||||
it('should return empty array if no traces found', async () => {
|
||||
mockDb.select().from().where.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await traceStore.getTracesByEvaluation('non-existent-eval');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTrace', () => {
|
||||
it('should retrieve a single trace with spans', async () => {
|
||||
const mockTrace = {
|
||||
id: '1',
|
||||
traceId: 'trace-1',
|
||||
evaluationId: 'eval-1',
|
||||
testCaseId: 'test-case-1',
|
||||
metadata: { source: 'test' },
|
||||
};
|
||||
const mockSpans = [
|
||||
{
|
||||
id: '1',
|
||||
traceId: 'trace-1',
|
||||
spanId: 'span-1',
|
||||
parentSpanId: null,
|
||||
name: 'span one',
|
||||
startTime: 1000,
|
||||
endTime: null,
|
||||
attributes: null,
|
||||
statusCode: null,
|
||||
statusMessage: null,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
traceId: 'trace-1',
|
||||
spanId: 'span-2',
|
||||
parentSpanId: 'span-1',
|
||||
name: 'span two',
|
||||
startTime: 2000,
|
||||
endTime: 2500,
|
||||
attributes: {
|
||||
foo: 'bar',
|
||||
authorization: 'Bearer trace-secret',
|
||||
headers: {
|
||||
Cookie: 'session=trace-secret',
|
||||
'x-api-key': 'gateway-secret',
|
||||
accept: 'application/json',
|
||||
},
|
||||
nested: {
|
||||
'api-key': 'nested-header-secret',
|
||||
api_key: 'nested-secret',
|
||||
safe: 'ok',
|
||||
},
|
||||
},
|
||||
statusCode: 1,
|
||||
statusMessage: 'ok',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock trace query - update the select chain to include limit
|
||||
const traceSelectChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn(() => Promise.resolve([mockTrace])),
|
||||
};
|
||||
|
||||
// Mock spans query
|
||||
const spanQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn(() => Promise.resolve(mockSpans)),
|
||||
};
|
||||
|
||||
vi.spyOn(mockDb, 'select')
|
||||
.mockImplementation(() => ({}))
|
||||
.mockReturnValueOnce(traceSelectChain)
|
||||
.mockReturnValueOnce(spanQuery);
|
||||
|
||||
const result = await traceStore.getTrace('trace-1');
|
||||
|
||||
expect(result).toEqual({
|
||||
traceId: 'trace-1',
|
||||
evaluationId: 'eval-1',
|
||||
testCaseId: 'test-case-1',
|
||||
metadata: { source: 'test' },
|
||||
spans: [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
parentSpanId: undefined,
|
||||
name: 'span one',
|
||||
startTime: 1000,
|
||||
endTime: undefined,
|
||||
attributes: undefined,
|
||||
statusCode: undefined,
|
||||
statusMessage: undefined,
|
||||
},
|
||||
{
|
||||
spanId: 'span-2',
|
||||
parentSpanId: 'span-1',
|
||||
name: 'span two',
|
||||
startTime: 2000,
|
||||
endTime: 2500,
|
||||
attributes: {
|
||||
foo: 'bar',
|
||||
authorization: '<redacted>',
|
||||
headers: {
|
||||
Cookie: '<redacted>',
|
||||
'x-api-key': '<redacted>',
|
||||
accept: 'application/json',
|
||||
},
|
||||
nested: {
|
||||
'api-key': '<redacted>',
|
||||
api_key: '<redacted>',
|
||||
safe: 'ok',
|
||||
},
|
||||
},
|
||||
statusCode: 1,
|
||||
statusMessage: 'ok',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow callers to retrieve raw span attributes for a single trace', async () => {
|
||||
const mockTrace = {
|
||||
id: '1',
|
||||
traceId: 'trace-1',
|
||||
evaluationId: 'eval-1',
|
||||
testCaseId: 'test-case-1',
|
||||
metadata: null,
|
||||
};
|
||||
const mockSpans = [
|
||||
{
|
||||
id: '1',
|
||||
traceId: 'trace-1',
|
||||
spanId: 'span-1',
|
||||
parentSpanId: null,
|
||||
name: 'tool call',
|
||||
startTime: 1000,
|
||||
endTime: null,
|
||||
attributes: {
|
||||
api_key: 'trace-aware-assertion-secret',
|
||||
token: 'trace-aware-token',
|
||||
arguments: { api_key: 'nested-secret' },
|
||||
},
|
||||
statusCode: null,
|
||||
statusMessage: null,
|
||||
},
|
||||
];
|
||||
|
||||
const traceSelectChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn(() => Promise.resolve([mockTrace])),
|
||||
};
|
||||
const spanQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn(() => Promise.resolve(mockSpans)),
|
||||
};
|
||||
|
||||
vi.spyOn(mockDb, 'select')
|
||||
.mockImplementation(() => ({}))
|
||||
.mockReturnValueOnce(traceSelectChain)
|
||||
.mockReturnValueOnce(spanQuery);
|
||||
|
||||
const result = await traceStore.getTrace('trace-1', { sanitizeAttributes: false });
|
||||
|
||||
expect(result?.spans[0].attributes).toEqual(mockSpans[0].attributes);
|
||||
});
|
||||
|
||||
it('should return null if trace not found', async () => {
|
||||
// Mock trace query - update the select chain to include limit
|
||||
const traceSelectChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn(() => Promise.resolve([])),
|
||||
};
|
||||
vi.spyOn(mockDb, 'select')
|
||||
.mockImplementation(() => ({}))
|
||||
.mockReturnValue(traceSelectChain);
|
||||
|
||||
const result = await traceStore.getTrace('non-existent-trace');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOldTraces', () => {
|
||||
it('should delete dependent spans before traces older than retention period', async () => {
|
||||
const retentionDays = 30;
|
||||
|
||||
await traceStore.deleteOldTraces(retentionDays);
|
||||
|
||||
expect(mockDb.transaction).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(mockDb.delete).toHaveBeenCalledTimes(2);
|
||||
expect(mockDeleteChain.where).toHaveBeenCalledTimes(2);
|
||||
expect(mockDeleteChain.run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle errors when deleting old traces', async () => {
|
||||
const error = new Error('Delete failed');
|
||||
mockDeleteChain.run.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(traceStore.deleteOldTraces(30)).rejects.toThrow('Delete failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getDb } from '../../src/database/index';
|
||||
import { spansTable, tracesTable } from '../../src/database/tables';
|
||||
import { runDbMigrations } from '../../src/migrate';
|
||||
import { getTraceSpans, TraceStore } from '../../src/tracing/store';
|
||||
import EvalFactory from '../factories/evalFactory';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function sqliteTimestampFromMs(timestampMs: number): string {
|
||||
return new Date(timestampMs).toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
describe('TraceStore retention cleanup', () => {
|
||||
beforeAll(async () => {
|
||||
await runDbMigrations();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const db = await getDb();
|
||||
await db.delete(spansTable).run();
|
||||
await db.delete(tracesTable).run();
|
||||
});
|
||||
|
||||
async function insertTrace(evalId: string, traceId: string, createdAt: number | string) {
|
||||
const db = await getDb();
|
||||
|
||||
await db
|
||||
.insert(tracesTable)
|
||||
.values({
|
||||
id: `${traceId}-id`,
|
||||
traceId,
|
||||
evaluationId: evalId,
|
||||
testCaseId: `${traceId}-test`,
|
||||
createdAt: createdAt as number,
|
||||
})
|
||||
.run();
|
||||
|
||||
await db
|
||||
.insert(spansTable)
|
||||
.values({
|
||||
id: `${traceId}-span-id`,
|
||||
traceId,
|
||||
spanId: `${traceId}-span`,
|
||||
name: `${traceId}-operation`,
|
||||
startTime: 1,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
it('prunes spans and traces for both numeric and SQLite timestamp rows', async () => {
|
||||
const eval_ = await EvalFactory.create({ numResults: 0 });
|
||||
const now = Date.now();
|
||||
|
||||
await insertTrace(eval_.id, 'numeric-old', now - 60 * DAY_MS);
|
||||
await insertTrace(eval_.id, 'numeric-new', now - DAY_MS);
|
||||
await insertTrace(eval_.id, 'legacy-old', sqliteTimestampFromMs(now - 60 * DAY_MS));
|
||||
await insertTrace(eval_.id, 'legacy-new', sqliteTimestampFromMs(now - DAY_MS));
|
||||
|
||||
await new TraceStore().deleteOldTraces(30);
|
||||
|
||||
const db = await getDb();
|
||||
const traces = (await db.select({ traceId: tracesTable.traceId }).from(tracesTable).all())
|
||||
.map(({ traceId }) => traceId)
|
||||
.sort();
|
||||
const spans = (await db.select({ traceId: spansTable.traceId }).from(spansTable).all())
|
||||
.map(({ traceId }) => traceId)
|
||||
.sort();
|
||||
|
||||
expect(traces).toEqual(['legacy-new', 'numeric-new']);
|
||||
expect(spans).toEqual(['legacy-new', 'numeric-new']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTraceSpans', () => {
|
||||
beforeAll(async () => {
|
||||
await runDbMigrations();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const db = await getDb();
|
||||
await db.delete(spansTable).run();
|
||||
await db.delete(tracesTable).run();
|
||||
});
|
||||
|
||||
it('returns spans for a stored trace', async () => {
|
||||
const eval_ = await EvalFactory.create({ numResults: 0 });
|
||||
const db = await getDb();
|
||||
|
||||
await db
|
||||
.insert(tracesTable)
|
||||
.values({
|
||||
id: 'trace-fetch-id',
|
||||
traceId: 'trace-fetch',
|
||||
evaluationId: eval_.id,
|
||||
testCaseId: 'trace-fetch-test',
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
.run();
|
||||
await db
|
||||
.insert(spansTable)
|
||||
.values({
|
||||
id: 'trace-fetch-span-id',
|
||||
traceId: 'trace-fetch',
|
||||
spanId: 'trace-fetch-span',
|
||||
name: 'fetch-operation',
|
||||
startTime: 100,
|
||||
})
|
||||
.run();
|
||||
|
||||
const spans = await getTraceSpans('trace-fetch');
|
||||
|
||||
expect(spans).toHaveLength(1);
|
||||
expect(spans[0]).toMatchObject({ spanId: 'trace-fetch-span', name: 'fetch-operation' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getToolNameFromAttributes,
|
||||
TOOL_ARGUMENT_ATTRIBUTE_KEYS,
|
||||
TOOL_NAME_ATTRIBUTE_KEYS,
|
||||
} from '../../src/tracing/toolAttributes';
|
||||
|
||||
describe('getToolNameFromAttributes', () => {
|
||||
it('returns undefined when attributes are missing', () => {
|
||||
expect(getToolNameFromAttributes(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no recognized keys are present', () => {
|
||||
expect(getToolNameFromAttributes({ foo: 'bar' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns Vercel AI SDK tool span names', () => {
|
||||
expect(
|
||||
getToolNameFromAttributes({
|
||||
'ai.toolCall.name': 'lookup_customer',
|
||||
}),
|
||||
).toBe('lookup_customer');
|
||||
});
|
||||
|
||||
it('prefers generic tool.name over vendor-specific keys', () => {
|
||||
expect(
|
||||
getToolNameFromAttributes({
|
||||
'tool.name': 'search_orders',
|
||||
'ai.toolCall.name': 'lookup_customer',
|
||||
}),
|
||||
).toBe('search_orders');
|
||||
});
|
||||
|
||||
it('recognizes function.name', () => {
|
||||
expect(
|
||||
getToolNameFromAttributes({
|
||||
'function.name': 'compose_reply',
|
||||
}),
|
||||
).toBe('compose_reply');
|
||||
});
|
||||
|
||||
it('trims whitespace from values', () => {
|
||||
expect(
|
||||
getToolNameFromAttributes({
|
||||
'tool.name': ' trimmed_tool ',
|
||||
}),
|
||||
).toBe('trimmed_tool');
|
||||
});
|
||||
|
||||
it('skips empty string values in favor of later recognized keys', () => {
|
||||
expect(
|
||||
getToolNameFromAttributes({
|
||||
'tool.name': ' ',
|
||||
'ai.toolCall.name': 'fallback_tool',
|
||||
}),
|
||||
).toBe('fallback_tool');
|
||||
});
|
||||
|
||||
it('skips non-string values', () => {
|
||||
expect(
|
||||
getToolNameFromAttributes({
|
||||
'tool.name': 42,
|
||||
'ai.toolCall.name': 'fallback_tool',
|
||||
}),
|
||||
).toBe('fallback_tool');
|
||||
});
|
||||
|
||||
it('does not match arbitrary attributes whose keys are not in the allowlist', () => {
|
||||
expect(
|
||||
getToolNameFromAttributes({
|
||||
'workflow.tool': 'should_not_match',
|
||||
'tool.description': 'not a tool name',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool attribute key tables', () => {
|
||||
it('exposes the expected set of tool-name keys', () => {
|
||||
expect([...TOOL_NAME_ATTRIBUTE_KEYS].sort()).toEqual([
|
||||
'agent.tool',
|
||||
'agent.toolName',
|
||||
'agent.tool_name',
|
||||
'ai.toolCall.name',
|
||||
'codex.mcp.tool',
|
||||
'function.name',
|
||||
'function_name',
|
||||
'gen_ai.tool.name',
|
||||
'tool',
|
||||
'tool.name',
|
||||
'tool_name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes the expected set of tool-argument keys', () => {
|
||||
expect([...TOOL_ARGUMENT_ATTRIBUTE_KEYS].sort()).toEqual([
|
||||
'agent.tool.args',
|
||||
'agent.tool.arguments',
|
||||
'agent.tool.input',
|
||||
'ai.toolCall.args',
|
||||
'ai.toolCall.arguments',
|
||||
'ai.toolCall.input',
|
||||
'args',
|
||||
'arguments',
|
||||
'codex.mcp.args',
|
||||
'codex.mcp.arguments',
|
||||
'codex.mcp.input',
|
||||
'function.args',
|
||||
'function.arguments',
|
||||
'function.input',
|
||||
'function_args',
|
||||
'function_arguments',
|
||||
'gen_ai.tool.args',
|
||||
'gen_ai.tool.arguments',
|
||||
'gen_ai.tool.call.args',
|
||||
'gen_ai.tool.call.arguments',
|
||||
'gen_ai.tool.input',
|
||||
'input',
|
||||
'tool.args',
|
||||
'tool.arguments',
|
||||
'tool.input',
|
||||
'tool_args',
|
||||
'tool_arguments',
|
||||
'tool_input',
|
||||
]);
|
||||
});
|
||||
|
||||
it('has no duplicate keys in either table', () => {
|
||||
expect(new Set(TOOL_NAME_ATTRIBUTE_KEYS).size).toBe(TOOL_NAME_ATTRIBUTE_KEYS.length);
|
||||
expect(new Set(TOOL_ARGUMENT_ATTRIBUTE_KEYS).size).toBe(TOOL_ARGUMENT_ATTRIBUTE_KEYS.length);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user