import type { OnApplicationShutdown } from '@nestjs/common'; import { Module } from '@nestjs/common'; import type { LoggerOptions } from '@rsdk/logging'; import { LoggerFactory } from '@rsdk/logging'; import path from 'node:path'; import pino, { type TransportMultiOptions, type TransportPipelineOptions, type TransportTargetOptions, } from 'pino'; import { LoggingConfig } from './logging.config'; /** * Абсолютный путь к скомпилированному pipeline-трансформу (рядом в dist). * Воркер pino грузит таргет по пути, поэтому ссылаемся на .js, а не на модуль. */ const OTEL_TIME_TRANSFORM = path.join(__dirname, 'otel-time.transform.js'); @Module({}) export class LoggerInitializingModule implements OnApplicationShutdown { private static logger = LoggerFactory.create(LoggerInitializingModule); constructor(private readonly config: LoggingConfig) { const cfg = (): void => { LoggerInitializingModule.logger.info( `Setting log level: <${config.level}>`, { logLevel: config.level, }, ); LoggerFactory.reconfigure({ ...config, timestampFormat: config.logTimestampFormat, ...this.buildDelivery(config), }); }; config.onUpdate(cfg); cfg(); } async onApplicationShutdown(): Promise { await LoggerFactory.shutdown(this.config.shutdownTimeout.millis()); } /** * Worker-транспорт нужен только для сетевых приёмников (OTEL/HTTP) либо по * явному запросу. Для логирования лишь в stdout остаёмся на in-process * stream — без воркеров, как было до multitransport. */ private buildDelivery(config: LoggingConfig): Partial { const needsTransport = config.otelEnabled || Boolean(config.httpEndpoint) || config.forceTransport; return needsTransport ? { transport: this.buildTransportOptions(config) } : { stream: pino.destination({ dest: process.stdout.fd, sync: config.sync, }), }; } private buildTransportOptions(config: LoggingConfig): TransportMultiOptions { const targets: (TransportTargetOptions | TransportPipelineOptions)[] = [ { target: 'pino/file', options: { destination: process.stdout.fd, }, level: config.level, }, ]; // OTEL включается флагом (endpoint опционален — резолвится из стандартных // OTEL_* env), а HTTP — наличием endpoint (у него стандартного discovery // нет). Отсюда разные модели enable у двух сетевых транспортов. if (config.otelEnabled) { targets.push(this.buildOtelTarget(config)); } if (config.httpEndpoint) { targets.push(this.buildHttpTarget(config.httpEndpoint, config)); } return { targets }; } /** * OTEL-таргет оборачивается в pipeline `[time-transform → otel]`: трансформ * приводит поле `time` к числовому epoch ms, которого требует OTLP (иначе при * дефолтном isoTime записи молча отбрасываются), не затрагивая формат времени * остальных таргетов. * * `otlp-logger` (через который работает pino-opentelemetry-transport) НЕ * читает ключи `endpoint`/`serviceName`: endpoint задаётся через * exporterOptions.url, а имя сервиса — через resourceAttributes/loggerName. */ private buildOtelTarget(config: LoggingConfig): TransportPipelineOptions { const exporterOptions: Record = {}; // Без явного протокола otlp-logger резолвит его из стандартного // OTEL_EXPORTER_OTLP_PROTOCOL env (default protobuf). if (config.otelProtocol) { exporterOptions.protocol = config.otelProtocol; } if (config.otelEndpoint) { const exporterUrl = { url: config.otelEndpoint.href }; // url кладём под все три ключа: otlp-logger прочитает только // соответствующий выбранному протоколу, остальные проигнорирует. exporterOptions.httpExporterOptions = exporterUrl; exporterOptions.protobufExporterOptions = exporterUrl; exporterOptions.grpcExporterOptions = exporterUrl; } const options: Record = { logRecordProcessorOptions: { recordProcessorType: 'batch', exporterOptions, }, }; if (config.otelServiceName) { options.loggerName = config.otelServiceName; options.resourceAttributes = { 'service.name': config.otelServiceName, }; } return { level: config.level, pipeline: [ { target: OTEL_TIME_TRANSFORM }, { target: 'pino-opentelemetry-transport', options }, ], }; } private buildHttpTarget( url: URL, config: LoggingConfig, ): TransportTargetOptions { return { target: 'pino-http-transport', options: { url: url.href, headers: config.httpHeaders, timeout: config.httpTimeout.millis(), batchSize: config.httpBatchSize, batchInterval: config.httpBatchInterval.millis(), maxRetries: config.httpMaxRetries, retryDelay: config.httpRetryDelay.millis(), maxBufferSize: config.httpMaxBufferSize, silent: config.httpSilent, }, level: config.level, }; } }