You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
445 lines
15 KiB
445 lines
15 KiB
/**
|
|
* Adapter test suite — sentry, datadog, logtail, loki, otel.
|
|
*
|
|
* All adapters use duck-typed SDK injection so the tests mock the SDK with
|
|
* plain objects/spies. No real network calls are made.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { LogLevel, createEngineLogger, type LogEntry } from '../index.ts';
|
|
import { sentryTransport, type SentryLike, type SentryScopeLike } from './sentry.ts';
|
|
import { datadogTransport, type DatadogLoggerLike } from './datadog.ts';
|
|
import { logtailTransport, type LogtailLike } from './logtail.ts';
|
|
import { lokiTransport } from './loki.ts';
|
|
import { otelTransport, type OtelLoggerLike, type OtelLogRecord } from './otel.ts';
|
|
|
|
// ============================================================================
|
|
// SENTRY
|
|
// ============================================================================
|
|
|
|
function mockSentry(): {
|
|
sentry: SentryLike;
|
|
calls: {
|
|
captureException: unknown[];
|
|
captureMessage: { msg: string; level?: string }[];
|
|
breadcrumbs: { message?: string; level?: string; category?: string }[];
|
|
tags: Record<string, string>;
|
|
contexts: Record<string, Record<string, unknown> | null>;
|
|
};
|
|
} {
|
|
const calls = {
|
|
captureException: [] as unknown[],
|
|
captureMessage: [] as { msg: string; level?: string }[],
|
|
breadcrumbs: [] as { message?: string; level?: string; category?: string }[],
|
|
tags: {} as Record<string, string>,
|
|
contexts: {} as Record<string, Record<string, unknown> | null>
|
|
};
|
|
|
|
const scope: SentryScopeLike = {
|
|
setTag(key, value) {
|
|
calls.tags[key] = value;
|
|
},
|
|
setContext(name, context) {
|
|
calls.contexts[name] = context;
|
|
}
|
|
};
|
|
|
|
const sentry: SentryLike = {
|
|
captureException(err) {
|
|
calls.captureException.push(err);
|
|
},
|
|
captureMessage(msg, level) {
|
|
calls.captureMessage.push({ msg, level });
|
|
},
|
|
addBreadcrumb(bc) {
|
|
calls.breadcrumbs.push(bc);
|
|
},
|
|
withScope(cb) {
|
|
cb(scope);
|
|
}
|
|
};
|
|
|
|
return { sentry, calls };
|
|
}
|
|
|
|
describe('sentryTransport', () => {
|
|
it('routes INFO to breadcrumb', () => {
|
|
const { sentry, calls } = mockSentry();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(sentryTransport(sentry));
|
|
log.info('auth', 'Login attempt');
|
|
|
|
expect(calls.breadcrumbs).toHaveLength(1);
|
|
expect(calls.breadcrumbs[0].message).toBe('Login attempt');
|
|
expect(calls.breadcrumbs[0].level).toBe('info');
|
|
expect(calls.captureMessage).toHaveLength(0);
|
|
});
|
|
|
|
it('routes ERROR without error to captureMessage', () => {
|
|
const { sentry, calls } = mockSentry();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(sentryTransport(sentry));
|
|
log.error('auth', 'Login failed');
|
|
|
|
expect(calls.captureMessage).toHaveLength(1);
|
|
expect(calls.captureMessage[0]).toEqual({ msg: 'Login failed', level: 'error' });
|
|
});
|
|
|
|
it('routes ERROR with error to captureException', () => {
|
|
const { sentry, calls } = mockSentry();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(sentryTransport(sentry));
|
|
const err = new Error('DB down');
|
|
log.error('db', 'Query failed', { error: err });
|
|
|
|
expect(calls.captureException).toHaveLength(1);
|
|
const captured = calls.captureException[0] as Error;
|
|
expect(captured.message).toBe('DB down');
|
|
expect(captured.name).toBe('Error');
|
|
});
|
|
|
|
it('routes FATAL to captureMessage with "fatal" severity', () => {
|
|
const { sentry, calls } = mockSentry();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(sentryTransport(sentry));
|
|
log.fatal('kernel', 'Unrecoverable');
|
|
expect(calls.captureMessage[0].level).toBe('fatal');
|
|
});
|
|
|
|
it('attaches category/traceId/tags as scope tags', () => {
|
|
const { sentry, calls } = mockSentry();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(sentryTransport(sentry));
|
|
log.error('auth', 'fail', { traceId: 'req-1', tags: ['login', 'oauth'] });
|
|
|
|
expect(calls.tags.category).toBe('auth');
|
|
expect(calls.tags.traceId).toBe('req-1');
|
|
expect(calls.tags['tag:login']).toBe('true');
|
|
expect(calls.tags['tag:oauth']).toBe('true');
|
|
});
|
|
|
|
it('attaches context and source as scope contexts', () => {
|
|
const { sentry, calls } = mockSentry();
|
|
const log = createEngineLogger({
|
|
level: LogLevel.TRACE,
|
|
globalContext: { appVersion: '1.0' },
|
|
transports: []
|
|
});
|
|
log.addTransport(sentryTransport(sentry));
|
|
log.error('t', 'fail', { context: { userId: 42 } });
|
|
|
|
expect(calls.contexts['log.context']).toEqual({ appVersion: '1.0', userId: 42 });
|
|
});
|
|
|
|
it('does not emit below breadcrumbLevel', () => {
|
|
const { sentry, calls } = mockSentry();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(sentryTransport(sentry, { breadcrumbLevel: LogLevel.INFO }));
|
|
log.debug('t', 'skipped');
|
|
log.trace('t', 'skipped');
|
|
expect(calls.breadcrumbs).toHaveLength(0);
|
|
expect(calls.captureMessage).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// DATADOG
|
|
// ============================================================================
|
|
|
|
function mockDatadog(): {
|
|
dd: DatadogLoggerLike;
|
|
calls: { level: string; message: string; context: unknown; error?: Error }[];
|
|
} {
|
|
const calls: { level: string; message: string; context: unknown; error?: Error }[] = [];
|
|
const dd: DatadogLoggerLike = {
|
|
debug(msg, ctx) {
|
|
calls.push({ level: 'debug', message: msg, context: ctx });
|
|
},
|
|
info(msg, ctx) {
|
|
calls.push({ level: 'info', message: msg, context: ctx });
|
|
},
|
|
warn(msg, ctx) {
|
|
calls.push({ level: 'warn', message: msg, context: ctx });
|
|
},
|
|
error(msg, ctx, err) {
|
|
calls.push({ level: 'error', message: msg, context: ctx, error: err });
|
|
}
|
|
};
|
|
return { dd, calls };
|
|
}
|
|
|
|
describe('datadogTransport', () => {
|
|
it('maps TRACE/DEBUG to debug, FATAL/ERROR to error', () => {
|
|
const { dd, calls } = mockDatadog();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
|
|
|
|
log.trace('t', 'a');
|
|
log.debug('t', 'b');
|
|
log.info('t', 'c');
|
|
log.warn('t', 'd');
|
|
log.error('t', 'e');
|
|
log.fatal('t', 'f');
|
|
|
|
expect(calls.map((c) => c.level)).toEqual(['debug', 'debug', 'info', 'warn', 'error', 'error']);
|
|
});
|
|
|
|
it('preserves original level in messageContext.logLevel', () => {
|
|
const { dd, calls } = mockDatadog();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
|
|
log.fatal('t', 'boom');
|
|
const ctx = calls[0].context as Record<string, unknown>;
|
|
expect(ctx.logLevel).toBe('FATAL');
|
|
});
|
|
|
|
it('uses reserved-safe names (logTags, sourceLocation, durationMs)', () => {
|
|
const { dd, calls } = mockDatadog();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
|
|
log.info('t', 'hi', {
|
|
tags: ['x'],
|
|
traceId: 'r1',
|
|
durationMs: 42
|
|
});
|
|
|
|
const ctx = calls[0].context as Record<string, unknown>;
|
|
expect(ctx.logTags).toEqual(['x']);
|
|
expect(ctx.traceId).toBe('r1');
|
|
expect(ctx.durationMs).toBe(42);
|
|
});
|
|
|
|
it('passes reconstructed Error to dd.error', () => {
|
|
const { dd, calls } = mockDatadog();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(datadogTransport(dd, { minLevel: LogLevel.TRACE }));
|
|
log.error('t', 'fail', { error: new Error('boom') });
|
|
|
|
expect(calls[0].error).toBeInstanceOf(Error);
|
|
expect(calls[0].error?.message).toBe('boom');
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// LOGTAIL
|
|
// ============================================================================
|
|
|
|
function mockLogtail(): {
|
|
lt: LogtailLike;
|
|
calls: { level: string; message: string; context: unknown }[];
|
|
} {
|
|
const calls: { level: string; message: string; context: unknown }[] = [];
|
|
const lt: LogtailLike = {
|
|
debug(msg, ctx) {
|
|
calls.push({ level: 'debug', message: msg, context: ctx });
|
|
},
|
|
info(msg, ctx) {
|
|
calls.push({ level: 'info', message: msg, context: ctx });
|
|
},
|
|
warn(msg, ctx) {
|
|
calls.push({ level: 'warn', message: msg, context: ctx });
|
|
},
|
|
error(msg, ctx) {
|
|
calls.push({ level: 'error', message: msg, context: ctx });
|
|
}
|
|
};
|
|
return { lt, calls };
|
|
}
|
|
|
|
describe('logtailTransport', () => {
|
|
it('maps levels correctly', () => {
|
|
const { lt, calls } = mockLogtail();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(logtailTransport(lt, { minLevel: LogLevel.TRACE }));
|
|
|
|
log.trace('t', 'a');
|
|
log.debug('t', 'b');
|
|
log.info('t', 'c');
|
|
log.warn('t', 'd');
|
|
log.error('t', 'e');
|
|
log.fatal('t', 'f');
|
|
|
|
expect(calls.map((c) => c.level)).toEqual(['debug', 'debug', 'info', 'warn', 'error', 'error']);
|
|
});
|
|
|
|
it('includes logLevel, category and error in context', () => {
|
|
const { lt, calls } = mockLogtail();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(logtailTransport(lt, { minLevel: LogLevel.TRACE }));
|
|
log.error('auth', 'fail', { error: new Error('boom') });
|
|
|
|
const ctx = calls[0].context as Record<string, unknown>;
|
|
expect(ctx.category).toBe('auth');
|
|
expect(ctx.logLevel).toBe('ERROR');
|
|
expect((ctx.error as { message: string }).message).toBe('boom');
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// LOKI
|
|
// ============================================================================
|
|
|
|
describe('lokiTransport', () => {
|
|
let originalFetch: typeof global.fetch;
|
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
originalFetch = global.fetch;
|
|
fetchMock = vi.fn(async () => new Response('', { status: 204 }));
|
|
global.fetch = fetchMock as unknown as typeof global.fetch;
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
it('POSTs to /loki/api/v1/push with stream labels and JSON line', async () => {
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(
|
|
lokiTransport({
|
|
url: 'https://loki.example.com/loki/api/v1/push',
|
|
labels: { app: 'test' },
|
|
minLevel: LogLevel.TRACE
|
|
})
|
|
);
|
|
|
|
log.info('auth', 'hi');
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toBe('https://loki.example.com/loki/api/v1/push');
|
|
expect(init.method).toBe('POST');
|
|
|
|
const body = JSON.parse(init.body as string) as {
|
|
streams: { stream: Record<string, string>; values: [string, string][] }[];
|
|
};
|
|
expect(body.streams[0].stream).toEqual({ app: 'test', level: 'info', category: 'auth' });
|
|
const line = JSON.parse(body.streams[0].values[0][1]) as { message: string; level: string };
|
|
expect(line.message).toBe('hi');
|
|
expect(line.level).toBe('INFO');
|
|
});
|
|
|
|
it('builds basic auth header from user/password', async () => {
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(
|
|
lokiTransport({
|
|
url: 'https://loki.example.com/push',
|
|
basicAuthUser: 'user',
|
|
basicAuthPassword: 'pass',
|
|
minLevel: LogLevel.TRACE
|
|
})
|
|
);
|
|
|
|
log.info('t', 'x');
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
|
const headers = init.headers as Record<string, string>;
|
|
const expected = `Basic ${btoa('user:pass')}`;
|
|
expect(headers.Authorization).toBe(expected);
|
|
});
|
|
|
|
it('sends X-Scope-OrgID header when tenantId is provided', async () => {
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(
|
|
lokiTransport({
|
|
url: 'https://loki.example.com/push',
|
|
tenantId: 'tenant-1',
|
|
minLevel: LogLevel.TRACE
|
|
})
|
|
);
|
|
|
|
log.info('t', 'x');
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
|
|
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
|
const headers = init.headers as Record<string, string>;
|
|
expect(headers['X-Scope-OrgID']).toBe('tenant-1');
|
|
});
|
|
|
|
it('rejects on non-2xx response (routed to deniedFor)', async () => {
|
|
fetchMock.mockResolvedValueOnce(new Response('bad', { status: 500 }));
|
|
|
|
const seen: LogEntry[] = [];
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport({ name: 'capture', write: (e) => seen.push(e) });
|
|
log.addTransport(
|
|
lokiTransport({ url: 'https://loki.example.com/push', minLevel: LogLevel.TRACE })
|
|
);
|
|
|
|
log.info('t', 'hi');
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
|
|
const failure = seen.find((e) => e.deniedFor !== undefined);
|
|
expect(failure).toBeDefined();
|
|
expect(failure?.deniedFor).toEqual(['loki']);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// OPENTELEMETRY
|
|
// ============================================================================
|
|
|
|
function mockOtelLogger(): {
|
|
otelLogger: OtelLoggerLike;
|
|
emitted: OtelLogRecord[];
|
|
} {
|
|
const emitted: OtelLogRecord[] = [];
|
|
const otelLogger: OtelLoggerLike = {
|
|
emit(record) {
|
|
emitted.push(record);
|
|
}
|
|
};
|
|
return { otelLogger, emitted };
|
|
}
|
|
|
|
describe('otelTransport', () => {
|
|
it('maps levels to OTel severityNumber (1/5/9/13/17/21)', () => {
|
|
const { otelLogger, emitted } = mockOtelLogger();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
|
|
|
|
log.trace('t', 'a');
|
|
log.debug('t', 'b');
|
|
log.info('t', 'c');
|
|
log.warn('t', 'd');
|
|
log.error('t', 'e');
|
|
log.fatal('t', 'f');
|
|
|
|
expect(emitted.map((r) => r.severityNumber)).toEqual([1, 5, 9, 13, 17, 21]);
|
|
});
|
|
|
|
it('sets severityText to the level name', () => {
|
|
const { otelLogger, emitted } = mockOtelLogger();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
|
|
log.info('t', 'hi');
|
|
expect(emitted[0].severityText).toBe('INFO');
|
|
});
|
|
|
|
it('flattens context as log.context.* attributes', () => {
|
|
const { otelLogger, emitted } = mockOtelLogger();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
|
|
log.info('auth', 'hi', { context: { userId: 42, region: 'eu' } });
|
|
|
|
const attrs = emitted[0].attributes ?? {};
|
|
expect(attrs['log.category']).toBe('auth');
|
|
expect(attrs['log.context.userId']).toBe(42);
|
|
expect(attrs['log.context.region']).toBe('eu');
|
|
});
|
|
|
|
it('maps error fields to exception.* attributes', () => {
|
|
const { otelLogger, emitted } = mockOtelLogger();
|
|
const log = createEngineLogger({ level: LogLevel.TRACE, transports: [] });
|
|
log.addTransport(otelTransport(otelLogger, { minLevel: LogLevel.TRACE }));
|
|
log.error('t', 'fail', { error: new Error('boom') });
|
|
|
|
const attrs = emitted[0].attributes ?? {};
|
|
expect(attrs['exception.type']).toBe('Error');
|
|
expect(attrs['exception.message']).toBe('boom');
|
|
expect(attrs['exception.stacktrace']).toBeDefined();
|
|
});
|
|
});
|