syntropylog
Version:
An instance manager with observability for Node.js applications
1,308 lines (1,296 loc) • 173 kB
JavaScript
'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][[