UNPKG

syntropylog

Version:

An instance manager with observability for Node.js applications

1,308 lines (1,296 loc) • 173 kB
'use strict'; var events = require('events'); var zod = require('zod'); var RegexTest = require('regex-test'); var node_async_hooks = require('node:async_hooks'); var crypto = require('crypto'); var util = require('node:util'); var chalk = require('chalk'); var redis = require('redis'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var util__namespace = /*#__PURE__*/_interopNamespaceDefault(util); /** * @file src/logger/levels.ts * @description Defines the available log levels, their names, and their severity weights. */ /** * @description A mapping of log level names to their severity weights. * Higher numbers indicate higher severity. */ const LOG_LEVEL_WEIGHTS = { fatal: 60, error: 50, warn: 40, info: 30, debug: 20, trace: 10, silent: 0, }; /** * @file src/logger/transports/Transport.ts * @description Defines the abstract base class for all log transports. */ /** * @class Transport * @description The abstract base class for all log transports. A transport is * responsible for the final output of a log entry, whether it's to the console, * a file, or a remote service. */ class Transport { /** * @constructor * @param {TransportOptions} [options] - The configuration options for this transport. */ constructor(options = {}) { this.level = options.level ?? 'info'; this.name = options.name ?? this.constructor.name; this.formatter = options?.formatter; this.sanitizationEngine = options?.sanitizationEngine; } /** * Determines if the transport should process a log entry based on its log level. * @param level - The level of the log entry to check. * @returns {boolean} - True if the transport is enabled for this level, false otherwise. */ isLevelEnabled(level) { return LOG_LEVEL_WEIGHTS[level] >= LOG_LEVEL_WEIGHTS[this.level]; } /** * A method to ensure all buffered logs are written before the application exits. * Subclasses should override this if they perform I/O buffering. * @returns {Promise<void>} A promise that resolves when flushing is complete. */ async flush() { // Default implementation does nothing, assuming no buffering. return Promise.resolve(); } } /** * FILE: src/masking/MaskingEngine.ts * DESCRIPTION: Ultra-fast data masking engine using JSON flattening strategy. * * This engine flattens complex nested objects into linear key-value pairs, * applies masking rules, and then reconstructs the original structure. * This approach provides extreme processing speed for any object depth. */ // Using type assertion for regex-test module since it lacks proper TypeScript declarations /** * @enum MaskingStrategy * @description Different masking strategies for various data types. */ var MaskingStrategy; (function (MaskingStrategy) { MaskingStrategy["CREDIT_CARD"] = "credit_card"; MaskingStrategy["SSN"] = "ssn"; MaskingStrategy["EMAIL"] = "email"; MaskingStrategy["PHONE"] = "phone"; MaskingStrategy["PASSWORD"] = "password"; MaskingStrategy["TOKEN"] = "token"; MaskingStrategy["CUSTOM"] = "custom"; })(MaskingStrategy || (MaskingStrategy = {})); /** * @class MaskingEngine * Ultra-fast data masking engine using JSON flattening strategy. * * Instead of processing nested objects recursively, we flatten them to a linear * structure for extreme processing speed. This approach provides O(n) performance * regardless of object depth or complexity. */ class MaskingEngine { constructor(options) { /** @private Array of masking rules */ this.rules = []; /** @private Whether the engine is initialized */ this.initialized = false; this.maskChar = options?.maskChar || '*'; this.preserveLength = options?.preserveLength ?? true; // Default to true for security this.regexTest = new RegexTest({ timeout: 100 }); // Add default rules if enabled if (options?.enableDefaultRules !== false) { this.addDefaultRules(); } // Add custom rules from options if (options?.rules) { for (const rule of options.rules) { this.addRule(rule); } } } /** * Adds default masking rules for common data types. * @private */ addDefaultRules() { const defaultRules = [ { pattern: /credit_card|card_number|payment_number/i, strategy: MaskingStrategy.CREDIT_CARD, preserveLength: true, maskChar: this.maskChar }, { pattern: /ssn|social_security|security_number/i, strategy: MaskingStrategy.SSN, preserveLength: true, maskChar: this.maskChar }, { pattern: /email/i, strategy: MaskingStrategy.EMAIL, preserveLength: true, maskChar: this.maskChar }, { pattern: /phone|phone_number|mobile_number/i, strategy: MaskingStrategy.PHONE, preserveLength: true, maskChar: this.maskChar }, { pattern: /password|pass|pwd|secret/i, strategy: MaskingStrategy.PASSWORD, preserveLength: true, maskChar: this.maskChar }, { pattern: /token|api_key|auth_token|jwt|bearer/i, strategy: MaskingStrategy.TOKEN, preserveLength: true, maskChar: this.maskChar } ]; for (const rule of defaultRules) { this.addRule(rule); } } /** * Adds a custom masking rule. * @param rule - The masking rule to add */ addRule(rule) { // Compile regex pattern for performance if (typeof rule.pattern === 'string') { rule._compiledPattern = new RegExp(rule.pattern, 'i'); } else { rule._compiledPattern = rule.pattern; } // Set defaults rule.preserveLength = rule.preserveLength ?? this.preserveLength; rule.maskChar = rule.maskChar ?? this.maskChar; this.rules.push(rule); } /** * Processes a metadata object and applies the configured masking rules. * Uses JSON flattening strategy for extreme performance. * @param meta - The metadata object to process * @returns A new object with the masked data */ process(meta) { // Set initialized flag on first use if (!this.initialized) { this.initialized = true; } try { // Apply masking rules directly to the data structure const masked = this.applyMaskingRules(meta); // Return the masked data return masked; } catch (error) { // Silent observer - return original data if masking fails return meta; } } /** * Applies masking rules to data recursively. * @param data - Data to mask * @returns Masked data * @private */ applyMaskingRules(data) { if (data === null || typeof data !== 'object') { return data; } if (Array.isArray(data)) { return data.map(item => this.applyMaskingRules(item)); } const masked = { ...data }; for (const key in data) { if (Object.prototype.hasOwnProperty.call(data, key)) { const value = data[key]; if (typeof value === 'string') { // Check each rule for (const rule of this.rules) { if (rule._compiledPattern && rule._compiledPattern.test(key)) { masked[key] = this.applyStrategy(value, rule); break; // First matching rule wins } } } else if (typeof value === 'object' && value !== null) { // Recursively mask nested objects masked[key] = this.applyMaskingRules(value); } } } return masked; } /** * Applies specific masking strategy to a value. * @param value - Value to mask * @param rule - Masking rule to apply * @returns Masked value * @private */ applyStrategy(value, rule) { if (rule.strategy === MaskingStrategy.CUSTOM && rule.customMask) { return rule.customMask(value); } switch (rule.strategy) { case MaskingStrategy.CREDIT_CARD: return this.maskCreditCard(value, rule); case MaskingStrategy.SSN: return this.maskSSN(value, rule); case MaskingStrategy.EMAIL: return this.maskEmail(value, rule); case MaskingStrategy.PHONE: return this.maskPhone(value, rule); case MaskingStrategy.PASSWORD: return this.maskPassword(value, rule); case MaskingStrategy.TOKEN: return this.maskToken(value, rule); default: return this.maskDefault(value, rule); } } /** * Masks credit card number. * @param value - Credit card number * @param rule - Masking rule * @returns Masked credit card * @private */ maskCreditCard(value, rule) { const clean = value.replace(/\D/g, ''); if (rule.preserveLength) { // Preserve original format, mask all but last 4 digits return value.replace(/\d/g, (match, offset) => { const digitIndex = value.substring(0, offset).replace(/\D/g, '').length; return digitIndex < clean.length - 4 ? rule.maskChar : match; }); } else { // Fixed format: ****-****-****-1111 return `${rule.maskChar.repeat(4)}-${rule.maskChar.repeat(4)}-${rule.maskChar.repeat(4)}-${clean.slice(-4)}`; } } /** * Masks SSN. * @param value - SSN * @param rule - Masking rule * @returns Masked SSN * @private */ maskSSN(value, rule) { const clean = value.replace(/\D/g, ''); if (rule.preserveLength) { // Preserve original format, mask all but last 4 digits return value.replace(/\d/g, (match, offset) => { const digitIndex = value.substring(0, offset).replace(/\D/g, '').length; return digitIndex < clean.length - 4 ? rule.maskChar : match; }); } else { // Fixed format: ***-**-6789 return `***-**-${clean.slice(-4)}`; } } /** * Masks email address. * @param value - Email address * @param rule - Masking rule * @returns Masked email * @private */ maskEmail(value, rule) { const atIndex = value.indexOf('@'); if (atIndex > 0) { const username = value.substring(0, atIndex); const domain = value.substring(atIndex); if (rule.preserveLength) { // Preserve original length: first char + asterisks + @domain const maskedUsername = username.length > 1 ? username.charAt(0) + rule.maskChar.repeat(username.length - 1) : rule.maskChar.repeat(username.length); return maskedUsername + domain; } else { return `${username.charAt(0)}***${domain}`; } } return this.maskDefault(value, rule); } /** * Masks phone number. * @param value - Phone number * @param rule - Masking rule * @returns Masked phone number * @private */ maskPhone(value, rule) { const clean = value.replace(/\D/g, ''); if (rule.preserveLength) { // Preserve original format, mask all but last 4 digits return value.replace(/\d/g, (match, offset) => { const digitIndex = value.substring(0, offset).replace(/\D/g, '').length; return digitIndex < clean.length - 4 ? rule.maskChar : match; }); } else { // Fixed format: ***-***-4567 return `${rule.maskChar.repeat(3)}-${rule.maskChar.repeat(3)}-${clean.slice(-4)}`; } } /** * Masks password. * @param value - Password * @param rule - Masking rule * @returns Masked password * @private */ maskPassword(value, rule) { return rule.maskChar.repeat(value.length); } /** * Masks token. * @param value - Token * @param rule - Masking rule * @returns Masked token * @private */ maskToken(value, rule) { if (rule.preserveLength) { return value.substring(0, 4) + rule.maskChar.repeat(value.length - 9) + value.substring(value.length - 5); } else { if (value.length > 8) { return value.substring(0, 4) + '...' + value.substring(value.length - 5); } return rule.maskChar.repeat(value.length); } } /** * Default masking strategy. * @param value - Value to mask * @param rule - Masking rule * @returns Masked value * @private */ maskDefault(value, rule) { if (rule.preserveLength) { return rule.maskChar.repeat(value.length); } else { return rule.maskChar.repeat(Math.min(value.length, 8)); } } /** * Gets masking engine statistics. * @returns Dictionary with masking statistics */ getStats() { return { initialized: this.initialized, totalRules: this.rules.length, defaultRules: this.rules.filter(r => [MaskingStrategy.CREDIT_CARD, MaskingStrategy.SSN, MaskingStrategy.EMAIL, MaskingStrategy.PHONE, MaskingStrategy.PASSWORD, MaskingStrategy.TOKEN].includes(r.strategy)).length, customRules: this.rules.filter(r => r.strategy === MaskingStrategy.CUSTOM).length, strategies: this.rules.map(r => r.strategy) }; } /** * Checks if the masking engine is initialized. * @returns True if initialized */ isInitialized() { return this.initialized; } /** * Shutdown the masking engine. */ shutdown() { this.rules = []; this.initialized = false; } } /** * FILE: src/config.schema.ts * DESCRIPTION: Defines the Zod validation schemas for the entire library's configuration. * These schemas are the single source of truth for the configuration's structure and types. */ /** * @description Schema for logger-specific options, including serialization and transports. * @private */ const loggerOptionsSchema = zod.z .object({ name: zod.z.string().optional(), level: zod.z .enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']) .optional(), serviceName: zod.z.string().optional(), /** * An array of transport instances to be used by the logger. */ transports: zod.z.array(zod.z.instanceof(Transport)).optional(), /** * A dictionary of custom serializer functions. The key is the field * to look for in the log object, and the value is the function that transforms it. */ serializers: zod.z .record(zod.z.string(), zod.z.function().args(zod.z.any()).returns(zod.z.string())) .optional(), /** * The maximum time in milliseconds a custom serializer can run before being timed out. * @default 50 */ serializerTimeoutMs: zod.z.number().int().positive().default(50), /** Configuration for pretty printing logs in development. */ prettyPrint: zod.z .object({ enabled: zod.z.boolean().optional().default(false), }) .optional(), }) .optional(); /** * @description Reusable schema for retry options, commonly used in client configurations. * @private */ const retryOptionsSchema = zod.z .object({ maxRetries: zod.z.number().int().positive().optional(), retryDelay: zod.z.number().int().positive().optional(), }) .optional(); /** * @description Schema for a single Redis instance, using a discriminated union for different connection modes. */ const redisInstanceConfigSchema = zod.z.discriminatedUnion('mode', [ zod.z.object({ mode: zod.z.literal('single'), instanceName: zod.z.string(), url: zod.z.string().url(), retryOptions: retryOptionsSchema, // --- NEW: Granular Logging Configuration for Redis --- logging: zod.z .object({ /** Level for successful commands. @default 'debug' */ onSuccess: zod.z.enum(['trace', 'debug', 'info']).default('debug'), /** Level for failed commands. @default 'error' */ onError: zod.z.enum(['warn', 'error', 'fatal']).default('error'), /** Whether to log command parameters. @default true */ logCommandValues: zod.z.boolean().default(true), /** Whether to log the return value of commands. @default false */ logReturnValue: zod.z.boolean().default(false), }) .optional(), }), // Apply the same 'logging' object structure to 'sentinel' and 'cluster' modes zod.z.object({ mode: zod.z.literal('sentinel'), instanceName: zod.z.string(), name: zod.z.string(), sentinels: zod.z.array(zod.z.object({ host: zod.z.string(), port: zod.z.number() })), sentinelPassword: zod.z.string().optional(), retryOptions: retryOptionsSchema, logging: zod.z .object({ onSuccess: zod.z.enum(['trace', 'debug', 'info']).default('debug'), onError: zod.z.enum(['warn', 'error', 'fatal']).default('error'), logCommandValues: zod.z.boolean().default(true), logReturnValue: zod.z.boolean().default(false), }) .optional(), }), zod.z.object({ mode: zod.z.literal('cluster'), instanceName: zod.z.string(), rootNodes: zod.z.array(zod.z.object({ host: zod.z.string(), port: zod.z.number() })), logging: zod.z .object({ /** Level for successful commands. @default 'debug' */ onSuccess: zod.z.enum(['trace', 'debug', 'info']).default('debug'), /** Level for failed commands. @default 'error' */ onError: zod.z.enum(['warn', 'error', 'fatal']).default('error'), /** Whether to log command parameters. @default true */ logCommandValues: zod.z.boolean().default(true), /** Whether to log the return value of commands. @default false */ logReturnValue: zod.z.boolean().default(false), }) .optional(), }), ]); /** * @description Schema for the main Redis configuration block, containing all Redis instances. */ const redisConfigSchema = zod.z .object({ /** An array of Redis instance configurations. */ instances: zod.z.array(redisInstanceConfigSchema), /** The name of the default Redis instance to use when no name is provided to `getInstance()`. */ default: zod.z.string().optional(), }) .optional(); /** * @description Schema for a single HTTP client instance. */ const httpInstanceConfigSchema = zod.z.object({ instanceName: zod.z.string(), adapter: zod.z.custom((val) => { return (typeof val === 'object' && val !== null && 'request' in val && typeof val.request === 'function'); }, "The provided adapter is invalid. It must be an object with a 'request' method."), isDefault: zod.z.boolean().optional(), propagate: zod.z.array(zod.z.string()).optional(), propagateFullContext: zod.z.boolean().optional(), logging: zod.z .object({ onSuccess: zod.z.enum(['trace', 'debug', 'info']).default('info'), onError: zod.z.enum(['warn', 'error', 'fatal']).default('error'), logSuccessBody: zod.z.boolean().default(false), logSuccessHeaders: zod.z.boolean().default(false), onRequest: zod.z.enum(['trace', 'debug', 'info']).default('info'), logRequestBody: zod.z.boolean().default(false), logRequestHeaders: zod.z.boolean().default(false), }) .partial() .optional(), }); /** * @description Schema for the main HTTP configuration block. */ const httpConfigSchema = zod.z .object({ /** An array of HTTP client instance configurations. */ instances: zod.z.array(httpInstanceConfigSchema), /** The name of the default HTTP client instance to use when no name is provided to `getInstance()`. */ default: zod.z.string().optional(), }) .optional(); /** * @description Schema for the main data masking configuration block. */ const maskingConfigSchema = zod.z .object({ /** Array of masking rules with patterns and strategies. */ rules: zod.z.array(zod.z.object({ /** Regex pattern to match field names */ pattern: zod.z.union([zod.z.string(), zod.z.instanceof(RegExp)]), /** Masking strategy to apply */ strategy: zod.z.nativeEnum(MaskingStrategy), /** Whether to preserve original length */ preserveLength: zod.z.boolean().optional(), /** Character to use for masking */ maskChar: zod.z.string().optional(), /** Custom masking function (for CUSTOM strategy) */ customMask: zod.z.function().args(zod.z.string()).returns(zod.z.string()).optional(), })).optional(), /** Default mask character */ maskChar: zod.z.string().optional(), /** Whether to preserve original length by default */ preserveLength: zod.z.boolean().optional(), /** Enable default rules for common data types */ enableDefaultRules: zod.z.boolean().optional(), }) .optional(); /** * @description Schema for a single message broker client instance. * It validates that a valid `IBrokerAdapter` is provided. * @private */ const brokerInstanceConfigSchema = zod.z.object({ instanceName: zod.z.string(), adapter: zod.z.custom((val) => { return (typeof val === 'object' && val !== null && typeof val.publish === 'function' && typeof val.subscribe === 'function'); }, 'The provided broker adapter is invalid.'), /** * An array of context keys to propagate as message headers/properties. * To propagate all keys, provide an array with a single wildcard: `['*']`. * If not provided, only `correlationId` and `transactionId` are propagated by default. */ propagate: zod.z.array(zod.z.string()).optional(), /** * @deprecated Use `propagate` instead. * If true, propagates the entire asynchronous context map as headers. * If false (default), only propagates `correlationId` and `transactionId`. */ propagateFullContext: zod.z.boolean().optional(), isDefault: zod.z.boolean().optional(), }); /** * @description Schema for the main message broker configuration block. */ const brokerConfigSchema = zod.z .object({ /** An array of broker client instance configurations. */ instances: zod.z.array(brokerInstanceConfigSchema), /** The name of the default broker instance to use when no name is provided to `getInstance()`. */ default: zod.z.string().optional(), }) .optional(); /** * @description Schema for the declarative logging matrix. * It controls which context properties are included in the final log output based on the log level. * @private */ const loggingMatrixSchema = zod.z .object({ /** An array of context keys to include in logs by default. Can be overridden by level-specific rules. */ default: zod.z.array(zod.z.string()).optional(), /** An array of context keys to include for 'trace' level logs. Use `['*']` to include all context properties. */ trace: zod.z.array(zod.z.string()).optional(), /** An array of context keys to include for 'debug' level logs. Use `['*']` to include all context properties. */ debug: zod.z.array(zod.z.string()).optional(), /** An array of context keys to include for 'info' level logs. Use `['*']` to include all context properties. */ info: zod.z.array(zod.z.string()).optional(), /** An array of context keys to include for 'warn' level logs. Use `['*']` to include all context properties. */ warn: zod.z.array(zod.z.string()).optional(), /** An array of context keys to include for 'error' level logs. Use `['*']` to include all context properties. */ error: zod.z.array(zod.z.string()).optional(), /** An array of context keys to include for 'fatal' level logs. Use `['*']` to include all context properties. */ fatal: zod.z.array(zod.z.string()).optional(), }) .optional(); /** * @description The main schema for the entire SyntropyLog configuration. * This is the single source of truth for validating the user's configuration object. */ const syntropyLogConfigSchema = zod.z.object({ /** Logger-specific configuration. */ logger: loggerOptionsSchema, /** Declarative matrix to control context data in logs. */ loggingMatrix: loggingMatrixSchema, /** Redis client configuration. */ redis: redisConfigSchema, /** HTTP client configuration. */ http: httpConfigSchema, /** Message broker client configuration. */ brokers: brokerConfigSchema, /** Centralized data masking configuration. */ masking: maskingConfigSchema, /** Context propagation configuration. */ context: zod.z .object({ /** The HTTP header name to use for the correlation ID. @default 'x-correlation-id' */ correlationIdHeader: zod.z.string().optional(), /** The HTTP header name to use for the external transaction/trace ID. @default 'x-trace-id' */ transactionIdHeader: zod.z.string().optional(), }) .optional(), /** * The maximum time in milliseconds to wait for a graceful shutdown before timing out. * @default 5000 */ shutdownTimeout: zod.z .number({ description: 'The maximum time in ms to wait for a graceful shutdown.', }) .int() .positive() .optional(), }); // @file src/context/ContextManager.ts // @description The default implementation of the IContextManager interface. It uses Node.js's // `AsyncLocalStorage` to create and manage asynchronous contexts, enabling // seamless propagation of data like correlation IDs across async operations. /** * Manages asynchronous context using Node.js `AsyncLocalStorage`. * This is the core component for propagating context-specific data * (like correlation IDs) without passing them through function arguments. * @implements {IContextManager} */ class ContextManager { constructor(loggingMatrix) { this.storage = new node_async_hooks.AsyncLocalStorage(); this.correlationIdHeader = 'x-correlation-id'; this.transactionIdHeader = 'x-trace-id'; this.storage = new node_async_hooks.AsyncLocalStorage(); this.loggingMatrix = loggingMatrix; } configure(options) { if (options.correlationIdHeader) { this.correlationIdHeader = options.correlationIdHeader; } if (options.transactionIdHeader) { this.transactionIdHeader = options.transactionIdHeader; } } /** * Reconfigures the logging matrix dynamically. * This method allows changing which context fields are included in logs * without affecting security configurations like masking or log levels. * @param newMatrix The new logging matrix configuration */ reconfigureLoggingMatrix(newMatrix) { this.loggingMatrix = newMatrix; } /** * Executes a function within a new, isolated asynchronous context. * Any data set via `set()` inside the callback will only be available * within that callback's asynchronous execution path. The new context * inherits values from the parent context, if one exists. * @template T The return type of the callback. * @param callback The function to execute within the new context. * @returns {T} The result of the callback function. */ run(fn) { return new Promise((resolve, reject) => { const parentContext = this.storage.getStore(); const newContextData = new Map(parentContext?.data); this.storage.run({ data: newContextData }, async () => { try { await Promise.resolve(fn()); resolve(); } catch (error) { reject(error); } }); }); } /** * Gets a value from the current asynchronous context by its key. * @template T The expected type of the value. * @param key The key of the value to retrieve. * @returns The value, or `undefined` if not found or if outside a context. */ get(key) { return this.storage.getStore()?.data.get(key); } /** * Gets the entire key-value store from the current asynchronous context. * @returns {ContextData} An object containing all context data, or an empty object if outside a context. */ getAll() { const store = this.storage.getStore(); if (!store) { return {}; } return Object.fromEntries(store.data.entries()); } /** * Sets a key-value pair in the current asynchronous context. This will have * no effect if called outside of a context created by `run()`. * This will only work if called within a context created by `run()`. * @param key The key for the value. * @param value The value to store. * @returns {void} */ set(key, value) { const store = this.storage.getStore(); if (store) { store.data.set(key, value); } } /** * Gets the correlation ID from the current context. * If no correlation ID exists, generates one automatically to ensure tracing continuity. * @returns {string} The correlation ID (never undefined). */ getCorrelationId() { let correlationId = this.get(this.correlationIdHeader) || this.get('correlationId'); if (!correlationId || typeof correlationId !== 'string') { // Generate correlationId if none exists to ensure tracing continuity correlationId = crypto.randomUUID(); this.set(this.correlationIdHeader, correlationId); } return correlationId; } /** * Sets the correlation ID in the current context. * This sets the value in the configured header name. * @param correlationId The correlation ID to set. */ setCorrelationId(correlationId) { this.set(this.correlationIdHeader, correlationId); } /** * Gets the transaction ID from the current context. * @returns {string | undefined} The transaction ID, or undefined if not set. */ getTransactionId() { return this.get('transactionId'); } /** * Sets the transaction ID in the current context. * @param transactionId The transaction ID to set. */ setTransactionId(transactionId) { this.set('transactionId', transactionId); } /** * Gets the configured HTTP header name for the correlation ID. * @returns {string} The header name. */ getCorrelationIdHeaderName() { return this.correlationIdHeader; } getTransactionIdHeaderName() { return this.transactionIdHeader; } /** * Gets the tracing headers to propagate the context (e.g., W3C Trace Context). * This base implementation does not support trace context propagation. * @returns `undefined` as this feature is not implemented by default. */ getTraceContextHeaders() { const headers = {}; // Only include headers if we're inside an active context const store = this.storage.getStore(); if (!store) { return headers; // Return empty object if outside context } const correlationId = this.getCorrelationId(); const transactionId = this.getTransactionId(); if (correlationId) { headers[this.getCorrelationIdHeaderName()] = correlationId; } if (transactionId) { headers[this.getTransactionIdHeaderName()] = transactionId; } return headers; } getFilteredContext(level) { const fullContext = this.getAll(); if (!this.loggingMatrix) { // Si no hay loggingMatrix, siempre incluir el correlationId const context = { ...fullContext }; const headerCorrelationId = this.get(this.correlationIdHeader); const internalCorrelationId = this.get('correlationId'); // Si no existe el correlationId del header, usar el interno if (!headerCorrelationId && internalCorrelationId) { context[this.correlationIdHeader] = internalCorrelationId; } return context; } const fieldsToKeep = this.loggingMatrix[level] ?? this.loggingMatrix.default; if (!fieldsToKeep) { return {}; } // Mapeo de nombres de campos del loggingMatrix a claves reales del contexto const fieldMapping = { correlationId: [this.correlationIdHeader, 'correlationId'], transactionId: [this.transactionIdHeader, 'transactionId'], userId: ['userId'], serviceName: ['serviceName'], operation: ['operation'], errorCode: ['errorCode'], tenantId: ['tenantId'], paymentId: ['paymentId'], orderId: ['orderId'], processorId: ['processorId'], eventType: ['eventType'], }; if (fieldsToKeep.includes('*')) { // Apply field mapping even for wildcard to ensure consistency const mappedContext = {}; // Map all fields using the same logic as specific fields for (const [key, value] of Object.entries(fullContext)) { // Find the mapped field name for this key let mappedFieldName = key; for (const [matrixField, possibleKeys] of Object.entries(fieldMapping)) { if (possibleKeys.includes(key)) { mappedFieldName = matrixField; break; } } mappedContext[mappedFieldName] = value; } return mappedContext; } const filteredContext = {}; for (const field of fieldsToKeep) { // Buscar en el mapeo de campos const possibleKeys = fieldMapping[field] || [field]; // Buscar la primera clave que exista en el contexto for (const key of possibleKeys) { if (Object.prototype.hasOwnProperty.call(fullContext, key)) { filteredContext[field] = fullContext[key]; break; } } // Si no se encontrĂ³ en el mapeo, buscar directamente if (!Object.prototype.hasOwnProperty.call(filteredContext, field) && Object.prototype.hasOwnProperty.call(fullContext, field)) { filteredContext[field] = fullContext[field]; } } return filteredContext; } } /** * @file src/logger/Logger.ts * @description The core implementation of the ILogger interface. */ /** * @class Logger * @description The core logger implementation. It orchestrates the entire logging * pipeline, from argument parsing and level checking to serialization, masking, * and dispatching to transports. */ class Logger { constructor(name, transports, dependencies, options = {}) { this.name = name; this.transports = transports; this.dependencies = dependencies; this.bindings = options.bindings ?? {}; this.level = options.level ?? 'info'; } /** * @private * The core asynchronous logging method that runs the full processing pipeline. * It handles argument parsing, level filtering, serialization, masking, * and finally dispatches the processed log entry to the appropriate transports. * @param {LogLevel} level - The severity level of the log message. * @param {...(LogFormatArg | LogMetadata | JsonValue)[]} args - The arguments to be logged, following the Pino-like signature (e.g., `(obj, msg, ...)` or `(msg, ...)`). * @returns {Promise<void>} */ async _log(level, ...args) { if (level === 'silent') { return; } // Type-guarded access to weights const weightedLevel = level; const weightedThisLevel = this.level; if (LOG_LEVEL_WEIGHTS[weightedLevel] < LOG_LEVEL_WEIGHTS[weightedThisLevel]) { return; } // Build the base log entry with context and bindings const context = this.dependencies.contextManager.getFilteredContext(level); const logEntry = { ...context, ...this.bindings, level, timestamp: new Date().toISOString(), service: this.name, message: '', // Will be set below }; // Parse arguments following Pino-like signature let message; let metadata = {}; if (args.length === 0) { message = ''; } else if (typeof args[0] === 'object' && args[0] !== null && !Array.isArray(args[0])) { // First argument is metadata object: (metadata, message, ...formatArgs) metadata = args[0]; message = args[1] || ''; const formatArgs = args.slice(2); if (message && formatArgs.length > 0) { message = util__namespace.format(message, ...formatArgs); } } else { // First argument is message: (message, ...formatArgs) message = args[0] || ''; const formatArgs = args.slice(1); if (message && formatArgs.length > 0) { message = util__namespace.format(message, ...formatArgs); } } // Ensure message is never undefined logEntry.message = message || ''; // Merge metadata into log entry Object.assign(logEntry, metadata); // 1. Apply custom serializers (e.g., for Error objects) const finalEntry = await this.dependencies.serializerRegistry.process(logEntry, this); // 2. Apply masking to the entire, serialized entry. const maskedEntry = this.dependencies.maskingEngine.process(finalEntry); // Dispatch to transports await Promise.all(this.transports.map((transport) => { if (transport.isLevelEnabled(level)) { // The type assertion is safe here because the masking engine preserves the structure. return transport.log(maskedEntry); } return Promise.resolve(); })); } /** * Logs a message at the 'info' level. * @param {...(LogFormatArg | LogMetadata | JsonValue)[]} args - The arguments to log. */ info(...args) { return this._log('info', ...args); } /** * Logs a message at the 'warn' level. * @param {...(LogFormatArg | LogMetadata | JsonValue)[]} args - The arguments to log. */ warn(...args) { return this._log('warn', ...args); } /** * Logs a message at the 'error' level. * @param {...(LogFormatArg | LogMetadata | JsonValue)[]} args - The arguments to log. */ error(...args) { return this._log('error', ...args); } /** * Logs a message at the 'debug' level. * @param {...(LogFormatArg | LogMetadata | JsonValue)[]} args - The arguments to log. */ debug(...args) { return this._log('debug', ...args); } /** * Logs a message at the 'trace' level. * @param {...(LogFormatArg | LogMetadata | JsonValue)[]} args - The arguments to log. */ trace(...args) { return this._log('trace', ...args); } /** * Logs a message at the 'fatal' level. * @param {...(LogFormatArg | LogMetadata | JsonValue)[]} args - The arguments to log. */ fatal(...args) { return this._log('fatal', ...args); } /** * Dynamically updates the minimum log level for this logger instance. * Any messages with a severity lower than the new level will be ignored. * @param {LogLevel} level - The new minimum log level. */ setLevel(level) { this.level = level; } /** * Creates a new child logger instance that inherits the parent's configuration * and adds the specified bindings. * @param {LogBindings} bindings - Key-value pairs to bind to the child logger. * @returns {ILogger} A new logger instance with the specified bindings. */ child(bindings) { const childLogger = new Logger(this.name, this.transports, this.dependencies, { level: this.level, bindings: { ...this.bindings, ...bindings }, }); return childLogger; } /** * Creates a new logger instance with a `source` field bound to it. * @param {string} source - The name of the source (e.g., 'redis', 'AuthModule'). * @returns {ILogger} A new logger instance with the `source` binding. */ withSource(source) { return this.child({ source }); } /** * Creates a new logger instance with a `retention` field bound to it. * @param {LogRetentionRules} rules - A JSON object containing the retention rules. * @returns {ILogger} A new logger instance with the `retention` binding. */ withRetention(rules) { return this.child({ retention: rules }); } /** * Creates a new logger instance with a `transactionId` field bound to it. * @param {string} transactionId - The unique ID of the transaction. * @returns {ILogger} A new logger instance with the `transactionId` binding. */ withTransactionId(transactionId) { return this.child({ transactionId }); } } /** * @file src/serialization/SerializerRegistry.ts * @description Manages and safely applies custom log object serializers. */ /** * @class SerializerRegistry * @description Manages and applies custom serializer functions to log metadata. * It ensures that serializers are executed safely, with timeouts and error handling, * to prevent them from destabilizing the logging pipeline. */ class SerializerRegistry { /** * @constructor * @param {SerializerRegistryOptions} [options] - Configuration options for the registry. */ constructor(options) { this.serializers = options?.serializers || {}; this.timeoutMs = options?.timeoutMs || 50; // Default to a 50ms timeout // Add a default, built-in serializer for Error objects if one isn't provided. if (!this.serializers['err']) { this.serializers['err'] = this.defaultErrorSerializer; } } /** * Processes a metadata object, applying any matching serializers. * @param {Record<string, unknown>} meta - The metadata object from a log call. * @param {ILogger} logger - A logger instance to report errors from the serialization process itself. * @returns {Promise<Record<string, unknown>>} A new metadata object with serialized values. */ async process(meta, logger) { const processedMeta = { ...meta }; for (const key in processedMeta) { if (Object.prototype.hasOwnProperty.call(this.serializers, key)) { const serializerFn = this.serializers[key]; const valueToSerialize = processedMeta[key]; try { // Execute the serializer within the secure executor const serializedValue = await this.secureExecute(serializerFn, valueToSerialize); processedMeta[key] = serializedValue; } catch (error) { logger.warn(`Custom serializer for key "${key}" failed or timed out.`, { error: error instanceof Error ? error.message : String(error) }); processedMeta[key] = `[SERIALIZER_ERROR: Failed to process key '${key}']`; } } } return processedMeta; } /** * @private * Safely executes a serializer function with a timeout. * @param {(value: unknown) => string} serializerFn - The serializer function to execute. * @param {unknown} value - The value to pass to the function. * @returns {Promise<string>} A promise that resolves with the serialized string. * @throws An error if the serializer throws an exception or times out. */ secureExecute(serializerFn, value) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`Serializer function timed out after ${this.timeoutMs}ms.`)); }, this.timeoutMs); try { // We use Promise.resolve() to handle both sync and async serializers. Promise.resolve(serializerFn(value)) .then((result) => { clearTimeout(timer); resolve(result); }) .catch((err) => { clearTimeout(timer); reject(err); }); } catch (err) { clearTimeout(timer); reject(err); } }); } /** * @private * The default serializer for Error objects. It creates a JSON string representation * of the error, explicitly including common properties like name, message, and stack. * @param {unknown} err - The value to serialize, expected to be an Error. * @returns {string} A JSON string representing the error. */ defaultErrorSerializer(err) { if (!(err instanceof Error)) { // For non-Error objects, a simple stringify is the best we can do. return JSON.stringify(err); } // For Error objects, explicitly pull out known, safe properties. const serializedError = { name: err.name, message: err.message, stack: err.stack, }; // Include common additional properties if they exist. if ('cause' in err) serializedError.cause = err.cause; if ('code' in err) serializedError.code = err.code; return JSON.stringify(serializedError, null, 2); } } /** * @class ConsoleTransport * @description A transport that writes logs to the console as a single, serialized JSON string. * This format is ideal for log aggregation systems that can parse JSON. * @extends {Transport} */ class ConsoleTransport extends Transport { /** * @constructor * @param {TransportOptions} [options] - Options for the transport, including level, formatter, and a sanitization engine. */ constructor(options) { super(options); } /** * Logs a structured entry to the console as a single JSON string. * The entry is first formatted (if a formatter is provided) and then sanitized * before being written to the console. * @param {LogEntry} entry - The log entry to process. * @returns {Promise<void>} */ async log(entry) { if (!this.isLevelEnabled(entry.level)) { return; } const finalObject = this.formatter ? this.formatter.format(entry) : entry; const logString = JSON.stringify(finalObject); switch (entry.level) { case 'fatal': case 'error': console.error(logString); break; case 'warn': console.warn(logString); break; default: console.log(logString); break; } } } /** * @file src/sanitization/SanitizationEngine.ts * @description Final security layer that sanitizes log entries before they are written by a transport. */ /** * @class SanitizationEngine * A security engine that makes log entries safe for printing by stripping * potentially malicious control characters, such as ANSI escape codes. * This prevents log injection attacks that could exploit terminal vulnerabilities. */ class SanitizationEngine { /** * @constructor * The engine is currently not configurable, but the constructor is in place for future enhancements. */ constructor(maskingEngine) { /** @private This regex matches ANSI escape codes used for colors, cursor movement, etc. */ // prettier-ignore // eslint-disable-next-line no-control-regex this.ansiRegex = /[\x1b\u009b][[