UNPKG

autotel

Version:
524 lines (441 loc) 16.3 kB
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { CanonicalLogLineProcessor } from './canonical-log-line-processor'; import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; import { SpanKind, SpanStatusCode } from '@opentelemetry/api'; import { resourceFromAttributes } from '@opentelemetry/resources'; import { logs } from '@opentelemetry/api-logs'; import type { Logger } from '../logger'; describe('CanonicalLogLineProcessor', () => { let mockLogger: Logger; let mockOTelLogger: ReturnType<typeof logs.getLogger>; let logEntries: Array<{ level: string; message: string; attrs: Record<string, unknown>; }>; beforeEach(() => { logEntries = []; mockLogger = { info: vi.fn((extra, msg) => { logEntries.push({ level: 'info', message: msg || '', attrs: extra || {}, }); }), warn: vi.fn((extra, msg) => { logEntries.push({ level: 'warn', message: msg || '', attrs: extra || {}, }); }), error: vi.fn((extra, msg) => { logEntries.push({ level: 'error', message: msg || '', attrs: extra || {}, }); }), debug: vi.fn((extra, msg) => { logEntries.push({ level: 'debug', message: msg || '', attrs: extra || {}, }); }), } as unknown as Logger; mockOTelLogger = { emit: vi.fn(), } as unknown as ReturnType<typeof logs.getLogger>; }); function createMockSpan(overrides: Partial<ReadableSpan> = {}): ReadableSpan { const defaultSpan: Partial<ReadableSpan> = { name: 'test.operation', spanContext: () => ({ traceId: '4bf92f3577b34da6a3ce929d0e0e4736', spanId: '00f067aa0ba902b7', traceFlags: 1, }), parentSpanContext: undefined, attributes: { 'user.id': 'user-123', 'cart.total_cents': 15_999, 'http.method': 'POST', }, status: { code: SpanStatusCode.OK }, duration: [0, 1_247_000_000], // 1.247 seconds in nanoseconds startTime: [1_703_044_800, 0], // Unix timestamp in nanoseconds endTime: [1_703_044_800, 1_247_000_000], kind: SpanKind.SERVER, resource: resourceFromAttributes({ 'service.name': 'test-service', 'service.version': '1.0.0', }), events: [], links: [], ...overrides, }; return defaultSpan as ReadableSpan; } describe('basic functionality', () => { it('should emit canonical log line with all span attributes', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger }); const span = createMockSpan(); processor.onEnd(span); expect(mockLogger.info).toHaveBeenCalledTimes(1); const call = logEntries[0]; expect(call.level).toBe('info'); expect(call.message).toContain('test.operation'); expect(call.attrs).toMatchObject({ operation: 'test.operation', traceId: '4bf92f3577b34da6a3ce929d0e0e4736', spanId: '00f067aa0ba902b7', correlationId: '4bf92f3577b34da6', 'user.id': 'user-123', 'cart.total_cents': 15_999, 'http.method': 'POST', duration_ms: expect.any(Number), status_code: SpanStatusCode.OK, }); }); it('should include resource attributes by default', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger }); const span = createMockSpan(); processor.onEnd(span); const call = logEntries[0]; expect(call.attrs).toMatchObject({ 'service.name': 'test-service', 'service.version': '1.0.0', }); }); it('should exclude resource attributes when disabled', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger, includeResourceAttributes: false, }); const span = createMockSpan(); processor.onEnd(span); const call = logEntries[0]; expect(call.attrs).not.toHaveProperty('service.name'); expect(call.attrs).not.toHaveProperty('service.version'); }); it('should calculate duration in milliseconds correctly', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger }); const span = createMockSpan({ duration: [0, 2_500_000_000], // 2.5 seconds }); processor.onEnd(span); const call = logEntries[0]; expect(call.attrs.duration_ms).toBeCloseTo(2500, 1); }); it('should format timestamp as ISO string', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger }); const span = createMockSpan({ startTime: [1_703_044_800, 0], // Fixed timestamp }); processor.onEnd(span); const call = logEntries[0]; expect(call.attrs.timestamp).toMatch( /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/, ); }); }); describe('rootSpansOnly option', () => { it('should emit log for root span when rootSpansOnly is true', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger, rootSpansOnly: true, }); const span = createMockSpan({ parentSpanContext: undefined }); processor.onEnd(span); expect(mockLogger.info).toHaveBeenCalledTimes(1); }); it('should skip child spans when rootSpansOnly is true', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger, rootSpansOnly: true, }); const span = createMockSpan({ parentSpanContext: { traceId: '4bf92f3577b34da6a3ce929d0e0e4736', spanId: 'local-parent-span-id', traceFlags: 1, isRemote: false, }, }); processor.onEnd(span); expect(mockLogger.info).not.toHaveBeenCalled(); }); it('should emit for spans with remote parent (distributed tracing)', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger, rootSpansOnly: true, }); const span = createMockSpan({ parentSpanContext: { traceId: '4bf92f3577b34da6a3ce929d0e0e4736', spanId: 'remote-parent-span-id', traceFlags: 1, isRemote: true, }, }); processor.onEnd(span); expect(mockLogger.info).toHaveBeenCalledTimes(1); }); it('should emit log for all spans when rootSpansOnly is false', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger, rootSpansOnly: false, }); const span = createMockSpan({ parentSpanContext: { traceId: '4bf92f3577b34da6a3ce929d0e0e4736', spanId: 'parent-span-id', traceFlags: 1, isRemote: false, }, }); processor.onEnd(span); expect(mockLogger.info).toHaveBeenCalledTimes(1); }); }); describe('log level determination', () => { it('should use error level for error spans', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger }); const span = createMockSpan({ status: { code: SpanStatusCode.ERROR, message: 'Something went wrong', }, }); processor.onEnd(span); expect(mockLogger.error).toHaveBeenCalledTimes(1); expect(logEntries[0].level).toBe('error'); expect(logEntries[0].attrs.status_code).toBe(SpanStatusCode.ERROR); expect(logEntries[0].attrs.status_message).toBe('Something went wrong'); }); it('should use info level for successful spans', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger }); const span = createMockSpan({ status: { code: SpanStatusCode.OK }, }); processor.onEnd(span); expect(mockLogger.info).toHaveBeenCalledTimes(1); expect(logEntries[0].level).toBe('info'); }); it('should use explicit autotel.log.level attribute when provided', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger }); const span = createMockSpan({ attributes: { 'autotel.log.level': 'warn', }, }); processor.onEnd(span); expect(mockLogger.warn).toHaveBeenCalledTimes(1); expect(logEntries[0].level).toBe('warn'); }); }); describe('minLevel option', () => { it('should respect minLevel and skip debug logs', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger, minLevel: 'info', }); const span = createMockSpan(); processor.onEnd(span); expect(mockLogger.info).toHaveBeenCalledTimes(1); }); it('should skip logs below minLevel', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger, minLevel: 'warn', }); const span = createMockSpan({ status: { code: SpanStatusCode.OK }, }); processor.onEnd(span); expect(mockLogger.info).not.toHaveBeenCalled(); }); }); describe('custom message format', () => { it('should use custom message format when provided', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger, messageFormat: (span) => { const status = span.status.code === 2 ? 'ERROR' : 'SUCCESS'; return `[${status}] ${span.name}`; }, }); const span = createMockSpan(); processor.onEnd(span); expect(logEntries[0].message).toBe('[SUCCESS] test.operation'); }); }); describe('emit control hooks', () => { it('should skip emit when shouldEmit returns false', () => { const shouldEmit = vi.fn(() => false); const processor = new CanonicalLogLineProcessor({ logger: mockLogger, shouldEmit, }); const span = createMockSpan(); processor.onEnd(span); expect(shouldEmit).toHaveBeenCalledTimes(1); expect(mockLogger.info).not.toHaveBeenCalled(); }); it('should call drain after emit with canonical event context', async () => { const drain = vi.fn(async () => {}); const processor = new CanonicalLogLineProcessor({ logger: mockLogger, drain, }); const span = createMockSpan(); processor.onEnd(span); await new Promise((resolve) => setImmediate(resolve)); expect(mockLogger.info).toHaveBeenCalledTimes(1); expect(drain).toHaveBeenCalledTimes(1); expect(drain.mock.calls[0][0]).toMatchObject({ level: 'info', message: expect.stringContaining('test.operation'), event: expect.objectContaining({ operation: 'test.operation', traceId: '4bf92f3577b34da6a3ce929d0e0e4736', }), }); }); it('should not keep below-threshold HTTP status when keep.status is configured', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger, keep: [{ status: 500 }], }); const span = createMockSpan({ status: { code: SpanStatusCode.ERROR }, attributes: { 'http.response.status_code': 404, }, }); processor.onEnd(span); expect(mockLogger.error).not.toHaveBeenCalled(); expect(mockLogger.info).not.toHaveBeenCalled(); expect(mockLogger.warn).not.toHaveBeenCalled(); expect(mockLogger.debug).not.toHaveBeenCalled(); }); }); describe('OTel Logs API fallback', () => { it('should use OTel Logs API when no logger provided', () => { // Mock the logs API const mockGetLogger = vi.fn(() => mockOTelLogger); vi.spyOn(logs, 'getLogger').mockImplementation(mockGetLogger); const processor = new CanonicalLogLineProcessor(); const span = createMockSpan(); processor.onEnd(span); expect(mockGetLogger).toHaveBeenCalledWith('autotel.canonical-log-line'); expect(mockOTelLogger.emit).toHaveBeenCalledTimes(1); const emitCall = (mockOTelLogger.emit as ReturnType<typeof vi.fn>).mock .calls[0][0]; expect(emitCall.body).toContain('test.operation'); expect(emitCall.attributes).toMatchObject({ operation: 'test.operation', traceId: '4bf92f3577b34da6a3ce929d0e0e4736', }); vi.restoreAllMocks(); }); }); describe('edge cases', () => { it('should handle spans with no attributes', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger }); const span = createMockSpan({ attributes: {} }); processor.onEnd(span); expect(mockLogger.info).toHaveBeenCalledTimes(1); const call = logEntries[0]; expect(call.attrs).toHaveProperty('operation'); expect(call.attrs).toHaveProperty('traceId'); expect(call.attrs).toHaveProperty('spanId'); }); it('should handle spans with many attributes', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger }); const manyAttrs: Record<string, unknown> = {}; for (let i = 0; i < 100; i++) { manyAttrs[`attr.${i}`] = `value-${i}`; } const span = createMockSpan({ attributes: manyAttrs }); processor.onEnd(span); expect(mockLogger.info).toHaveBeenCalledTimes(1); const call = logEntries[0]; expect(Object.keys(call.attrs).length).toBeGreaterThan(100); }); it('should handle missing status message gracefully', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger }); const span = createMockSpan({ status: { code: SpanStatusCode.OK }, }); processor.onEnd(span); const call = logEntries[0]; expect(call.attrs.status_message).toBeUndefined(); }); }); describe('attribute redaction', () => { it('should apply attribute redactor to span attributes', () => { const redactor = vi.fn((key: string, value: unknown) => { if (key === 'user.password') return '[REDACTED]'; if (key === 'user.email' && typeof value === 'string') { return value.replace(/@.*/, '@[REDACTED]'); } return value; }); const processor = new CanonicalLogLineProcessor({ logger: mockLogger, attributeRedactor: redactor, }); const span = createMockSpan({ attributes: { 'user.id': 'user-123', 'user.email': 'alice@example.com', 'user.password': 'secret123', }, }); processor.onEnd(span); const call = logEntries[0]; expect(call.attrs['user.id']).toBe('user-123'); expect(call.attrs['user.email']).toBe('alice@[REDACTED]'); expect(call.attrs['user.password']).toBe('[REDACTED]'); }); it('should not modify attributes when no redactor configured', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger }); const span = createMockSpan({ attributes: { 'user.password': 'secret123', }, }); processor.onEnd(span); const call = logEntries[0]; expect(call.attrs['user.password']).toBe('secret123'); }); }); describe('attribute collision prevention', () => { it('should not allow span attributes to overwrite core metadata', () => { const processor = new CanonicalLogLineProcessor({ logger: mockLogger }); const span = createMockSpan({ attributes: { traceId: 'malicious-trace-id', spanId: 'malicious-span-id', timestamp: 'malicious-timestamp', operation: 'malicious-operation', duration_ms: 999_999, status_code: 42, correlationId: 'malicious-correlation', }, }); processor.onEnd(span); const call = logEntries[0]; expect(call.attrs.traceId).toBe('4bf92f3577b34da6a3ce929d0e0e4736'); expect(call.attrs.spanId).toBe('00f067aa0ba902b7'); expect(call.attrs.operation).toBe('test.operation'); expect(call.attrs.correlationId).toBe('4bf92f3577b34da6'); expect(call.attrs.status_code).toBe(SpanStatusCode.OK); expect(call.attrs.duration_ms).toBeCloseTo(1247, 0); expect(call.attrs.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); }); });