UNPKG

autotel

Version:
546 lines (461 loc) 15.8 kB
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { PrettyConsoleExporter, formatDuration, getDurationColor, hrTimeToMs, type PrettyConsoleExporterOptions, } from './pretty-console-exporter'; import { SpanStatusCode } from '@opentelemetry/api'; import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; /** * Create a mock span for testing */ function createMockSpan( overrides: Partial<{ name: string; traceId: string; spanId: string; parentSpanId: string | undefined; startTime: [number, number]; duration: [number, number]; status: { code: number; message?: string }; attributes: Record<string, unknown>; instrumentationScope: { name: string; version?: string }; }> = {}, ): ReadableSpan { const defaults = { name: 'test-span', traceId: '0af7651916cd43dd8448eb211c80319c', spanId: 'b7ad6b7169203331', parentSpanId: undefined as string | undefined, startTime: [1000, 0] as [number, number], duration: [0, 50_000_000] as [number, number], // 50ms status: { code: SpanStatusCode.OK }, attributes: {}, instrumentationScope: { name: 'test', version: '1.0.0' }, }; const config = { ...defaults, ...overrides }; // Build parentSpanContext if parentSpanId is provided const parentSpanContext = config.parentSpanId ? { traceId: config.traceId, spanId: config.parentSpanId, traceFlags: 1 } : undefined; return { name: config.name, spanContext: () => ({ traceId: config.traceId, spanId: config.spanId, traceFlags: 1, isRemote: false, }), parentSpanContext, startTime: config.startTime, duration: config.duration, status: config.status, attributes: config.attributes, instrumentationScope: config.instrumentationScope, kind: 0, links: [], events: [], resource: { attributes: {} }, ended: true, endTime: [config.startTime[0], config.startTime[1] + config.duration[1]], droppedAttributesCount: 0, droppedEventsCount: 0, droppedLinksCount: 0, } as unknown as ReadableSpan; } describe('PrettyConsoleExporter', () => { describe('utility functions', () => { describe('hrTimeToMs', () => { it('converts [seconds, nanoseconds] to milliseconds', () => { expect(hrTimeToMs([0, 0])).toBe(0); expect(hrTimeToMs([0, 1_000_000])).toBe(1); // 1ms expect(hrTimeToMs([0, 500_000])).toBe(0.5); // 0.5ms expect(hrTimeToMs([1, 0])).toBe(1000); // 1s expect(hrTimeToMs([1, 500_000_000])).toBe(1500); // 1.5s expect(hrTimeToMs([2, 250_000_000])).toBe(2250); // 2.25s }); }); describe('formatDuration', () => { it('formats sub-millisecond durations as microseconds', () => { expect(formatDuration(0.5)).toBe('500µs'); expect(formatDuration(0.001)).toBe('1µs'); expect(formatDuration(0.999)).toBe('999µs'); }); it('formats millisecond durations', () => { expect(formatDuration(1)).toBe('1ms'); expect(formatDuration(50)).toBe('50ms'); expect(formatDuration(999)).toBe('999ms'); }); it('formats second durations', () => { expect(formatDuration(1000)).toBe('1.00s'); expect(formatDuration(1500)).toBe('1.50s'); expect(formatDuration(2250)).toBe('2.25s'); expect(formatDuration(60_000)).toBe('60.00s'); }); }); describe('getDurationColor', () => { it('returns green for fast operations (<100ms)', () => { expect(getDurationColor(0)).toBe('green'); expect(getDurationColor(50)).toBe('green'); expect(getDurationColor(99)).toBe('green'); }); it('returns yellow for medium operations (100-500ms)', () => { expect(getDurationColor(100)).toBe('yellow'); expect(getDurationColor(250)).toBe('yellow'); expect(getDurationColor(499)).toBe('yellow'); }); it('returns red for slow operations (>=500ms)', () => { expect(getDurationColor(500)).toBe('red'); expect(getDurationColor(1000)).toBe('red'); expect(getDurationColor(5000)).toBe('red'); }); }); }); describe('constructor options', () => { it('uses default options when none provided', () => { const exporter = new PrettyConsoleExporter(); // Can't directly access private options, but we can verify behavior expect(exporter).toBeDefined(); }); it('accepts custom options', () => { const options: PrettyConsoleExporterOptions = { colors: false, showAttributes: false, maxValueLength: 100, showScope: false, hideAttributes: ['secret'], showTraceId: true, }; const exporter = new PrettyConsoleExporter(options); expect(exporter).toBeDefined(); }); }); describe('export', () => { let consoleLogs: string[]; let originalConsoleLog: typeof console.log; beforeEach(() => { consoleLogs = []; originalConsoleLog = console.log; console.log = (...args: unknown[]) => { consoleLogs.push(args.map(String).join(' ')); }; }); afterEach(() => { console.log = originalConsoleLog; }); it('calls resultCallback with SUCCESS for empty spans', () => { const exporter = new PrettyConsoleExporter({ colors: false }); let result: { code: number } | undefined; exporter.export([], (r) => { result = r; }); expect(result?.code).toBe(0); // ExportResultCode.SUCCESS expect(consoleLogs).toHaveLength(0); }); it('prints spans with success status', () => { const exporter = new PrettyConsoleExporter({ colors: false }); const span = createMockSpan({ name: 'GET /api/users' }); exporter.export([span], () => {}); expect(consoleLogs.some((log) => log.includes('✓'))).toBe(true); expect(consoleLogs.some((log) => log.includes('GET /api/users'))).toBe( true, ); }); it('prints spans with error status', () => { const exporter = new PrettyConsoleExporter({ colors: false }); const span = createMockSpan({ name: 'POST /api/orders', status: { code: SpanStatusCode.ERROR, message: 'Payment failed' }, }); exporter.export([span], () => {}); expect(consoleLogs.some((log) => log.includes('✗'))).toBe(true); expect(consoleLogs.some((log) => log.includes('POST /api/orders'))).toBe( true, ); expect( consoleLogs.some((log) => log.includes('Error: Payment failed')), ).toBe(true); }); it('shows duration in output', () => { const exporter = new PrettyConsoleExporter({ colors: false }); const span = createMockSpan({ name: 'db.query', duration: [0, 123_000_000], // 123ms }); exporter.export([span], () => {}); expect(consoleLogs.some((log) => log.includes('123ms'))).toBe(true); }); it('shows instrumentation scope name', () => { const exporter = new PrettyConsoleExporter({ colors: false, showScope: true, }); const span = createMockSpan({ name: 'query', instrumentationScope: { name: '@opentelemetry/instrumentation-pg' }, }); exporter.export([span], () => {}); expect(consoleLogs.some((log) => log.includes('[pg]'))).toBe(true); }); it('hides scope name when showScope is false', () => { const exporter = new PrettyConsoleExporter({ colors: false, showScope: false, }); const span = createMockSpan({ name: 'query', instrumentationScope: { name: '@opentelemetry/instrumentation-pg' }, }); exporter.export([span], () => {}); expect(consoleLogs.some((log) => log.includes('[pg]'))).toBe(false); }); it('shows attributes when enabled', () => { const exporter = new PrettyConsoleExporter({ colors: false, showAttributes: true, }); const span = createMockSpan({ name: 'db.query', attributes: { 'db.system': 'postgresql', 'db.name': 'users', }, }); exporter.export([span], () => {}); expect( consoleLogs.some((log) => log.includes('db.system=postgresql')), ).toBe(true); expect(consoleLogs.some((log) => log.includes('db.name=users'))).toBe( true, ); }); it('hides attributes when showAttributes is false', () => { const exporter = new PrettyConsoleExporter({ colors: false, showAttributes: false, }); const span = createMockSpan({ name: 'db.query', attributes: { 'db.system': 'postgresql' }, }); exporter.export([span], () => {}); expect(consoleLogs.some((log) => log.includes('db.system'))).toBe(false); }); it('hides specific attributes from hideAttributes list', () => { const exporter = new PrettyConsoleExporter({ colors: false, showAttributes: true, hideAttributes: ['http.user_agent', 'secret'], }); const span = createMockSpan({ name: 'request', attributes: { 'http.method': 'GET', 'http.user_agent': 'Mozilla/5.0...', secret: 'should-not-show', }, }); exporter.export([span], () => {}); expect(consoleLogs.some((log) => log.includes('http.method=GET'))).toBe( true, ); expect(consoleLogs.some((log) => log.includes('http.user_agent'))).toBe( false, ); expect(consoleLogs.some((log) => log.includes('secret'))).toBe(false); }); it('truncates long attribute values', () => { const exporter = new PrettyConsoleExporter({ colors: false, showAttributes: true, maxValueLength: 20, }); const span = createMockSpan({ name: 'request', attributes: { 'long.value': 'This is a very long attribute value that should be truncated', }, }); exporter.export([span], () => {}); expect( consoleLogs.some((log) => log.includes('long.value=This is a very lo...'), ), ).toBe(true); }); it('shows trace ID when showTraceId is true', () => { const exporter = new PrettyConsoleExporter({ colors: false, showTraceId: true, }); const span = createMockSpan({ traceId: 'abc123def456', }); exporter.export([span], () => {}); expect( consoleLogs.some((log) => log.includes('trace: abc123def456')), ).toBe(true); }); }); describe('span tree building', () => { let consoleLogs: string[]; let originalConsoleLog: typeof console.log; beforeEach(() => { consoleLogs = []; originalConsoleLog = console.log; console.log = (...args: unknown[]) => { consoleLogs.push(args.map(String).join(' ')); }; }); afterEach(() => { console.log = originalConsoleLog; }); it('shows parent-child relationships with tree characters', () => { const exporter = new PrettyConsoleExporter({ colors: false, showAttributes: false, }); const parentSpan = createMockSpan({ name: 'parent', traceId: 'trace1', spanId: 'span1', parentSpanId: undefined, }); const childSpan = createMockSpan({ name: 'child', traceId: 'trace1', spanId: 'span2', parentSpanId: 'span1', }); exporter.export([parentSpan, childSpan], () => {}); // Parent should be at root level (no prefix) expect(consoleLogs.some((log) => log.includes('✓ parent'))).toBe(true); // Child should have tree prefix expect(consoleLogs.some((log) => log.includes('└─ ✓ child'))).toBe(true); }); it('handles multiple children with proper tree characters', () => { const exporter = new PrettyConsoleExporter({ colors: false, showAttributes: false, }); const parent = createMockSpan({ name: 'parent', traceId: 'trace1', spanId: 'p1', parentSpanId: undefined, startTime: [1000, 0], }); const child1 = createMockSpan({ name: 'child1', traceId: 'trace1', spanId: 'c1', parentSpanId: 'p1', startTime: [1000, 100_000], }); const child2 = createMockSpan({ name: 'child2', traceId: 'trace1', spanId: 'c2', parentSpanId: 'p1', startTime: [1000, 200_000], }); exporter.export([parent, child1, child2], () => {}); // First child should use ├─ expect(consoleLogs.some((log) => log.includes('├─ ✓ child1'))).toBe(true); // Last child should use └─ expect(consoleLogs.some((log) => log.includes('└─ ✓ child2'))).toBe(true); }); it('groups spans by trace ID', () => { const exporter = new PrettyConsoleExporter({ colors: false, showAttributes: false, showTraceId: true, }); const span1 = createMockSpan({ name: 'span1', traceId: 'trace-a', spanId: 's1', }); const span2 = createMockSpan({ name: 'span2', traceId: 'trace-b', spanId: 's2', }); exporter.export([span1, span2], () => {}); // Both trace IDs should appear expect(consoleLogs.some((log) => log.includes('trace: trace-a'))).toBe( true, ); expect(consoleLogs.some((log) => log.includes('trace: trace-b'))).toBe( true, ); }); }); describe('shutdown and forceFlush', () => { it('shutdown returns resolved promise', async () => { const exporter = new PrettyConsoleExporter(); await expect(exporter.shutdown()).resolves.toBeUndefined(); }); it('forceFlush returns resolved promise', async () => { const exporter = new PrettyConsoleExporter(); await expect(exporter.forceFlush()).resolves.toBeUndefined(); }); }); describe('error handling', () => { let originalConsoleLog: typeof console.log; beforeEach(() => { originalConsoleLog = console.log; }); afterEach(() => { console.log = originalConsoleLog; }); it('returns SUCCESS even if formatting throws', () => { const exporter = new PrettyConsoleExporter({ colors: false }); let result: { code: number } | undefined; // Mock console.log to throw console.log = () => { throw new Error('Console broken'); }; // Create a span that will trigger the error const span = createMockSpan({ name: 'test' }); exporter.export([span], (r) => { result = r; }); // Should still return success (fail-open behavior) expect(result?.code).toBe(0); }); }); describe('array attribute formatting', () => { let consoleLogs: string[]; let originalConsoleLog: typeof console.log; beforeEach(() => { consoleLogs = []; originalConsoleLog = console.log; console.log = (...args: unknown[]) => { consoleLogs.push(args.map(String).join(' ')); }; }); afterEach(() => { console.log = originalConsoleLog; }); it('formats array attributes with brackets', () => { const exporter = new PrettyConsoleExporter({ colors: false, showAttributes: true, }); const span = createMockSpan({ name: 'request', attributes: { tags: ['a', 'b', 'c'], }, }); exporter.export([span], () => {}); expect(consoleLogs.some((log) => log.includes('tags=[a, b, c]'))).toBe( true, ); }); }); });