import { Timespan } from '@rsdk/common'; import { LoggerFactory, LogLevel, LogTimestampFormat } from '@rsdk/logging'; import type { TransportTargetOptions } from 'pino'; import pino from 'pino'; import { LoggerInitializingModule } from './logger-initializing.module'; import type { LoggingConfig } from './logging.config'; import { LogOtelProtocol } from './logging.config'; function makeConfig(overrides: Partial = {}): LoggingConfig { return { level: LogLevel.info, redact: [], sync: false, shutdownTimeout: new Timespan(1, 's'), forceTransport: false, httpEndpoint: undefined, httpHeaders: {}, httpTimeout: new Timespan(2500, 'ms'), httpBatchSize: 100, httpBatchInterval: new Timespan(5, 's'), httpMaxRetries: 2, httpRetryDelay: new Timespan(1, 's'), httpMaxBufferSize: 100000, httpSilent: false, otelEnabled: false, otelEndpoint: undefined, otelProtocol: undefined, otelServiceName: undefined, logTimestampFormat: LogTimestampFormat.isoTime, onUpdate: jest.fn(), ...overrides, } as unknown as LoggingConfig; } type Delivery = Record; /** Конструирует модуль и возвращает то, что ушло в LoggerFactory.reconfigure. */ function capture(config: LoggingConfig): Delivery { const spy = jest .spyOn(LoggerFactory, 'reconfigure') .mockImplementation(() => {}); // eslint-disable-next-line no-new new LoggerInitializingModule(config); return spy.mock.calls[0][0] as Delivery; } function targetOf(delivery: Delivery, name: string): Delivery | undefined { return delivery.transport?.targets?.find((t: Delivery) => t.target === name); } /** OTEL-таргет — это pipeline [time-transform → pino-opentelemetry-transport]. */ function otelPipeline(delivery: Delivery): Delivery | undefined { return delivery.transport?.targets?.find((t: Delivery) => t.pipeline?.some( (s: Delivery) => s.target === 'pino-opentelemetry-transport', ), ); } /** Стейдж OTEL-экспортёра внутри pipeline. */ function otelStage(delivery: Delivery): Delivery | undefined { return otelPipeline(delivery)?.pipeline?.find( (s: Delivery) => s.target === 'pino-opentelemetry-transport', ); } describe('LoggerInitializingModule — выбор доставки', () => { beforeEach(() => { (LoggerInitializingModule as any).logger = { info: jest.fn() }; // не создаём реальные SonicBoom — мокаем destination маркером с opts jest .spyOn(pino, 'destination') .mockImplementation((opts: any) => ({ __stream: true, opts }) as any); }); afterEach(() => { jest.restoreAllMocks(); }); describe('stream-режим (только stdout)', () => { it('без OTEL/HTTP/force → in-process stream, без transport', () => { const d = capture(makeConfig()); expect(d.transport).toBeUndefined(); expect(d.stream).toBeDefined(); expect(d.stream.opts.dest).toBe(process.stdout.fd); }); it('LOG_SYNC=false → async stream', () => { const d = capture(makeConfig({ sync: false })); expect(d.stream.opts.sync).toBe(false); }); it('LOG_SYNC=true → sync stream', () => { const d = capture(makeConfig({ sync: true })); expect(d.stream.opts.sync).toBe(true); }); }); describe('переход на worker-transport', () => { it('forceTransport=true → transport c единственным stdout-таргетом', () => { const d = capture(makeConfig({ forceTransport: true })); expect(d.stream).toBeUndefined(); expect(d.transport.targets).toHaveLength(1); const file = targetOf(d, 'pino/file'); expect(file?.options).toEqual({ destination: process.stdout.fd }); expect(file?.level).toBe(LogLevel.info); }); it('otelEnabled=true → transport (stdout + otel-pipeline)', () => { const d = capture(makeConfig({ otelEnabled: true })); expect(d.stream).toBeUndefined(); expect(targetOf(d, 'pino/file')).toBeDefined(); expect(otelStage(d)).toBeDefined(); }); it('OTEL обёрнут в pipeline [time-transform → otel]', () => { const d = capture(makeConfig({ otelEnabled: true })); const pipeline = otelPipeline(d)!.pipeline as Delivery[]; expect(pipeline).toHaveLength(2); expect(pipeline[0].target).toMatch(/otel-time\.transform\.js$/); expect(pipeline[1].target).toBe('pino-opentelemetry-transport'); }); it('httpEndpoint задан → transport (stdout + http)', () => { const d = capture( makeConfig({ httpEndpoint: new URL('https://logs.example.com/in') }), ); expect(d.stream).toBeUndefined(); expect(targetOf(d, 'pino-http-transport')).toBeDefined(); }); it('otel + http → три таргета', () => { const d = capture( makeConfig({ otelEnabled: true, httpEndpoint: new URL('https://logs.example.com/in'), }), ); expect(d.transport.targets).toHaveLength(3); expect(targetOf(d, 'pino/file')).toBeDefined(); expect(otelStage(d)).toBeDefined(); expect(targetOf(d, 'pino-http-transport')).toBeDefined(); }); }); describe('OTEL target', () => { it('минимальный: без endpoint/protocol/serviceName', () => { const d = capture(makeConfig({ otelEnabled: true })); const opts = otelStage(d)!.options as any; expect(opts.logRecordProcessorOptions.recordProcessorType).toBe('batch'); expect(opts.logRecordProcessorOptions.exporterOptions).toEqual({}); expect(opts.loggerName).toBeUndefined(); expect(opts.resourceAttributes).toBeUndefined(); }); it('полный: endpoint раскладывается по трём ключам, protocol и service.name выставлены', () => { const d = capture( makeConfig({ otelEnabled: true, otelEndpoint: new URL('http://collector:4318'), otelProtocol: LogOtelProtocol.grpc, otelServiceName: 'my-svc', }), ); const opts = otelStage(d)!.options as any; const exporter = opts.logRecordProcessorOptions.exporterOptions; const url = 'http://collector:4318/'; expect(exporter.protocol).toBe('grpc'); expect(exporter.httpExporterOptions).toEqual({ url }); expect(exporter.protobufExporterOptions).toEqual({ url }); expect(exporter.grpcExporterOptions).toEqual({ url }); expect(opts.loggerName).toBe('my-svc'); expect(opts.resourceAttributes).toEqual({ 'service.name': 'my-svc' }); }); it('endpoint без явного protocol → protocol не задаётся (env-fallback)', () => { const d = capture( makeConfig({ otelEnabled: true, otelEndpoint: new URL('http://collector:4318'), }), ); const exporter = (otelStage(d)!.options as any).logRecordProcessorOptions .exporterOptions; expect(exporter.protocol).toBeUndefined(); expect(exporter.protobufExporterOptions).toEqual({ url: 'http://collector:4318/', }); }); }); describe('HTTP target', () => { it('url/headers проброшены, длительности конвертированы в мс', () => { const d = capture( makeConfig({ httpEndpoint: new URL('https://logs.example.com/ingest'), httpHeaders: { 'x-api-key': 'secret' }, httpTimeout: new Timespan(3, 's'), httpBatchInterval: new Timespan(10, 's'), httpRetryDelay: new Timespan(500, 'ms'), httpBatchSize: 50, httpMaxRetries: 5, httpMaxBufferSize: 42, httpSilent: true, }), ); const opts = targetOf(d, 'pino-http-transport')!.options as any; expect(opts.url).toBe('https://logs.example.com/ingest'); expect(opts.headers).toEqual({ 'x-api-key': 'secret' }); expect(opts.timeout).toBe(3000); expect(opts.batchInterval).toBe(10000); expect(opts.retryDelay).toBe(500); expect(opts.batchSize).toBe(50); expect(opts.maxRetries).toBe(5); expect(opts.maxBufferSize).toBe(42); expect(opts.silent).toBe(true); }); }); describe('level', () => { it('каждый таргет наследует config.level', () => { const d = capture( makeConfig({ level: LogLevel.warn, otelEnabled: true, httpEndpoint: new URL('https://logs.example.com/in'), }), ); for (const target of d.transport.targets as TransportTargetOptions[]) { expect(target.level).toBe(LogLevel.warn); } }); }); describe('timestampFormat', () => { it('logTimestampFormat маппится в timestampFormat (LOG_TIMESTAMP_FORMAT)', () => { const d = capture( makeConfig({ logTimestampFormat: LogTimestampFormat.epochTime }), ); expect(d.timestampFormat).toBe(LogTimestampFormat.epochTime); }); }); });