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

/**
* 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();
});
});

Powered by TurnKey Linux.