UNPKG

autotel

Version:
400 lines (340 loc) 13 kB
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { NodeSDK } from '@opentelemetry/sdk-node'; import type { DeepMockProxy } from 'vitest-mock-extended'; import { mockDeep } from 'vitest-mock-extended'; import { _setAutoInstrumentationsLoader, _resetAutoInstrumentationsLoader, type AutoInstrumentationsLoader, } from './init'; type SdkRecord = { options: Record<string, unknown>; instance: DeepMockProxy<NodeSDK>; }; const mockedModules = [ '@opentelemetry/sdk-node', '@opentelemetry/exporter-trace-otlp-http', '@opentelemetry/exporter-metrics-otlp-http', '@opentelemetry/sdk-metrics', ]; // Mock instrumentation classes with exact names from OpenTelemetry. // NodeSDK.start() calls lifecycle hooks on each instrumentation instance. class MockInstrumentationBase { constructor(public config?: Record<string, unknown>) {} setConfig(_config: Record<string, unknown>) {} setTracerProvider(_provider: unknown) {} setMeterProvider(_provider: unknown) {} setLoggerProvider(_provider: unknown) {} enable() {} disable() {} } class MongoDBInstrumentation extends MockInstrumentationBase {} class MongooseInstrumentation extends MockInstrumentationBase {} class HttpInstrumentation extends MockInstrumentationBase {} async function loadInitWithMocks() { const sdkInstances: SdkRecord[] = []; const traceExporterOptions: Record<string, unknown>[] = []; const metricExporterOptions: Record<string, unknown>[] = []; const autoInstrumentationsConfig: Record<string, { enabled?: boolean }>[] = []; const logMessages: { level: string; message: string; }[] = []; 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 { constructor(public options: Record<string, unknown>) {} } // Mock getNodeAutoInstrumentations function const mockGetNodeAutoInstrumentations = vi.fn( (config?: Record<string, { enabled?: boolean }>) => { if (config) { autoInstrumentationsConfig.push(config); } // Simulate returning auto-instrumentations based on config const instrumentations: unknown[] = []; // If MongoDB is not explicitly disabled, add it if ( !config || !config['@opentelemetry/instrumentation-mongodb'] || config['@opentelemetry/instrumentation-mongodb'].enabled !== false ) { instrumentations.push(new MongoDBInstrumentation()); } // If Mongoose is not explicitly disabled, add it if ( !config || !config['@opentelemetry/instrumentation-mongoose'] || config['@opentelemetry/instrumentation-mongoose'].enabled !== false ) { instrumentations.push(new MongooseInstrumentation()); } // If HTTP is not explicitly disabled, add it if ( !config || !config['@opentelemetry/instrumentation-http'] || config['@opentelemetry/instrumentation-http'].enabled !== false ) { instrumentations.push(new HttpInstrumentation()); } return instrumentations; }, ); // Mock logger to capture log messages (Pino-compatible signature: extra, message) const mockLogger = { info: vi.fn((extra: Record<string, unknown>, msg?: string) => { logMessages.push({ level: 'info', message: msg || '' }); }), warn: vi.fn((extra: Record<string, unknown>, msg?: string) => { logMessages.push({ level: 'warn', message: msg || '' }); }), error: vi.fn((extra: Record<string, unknown>, msg?: string) => { logMessages.push({ level: 'error', message: msg || '' }); }), debug: vi.fn((extra: Record<string, unknown>, msg?: string) => { logMessages.push({ level: 'debug', message: msg || '' }); }), }; 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, })); const initModule = await import('./init'); // Inject the mock loader via the exported setter initModule._setAutoInstrumentationsLoader( () => mockGetNodeAutoInstrumentations as AutoInstrumentationsLoader, ); return { init: initModule.init, getConfig: initModule.getConfig, sdkInstances, traceExporterOptions, metricExporterOptions, autoInstrumentationsConfig, logMessages, mockLogger, mockGetNodeAutoInstrumentations, initModule, }; } describe('init() integrations vs instrumentations', () => { beforeEach(() => { vi.resetModules(); _resetAutoInstrumentationsLoader(); }); afterEach(async () => { for (const mod of mockedModules) { vi.doUnmock(mod); } vi.clearAllMocks(); _resetAutoInstrumentationsLoader(); delete process.env.AUTOTEL_METRICS; // Reset global OTel state that can leak between forked test files const { trace, context, propagation } = await import('@opentelemetry/api'); trace.disable(); context.disable(); propagation.disable(); }); it('excludes manual instrumentations from auto-instrumentations when autoInstrumentations: true', async () => { const { init, sdkInstances, autoInstrumentationsConfig, mockLogger, logMessages, } = await loadInitWithMocks(); const manualMongoDBInstrumentation = new MongoDBInstrumentation({ requireParentSpan: false, }); const manualMongooseInstrumentation = new MongooseInstrumentation({ requireParentSpan: false, }); init({ service: 'test-app', autoInstrumentations: true, instrumentations: [ manualMongoDBInstrumentation, manualMongooseInstrumentation, ], logger: mockLogger, }); // Check that auto-instrumentations were called with exclusion config expect(autoInstrumentationsConfig).toHaveLength(1); const config = autoInstrumentationsConfig[0]; expect(config['@opentelemetry/instrumentation-mongodb']).toEqual({ enabled: false, }); expect(config['@opentelemetry/instrumentation-mongoose']).toEqual({ enabled: false, }); // Check that manual instrumentations are in the final list const options = sdkInstances.at(-1)?.options as Record<string, unknown>; const instrumentations = options.instrumentations as unknown[]; expect(instrumentations).toContain(manualMongoDBInstrumentation); expect(instrumentations).toContain(manualMongooseInstrumentation); // Check that warning was logged about detected manual instrumentations const manualInstrumentationWarnings = logMessages.filter( (log) => log.level === 'info' && log.message.includes('Detected manual'), ); expect(manualInstrumentationWarnings).toHaveLength(1); expect(manualInstrumentationWarnings[0].message).toContain( 'Detected manual instrumentations', ); expect(manualInstrumentationWarnings[0].message).toContain( 'MongoDBInstrumentation', ); expect(manualInstrumentationWarnings[0].message).toContain( 'MongooseInstrumentation', ); }); it('excludes manual instrumentations from specific autoInstrumentations list', async () => { const { init, sdkInstances, autoInstrumentationsConfig, mockLogger, logMessages, } = await loadInitWithMocks(); const manualMongoDBInstrumentation = new MongoDBInstrumentation({ requireParentSpan: false, }); init({ service: 'test-app', autoInstrumentations: ['http', 'mongodb'], instrumentations: [manualMongoDBInstrumentation], logger: mockLogger, }); // Check that auto-instrumentations were called with MongoDB disabled expect(autoInstrumentationsConfig).toHaveLength(1); const config = autoInstrumentationsConfig[0]; expect(config['@opentelemetry/instrumentation-mongodb']).toEqual({ enabled: false, }); expect(config['@opentelemetry/instrumentation-http']).toEqual({ enabled: true, }); // Check that manual MongoDB instrumentation is in the final list const options = sdkInstances.at(-1)?.options as Record<string, unknown>; const instrumentations = options.instrumentations as unknown[]; expect(instrumentations).toContain(manualMongoDBInstrumentation); // Check that warning was logged about detected manual instrumentations const manualInstrumentationWarnings = logMessages.filter( (log) => log.level === 'info' && log.message.includes('Detected manual'), ); expect(manualInstrumentationWarnings).toHaveLength(1); expect(manualInstrumentationWarnings[0].message).toContain( 'MongoDBInstrumentation', ); }); it('does not log warning when no manual instrumentations provided', async () => { const { init, mockLogger, logMessages } = await loadInitWithMocks(); init({ service: 'test-app', autoInstrumentations: true, logger: mockLogger, }); // Check that no warning was logged const infoMessages = logMessages.filter( (log) => log.level === 'info' && log.message.includes('Detected manual'), ); expect(infoMessages).toHaveLength(0); }); it('does not log warning when autoInstrumentations is false', async () => { const { init, mockLogger, logMessages } = await loadInitWithMocks(); const manualMongoDBInstrumentation = new MongoDBInstrumentation({ requireParentSpan: false, }); init({ service: 'test-app', autoInstrumentations: false, instrumentations: [manualMongoDBInstrumentation], logger: mockLogger, }); // Check that no warning was logged const infoMessages = logMessages.filter( (log) => log.level === 'info' && log.message.includes('Detected manual'), ); expect(infoMessages).toHaveLength(0); }); it('handles object-style autoInstrumentations config with manual instrumentations', async () => { const { init, sdkInstances, autoInstrumentationsConfig, mockLogger } = await loadInitWithMocks(); const manualMongoDBInstrumentation = new MongoDBInstrumentation({ requireParentSpan: false, }); init({ service: 'test-app', autoInstrumentations: { http: { enabled: true }, mongodb: { enabled: true }, }, instrumentations: [manualMongoDBInstrumentation], logger: mockLogger, }); // Check that auto-instrumentations were called with MongoDB disabled expect(autoInstrumentationsConfig).toHaveLength(1); const config = autoInstrumentationsConfig[0]; expect(config['@opentelemetry/instrumentation-mongodb']).toEqual({ enabled: false, }); // Check that manual MongoDB instrumentation is in the final list const options = sdkInstances.at(-1)?.options as Record<string, unknown>; const instrumentations = options.instrumentations as unknown[]; expect(instrumentations).toContain(manualMongoDBInstrumentation); }); it('works correctly when no conflicts exist', async () => { const { init, sdkInstances, mockLogger, logMessages } = await loadInitWithMocks(); const manualHttpInstrumentation = new HttpInstrumentation({ requireParentSpan: false, }); init({ service: 'test-app', autoInstrumentations: ['mongodb', 'mongoose'], instrumentations: [manualHttpInstrumentation], logger: mockLogger, }); // Check that manual instrumentation is in the final list const options = sdkInstances.at(-1)?.options as Record<string, unknown>; const instrumentations = options.instrumentations as unknown[]; expect(instrumentations).toContain(manualHttpInstrumentation); // Check that warning was logged (because manual HTTP provided with auto-integrations) const manualInstrumentationWarnings = logMessages.filter( (log) => log.level === 'info' && log.message.includes('Detected manual'), ); expect(manualInstrumentationWarnings).toHaveLength(1); expect(manualInstrumentationWarnings[0].message).toContain( 'HttpInstrumentation', ); }); });