UNPKG

autotel

Version:
1,371 lines (1,102 loc) 42.3 kB
/* eslint-disable @typescript-eslint/no-unused-vars */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { trace, withTracing, instrument, ctx, span, withBaggage, } from './functional'; import type { TraceContext } from './trace-helpers'; import type { TracingOptions } from './functional'; function traceFactory<Args extends unknown[], Return>( factory: (ctx: TraceContext) => (...args: Args) => Return, ): (...args: Args) => Return { return trace( factory as (ctx: TraceContext) => (...args: Args) => Return, ) as unknown as (...args: Args) => Return; } function traceNamedFactory<Args extends unknown[], Return>( name: string, factory: (ctx: TraceContext) => (...args: Args) => Return, ): (...args: Args) => Return { return trace( name, factory as (ctx: TraceContext) => (...args: Args) => Return, ) as unknown as (...args: Args) => Return; } function traceOptionsFactory<Args extends unknown[], Return>( options: TracingOptions<Args, Return>, factory: (ctx: TraceContext) => (...args: Args) => Return, ): (...args: Args) => Return { return trace( options, factory as (ctx: TraceContext) => (...args: Args) => Return, ) as unknown as (...args: Args) => Return; } import { createTraceCollector } from './testing'; import { AlwaysSampler, NeverSampler } from './sampling'; import { init } from './init'; describe('Functional API', () => { beforeEach(() => { vi.clearAllMocks(); // Initialize for all tests init({ service: 'test-service', }); }); describe('span()', () => { it('returns synchronous value when callback is sync', () => { const result = span({ name: 'sync-span' }, () => 42); expect(result).toBe(42); }); it('returns promise when callback is async', async () => { const promise = span({ name: 'async-span' }, async () => 84); expect(promise).toBeInstanceOf(Promise); await expect(promise).resolves.toBe(84); }); }); describe('trace()', () => { it('does not execute sync function during instrumentation', () => { let executions = 0; const traced = trace(function add(a: number, b: number) { executions += 1; return a + b; }); expect(executions).toBe(0); const result = traced(2, 3); expect(result).toBe(5); expect(executions).toBe(1); }); it('detects ctx factories by parameter name', async () => { const collector = createTraceCollector(); const traced = trace( (_ctx: TraceContext) => async function detected(name: string) { _ctx.setAttribute('user.name', name); return name; }, ); await traced('Alice'); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.attributes['user.name']).toBe('Alice'); }); describe('overload 1: trace(fn)', () => { it('should trace function with inferred name', async () => { const collector = createTraceCollector(); const createUser = traceFactory( (_ctx: TraceContext) => async function inferredName(name: string) { return { id: '123', name }; }, ); const result = await createUser('Alice'); expect(result).toEqual({ id: '123', name: 'Alice' }); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe('inferredName'); }); it('should infer name from const assignment for factory pattern with arrow functions', async () => { const collector = createTraceCollector(); // This is the factory pattern that was producing "unknown" trace names const processDocuments = traceFactory( (_ctx: TraceContext) => async (data: string) => { return data.toUpperCase(); }, ); const result = await processDocuments('test'); expect(result).toBe('TEST'); const spans = collector.getSpans(); expect(spans).toHaveLength(1); // Should infer 'processDocuments' from the const assignment, not 'unknown' expect(spans[0]!.name).toBe('processDocuments'); }); it('preserves sync return type for factory functions', () => { const collector = createTraceCollector(); const add = traceFactory( (ctx: TraceContext) => function addSync(a: number, b: number) { expect(ctx.traceId).toBeDefined(); return a + b; }, ); const result = add(2, 3); expect(result).toBe(5); expect(result).not.toBeInstanceOf(Promise); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe('addSync'); }); it('should handle errors correctly', async () => { const collector = createTraceCollector(); const failingFn = traceFactory((_ctx: TraceContext) => async () => { throw new Error('Test error'); }); await expect(failingFn()).rejects.toThrow('Test error'); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.status.code).toBe(2); // ERROR expect(spans[0]!.attributes['exception.message']).toBe('Test error'); }); }); describe('zero-arg factory pattern (no ctx parameter)', () => { it('should detect zero-arg sync factory and execute inner function', () => { const collector = createTraceCollector(); const addOne = trace(() => (i: number) => { return i + 1; }); const result = addOne(1); expect(result).toBe(2); expect(result).not.toBeInstanceOf(Promise); const spans = collector.getSpans(); expect(spans).toHaveLength(1); }); it('should detect zero-arg async factory and execute inner function', async () => { const collector = createTraceCollector(); const fetchData = trace(() => async (query: string) => { return query.toUpperCase(); }); const result = await fetchData('test'); expect(result).toBe('TEST'); const spans = collector.getSpans(); expect(spans).toHaveLength(1); }); it('should work with named zero-arg factory', () => { const collector = createTraceCollector(); const addOne = trace('addOne', () => (i: number) => { return i + 1; }); const result = addOne(1); expect(result).toBe(2); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe('addOne'); }); it('should handle multiple zero-arg factories combined', () => { const collector = createTraceCollector(); const addOne = trace('addOne', () => (i: number) => i + 1); const addTwo = trace('addTwo', () => (i: number) => i + 2); const result = addOne(1) + addTwo(1); expect(result).toBe(5); const spans = collector.getSpans(); expect(spans).toHaveLength(2); }); }); describe('overload 2: trace(name, fn)', () => { it('should use custom name', async () => { const collector = createTraceCollector(); const createUser = traceNamedFactory( 'user.create', (ctx: TraceContext) => async (name: string) => { return { id: '123', name }; }, ); await createUser('Alice'); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe('user.create'); }); }); describe('overload 3: trace(options, fn)', () => { it('should use options', async () => { const collector = createTraceCollector(); const createUser = traceOptionsFactory( { name: 'user.create', sampler: new AlwaysSampler(), attributesFromArgs: ([name]) => ({ userName: name }), }, (ctx: TraceContext) => async (name: string) => { return { id: '123', name }; }, ); await createUser('Alice'); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe('user.create'); expect(spans[0]!.attributes['userName']).toBe('Alice'); }); it('should use serviceName to compose span name', async () => { const collector = createTraceCollector(); const createUser = traceOptionsFactory( { serviceName: 'user' }, (ctx: TraceContext) => async function serviceNameTest(name: string) { return { id: '123', name }; }, ); await createUser('Alice'); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe('user.serviceNameTest'); }); it('should extract result attributes', async () => { const collector = createTraceCollector(); const createUser = traceOptionsFactory( { name: 'user.create', attributesFromResult: (result) => ({ userId: (result as unknown as { id: string }).id, }), }, (ctx: TraceContext) => async (name: string) => { return { id: '456', name }; }, ); await createUser('Alice'); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.attributes['userId']).toBe('456'); }); it('should respect NeverSampler', async () => { const collector = createTraceCollector(); const createUser = traceOptionsFactory( { name: 'user.create', sampler: new NeverSampler(), }, (ctx: TraceContext) => async (name: string) => { return { id: '123', name }; }, ); await createUser('Alice'); const spans = collector.getSpans(); expect(spans).toHaveLength(0); }); }); }); describe('withTracing()', () => { it('should create reusable wrapper', async () => { const collector = createTraceCollector(); const trace = withTracing({ serviceName: 'user' }); const createUser = trace( (_ctx: TraceContext) => async function reusableCreate(name: string) { return { id: '123', name }; }, ); const updateUser = trace( (_ctx: TraceContext) => async function reusableUpdate(id: string, name: string) { return { id, name }; }, ); await createUser('Alice'); await updateUser('123', 'Bob'); const spans = collector.getSpans(); expect(spans).toHaveLength(2); expect(spans[0]!.name).toBe('user.reusableCreate'); expect(spans[1]!.name).toBe('user.reusableUpdate'); }); it('preserves sync return values', () => { const traceSync = withTracing({ name: 'math.add' }); const add = traceSync( (_ctx: TraceContext) => function addSync(a: number, b: number) { return a + b; }, ); const result = add(4, 5); expect(result).toBe(9); }); it('should support explicit name', async () => { const collector = createTraceCollector(); const createUser = withTracing({ name: 'user.create' })( (ctx: TraceContext) => async (name: string) => { return { id: '123', name }; }, ); await createUser('Alice'); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe('user.create'); }); it('should handle errors', async () => { const collector = createTraceCollector(); const failingFn = withTracing({ name: 'test.fail' })( (ctx) => async () => { throw new Error('Fail'); }, ); await expect(failingFn()).rejects.toThrow('Fail'); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.status.code).toBe(2); // ERROR }); }); describe('instrument()', () => { it('should instrument all functions', async () => { const collector = createTraceCollector(); const userService = instrument({ functions: { createUser: async (name: string) => { return { id: '123', name }; }, updateUser: async (id: string, name: string) => { return { id, name }; }, deleteUser: async (id: string) => { return { id }; }, }, serviceName: 'user', }); await userService.createUser('Alice'); await userService.updateUser('123', 'Bob'); await userService.deleteUser('123'); const spans = collector.getSpans(); expect(spans).toHaveLength(3); expect(spans[0]!.name).toBe('user.createUser'); expect(spans[1]!.name).toBe('user.updateUser'); expect(spans[2]!.name).toBe('user.deleteUser'); }); it('should skip functions with _ prefix by default', async () => { const collector = createTraceCollector(); const service = instrument({ functions: { publicFn: async () => 'public', _privateFn: async () => 'private', }, serviceName: 'test', }); await service.publicFn(); await service._privateFn(); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe('test.publicFn'); }); it('should support custom skip rules', async () => { const collector = createTraceCollector(); const service = instrument({ functions: { publicFn: async () => 'public', testFn: async () => 'test', debugFn: async () => 'debug', }, serviceName: 'test', skip: [ /^test/, // Skip functions starting with 'test' (key) => key.includes('debug'), // Skip functions containing 'debug' ], }); await service.publicFn(); await service.testFn(); await service.debugFn(); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe('test.publicFn'); }); it('should support per-function overrides', async () => { const collector = createTraceCollector(); const service = instrument({ functions: { createUser: async (name: string) => { return { id: '123', name }; }, deleteUser: async (id: string) => { return { id }; }, }, serviceName: 'user', sampler: new NeverSampler(), // Default: don't sample overrides: { deleteUser: { sampler: new AlwaysSampler(), // Always sample deletes! }, }, }); await service.createUser('Alice'); await service.deleteUser('123'); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe('user.deleteUser'); }); it('should preserve function behavior', async () => { const service = instrument({ functions: { add: async (a: number, b: number) => a + b, subtract: async (a: number, b: number) => a - b, }, serviceName: 'math', }); expect(await service.add(5, 3)).toBe(8); expect(await service.subtract(5, 3)).toBe(2); }); it('should not wrap non-functions', () => { const service = instrument({ functions: { fn: async () => 'function', value: 42, obj: { nested: true }, }, serviceName: 'test', }); expect(typeof service.fn).toBe('function'); expect(service.value).toBe(42); expect(service.obj).toEqual({ nested: true }); }); it('should preserve this context for methods that rely on it', async () => { const collector = createTraceCollector(); // Service object with state on 'this' const svc = { prefix: 'user', count: 0, build: async function (id: string) { return `${this.prefix}-${id}`; }, increment: async function () { this.count++; return this.count; }, }; const instrumented = instrument({ functions: svc, serviceName: 'svc', }) as typeof svc; // Test that this.prefix is accessible const result1 = await instrumented.build('123'); expect(result1).toBe('user-123'); // Should not be 'undefined-123' // Test that this.count is accessible and modifiable const result2 = await instrumented.increment(); expect(result2).toBe(1); const result3 = await instrumented.increment(); expect(result3).toBe(2); const spans = collector.getSpans(); expect(spans).toHaveLength(3); }); it('should not call attributesFromArgs when sampler rejects tracing', async () => { const collector = createTraceCollector(); // Mock expensive attribute extraction const expensiveAttributeExtraction = vi.fn((args: unknown[]) => { // Simulate expensive operation (JSON cloning, payload scrubbing, etc.) return { arg0: args[0] }; }); const service = instrument({ functions: { createUser: async (name: string) => { return { id: '123', name }; }, }, serviceName: 'user', sampler: new NeverSampler(), // Never sample attributesFromArgs: expensiveAttributeExtraction, }); // Execute function with NeverSampler await service.createUser('Alice'); // attributesFromArgs should NOT be called since we're not tracing expect(expensiveAttributeExtraction).not.toHaveBeenCalled(); // No spans should be created const spans = collector.getSpans(); expect(spans).toHaveLength(0); }); it('should call attributesFromArgs when sampler accepts tracing', async () => { const collector = createTraceCollector(); // Mock attribute extraction const attributeExtraction = vi.fn((args: unknown[]) => { return { arg0: args[0] }; }); const service = instrument({ functions: { createUser: async (name: string) => { return { id: '123', name }; }, }, serviceName: 'user', sampler: new AlwaysSampler(), // Always sample attributesFromArgs: attributeExtraction, }); // Execute function with AlwaysSampler await service.createUser('Alice'); // attributesFromArgs SHOULD be called since we're tracing // Note: args will include context as first element expect(attributeExtraction).toHaveBeenCalledTimes(1); expect(attributeExtraction).toHaveBeenCalledWith( expect.arrayContaining(['Alice']), ); // Span should be created with attributes const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.attributes['arg0']).toBe('Alice'); }); }); describe('Span naming priority', () => { it('should prioritize explicit name over serviceName', async () => { const collector = createTraceCollector(); const fn = traceOptionsFactory( { name: 'explicit.name', serviceName: 'ignored', }, (ctx: TraceContext) => async () => 'result', ); await fn(); const spans = collector.getSpans(); expect(spans[0]!.name).toBe('explicit.name'); }); it('should use serviceName + fnName when no explicit name', async () => { const collector = createTraceCollector(); const myFunction = traceOptionsFactory( { serviceName: 'service', }, (ctx: TraceContext) => async function priorityTest() { return 'result'; }, ); await myFunction(); const spans = collector.getSpans(); expect(spans[0]!.name).toBe('service.priorityTest'); }); it('should fall back to inferred name', async () => { const collector = createTraceCollector(); const namedFunction = traceFactory( (_ctx: TraceContext) => async function fallbackName() { return 'result'; }, ); await namedFunction(); const spans = collector.getSpans(); expect(spans[0]!.name).toBe('fallbackName'); }); }); describe('Error handling', () => { it('should truncate long error messages', async () => { const collector = createTraceCollector(); const longError = 'x'.repeat(600); const fn = traceFactory((_ctx: TraceContext) => async () => { throw new Error(longError); }); await expect(fn()).rejects.toThrow(); const spans = collector.getSpans(); const errorMsg = spans[0]!.attributes['exception.message'] as string; expect(errorMsg.length).toBeLessThan(600); expect(errorMsg).toContain('(truncated)'); }); it('should record exception type', async () => { const collector = createTraceCollector(); class CustomError extends Error { constructor(message: string) { super(message); this.name = 'CustomError'; } } const fn = traceFactory((_ctx: TraceContext) => async () => { throw new CustomError('Custom error'); }); await expect(fn()).rejects.toThrow(); const spans = collector.getSpans(); expect(spans[0]!.attributes['exception.type']).toBe('CustomError'); }); it('should include stack trace', async () => { const collector = createTraceCollector(); const fn = traceFactory((_ctx: TraceContext) => async () => { throw new Error('Stack test'); }); await expect(fn()).rejects.toThrow(); const spans = collector.getSpans(); expect(spans[0]!.attributes['exception.stack']).toBeDefined(); }); }); describe('Type preservation', () => { it('should preserve exact types', async () => { interface User { id: string; name: string; } const createUser = traceFactory( (_ctx: TraceContext) => async (name: string): Promise<User> => { return { id: '123', name }; }, ); const result = await createUser('Alice'); // TypeScript should know result is User expect(result.id).toBe('123'); expect(result.name).toBe('Alice'); }); it('should preserve argument types', async () => { const fn = traceFactory( (ctx: TraceContext) => async (a: number, b: string, c: { x: boolean }): Promise<void> => { expect(typeof a).toBe('number'); expect(typeof b).toBe('string'); expect(typeof c.x).toBe('boolean'); }, ); await fn(42, 'hello', { x: true }); }); }); describe('ctx() helper', () => { it('should return trace context when span is active', async () => { const collector = createTraceCollector(); const createUser = traceFactory( (_ctx: TraceContext) => async (name: string) => { expect(ctx.traceId).toBeDefined(); expect(ctx.spanId).toBeDefined(); expect(ctx.correlationId).toBeDefined(); return { id: '123', name }; }, ); const result = await createUser('Alice'); expect(result).toEqual({ id: '123', name: 'Alice' }); const spans = collector.getSpans(); expect(spans).toHaveLength(1); }); it('should provide span methods on context', async () => { const collector = createTraceCollector(); const createUser = traceFactory( (_ctx: TraceContext) => async (name: string) => { if (ctx.traceId) { ctx.setAttribute('user.name', name); ctx.setAttributes({ 'user.id': '123', 'user.active': true }); } return { id: '123', name }; }, ); await createUser('Alice'); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.attributes['user.name']).toBe('Alice'); expect(spans[0]!.attributes['user.id']).toBe('123'); expect(spans[0]!.attributes['user.active']).toBe(true); }); it('should return undefined properties when no span is active', () => { expect(ctx.traceId).toBeUndefined(); expect(ctx.spanId).toBeUndefined(); }); it('should record exceptions via context', async () => { const collector = createTraceCollector(); const failingFn = traceFactory((_ctx: TraceContext) => async () => { const error = new Error('Test exception'); if (ctx.traceId) { ctx.recordException(error); } throw error; }); await expect(failingFn()).rejects.toThrow('Test exception'); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.status.code).toBe(2); // ERROR }); }); describe('Immediate execution pattern', () => { it('should execute async function immediately with context', async () => { const collector = createTraceCollector(); const result = await trace(async (ctx: TraceContext) => { ctx.setAttribute('test.key', 'value'); return { data: 'test' }; }); expect(result).toEqual({ data: 'test' }); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.attributes['test.key']).toBe('value'); }); it('should execute sync function immediately with context', () => { const collector = createTraceCollector(); const result = trace((ctx: TraceContext) => { ctx.setAttribute('test.key', 'sync-value'); return 42; }); expect(result).toBe(42); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.attributes['test.key']).toBe('sync-value'); }); it('should support custom name with immediate execution', async () => { const collector = createTraceCollector(); const result = await trace( 'custom.operation', async (ctx: TraceContext) => { ctx.setAttribute('operation.id', '123'); return 'success'; }, ); expect(result).toBe('success'); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe('custom.operation'); expect(spans[0]!.attributes['operation.id']).toBe('123'); }); it('should support options with immediate execution', async () => { const collector = createTraceCollector(); const result = await trace( { name: 'options.test', withMetrics: true }, async (ctx: TraceContext) => { ctx.setAttribute('test.option', 'enabled'); return 100; }, ); expect(result).toBe(100); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe('options.test'); expect(spans[0]!.attributes['test.option']).toBe('enabled'); }); it('should distinguish between factory and immediate execution', async () => { const collector = createTraceCollector(); // Factory pattern - returns a function const factory = trace((ctx: TraceContext) => async (name: string) => { ctx.setAttribute('user.name', name); return { name }; }); // Immediate execution - returns result directly const immediate = await trace(async (ctx: TraceContext) => { ctx.setAttribute('immediate', true); return 'done'; }); expect(typeof factory).toBe('function'); expect(immediate).toBe('done'); // Now call the factory const factoryResult = await factory('Alice'); expect(factoryResult).toEqual({ name: 'Alice' }); const spans = collector.getSpans(); expect(spans).toHaveLength(2); // First span is from immediate execution expect(spans[0]!.attributes['immediate']).toBe(true); // Second span is from factory call expect(spans[1]!.attributes['user.name']).toBe('Alice'); }); it('should work with wrapper function pattern from feedback', async () => { const collector = createTraceCollector(); // The exact use case from the feedback function timed<T>( requestId: string, operation: string, fn: () => Promise<T>, ): Promise<T> { return trace(operation, async (ctx: TraceContext) => { ctx.setAttributes({ 'request.id': requestId, 'operation.name': operation, }); const result = await fn(); return result; }); } // Test it const mockFn = async () => { return { userId: '123', status: 'active' }; }; const result = await timed('req-456', 'fetchUser', mockFn); expect(result).toEqual({ userId: '123', status: 'active' }); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe('fetchUser'); expect(spans[0]!.attributes['request.id']).toBe('req-456'); expect(spans[0]!.attributes['operation.name']).toBe('fetchUser'); }); it('should not create orphan spans when nesting span() inside trace() immediate execution', async () => { const collector = createTraceCollector(); // This was causing a bug where span() was called during pattern detection, // creating an orphan span outside of the trace() context await trace('user-request-trace', async (ctx: TraceContext) => { ctx.setAttribute('input.query', 'What is the capital of France?'); // Nested span should be a child of user-request-trace await span( { name: 'llm-call', attributes: { model: 'gpt-4' }, }, async () => { // Simulate LLM call return 'The capital of France is Paris.'; }, ); ctx.setAttribute('output', 'Successfully answered.'); }); const spans = collector.getSpans(); // KEY ASSERTION: Should have exactly 2 spans, NOT 3 // Before the fix, there would be 3 spans: // 1. An orphan llm-call (created during pattern detection) // 2. user-request-trace (the parent) // 3. llm-call (proper child) expect(spans).toHaveLength(2); // Verify we have the correct span names const spanNames = spans.map((s) => s.name).toSorted(); expect(spanNames).toEqual(['llm-call', 'user-request-trace']); // Verify attributes on each span const parentSpan = spans.find((s) => s.name === 'user-request-trace'); const childSpan = spans.find((s) => s.name === 'llm-call'); expect(parentSpan).toBeDefined(); expect(childSpan).toBeDefined(); expect(parentSpan!.attributes['input.query']).toBe( 'What is the capital of France?', ); expect(parentSpan!.attributes['output']).toBe('Successfully answered.'); expect(childSpan!.attributes['model']).toBe('gpt-4'); }); it('should not execute async function during pattern detection', async () => { const collector = createTraceCollector(); let executionCount = 0; // This async function should only be executed ONCE, not twice // (once during pattern detection + once for actual execution = BUG) await trace('single-execution', async (ctx: TraceContext) => { executionCount++; ctx.setAttribute('execution.count', executionCount); return 'done'; }); // Function should have been executed exactly once expect(executionCount).toBe(1); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.attributes['execution.count']).toBe(1); }); }); describe('baggage', () => { it('should get baggage entry from context', async () => { const collector = createTraceCollector(); const { context, propagation } = await import('@opentelemetry/api'); // Create context with baggage const activeContext = context.active(); const baggage = propagation.createBaggage(); const updatedBaggage = baggage.setEntry('tenant.id', { value: 'tenant-123', }); const contextWithBaggage = propagation.setBaggage( activeContext, updatedBaggage, ); await context.with(contextWithBaggage, async () => { await trace((ctx) => async () => { const tenantId = ctx.getBaggage('tenant.id'); expect(tenantId).toBe('tenant-123'); return 'done'; })(); }); expect(collector.getSpans()).toHaveLength(1); }); it('withBaggage should set baggage for child spans', async () => { const collector = createTraceCollector(); await trace((ctx) => async () => { return await withBaggage({ baggage: { 'tenant.id': 'tenant-456', 'user.id': 'user-789' }, fn: async () => { // Check baggage is available expect(ctx.getBaggage('tenant.id')).toBe('tenant-456'); expect(ctx.getBaggage('user.id')).toBe('user-789'); // Create child span - should inherit baggage await trace((childCtx) => async () => { expect(childCtx.getBaggage('tenant.id')).toBe('tenant-456'); return 'child-done'; })(); return 'parent-done'; }, }); })(); const spans = collector.getSpans(); expect(spans).toHaveLength(2); }); it('withBaggage should work with sync functions', () => { let capturedBaggage: string | undefined; trace((ctx) => () => { return withBaggage({ baggage: { key: 'value' }, fn: () => { capturedBaggage = ctx.getBaggage('key'); return 'sync-result'; }, }); })(); expect(capturedBaggage).toBe('value'); }); it('withBaggage should merge with existing baggage', async () => { const collector = createTraceCollector(); const { context, propagation } = await import('@opentelemetry/api'); // Set initial baggage const activeContext = context.active(); const baggage = propagation.createBaggage(); const updatedBaggage = baggage.setEntry('existing.key', { value: 'existing-value', }); const contextWithBaggage = propagation.setBaggage( activeContext, updatedBaggage, ); await context.with(contextWithBaggage, async () => { await trace((ctx) => async () => { // New baggage should be available expect(ctx.getBaggage('new.key')).toBeUndefined(); // Not set yet return await withBaggage({ baggage: { 'new.key': 'new-value' }, fn: async () => { // New baggage should be available expect(ctx.getBaggage('new.key')).toBe('new-value'); // Existing baggage should still be available (if propagator preserves it) return 'done'; }, }); })(); }); // Only 1 span created (the outer trace) expect(collector.getSpans()).toHaveLength(1); }); it('withBaggage should not leak baggage after callback completes', async () => { const collector = createTraceCollector(); await trace((ctx) => async () => { expect(ctx.getBaggage('tenant.id')).toBeUndefined(); await withBaggage({ baggage: { 'tenant.id': 'tenant-456' }, fn: async () => { expect(ctx.getBaggage('tenant.id')).toBe('tenant-456'); }, }); // Child spans created after withBaggage must not inherit scoped baggage. // (Same-ctx after await may still see baggage due to async context propagation.) await trace((childCtx) => async () => { expect(childCtx.getBaggage('tenant.id')).toBeUndefined(); })(); })(); expect(collector.getSpans()).toHaveLength(2); }); it('ctx.getAllBaggage should return all baggage entries', async () => { const collector = createTraceCollector(); const { context, propagation } = await import('@opentelemetry/api'); // Create context with multiple baggage entries const activeContext = context.active(); let baggage = propagation.createBaggage(); baggage = baggage.setEntry('key1', { value: 'value1' }); baggage = baggage.setEntry('key2', { value: 'value2' }); const contextWithBaggage = propagation.setBaggage(activeContext, baggage); await context.with(contextWithBaggage, async () => { await trace((ctx) => async () => { const allBaggage = ctx.getAllBaggage(); expect(allBaggage.size).toBeGreaterThanOrEqual(2); expect(allBaggage.get('key1')?.value).toBe('value1'); expect(allBaggage.get('key2')?.value).toBe('value2'); return 'done'; })(); }); expect(collector.getSpans()).toHaveLength(1); }); }); describe('Array attribute support', () => { it('should support string array attributes', async () => { const collector = createTraceCollector(); await trace(async (ctx: TraceContext) => { ctx.setAttribute('tags', ['qa', 'test', 'automated']); return 'done'; }); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.attributes['tags']).toEqual(['qa', 'test', 'automated']); }); it('should support number array attributes', async () => { const collector = createTraceCollector(); await trace(async (ctx: TraceContext) => { ctx.setAttribute('scores', [95, 87, 92]); return 'done'; }); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.attributes['scores']).toEqual([95, 87, 92]); }); it('should support boolean array attributes', async () => { const collector = createTraceCollector(); await trace(async (ctx: TraceContext) => { ctx.setAttribute('flags', [true, false, true]); return 'done'; }); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.attributes['flags']).toEqual([true, false, true]); }); it('should support mixed attributes including arrays via setAttributes', async () => { const collector = createTraceCollector(); await trace(async (ctx: TraceContext) => { ctx.setAttributes({ 'user.id': 'user_123', environment: 'development', version: '1.0.0', tags: ['qa', 'test'], scores: [1, 2, 3], }); return 'done'; }); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.attributes['user.id']).toBe('user_123'); expect(spans[0]!.attributes['environment']).toBe('development'); expect(spans[0]!.attributes['tags']).toEqual(['qa', 'test']); expect(spans[0]!.attributes['scores']).toEqual([1, 2, 3]); }); }); describe('Full OTel Span API', () => { it('should support addEvent for span events', async () => { const collector = createTraceCollector(); // Verify the method can be called without error const result = await trace(async (ctx: TraceContext) => { ctx.addEvent('order.started', { 'order.id': '123' }); ctx.addEvent('items.fetched', { 'item.count': 5 }); return 'done'; }); expect(result).toBe('done'); expect(collector.getSpans()).toHaveLength(1); }); it('should support updateName for dynamic span naming', async () => { const collector = createTraceCollector(); await trace('initial.name', async (ctx: TraceContext) => { ctx.updateName('updated.name'); return 'done'; }); const spans = collector.getSpans(); expect(spans).toHaveLength(1); expect(spans[0]!.name).toBe('updated.name'); }); it('should support isRecording', async () => { const collector = createTraceCollector(); let wasRecording = false; await trace(async (ctx: TraceContext) => { wasRecording = ctx.isRecording(); return 'done'; }); expect(wasRecording).toBe(true); expect(collector.getSpans()).toHaveLength(1); }); it('should support addLink for span links', async () => { const collector = createTraceCollector(); // Create a mock span context to link to const linkContext = { traceId: '0af7651916cd43dd8448eb211c80319c', spanId: 'b7ad6b7169203331', traceFlags: 1, }; // Verify the method can be called without error const result = await trace(async (ctx: TraceContext) => { ctx.addLink({ context: linkContext }); return 'done'; }); expect(result).toBe('done'); expect(collector.getSpans()).toHaveLength(1); }); it('should support addLinks for multiple span links', async () => { const collector = createTraceCollector(); const links = [ { context: { traceId: '0af7651916cd43dd8448eb211c80319c', spanId: 'b7ad6b7169203331', traceFlags: 1, }, }, { context: { traceId: '0af7651916cd43dd8448eb211c80319d', spanId: 'b7ad6b7169203332', traceFlags: 1, }, }, ]; // Verify the method can be called without error const result = await trace(async (ctx: TraceContext) => { ctx.addLinks(links); return 'done'; }); expect(result).toBe('done'); expect(collector.getSpans()).toHaveLength(1); }); }); });