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