autotel
Version:
Write Once, Observe Anywhere
400 lines (340 loc) • 13 kB
text/typescript
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',
);
});
});