UNPKG

autotel

Version:
515 lines (411 loc) 15.3 kB
import { afterEach, describe, expect, it, vi } from 'vitest'; import type { MetricReader } from '@opentelemetry/sdk-metrics'; import type { NodeSDK } from '@opentelemetry/sdk-node'; import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; import { mock, mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; import { AlwaysSampler, NeverSampler } from './sampling'; type SdkRecord = { options: Record<string, unknown>; instance: DeepMockProxy<NodeSDK>; }; async function loadInitWithMocks() { const sdkInstances: SdkRecord[] = []; const traceExporterOptions: Record<string, unknown>[] = []; const metricExporterOptions: Record<string, unknown>[] = []; const metricReaderOptions: Record<string, unknown>[] = []; const logExporterOptions: Record<string, unknown>[] = []; const logProcessorOptions: Record<string, unknown>[] = []; class MockNodeSDK { constructor(options: Record<string, unknown>) { const instance = mockDeep<NodeSDK>(); instance.start.mockImplementation(() => {}); instance.shutdown.mockResolvedValue(); sdkInstances.push({ options, instance }); return instance; } } class MockOTLPTraceExporter { options: Record<string, unknown>; constructor(options: Record<string, unknown>) { this.options = options; traceExporterOptions.push(options); } } class MockOTLPMetricExporter { options: Record<string, unknown>; constructor(options: Record<string, unknown>) { this.options = options; metricExporterOptions.push(options); } } class MockPeriodicExportingMetricReader { options: Record<string, unknown>; constructor(options: Record<string, unknown>) { this.options = options; metricReaderOptions.push(options); } } // Reset modules immediately before mocking to ensure clean state vi.resetModules(); vi.doMock('@opentelemetry/sdk-node', () => ({ NodeSDK: MockNodeSDK, })); vi.doMock('@opentelemetry/exporter-trace-otlp-http', () => ({ OTLPTraceExporter: MockOTLPTraceExporter, })); vi.doMock('@opentelemetry/exporter-metrics-otlp-http', () => ({ OTLPMetricExporter: MockOTLPMetricExporter, })); vi.doMock('@opentelemetry/sdk-metrics', () => ({ PeriodicExportingMetricReader: MockPeriodicExportingMetricReader, })); class MockOTLPLogExporter { options: Record<string, unknown>; constructor(options: Record<string, unknown>) { this.options = options; logExporterOptions.push(options); } } class MockBatchLogRecordProcessor { exporter: unknown; constructor(exporter: unknown) { this.exporter = exporter; logProcessorOptions.push({ exporter }); } onEmit() {} shutdown() { return Promise.resolve(); } forceFlush() { return Promise.resolve(); } } vi.doMock('@opentelemetry/exporter-logs-otlp-http', () => ({ OTLPLogExporter: MockOTLPLogExporter, })); vi.doMock('@opentelemetry/sdk-logs', () => ({ BatchLogRecordProcessor: MockBatchLogRecordProcessor, })); const mod = await import('./init'); return { init: mod.init, getConfig: mod.getConfig, getDefaultSampler: mod.getDefaultSampler, resolveLogsFlag: mod.resolveLogsFlag, setOptionalRequireForTesting: mod._setOptionalRequireForTesting, resetOptionalRequireForTesting: mod._resetOptionalRequireForTesting, getEmbeddedDevtoolsCloseForTesting: mod._getEmbeddedDevtoolsCloseForTesting, sdkInstances, traceExporterOptions, metricExporterOptions, metricReaderOptions, logExporterOptions, logProcessorOptions, }; } describe('init() customization', () => { afterEach(() => { vi.restoreAllMocks(); delete process.env.AUTOTEL_METRICS; delete process.env.AUTOTEL_LOGS; delete process.env.OTEL_LOGS_EXPORTER; delete process.env.OTEL_TRACES_SAMPLER; delete process.env.OTEL_TRACES_SAMPLER_ARG; delete process.env.NODE_ENV; }); it('auto-configures local devtools endpoint and logs when devtools is enabled', async () => { const { init, sdkInstances, traceExporterOptions, metricExporterOptions, logExporterOptions, } = await loadInitWithMocks(); init({ service: 'devtools-app', devtools: true }); expect(traceExporterOptions[0]).toMatchObject({ url: 'http://127.0.0.1:4318/v1/traces', }); expect(metricExporterOptions[0]).toMatchObject({ url: 'http://127.0.0.1:4318/v1/metrics', }); expect(logExporterOptions[0]).toMatchObject({ url: 'http://127.0.0.1:4318/v1/logs', }); const options = sdkInstances.at(-1)?.options as Record<string, unknown>; expect(options.logRecordProcessors).toBeDefined(); }); it('starts embedded autotel-devtools when requested and installed', async () => { const { init, setOptionalRequireForTesting, getEmbeddedDevtoolsCloseForTesting, traceExporterOptions, logExporterOptions, } = await loadInitWithMocks(); const close = vi.fn(); setOptionalRequireForTesting((id: string) => { if (id === 'autotel-devtools') { return { createDevtools: () => ({ port: 9876, close, }), } as any; } return undefined; }); init({ service: 'embedded-devtools-app', devtools: { embedded: true, host: '127.0.0.1', port: 0 }, }); expect(traceExporterOptions[0]).toMatchObject({ url: 'http://127.0.0.1:9876/v1/traces', }); expect(logExporterOptions[0]).toMatchObject({ url: 'http://127.0.0.1:9876/v1/logs', }); expect(getEmbeddedDevtoolsCloseForTesting()).toBe(close); }); it('falls back cleanly when embedded devtools is requested but unavailable', async () => { const { init, setOptionalRequireForTesting, traceExporterOptions } = await loadInitWithMocks(); setOptionalRequireForTesting(() => undefined); init({ service: 'embedded-devtools-fallback-app', devtools: { embedded: true }, }); expect(traceExporterOptions[0]).toMatchObject({ url: 'http://127.0.0.1:4318/v1/traces', }); }); it( 'passes custom instrumentations to the NodeSDK', { timeout: 10_000 }, async () => { const { init, sdkInstances } = await loadInitWithMocks(); const instrumentation = { name: 'http' } as any; init({ service: 'instrumented-app', instrumentations: [instrumentation], }); const options = sdkInstances.at(-1)?.options as Record<string, unknown>; expect(options.instrumentations).toBeDefined(); expect(options.instrumentations).toContain(instrumentation); }, ); it('merges resource attributes with defaults', async () => { const { init, getConfig, sdkInstances } = await loadInitWithMocks(); init({ service: 'resource-app', resourceAttributes: { 'cloud.region': 'eu-central-1' }, }); const resource = sdkInstances.at(-1)?.options.resource as | { attributes?: Record<string, unknown>; } | undefined; if (resource?.attributes) { expect(resource.attributes['cloud.region']).toBe('eu-central-1'); expect(resource.attributes['service.name']).toBe('resource-app'); return; } const config = getConfig(); expect(config.service).toBe('resource-app'); expect(config.resourceAttributes).toMatchObject({ 'cloud.region': 'eu-central-1', }); }); it('creates a default OTLP metric reader when metrics enabled', async () => { const { init, metricReaderOptions, metricExporterOptions } = await loadInitWithMocks(); init({ service: 'metrics-app', endpoint: 'http://localhost:4318' }); expect(metricReaderOptions).toHaveLength(1); expect(metricExporterOptions).toHaveLength(1); }); it('skips default metric reader when metrics disabled', async () => { const { init, metricReaderOptions } = await loadInitWithMocks(); init({ service: 'no-metrics', metrics: false }); expect(metricReaderOptions).toHaveLength(0); }); it('respects custom metric readers', async () => { const { init, sdkInstances, metricReaderOptions } = await loadInitWithMocks(); const customMetricReader = mock<MetricReader>(); init({ service: 'custom-metrics', metricReaders: [customMetricReader] }); expect(sdkInstances).toHaveLength(1); const options = sdkInstances.at(-1)!.options as Record<string, unknown>; expect(options.metricReaders).toEqual([customMetricReader]); expect(metricReaderOptions).toHaveLength(0); }); it('applies OTLP headers for default exporters', async () => { const { init, traceExporterOptions, metricExporterOptions } = await loadInitWithMocks(); init({ service: 'headers-app', endpoint: 'http://localhost:4318', headers: 'Authorization=Basic abc123', }); expect(traceExporterOptions[0]).toMatchObject({ headers: { Authorization: 'Basic abc123' }, }); expect(metricExporterOptions[0]).toMatchObject({ headers: { Authorization: 'Basic abc123' }, }); }); it('resolves sampling preset shorthand to a sampler instance', async () => { const { init, getDefaultSampler } = await loadInitWithMocks(); init({ service: 'sampling-preset-app', sampling: 'development', }); const sampler = getDefaultSampler(); expect(sampler.constructor.name).toBe('AlwaysSampler'); expect(sampler.shouldSample({ operationName: 'test', args: [] })).toBe( true, ); }); it('prefers explicit sampler over sampling preset shorthand', async () => { const { init, getDefaultSampler } = await loadInitWithMocks(); const explicitSampler = new NeverSampler(); init({ service: 'sampling-precedence-app', sampler: explicitSampler, sampling: 'development', }); expect(getDefaultSampler()).toBe(explicitSampler); }); it('uses OTEL_TRACES_SAMPLER when no explicit sampling config is provided', async () => { process.env.OTEL_TRACES_SAMPLER = 'always_off'; const { init, sdkInstances } = await loadInitWithMocks(); init({ service: 'env-sampler-app', }); const options = sdkInstances.at(-1)?.options as Record<string, unknown>; expect((options.sampler as { toString(): string }).toString()).toContain( 'AlwaysOffSampler', ); }); it('prefers explicit sampling config over OTEL_TRACES_SAMPLER', async () => { process.env.OTEL_TRACES_SAMPLER = 'always_off'; const { init, sdkInstances } = await loadInitWithMocks(); init({ service: 'explicit-over-env-sampler-app', sampling: 'development', }); const options = sdkInstances.at(-1)?.options as Record<string, unknown>; expect((options.sampler as { toString(): string }).toString()).toBe( 'AutotelSamplerAdapter', ); }); it('supports sdkFactory overrides', async () => { const { init, sdkInstances } = await loadInitWithMocks(); const customSdk = mockDeep<NodeSDK>(); customSdk.start.mockImplementation(() => {}); customSdk.shutdown.mockResolvedValue(); init({ service: 'custom-sdk', endpoint: 'http://localhost:4318', metrics: false, sdkFactory: (defaults) => { expect(defaults.spanProcessors).toBeDefined(); return customSdk; }, }); expect(sdkInstances).toHaveLength(0); expect(customSdk.start).toHaveBeenCalled(); }); it('uses provided spanProcessors when supplied', async () => { const { init, sdkInstances } = await loadInitWithMocks(); const customProcessor = mock<SpanProcessor>(); customProcessor.shutdown.mockResolvedValue(); customProcessor.forceFlush.mockResolvedValue(); init({ service: 'custom-span', spanProcessors: [customProcessor] }); const options = sdkInstances.at(-1)?.options as Record<string, unknown>; expect(options.spanProcessors).toEqual([customProcessor]); }); it('auto-configures OTLP log exporter when logs enabled with endpoint', async () => { const { init, sdkInstances, logExporterOptions } = await loadInitWithMocks(); init({ service: 'log-app', endpoint: 'http://localhost:4318', logs: true, }); expect(logExporterOptions).toHaveLength(1); expect(logExporterOptions[0]!.url).toBe('http://localhost:4318/v1/logs'); const options = sdkInstances.at(-1)?.options as Record<string, unknown>; expect(options.logRecordProcessors).toBeDefined(); expect( (options.logRecordProcessors as unknown[]).length, ).toBeGreaterThanOrEqual(1); }); it('does not auto-configure logs when logRecordProcessors are omitted', async () => { const { init, sdkInstances, logExporterOptions } = await loadInitWithMocks(); init({ service: 'default-logs', endpoint: 'http://localhost:4318', }); expect(logExporterOptions).toHaveLength(0); const options = sdkInstances.at(-1)?.options as Record<string, unknown>; expect(options.logRecordProcessors).toBeUndefined(); }); it('does not override OTEL_LOGS_EXPORTER env configuration by default', async () => { const { init, sdkInstances, logExporterOptions } = await loadInitWithMocks(); process.env.OTEL_LOGS_EXPORTER = 'none'; init({ service: 'env-logs', endpoint: 'http://localhost:4318', }); expect(logExporterOptions).toHaveLength(0); const options = sdkInstances.at(-1)?.options as Record<string, unknown>; expect(options.logRecordProcessors).toBeUndefined(); }); it('auto-configures logs when logs: true is set', async () => { const { init, logExporterOptions } = await loadInitWithMocks(); init({ service: 'default-logs', endpoint: 'http://localhost:4318', logs: true, }); expect(logExporterOptions).toHaveLength(1); }); it('skips log exporter when logs: false', async () => { const { init, logExporterOptions } = await loadInitWithMocks(); init({ service: 'no-logs', endpoint: 'http://localhost:4318', logs: false, }); expect(logExporterOptions).toHaveLength(0); }); it('skips log exporter when no endpoint', async () => { const { init, logExporterOptions } = await loadInitWithMocks(); init({ service: 'no-endpoint', logs: true }); expect(logExporterOptions).toHaveLength(0); }); it('respects AUTOTEL_LOGS env var override', async () => { const { resolveLogsFlag } = await loadInitWithMocks(); process.env.AUTOTEL_LOGS = 'off'; expect(resolveLogsFlag(true)).toBe(false); process.env.AUTOTEL_LOGS = 'on'; expect(resolveLogsFlag(false)).toBe(true); delete process.env.AUTOTEL_LOGS; expect(resolveLogsFlag(true)).toBe(true); expect(resolveLogsFlag(false)).toBe(false); }); it('passes OTLP headers to log exporter', async () => { const { init, logExporterOptions } = await loadInitWithMocks(); init({ service: 'headers-logs', endpoint: 'http://localhost:4318', logs: true, headers: { Authorization: 'Bearer token' }, }); expect(logExporterOptions).toHaveLength(1); expect(logExporterOptions[0]!.headers).toEqual({ Authorization: 'Bearer token', }); }); });