autotel
Version:
Write Once, Observe Anywhere
524 lines (441 loc) • 16.3 kB
text/typescript
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/);
});
});
});