syntropylog
Version:
An instance manager with observability for Node.js applications
4,273 lines • 171 kB
JavaScript
import { EventEmitter } from 'events';
import { z, ZodError } from 'zod';
import RegexTest from 'regex-test';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'crypto';
import * as util from 'node:util';
import chalk from 'chalk';
import { createClient } from 'redis';
/**
* @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 = z
.object({
name: z.string().optional(),
level: z
.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent'])
.optional(),
serviceName: z.string().optional(),
/**
* An array of transport instances to be used by the logger.
*/
transports: z.array(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: z
.record(z.string(), z.function().args(z.any()).returns(z.string()))
.optional(),
/**
* The maximum time in milliseconds a custom serializer can run before being timed out.
* @default 50
*/
serializerTimeoutMs: z.number().int().positive().default(50),
/** Configuration for pretty printing logs in development. */
prettyPrint: z
.object({
enabled: z.boolean().optional().default(false),
})
.optional(),
})
.optional();
/**
* @description Reusable schema for retry options, commonly used in client configurations.
* @private
*/
const retryOptionsSchema = z
.object({
maxRetries: z.number().int().positive().optional(),
retryDelay: z.number().int().positive().optional(),
})
.optional();
/**
* @description Schema for a single Redis instance, using a discriminated union for different connection modes.
*/
const redisInstanceConfigSchema = z.discriminatedUnion('mode', [
z.object({
mode: z.literal('single'),
instanceName: z.string(),
url: z.string().url(),
retryOptions: retryOptionsSchema,
// --- NEW: Granular Logging Configuration for Redis ---
logging: z
.object({
/** Level for successful commands. @default 'debug' */
onSuccess: z.enum(['trace', 'debug', 'info']).default('debug'),
/** Level for failed commands. @default 'error' */
onError: z.enum(['warn', 'error', 'fatal']).default('error'),
/** Whether to log command parameters. @default true */
logCommandValues: z.boolean().default(true),
/** Whether to log the return value of commands. @default false */
logReturnValue: z.boolean().default(false),
})
.optional(),
}),
// Apply the same 'logging' object structure to 'sentinel' and 'cluster' modes
z.object({
mode: z.literal('sentinel'),
instanceName: z.string(),
name: z.string(),
sentinels: z.array(z.object({ host: z.string(), port: z.number() })),
sentinelPassword: z.string().optional(),
retryOptions: retryOptionsSchema,
logging: z
.object({
onSuccess: z.enum(['trace', 'debug', 'info']).default('debug'),
onError: z.enum(['warn', 'error', 'fatal']).default('error'),
logCommandValues: z.boolean().default(true),
logReturnValue: z.boolean().default(false),
})
.optional(),
}),
z.object({
mode: z.literal('cluster'),
instanceName: z.string(),
rootNodes: z.array(z.object({ host: z.string(), port: z.number() })),
logging: z
.object({
/** Level for successful commands. @default 'debug' */
onSuccess: z.enum(['trace', 'debug', 'info']).default('debug'),
/** Level for failed commands. @default 'error' */
onError: z.enum(['warn', 'error', 'fatal']).default('error'),
/** Whether to log command parameters. @default true */
logCommandValues: z.boolean().default(true),
/** Whether to log the return value of commands. @default false */
logReturnValue: z.boolean().default(false),
})
.optional(),
}),
]);
/**
* @description Schema for the main Redis configuration block, containing all Redis instances.
*/
const redisConfigSchema = z
.object({
/** An array of Redis instance configurations. */
instances: z.array(redisInstanceConfigSchema),
/** The name of the default Redis instance to use when no name is provided to `getInstance()`. */
default: z.string().optional(),
})
.optional();
/**
* @description Schema for a single HTTP client instance.
*/
const httpInstanceConfigSchema = z.object({
instanceName: z.string(),
adapter: 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: z.boolean().optional(),
propagate: z.array(z.string()).optional(),
propagateFullContext: z.boolean().optional(),
logging: z
.object({
onSuccess: z.enum(['trace', 'debug', 'info']).default('info'),
onError: z.enum(['warn', 'error', 'fatal']).default('error'),
logSuccessBody: z.boolean().default(false),
logSuccessHeaders: z.boolean().default(false),
onRequest: z.enum(['trace', 'debug', 'info']).default('info'),
logRequestBody: z.boolean().default(false),
logRequestHeaders: z.boolean().default(false),
})
.partial()
.optional(),
});
/**
* @description Schema for the main HTTP configuration block.
*/
const httpConfigSchema = z
.object({
/** An array of HTTP client instance configurations. */
instances: z.array(httpInstanceConfigSchema),
/** The name of the default HTTP client instance to use when no name is provided to `getInstance()`. */
default: z.string().optional(),
})
.optional();
/**
* @description Schema for the main data masking configuration block.
*/
const maskingConfigSchema = z
.object({
/** Array of masking rules with patterns and strategies. */
rules: z.array(z.object({
/** Regex pattern to match field names */
pattern: z.union([z.string(), z.instanceof(RegExp)]),
/** Masking strategy to apply */
strategy: z.nativeEnum(MaskingStrategy),
/** Whether to preserve original length */
preserveLength: z.boolean().optional(),
/** Character to use for masking */
maskChar: z.string().optional(),
/** Custom masking function (for CUSTOM strategy) */
customMask: z.function().args(z.string()).returns(z.string()).optional(),
})).optional(),
/** Default mask character */
maskChar: z.string().optional(),
/** Whether to preserve original length by default */
preserveLength: z.boolean().optional(),
/** Enable default rules for common data types */
enableDefaultRules: z.boolean().optional(),
})
.optional();
/**
* @description Schema for a single message broker client instance.
* It validates that a valid `IBrokerAdapter` is provided.
* @private
*/
const brokerInstanceConfigSchema = z.object({
instanceName: z.string(),
adapter: 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: z.array(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: z.boolean().optional(),
isDefault: z.boolean().optional(),
});
/**
* @description Schema for the main message broker configuration block.
*/
const brokerConfigSchema = z
.object({
/** An array of broker client instance configurations. */
instances: z.array(brokerInstanceConfigSchema),
/** The name of the default broker instance to use when no name is provided to `getInstance()`. */
default: 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 = z
.object({
/** An array of context keys to include in logs by default. Can be overridden by level-specific rules. */
default: z.array(z.string()).optional(),
/** An array of context keys to include for 'trace' level logs. Use `['*']` to include all context properties. */
trace: z.array(z.string()).optional(),
/** An array of context keys to include for 'debug' level logs. Use `['*']` to include all context properties. */
debug: z.array(z.string()).optional(),
/** An array of context keys to include for 'info' level logs. Use `['*']` to include all context properties. */
info: z.array(z.string()).optional(),
/** An array of context keys to include for 'warn' level logs. Use `['*']` to include all context properties. */
warn: z.array(z.string()).optional(),
/** An array of context keys to include for 'error' level logs. Use `['*']` to include all context properties. */
error: z.array(z.string()).optional(),
/** An array of context keys to include for 'fatal' level logs. Use `['*']` to include all context properties. */
fatal: z.array(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 = 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: z
.object({
/** The HTTP header name to use for the correlation ID. @default 'x-correlation-id' */
correlationIdHeader: z.string().optional(),
/** The HTTP header name to use for the external transaction/trace ID. @default 'x-trace-id' */
transactionIdHeader: z.string().optional(),
})
.optional(),
/**
* The maximum time in milliseconds to wait for a graceful shutdown before timing out.
* @default 5000
*/
shutdownTimeout: 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 AsyncLocalStorage();
this.correlationIdHeader = 'x-correlation-id';
this.transactionIdHeader = 'x-trace-id';
this.storage = new 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 = 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.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.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][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
this.maskingEngine = maskingEngine;
}
/**
* Processes a log metadata object, sanitizing all its string values.
* @param {Record<string, any>} meta - The metadata object to sanitize.
* @returns {Record<string, any>} A new, sanitized metadata object.
*/
process(meta) {
let sanitized = this.sanitizeRecursively(meta);
if (this.maskingEngine) {
sanitized = this.maskingEngine.process(sanitized);
}
return sanitized;
}
/**
* @private
* Recursively traverses an object or array to sanitize all string values.
* @param {any} data - The data to process.
* @returns {any} The sanitized data.
*/
sanitizeRecursively(data) {
if (typeof data === 'string') {
return data.replace(this.ansiRegex, '');
}
if (Array.isArray(data)) {
return data.map((item) => this.sanitizeRecursively(item));
}
// Clave: Solo procesar objetos planos para no corromper instancias de clases.
if (typeof data === 'object' &&
data !== null &&
data.constructor === Object) {
const sanitizedObject = {};
for (const key in data) {
// hasOwnProperty sigue siendo una buena práctica aquí.
if (Object.prototype.hasOwnProperty.call(data, key)) {
sanitizedObject[key] = this.sanitizeRecursively(data[key]);
}
}
return sanitizedObject;
}
// Devuelve cualquier otro tipo de dato (números, booleans, instancias, etc.) sin modificar.
return data;
}
}
/**
* @class LoggerFactory
* @description Manages the lifecycle and configuration of all logging components.
* An instance of this factory is created by `syntropyLog.init()` and acts as the central
* orchestrator for creating and managing logger instances and their dependencies.
*/
class LoggerFactory {
/**
* @constructor
* @param {SyntropyLogConfig} config - The global configuration object.
* @param {IContextManager} contextManager - The shared context manager instance.
* @param {SyntropyLog} syntropyLogInstance - The main framework instance for mediation.
* @description Initializes all core logging engines and orchestrates transport setup.
* It follows a key principle for transport configuration:
* - **If `config.logger.transports` is provided:** The factory trusts the user's
* configuration completely and uses the provided transports as-is. It is the user's
* responsibility to configure them correctly (e.g., adding sanitization).
* - **If no transports are provided:** The factory creates a single, production-safe
* `ConsoleTransport` by default, which includes a built-in `SanitizationEngine`.
*/
constructor(config, contextManager, syntropyLogInstance) {
/** @private A pool to cache logger instances by name for performance. */
this.loggerPool = new Map();
this.contextManager = contextManager;
this.syntropyLogInstance = syntropyLogInstance;
// Configure the context manager by passing the entire context config object.
if (config.context) {
this.contextManager.configure(config.context);
}
// Configure the HTTP manager if http instances are provided
if (config.http?.instances) ;
// If the user provides a specific list of transports, we use them directly.
// We trust the user to have configured them correctly (e.g., providing a
// SanitizationEngine to their production transports).
if (config.logger?.transports) {
this.transports = config.logger.transports;
}
else {
// If no transports are provided, we create a safe, default production transport.
// This transport includes a default sanitization engine.
const sanitizationEngine = new SanitizationEngine();
this.transports = [new ConsoleTransport({ sanitizationEngine })];
}
this.globalLogLevel = config.logger?.level ?? 'info';
this.serviceName = config.logger?.serviceName ?? 'unknown-service';
this.serializerRegistry = new SerializerRegistry({
serializers: config.logger?.serializers,
timeoutMs: config.logger?.serializerTimeoutMs,
});
this.maskingEngine = new MaskingEngine({
rules: config.masking?.rules,
maskChar: config.masking?.maskChar,
preserveLength: config.masking?.preserveLength,
enableDefaultRules: config.masking?.enableDefaultRules !== false,
});
}
/**
* Retrieves a logger instance by name. If the logger does not exist, it is created
* and cached for subsequent calls.
* @param {string} [name='default'] - The name of the logger to retrieve.
* @param {Record<string, JsonValue>} [bindings] - Optional bindings to apply to the logger.
* @returns {ILogger} The logger instance.
*/
getLogger(name = 'default', bindings) {
// Create a stable cache key that doesn't depend on object reference
const cacheKey = this.createCacheKey(name, bindings);
if (this.loggerPool.has(cacheKey)) {
return this.loggerPool.get(cacheKey);
}
const loggerName = name === 'default' ? this.serviceName : name;
const dependencies = {
contextManager: this.contextManager,
serializerRegistry: this.serializerRegistry,
maskingEngine: this.maskingEngine,
syntropyLogInstance: this.syntropyLogInstance,
};
const logger = new Logger(loggerName, this.transports, dependencies, {
bindings,
});
logger.level = this.globalLogLevel;
this.loggerPool.set(cacheKey, logger);
return logger;
}
/**
* Creates a stable cache key for logger instances.
* @private
*/
createCacheKey(name, bindings) {
if (!bindings || Object.keys(bindings).length === 0) {
return name;
}
// Sort keys to ensure consistent cache keys regardless of property order
const sortedBindings = Object.keys(bindings)
.sort()
.reduce((result, key) => {
result[key] = bindings[key];
return result;
}, {});
try {
return `${name}:${JSON.stringify(sortedBindings)}`;
}
catch {
// Fallback for non-serializable objects
return `${name}:${Object.keys(sortedBindings).sort().join(',')}`;
}
}
/**
* Calls the `flush` method on all configured transports to ensure buffered
* logs are written before the application exits.
*/
async flushAllTransports() {
const flushPromises = this.transports.map((transport) => transport.flush().catch((err) => {
console.error(`Error flushing transport ${transport.constructor.name}:`, err);
}));
await Promise.allSettled(flushPromises);
}
/**
* Shuts down the logger factory and all its transports.
* This ensures that all buffered logs are written and resources are cleaned up.
*/
async shutdown() {
try {
// Flush all transports first
await this.flushAllTransports();
// Clear the logger pool
this.loggerPool.clear();
// Shutdown all transports if they have a shutdown method
const shutdownPromises = this.transports.map((transport) => {
if (typeof transport.shutdown ===
'function') {
return transport
.shutdown()
.catch((err) => {
console.error(`Error shutting down transport ${transport.constructor.name}:`, err);
});
}
return Promise.resolve();
});
await Promise.allSettled(shutdownPromises);
}
catch (error) {
console.error('Error during LoggerFactory shutdown:', error);
}
}
}
/**
* FILE: src/utils/sanitizeConfig.ts
* DESCRIPTION: Utilities for sanitizing the SyntropyLog configuration object.
*/
const MASK = '[CONFIG_MASKED]';
const SENSITIVE_KEYS = [
'password',
'token',
'secret',
'apikey',
'credential',
'pass',
'key',
'accesstoken',
'refreshtoken',
'clientsecret',
'sentinelpassword',
'sasl',
];
/**
* @private
* A helper function to detect if a value is a special class instance
* (like a Transport or an Adapter) that should not be deeply cloned or sanitized.
* This is crucial to preserve methods and internal state of user-provided instances.
* @param {any} value - The value to check.
* @returns {value is Transport | IHttpClientAdapter | IBrokerAdapter} True if the value is a special instance.
*/
function isSpecialInstance(value) {
if (value instanceof Transport) {
return true;
}
// Duck-typing for adapters: if it has the core method, we treat it as an adapter.
if (typeof value === 'object' &&
value !== null &&
(typeof value.request === 'function' ||
typeof value.publish === 'function')) {
return true;
}
return false;
}
/**
* Recursively sanitizes a configuration object for safe logging.
* It masks values for keys that are known to be sensitive and redacts credentials from URLs.
* It intelligently skips special class instances (Transports, Adapters) to preserve their methods.
* @param {T} config - The configuration object to sanitize.
* @returns {T} A new, sanitized configuration object.
*/
function sanitizeConfig(config) {
// If the object is a special instance (like a Transport or Adapter), return it without processing.
if (isSpecialInstance(config)) {
return config;
}
if (config === null || typeof config !== 'object') {
return config;
}
if (Array.isArray(config)) {
return config.map((item) => sanitizeConfig(item));
}
const sanitized = {};
const sensitiveLower = SENSITIVE_KEYS.map((k) => k.toLowerCase());
for (const key in config) {
if (Object.prototype.hasOwnProperty.call(config, key)) {
const lowerKey = key.toLowerCase();
const value = config[key];
if (sensitiveLower.includes(lowerKey)) {
sanitized[key] = MASK;
}
else if ((lowerKey.includes('url') || lowerKey.includes('uri')) &&
typeof value === 'string') {
// Redact user:pass from connection strings.
sanitized[key] = value.replace(/(?<=:\/\/)[^:]+:[^@]+@/, `${MASK}@`);
}
else if (typeof value === 'object' &&
value !== null &&
!(value instanceof RegExp)) {
// The recursive call will also respect special instances.
sanitized[key] = sanitizeConfig(value);
}
else {
sanitized[key] = value;
}
}
}
return sanitized;
}
/**
* @file src/http/InstrumentedHttpClient.ts
* @description This class is the heart of the HTTP instrumentation architecture.
* It wraps any adapter that complies with `IHttpClientAdapter` and adds a centralized
* layer of instrumentation (logging, context, timers).
*/
/**
* @class InstrumentedHttpClient
* @description Wraps an `IHttpClientAdapter` to provide automatic logging,
* context propagation, and timing for all HTTP requests.
*/
class InstrumentedHttpClient {
/**
* @constructor
* @param {IHttpClientAdapter} adapter - The underlying HTTP client adapter (e.g., AxiosAdapter).
* @param {ILogger} logger - The logger instance for this client.
* @param {IContextManager} contextManager - The manager for handling asynchronous contexts.
* @param {HttpClientInstanceConfig} config - The configuration for this specific instance.
*/
constructor(adapter, logger, contextManager, config) {
this.adapter = adapter;
this.logger = logger;
this.contextManager = contextManager;
this.config = config;
this.instanceName = config.instanceName;
// Extract instrumentation options from the main config for clarity.
this.instrumentorOptions = {
logRequestHeaders: this.config.logging?.logRequestHeaders,
logRequestBody: this.config.logging?.logRequestBody,
logSuccessHeaders: this.config.logging?.logSuccessHeaders,
logSuccessBody: this.config.logging?.logSuccessBody,
logLevel: {
onRequest: this.config.logging?.onRequest,
onSuccess: this.config.logging?.onSuccess,
onError: this.config.logging?.onError,
},
};
}
/**
* The single public method. It executes an HTTP request through the wrapped
* adapter, applying all instrumentation logic.
* @template T The expected type of the response data.
* @param {AdapterHttpRequest} request - The generic HTTP request to execute.
* @returns {Promise<AdapterHttpResponse<T>>} A promise that resolves with the normalized response.
* @throws {AdapterHttpError | Error} Throws the error from the adapter, which is re-thrown after being logged.
*/
async request(request) {
const startTime = Date.now();
if (!request.headers) {
request.headers = {};
}
// 1. Inject context into headers based on the configuration.
if (this.config.propagate?.includes('*')) {
// Wildcard behavior: Propagate the entire context map.
const contextObject = this.contextManager.getAll();
for (const key in contextObject) {
if (Object.prototype.hasOwnProperty.call(contextObject, key)) {
const value = contextObject[key];
if (typeof value === 'string') {
request.headers[key] = value;
}
}
}
}
else if (this.config.propagate && Array.isArray(this.config.propagate)) {
// New behavior: Propagate only specified context keys.
for (const key of this.config.propagate) {
const value = this.contextManager.get(key);
if (typeof value === 'string') {
request.headers[key] = value;
}
}
}
else if (this.config.propagateFullContext) {
// DEPRECATED: Propagate the entire context map.
const contextObject = this.contextManager.getAll();
for (const key in contextObject) {
if (Object.prototype.hasOwnProperty.call(contextObject, key)) {
const value = contextObject[key];
if (typeof value === 'string') {
request.headers[key] = value;
}
}
}
}
// Always propagate correlation and transaction IDs, as they are fundamental.
const correlationId = this.contextManager.getCorrelationId();
if (correlationId) {
request.headers[this.contextManager.getCorrelationIdHeaderName()] =
correlationId;
}
const transactionId = this.contextManager.getTransactionId();
if (transactionId) {
request.headers[this.contextManager.getTransactionIdHeaderName()] =
transactionId;
}
// 2. Log the start of the request.
this.logRequestStart(request);
try {
// 3. Delegate execution to the adapter.
const response = await this.adapter.request(request);
const durationMs = Date.now() - startTime;
// 4. Log the successful completion of the request.
this.logRequestSuccess(request, response, durationMs);
return response;
}
catch (error) {
const durationMs = Date.now() - startTime;
// 5. Log the failure of the request.
this.logRequestFailure(request, error, durationMs);
// 6. Re-throw the error so the user's code can handle it.
throw error;
}
}
/**
* @private
* Logs the start of an HTTP request, respecting the configured options.
* @param {AdapterHttpRequest} request - The outgoing request.
*/
logRequestStart(request) {
const logLevel = this.instrumentorOptions.logLevel?.onRequest ?? 'info';
const logPayload = {
method: request.method,
url: request.url,
};
if (this.instrumentorOptions.logRequestHeaders) {
logPayload.headers = request.headers;
}
if (this.instrumentorOptions.logRequestBody) {
logPayload.body = request.body;
}
this.logger[logLevel](logPayload, 'Starting HTTP request');
}
/**
* @private
* Logs the successful completion of an HTTP request.
* @template T
* @param {AdapterHttpRequest} request - The original request.
* @param {AdapterHttpResponse<T>} response - The received response.
* @param {number} durationMs - The total duration of the request in milliseconds.
*/
logRequestSuccess(request, response, durationMs) {
const logLevel = this.instrumentorOptions.logLevel?.onSuccess ?? 'info';
const logPayload = {
statusCode: response.statusCode,
url: request.url,
method: request.method,
durationMs,
};
if (this.instrumentorOptions.logSuccessHeaders) {
logPayload.headers = response.headers;
}
if (this.instrumentorOptions.logSuccessBody) {
logPayload.body = response.data;
}
this.logger[logLevel](logPayload, 'HTTP response received');
}
/**
* @private
* Logs the failure of an HTTP request.
* @param {AdapterHttpRequest} request - The original request.
* @param {unknown} error - The error that was thrown.
* @param {number} durationMs - The total duration of the request until failure.
*/
logRequestFailure(request, error, durationMs) {
const logLevel = this.instrumentorOptions.logLevel?.onError ?? 'error';
// Use the normalized adapter error if available for richer logging.
if (error && error.isAdapterError) {
const adapterError = error;
const logPayload = {
err: adapterError, // The logger's serializer will handle this.
url: request.url,
method: request.method,
durationMs,
response: adapterError.response
? {
statusCode: adapterError.response.statusCode,
headers: adapterError.response.headers,
body: adapterError.response.data,
}
: 'No response',
};
this.logger[logLevel](logPayload, 'HTTP request failed');
}
else {
// If it's an unexpected error, log it as well.
this.logger[logLevel]({ err: error, url: request.url, method: request.method, durationMs }, 'HTTP request failed with an unexpected error');
}
}
}
/**
* Internal Types for SyntropyLog Framework
*
* These types and utilities are for advanced usage and internal framework operations.
* Use with caution - they may change between versions.
*/
/**
* Helper function to convert unknown error to JsonValue
* Moved from @syntropylog/types to internal types
*/
function errorToJsonValue(error) {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack || null,
};
}
return String(error);
}
/**
* FILE: src/http/HttpManager.ts
* @description Manages the lifecycle and creation of multiple instrumented HTTP client instances.
*/
/**
* @class HttpManager
* @description Manages the creation and retrieval of multiple instrumented HTTP client instances.
* It reads the configuration, creates an `InstrumentedHttpClient` for each defined
* instance by wrapping the user-provided adapter, and provides a way to retrieve them.
*/
class HttpManager {
constructor(config, logger, contextManager) {
/** @private A map storing the created instrumented client instances by name. */
this.instances = new Map();
this.config = config;
this.logger = logger.child({ module: 'HttpManager' });
this.contextManager = contextManager;
}
init() {
this.logger.trace('Initializing HttpManager...');
if (!this.config.instances || this.config.instances.length === 0) {
this.logger.debug('HttpManager initialized, but no HTTP client instances were defined.');
return;
}
for (const instanceConfig of this.config.instances) {
try {
const client = new InstrumentedHttpClient(instanceConfig.adapter, this.logger, this.contextManager, instanceConfig);
this.instances.set(instanceConfig.instanceName, client);
this.logger.info(`HTTP client instance "${instanceConfig.instanceName}" created successfully via adapter.`);
if (instanceConfig.isDefault) {
if (this.defaultInstance) {
this.logger.warn(`Multiple default HTTP instances defined. Overwriting previous default "${this.defaultInstance.instanceName}" with "${instanceConfig.instanceName}".`);
}
this.logger.trace(`Setting default HTTP instance: ${instanceConfig.instanceName}`);
this.defaultInstance = client;
}
}
catch (error) {
this.logger.error({ error: errorToJsonValue(error) }, `Failed to create HTTP client instance "${instanceConfig.instanceName}"`);
}
}
if (!this.defaultInstance && this.instances.size > 0) {
const firstInstance = this.instances.values().next().value;
const firstName = this.instances.keys().next().value;
this.logger.trace(`No default HTTP instance configured. Using first available instance: ${firstName}`);
this.defaultInstance = firstInstance;
}
}
/**
* Retrieves a managed and instrumented HTTP client instance by its name.
* The returned client has a unified API via its `.request()` method.
* @param {string} name - The name of the HTTP client instance to retrieve.
* @returns {InstrumentedHttpClient} The requested client instance.
* @throws {Error} If no instance with the given name is found.
*/
getInstance(name) {
const instanceName = name ?? this.defaultInstance?.instanceName;
if (!instanceName) {
throw new Error('A specific instance name was not provided and no default HTTP instance is configured.');
}
const instance = this.instances.get(instanceName);
if (!instance) {
throw new Error(`HTTP client instance with name "${instanceName}" was not found. Check your configuration.`);
}
return instance;
}
/**
* Clears all managed HTTP client instances. This is a simple cleanup operation.
*/
async shutdown() {
this.logger.info('Shutting down HTTP clients.');
this.instances.clear();
// HTTP clients do not require explicit shutdown, so we just clear the map.
return Promise.resolve();
}
}
/**
* @class InstrumentedBrokerClient
* @description Wraps a user-provided broker adapter to automatically handle
* logging, context propagation, and distributed tracing.
*/
class InstrumentedBrokerClient {
/**
* @constructor
* @param {IBrokerAdapter} adapter - The concrete broker adapter implementation (e.g., for RabbitMQ, Kafka).
* @param {ILogger} logger - The logger instance for this client.
* @param {IContextManager} contextManager - The manager for handling asynchronous contexts.
* @param {BrokerInstanceConfig} config - The configuration for this specific instance.
*/
constructor(adapter, logger, contextManager, config) {
this.adapter = adapter;
this.logger = logger;
this.contextManager = contextManager;
this.config = config;
this.instanceName = config.instanceName;
}
/**
* Establishes a connection to the broker, wrapping the adapter's connect
* method with logging.
* @returns {Promise<void>}
*/
async connect() {
this.logger.info('Connecting to broker...');
await this.adapter.connect();
this.logger.info('Successfully connected to broker.');
}
/**
* Disconnects from the broker, wrapping the adapter's disconnect method
* with logging.
* @returns {Promise<void>}
*/
async disconnect() {
this.logger.info('Disconnecting from broker...');
await this.adapter.disconnect();
this.logger.info('Successfully disconnected from broker.');
}
/**
* Publishes a message, automatically injecting the current `correlation-id`
* from the active context into the message headers.
* @param {string} topic - The destination topic or routing key for the message.
* @param {BrokerMessage} message - The message to be published. The `correlation-id`
* will be added to its headers if not present.
* @returns {Promise<void>}
*/
async publish(topic, message) {
if (!message.headers) {
message.headers = {};
}
// Get current correlation ID from the active context (only if it exists, don't generate new)
const currentCorrelationId = (this.contextManager.get(this.contextManager.getCorrelationIdHeaderName()) || this.contextManager.get('correlationId'));
// 1. Inject context into headers based on the configuration.
if (this.config.propagate?.includes('*')) {
// Wildcard behavior: Propagate the entire context map.
const contextObject = this.contextManager.getAll();
for (const key in contextObject) {
if (Object.prototype.hasOwnProperty.call(contextObject, key)) {
const value = contextObject[key];
if (typeof value === 'string' || Buffer.isBuffer(value)) {
message.headers[key] = value;
}
}
}
}
else if (this.config.propagate && Array.isArray(this.config.propagate)) {
// New behavior: Propagate only specified context keys.
for (const key of this.config.propagate) {
const value = this.contextManager.get(key);
if (typeof value === 'string' || Buffer.isBuffer(value)) {
message.headers[key] = value;
}
}
}
else if (this.config.propagateFullContext) {
// DEPRECATED: Propagate the entire context map.
const contextObject = this.contextManager.getAll();
for (const key in contextObject) {
if (Object.prototype.hasOwnProperty.call(contextObject, key)) {
const value = contextObject[key];
if (typeof value === 'string' || Buffer.isBuffer(value)) {
// Note: Broker headers typically support string | Buffer.
message.headers[key] = value;
}
}
}
}
// Only propagate correlation ID if it exists in the context (don't generate new)
if (currentCorrelationId) {
message.headers[this.contextManager.getCorrelationIdHeaderName()] =
currentCorrelationId;
}
const transactionId = this.contextManager.getTransactionId();
if (transactionId) {
message.headers[this.contextManager.getTransactionIdHeaderName()] =
transactionId;
}
this.logger.info({
topic,
messageId: message.headers?.['id'] instanceof Buffer
? message.headers?.['id'].toString()
: message.headers?.['id'],
correlationId: currentCorrelationId, // Log the correlation ID being used
}, 'Publishing message...');
await this.adapter.publish(topic, message);
this.logger.info({
topic,
messageId: message.headers?.['id'] instanceof Buffer
? message.headers?.['id'].toString()
: message.headers?.['id'],
correlationId: currentCorrelationId, // Log the correlation ID being used
}, 'Message published successfully.');
}
/**
* Subscribes to a topic. It wraps the user's message handler to automatically
* create a new asynchronous context for each incoming message. If a `correlation-id`
* is found in the message headers, it is used to initialize the new context.
* @param {string} topic - The topic or queue to subscribe to.
* @param {MessageHandler} handler - The user-provided function to process incoming messages.
* @returns {Promise<void>}
*/
async subscribe(topic, handler) {
this.logger.info({ topic }, 'Subscribing to topic...');
// Wrap the user's handler to implement automatic context propagation.
const instrumentedHandler = async (message, controls) => {
// Get correlation ID from message headers first
const messageCorrelationId = message.headers?.[this.contextManager.getCorrelationIdHeaderName()];
// Get current correlation ID from context (but don't generate new one if not exists)
const currentCorrelationId = this.contextManager.get(this.contextManager.getCorrelationIdHeaderName());
// If message has different correlation ID, restore context from message
if (messageCorrelationId &&
messageCorrelationId !== currentCorrelationId) {
await this.contextManager.run(async () => {
if (message.headers) {
for (const key in message.headers) {
this.contextManager.set(key, message.headers[key]);
}
}
// Use the message correlation ID for logging instead of generating a new one
const correlationId = messageCorrelationId;
this.logger.info({ topic, correlationId }, 'Received message.');
// Also wrap the lifecycle controls to add logging for ack/nack actions.
const instrumentedControls = {
ack: async () => {
await controls.ack();
this.logger.debug({ topic, correlationId }, 'Message acknowledged (ack).');
},
nack: async (requeue) => {
await controls.nack(requeue);
this.logger.warn({ topic, correlationId, requeue }, 'Message negatively acknowledged (nack).');
},
};
// Execute the original user-provided handler.
await handler(message, instrumentedControls);
});
}
else {
// Use current context, just set message headers if needed
if (message.headers) {
for (const key in message.headers) {
this.contextManager.set(key, message.headers[key]);
}
}
// Use the message correlation ID if available, otherwise use current context (but don't generate new one)
const correlationId = messageCorrelationId ||
this.contextManager.get(this.contextManager.getCorrelationIdHeaderName());
this.logger.info({ topic, correlationId }, 'Received message.');
// Also wrap the lifecycle controls to add logging for ack/nack actions.
const instrumentedControls = {
ack: async () => {
await controls.ack();
this.logger.debug({ topic, correlationId }, 'Message acknowledged (ack).');
},
nack: async (requeue) => {
await controls.nack(requeue);
this.logger.warn({ topic, correlationId, requeue }, 'Message negatively acknowledged (nack).');
},
};
// Execute the original user-provided handler.
await handler(message, instrumentedControls);
}
};
await this.adapter.subscribe(topic, instrumentedHandler);
this.logger.info({ topic }, 'Successfully subscribed to topic.');
}
}
/**
* FILE: src/brokers/BrokerManager.ts
* DESCRIPTION:
* Manages the lifecycle and creation of multiple instrumented broker client instances,
* following the same pattern as HttpManager and RedisManager.
*/
/**
* @class BrokerManager
* @description Manages the lifecycle and creation of multiple instrumented broker client instances.
* It reads the configuration, creates an `InstrumentedBrokerClient` for each defined
* instance, and provides a way to retrieve them and shut them down gracefully.
*/
class BrokerManager {
constructor(config, logger, contextManager) {
this.instances = new Map();
this.config = config;
this.logger = logger.child({ module: 'BrokerManager' });
this.contextManager = contextManager;
}
async init() {
this.logger.trace('Initializing BrokerManager...');
if (!this.config.instances || this.config.instances.length === 0) {
this.logger.debug('BrokerManager initialized, but no broker instances were defined.');
return;
}
const creationPromises = this.config.instances.map(async (instanceConfig) => {
try {
const client = new InstrumentedBrokerClient(instanceConfig.adapter, this.logger, this.contextManager, instanceConfig);
await client.connect(); // Connect is likely async
this.instances.set(instanceConfig.instanceName, client);
this.logger.info(`Broker client instance "${instanceConfig.instanceName}" created and connected successfully.`);
if (instanceConfig.instanceName === this.config.default) {
this.logger.trace(`Setting default broker instance: ${instanceConfig.instanceName}`);
this.defaultInstance = client;
}
}
catch (error) {
this.logger.error(`Failed to create broker instance "${instanceConfig.instanceName}":`, errorToJsonValue(error));
}
});
await Promise.all(creationPromises);
if (!this.defaultInstance && this.instances.size > 0) {
const firstInstance = this.instances.values().next().value;
const firstName = this.instances.keys().next().value;
this.logger.trace(`No default broker instance configured. Using first available instance: ${firstName}`);
this.defaultInstance = firstInstance;
}
}
getInstance(name) {
const instanceName = name ?? this.defaultInstance?.instanceName;
if (!instanceName) {
throw new Error('A specific instance name was not provided and no default Broker instance is configured.');
}
const instance = this.instances.get(instanceName);
if (!instance) {
throw new Error(`Broker client instance with name "${instanceName}" was not found. Check your configuration.`);
}
return instance;
}
async shutdown() {
this.logger.info('Disconnecting all broker clients...');
const shutdownPromises = Array.from(this.instances.values()).map((instance) => instance.disconnect());
await Promise.allSettled(shutdownPromises);
}
}
class LifecycleManager extends EventEmitter {
constructor(syntropyFacade) {
super();
this.state = 'NOT_INITIALIZED';
this.logger = null;
this.syntropyFacade = syntropyFacade;
// Initialize properties here to satisfy TypeScript's strict checks
this.config = {};
this.serializerRegistry = new SerializerRegistry({});
this.maskingEngine = new MaskingEngine({});
}
getState() {
return this.state;
}
async init(config) {
if (this.state !== 'NOT_INITIALIZED') {
this.logger?.warn(`LifecycleManager.init() called while in state '${this.state}'. Ignoring subsequent call.`);
return;
}
this.state = 'INITIALIZING';
try {
const parsedConfig = syntropyLogConfigSchema.parse(config);
const sanitizedConfig = sanitizeConfig(parsedConfig);
this.config = sanitizedConfig;
this.contextManager = new ContextManager(this.config.loggingMatrix);
if (this.config.context) {
this.contextManager.configure(this.config.context);
}
this.serializerRegistry = new SerializerRegistry({
serializers: this.config.logger?.serializers,
timeoutMs: this.config.logger?.serializerTimeoutMs,
});
this.maskingEngine = new MaskingEngine({
rules: this.config.masking?.rules,
maskChar: this.config.masking?.maskChar,
preserveLength: this.config.masking?.preserveLength,
enableDefaultRules: this.config.masking?.enableDefaultRules !== false,
});
this.loggerFactory = new LoggerFactory(this.config, this.contextManager, this.syntropyFacade);
const logger = this.loggerFactory.getLogger('syntropylog-main');
this.logger = logger;
if (this.config.redis) {
try {
const { RedisManager } = await Promise.resolve().then(function () { return RedisManager$1; });
this.redisManager = new RedisManager(this.config.redis, logger.withSource('redis-manager'), this.contextManager);
this.redisManager.init();
}
catch (error) {
logger.error('Failed to initialize Redis manager. Make sure redis package is installed.', { error: errorToJsonValue(error) });
}
}
if (this.config.http) {
this.httpManager = new HttpManager(this.config.http, logger.withSource('http-manager'), this.contextManager);
this.httpManager.init();
}
if (this.config.brokers) {
this.brokerManager = new BrokerManager(this.config.brokers, logger.withSource('broker-manager'), this.contextManager);
await this.brokerManager.init();
}
logger.info('SyntropyLog framework initialized successfully.');
this.state = 'READY';
this.emit('ready');
}
catch (error) {
this.state = 'ERROR';
this.emit('error', error);
if (error instanceof ZodError) {
console.error('[SyntropyLog] Configuration validation failed:', error.errors);
}
else {
console.error('[SyntropyLog] Failed to initialize framework:', error);
}
throw error;
}
}
async shutdown() {
this.logger?.info(`🔄 LifecycleManager.shutdown() called. Current state: ${this.state}`);
if (this.state !== 'READY') {
this.logger?.warn(`❌ Cannot perform shutdown. Current state: ${this.state}`);
return;
}
this.state = 'SHUTTING_DOWN';
this.emit('shutting_down');
this.logger?.info('🔄 State changed to SHUTTING_DOWN');
try {
this.logger?.info('Shutting down SyntropyLog framework...');
const shutdownPromises = [
this.redisManager?.shutdown(),
this.brokerManager?.shutdown(),
this.httpManager?.shutdown(),
this.loggerFactory?.shutdown?.(),
].filter(Boolean);
this.logger?.info(`📋 Executing ${shutdownPromises.length} shutdown promises...`);
await Promise.allSettled(shutdownPromises);
this.logger?.info('✅ Shutdown promises completed');
// Terminate external processes that might keep the process active
this.logger?.info('🔍 Starting external process termination...');
await this.terminateExternalProcesses();
this.logger?.info('All managers have been shut down.');
this.state = 'SHUTDOWN';
this.emit('shutdown');
this.logger?.info('✅ State changed to SHUTDOWN');
}
catch (error) {
this.state = 'ERROR';
this.emit('error', error);
this.logger?.error('❌ Error during shutdown:', {
error: errorToJsonValue(error),
});
}
}
/**
* Terminates external processes that might keep the Node.js process active.
* This includes regex-test workers and other child processes.
*/
async terminateExternalProcesses() {
try {
this.logger?.info('🔍 Starting external process termination...');
// Get all active handles
const activeHandles = process._getActiveHandles?.() || [];
this.logger?.debug(`Total active handles: ${activeHandles.length}`);
// Filter child processes that need to be terminated
const childProcesses = activeHandles.filter((handle) => {
const isChildProcess = handle.constructor.name === 'ChildProcess';
const isConnected = handle.connected;
const hasRegexTest = handle.spawnargs?.some((arg) => arg.includes('regex-test'));
this.logger?.debug(`Handle: ${handle.constructor.name}, connected: ${isConnected}, hasRegexTest: ${hasRegexTest}`);
return isChildProcess && isConnected && hasRegexTest;
});
this.logger?.info(`Found ${childProcesses.length} regex-test processes to terminate`);
if (childProcesses.length > 0) {
this.logger?.info(`Terminating ${childProcesses.length} external processes...`);
// Terminate each child process directly with SIGKILL for maximum effectiveness
for (const childProcess of childProcesses) {
try {
this.logger?.debug(`Terminating process ${childProcess.pid} with SIGKILL...`);
childProcess.kill('SIGKILL');
this.logger?.debug(`Process ${childProcess.pid} terminated with SIGKILL`);
}
catch (error) {
this.logger?.warn(`Error terminating process ${childProcess.pid}:`, { error: errorToJsonValue(error) });
}
}
// Wait a bit for processes to terminate
this.logger?.debug('Waiting 200ms for processes to terminate...');
await new Promise((resolve) => setTimeout(resolve, 200));
// Check if processes are still active
const remainingHandles = process._getActiveHandles?.() || [];
const remainingChildProcesses = remainingHandles.filter((handle) => handle.constructor.name === 'ChildProcess' &&
handle.connected &&
handle.spawnargs?.some((arg) => arg.includes('regex-test')));
if (remainingChildProcesses.length > 0) {
this.logger?.warn(`${remainingChildProcesses.length} regex-test processes still active after SIGKILL`);
// Try to disconnect the processes
for (const childProcess of remainingChildProcesses) {
try {
childProcess.disconnect();
this.logger?.debug(`Process ${childProcess.pid} disconnected`);
}
catch (error) {
this.logger?.warn(`Error disconnecting process ${childProcess.pid}:`, { error: errorToJsonValue(error) });
}
}
}
else {
this.logger?.info('✅ All regex-test processes terminated successfully');
}
}
else {
this.logger?.info('No regex-test processes found to terminate');
}
}
catch (error) {
this.logger?.warn('Error terminating external processes:', {
error: errorToJsonValue(error),
});
}
}
ensureReady() {
if (this.state !== 'READY') {
throw new Error(`SyntropyLog is not ready. Current state: '${this.state}'. Ensure init() has completed successfully by listening for the 'ready' event.`);
}
}
}
/**
* @file src/SyntropyLog.ts
* @description The main public-facing singleton class for the SyntropyLog framework.
* This class acts as a Facade, providing a simple and clean API surface
* while delegating all complex lifecycle and orchestration work to the internal
* LifecycleManager.
*/
/**
* @class SyntropyLog
* @description The main public entry point for the framework. It follows the
* Singleton pattern and acts as an EventEmitter to report on its lifecycle,
* proxying events from its internal LifecycleManager.
*/
class SyntropyLog extends EventEmitter {
constructor() {
super();
this.lifecycleManager = new LifecycleManager(this);
// Proxy events from the lifecycle manager to the public facade
this.lifecycleManager.on('ready', () => this.emit('ready'));
this.lifecycleManager.on('error', (err) => this.emit('error', err));
this.lifecycleManager.on('shutting_down', () => this.emit('shutting_down'));
this.lifecycleManager.on('shutdown', () => this.emit('shutdown'));
}
static getInstance() {
if (!SyntropyLog.instance) {
SyntropyLog.instance = new SyntropyLog();
}
return SyntropyLog.instance;
}
getState() {
return this.lifecycleManager.getState();
}
async init(config) {
return this.lifecycleManager.init(config);
}
async shutdown() {
return this.lifecycleManager.shutdown();
}
getLogger(name = 'default', bindings) {
if (!this.lifecycleManager.loggerFactory) {
throw new Error('Logger Factory not available.');
}
return this.lifecycleManager.loggerFactory.getLogger(name, bindings);
}
async getRedis(name) {
this.lifecycleManager.ensureReady();
if (!this.lifecycleManager.redisManager) {
throw new Error('Redis manager not available. Make sure Redis is configured and redis package is installed.');
}
return this.lifecycleManager.redisManager.getInstance(name);
}
getHttp(name) {
this.lifecycleManager.ensureReady();
return this.lifecycleManager.httpManager.getInstance(name);
}
getBroker(name) {
this.lifecycleManager.ensureReady();
return this.lifecycleManager.brokerManager.getInstance(name);
}
getContextManager() {
this.lifecycleManager.ensureReady();
return this.lifecycleManager.contextManager;
}
getConfig() {
this.lifecycleManager.ensureReady();
return this.lifecycleManager.config;
}
getFilteredContext(level) {
this.lifecycleManager.ensureReady();
return this.lifecycleManager.contextManager.getFilteredContext(level);
}
/**
* 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 matrix The new logging matrix configuration
*/
reconfigureLoggingMatrix(matrix) {
this.lifecycleManager.ensureReady();
this.lifecycleManager.contextManager.reconfigureLoggingMatrix(matrix);
}
getMasker() {
if (!this.lifecycleManager.maskingEngine) {
throw new Error('MaskingEngine not available.');
}
return this.lifecycleManager.maskingEngine;
}
getSerializer() {
if (!this.lifecycleManager.serializerRegistry) {
throw new Error('SerializerRegistry not available.');
}
return this.lifecycleManager.serializerRegistry;
}
_resetForTesting() {
// This needs to re-create the lifecycle manager to properly reset state
this.lifecycleManager.removeAllListeners();
this.lifecycleManager = new LifecycleManager(this);
this.removeAllListeners();
this.lifecycleManager.on('ready', () => this.emit('ready'));
this.lifecycleManager.on('error', (err) => this.emit('error', err));
this.lifecycleManager.on('shutting_down', () => this.emit('shutting_down'));
this.lifecycleManager.on('shutdown', () => this.emit('shutdown'));
}
}
/** The singleton instance of the SyntropyLog framework. */
const syntropyLog = SyntropyLog.getInstance();
/**
* @file src/logger/transports/BaseConsolePrettyTransport.ts
* @description An abstract base class for console transports that provide colored, human-readable output.
*/
/**
* @class BaseConsolePrettyTransport
* @description Provides common functionality for "pretty" console transports,
* including color handling and console method selection. Subclasses must
* implement the `formatLogString` method to define the final output format.
* @extends {Transport}
*/
class BaseConsolePrettyTransport extends Transport {
constructor(options) {
super(options);
// Chalk v4 is used directly, not instantiated.
this.chalk = chalk;
}
/**
* The core log method. It handles common logic and delegates specific
* formatting to the subclass.
* @param {LogEntry} entry - The log entry to process.
* @returns {Promise<void>}
*/
async log(entry) {
if (!this.isLevelEnabled(entry.level)) {
return;
}
// Apply the formatter first if it exists.
const finalObject = this.formatter ? this.formatter.format(entry) : entry;
// Let the subclass format the final string.
const logString = this.formatLogString(finalObject);
// Select the appropriate console method based on the log level.
const consoleMethod = this.getConsoleMethod(finalObject.level);
consoleMethod(logString);
}
/**
* Determines which console method to use based on the log level.
* @param {LogLevel} level - The log level.
* @returns {Function} The corresponding console method (e.g., console.log).
*/
getConsoleMethod(level) {
switch (level) {
case 'fatal':
case 'error':
return console.error;
case 'warn':
return console.warn;
default:
return console.log;
}
}
}
/**
* @class PrettyConsoleTransport
* @description A transport that writes logs to the console in a human-readable, colorful format.
* Ideal for use in development environments.
* @extends {BaseConsolePrettyTransport}
*/
class PrettyConsoleTransport extends BaseConsolePrettyTransport {
/**
* @constructor
* @param {TransportOptions} [options] - Options for the transport, such as level or a formatter.
*/
constructor(options) {
super(options);
this.levelColorMap = {
fatal: this.chalk.bgRed.white.bold,
error: this.chalk.red.bold,
warn: this.chalk.yellow.bold,
info: this.chalk.blue.bold,
debug: this.chalk.green,
trace: this.chalk.gray,
};
}
/**
* Formats the log object into a pretty, human-readable string.
* @param {LogEntry} logObject - The log object to format.
* @returns {string} The formatted string.
*/
formatLogString(logObject) {
const { timestamp, level, service, message, ...rest } = logObject;
const colorizer = this.levelColorMap[level] ||
this.chalk.white;
// Format the main log line
const time = this.chalk.gray(new Date(timestamp).toLocaleTimeString());
const levelString = colorizer(`[${level.toUpperCase()}]`);
const serviceString = this.chalk.cyan(`(${service})`);
const messageText = message || '';
let logString = `${time} ${levelString} ${serviceString}: ${messageText}`;
// Handle additional metadata, ensuring it's not empty
const metaKeys = Object.keys(rest);
if (metaKeys.length > 0) {
// Use a more subtle color for metadata
const metaString = this.chalk.gray(JSON.stringify(rest, null, 2));
logString += `\n${metaString}`;
}
return logString;
}
}
/**
* @class CompactConsoleTransport
* A transport that writes logs to the console in a compact, single-line format
* for metadata, optimized for developer productivity.
* @extends {BaseConsolePrettyTransport}
*/
class CompactConsoleTransport extends BaseConsolePrettyTransport {
/**
* @constructor
* @param {TransportOptions} [options] - Options for the transport, such as level or a formatter.
*/
constructor(options) {
super(options);
this.levelColorMap = {
fatal: this.chalk.bgRed.white.bold,
error: this.chalk.red.bold,
warn: this.chalk.yellow.bold,
info: this.chalk.cyan.bold, // Using cyan for better contrast in compact view.
debug: this.chalk.green,
trace: this.chalk.gray,
};
}
/**
* Formats the log object into a compact, human-readable string.
* @param {LogEntry} logObject - The log object to format.
* @returns {string} The formatted string.
*/
formatLogString(logObject) {
const { timestamp, level, service, message, ...rest } = logObject;
const colorizer = this.levelColorMap[level] ||
this.chalk.white;
const time = this.chalk.gray(new Date(timestamp).toLocaleTimeString());
const levelString = colorizer(`[${level.toUpperCase()}]`);
const serviceString = this.chalk.blue(`(${service})`);
const messageText = message || '';
let logString = `${time} ${levelString} ${serviceString}: ${messageText}`;
// Format metadata into a single, compact line.
const metaKeys = Object.keys(rest);
if (metaKeys.length > 0) {
const metaString = metaKeys
.map((key) => {
const value = rest[key];
// Simple stringify for objects/arrays in metadata.
const formattedValue = typeof value === 'object' && value !== null
? JSON.stringify(value)
: value;
return `${this.chalk.dim(key)}=${this.chalk.gray(formattedValue)}`;
})
.join(' ');
// Append metadata on a new, indented line for clarity.
logString += `\n ${this.chalk.dim('└─')} ${metaString}`;
}
return logString;
}
}
/**
* @class ClassicConsoleTransport
* A transport that writes logs to the console in a classic single-line format,
* reminiscent of traditional Java logging frameworks.
* @extends {BaseConsolePrettyTransport}
*/
class ClassicConsoleTransport extends BaseConsolePrettyTransport {
/**
* @constructor
* @param {TransportOptions} [options] - Options for the transport, such as level or a formatter.
*/
constructor(options) {
super(options);
this.levelColorMap = {
fatal: this.chalk.bgRed.white.bold,
error: this.chalk.red.bold,
warn: this.chalk.yellow.bold,
info: this.chalk.green, // Using green for info in this style
debug: this.chalk.blue,
trace: this.chalk.gray,
};
}
/**
* @private
* Formats a date object into a 'YYYY-MM-DD HH:mm:ss' string.
* @param {string} ts - The ISO timestamp string to format.
* @returns {string} The formatted timestamp.
*/
formatTimestamp(ts) {
const date = new Date(ts);
const YYYY = date.getFullYear();
const MM = String(date.getMonth() + 1).padStart(2, '0');
const DD = String(date.getDate()).padStart(2, '0');
const HH = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${YYYY}-${MM}-${DD} ${HH}:${min}:${ss}`;
}
/**
* Formats the log object into a classic, single-line string.
* @param {LogEntry} logObject - The log object to format.
* @returns {string} The formatted string.
*/
formatLogString(logObject) {
const { timestamp, level, service, message, context, ...rest } = logObject;
const colorizer = this.levelColorMap[level] ||
this.chalk.white;
// 1. Format the timestamp.
const timeStr = this.formatTimestamp(timestamp);
// 2. Format the level, padded to a fixed width for alignment.
const levelStr = colorizer(level.toUpperCase().padEnd(5));
// 3. Format the service name.
const serviceStr = this.chalk.magenta(`[${service}]`);
// 4. Combine context, other metadata, and message, then format it.
const allMeta = {
...(context || {}),
...rest,
message,
};
const metaKeys = Object.keys(allMeta);
let metaStr = '';
if (metaKeys.length > 0) {
metaStr = this.chalk.dim(' [' +
metaKeys
.map((key) => `${key}=${JSON.stringify(allMeta[key])}`)
.join(' ') +
']');
}
// 5. Assemble the final string.
const logString = `${timeStr} ${levelStr} ${serviceStr}${metaStr}`;
return logString;
}
}
/**
* @class SpyTransport
* A transport designed for testing. It captures log entries in memory,
* allowing you to make assertions on what has been logged.
* @extends {Transport}
*/
class SpyTransport extends Transport {
/**
* @constructor
* @param {TransportOptions} [options] - Options for the transport, such as level.
*/
constructor(options) {
super(options);
this.entries = [];
}
/**
* Stores the log entry in an in-memory array.
* @param {LogEntry} entry - The log entry to capture.
* @returns {Promise<void>}
*/
async log(entry) {
this.entries.push(entry);
}
/**
* Returns all log entries captured by this transport.
* @returns {LogEntry[]} A copy of all captured log entries.
*/
getEntries() {
return [...this.entries];
}
/**
* Finds log entries where the properties match the given predicate.
* Note: This performs a shallow comparison on the entry's properties.
* @param {Partial<LogEntry> | ((entry: LogEntry) => boolean)} predicate - An object with properties to match or a function that returns true for matching entries.
* @returns {LogEntry[]} An array of matching log entries.
*/
findEntries(predicate) {
if (typeof predicate === 'function') {
// If the predicate is a function, use it directly with filter.
return this.entries.filter(predicate);
}
// If the predicate is an object, perform a shallow property comparison.
return this.entries.filter((entry) => {
return Object.keys(predicate).every((key) => {
const k = key;
return predicate[k] === entry[k];
});
});
}
/**
* Clears all captured log entries. Call this in your test setup
* (e.g., `beforeEach`) to ensure test isolation.
* @returns {void}
*/
clear() {
this.entries = [];
}
/**
* Returns the first log entry that was captured.
* @returns {LogEntry | undefined} The first entry, or undefined if none were captured.
*/
getFirstEntry() {
return this.entries[0];
}
/**
* Returns the most recent log entry that was captured.
* @returns {LogEntry | undefined} The last entry, or undefined if none were captured.
*/
getLastEntry() {
return this.entries[this.entries.length - 1];
}
}
/**
* MockBrokerAdapter - Framework Agnostic Mock
*
* This mock provides a testing-agnostic version of IBrokerAdapter
* that can be used with both Vitest and Jest without conflicts.
*/
/**
* Creates a simple agnostic mock function without spy capabilities
*/
class MockBrokerAdapter {
constructor(spyFn) {
this.spyFn = null;
this.errors = new Map();
this.timeouts = new Map();
this.spyFn = spyFn || null;
// Initialize mocks after spyFn is set
this.connect = this.createMock().mockImplementation(async () => {
// Check for timeout first
if (this.timeouts.has('connect')) {
await new Promise((resolve) => setTimeout(resolve, this.timeouts.get('connect') + 10));
throw new Error(`Mock broker timed out after ${this.timeouts.get('connect')}ms`);
}
// Check for error simulation
if (this.errors.has('connect')) {
throw this.errors.get('connect');
}
return undefined;
});
this.disconnect = this.createMock().mockImplementation(async () => {
if (this.timeouts.has('disconnect')) {
await new Promise((resolve) => setTimeout(resolve, this.timeouts.get('disconnect') + 10));
throw new Error(`Mock broker timed out after ${this.timeouts.get('disconnect')}ms`);
}
if (this.errors.has('disconnect')) {
throw this.errors.get('disconnect');
}
return undefined;
});
this.publish = this.createMock().mockImplementation(async (topic, message) => {
if (this.timeouts.has('publish')) {
await new Promise((resolve) => setTimeout(resolve, this.timeouts.get('publish') + 10));
throw new Error(`Mock broker timed out after ${this.timeouts.get('publish')}ms`);
}
if (this.errors.has('publish')) {
throw this.errors.get('publish');
}
return undefined;
});
this.subscribe = this.createMock().mockImplementation(async (topic, handler) => {
if (this.timeouts.has('subscribe')) {
await new Promise((resolve) => setTimeout(resolve, this.timeouts.get('subscribe') + 10));
throw new Error(`Mock broker timed out after ${this.timeouts.get('subscribe')}ms`);
}
if (this.errors.has('subscribe')) {
throw this.errors.get('subscribe');
}
return undefined;
});
this.setError = this.createMock().mockImplementation((method, error) => {
this.errors.set(method, error);
});
this.setTimeout = this.createMock().mockImplementation((method, timeoutMs) => {
this.timeouts.set(method, timeoutMs);
});
this.reset = this.createMock().mockImplementation(() => {
this.errors.clear();
this.timeouts.clear();
this.connect.mockReset();
this.disconnect.mockReset();
this.publish.mockReset();
this.subscribe.mockReset();
// Restore default implementations
this.connect.mockImplementation(async () => undefined);
this.disconnect.mockImplementation(async () => undefined);
this.publish.mockImplementation(async () => undefined);
this.subscribe.mockImplementation(async () => undefined);
});
}
createMock(implementation) {
if (!this.spyFn) {
throw new Error(`
🚨 SPY FUNCTION NOT INJECTED! 😡
To use spy functions like toHaveBeenCalled(), toHaveBeenCalledWith(), etc.
YOU MUST inject your spy function in the constructor:
// For Vitest:
const mockBroker = new MockBrokerAdapter(vi.fn);
// For Jest:
const mockBroker = new MockBrokerAdapter(jest.fn);
// For Jasmine:
const mockBroker = new MockBrokerAdapter(jasmine.createSpy);
// Without spy (basic functionality only):
const mockBroker = new MockBrokerAdapter();
DON'T FORGET AGAIN! 😤
`);
}
return this.spyFn(implementation);
}
}
/**
* MockHttpClient - Framework Agnostic Mock
*
* This mock provides a testing-agnostic version of IHttpClientAdapter
* that can be used with both Vitest and Jest without conflicts.
*/
/**
* Creates a simple agnostic mock function without spy capabilities
*/
class MockHttpClient {
constructor(spyFn) {
this.spyFn = null;
this.timeouts = new Map();
this.spyFn = spyFn || null;
// Initialize mocks after spyFn is set
this.request = this.createMock().mockImplementation(async (request) => {
// Default successful response
return {
statusCode: 200,
data: { message: 'Mock response' },
headers: { 'content-type': 'application/json' },
};
});
this.get = this.createMock().mockImplementation(async (url, headers) => {
return this.request({
url,
method: 'GET',
headers: headers || {},
});
});
this.post = this.createMock().mockImplementation(async (url, body, headers) => {
return this.request({
url,
method: 'POST',
headers: headers || {},
body,
});
});
this.put = this.createMock().mockImplementation(async (url, body, headers) => {
return this.request({
url,
method: 'PUT',
headers: headers || {},
body,
});
});
this.delete = this.createMock().mockImplementation(async (url, headers) => {
return this.request({
url,
method: 'DELETE',
headers: headers || {},
});
});
this.patch = this.createMock().mockImplementation(async (url, body, headers) => {
return this.request({
url,
method: 'PATCH',
headers: headers || {},
body,
});
});
// Initialize method implementations
this.updateMethodImplementations();
this.setResponse = this.createMock().mockImplementation((method, response) => {
// Configure the request method to return the specified response
this.request.mockImplementation(async (req) => {
if (req.method.toUpperCase() === method.toUpperCase()) {
return response;
}
// Default response for other methods
return {
statusCode: 200,
data: { message: 'Mock response' },
headers: { 'content-type': 'application/json' },
};
});
// Also update individual method implementations
this.updateMethodImplementations();
});
this.setError = this.createMock().mockImplementation((method, error) => {
// Configure the request method to throw the specified error
this.request.mockImplementation(async (req) => {
if (req.method.toUpperCase() === method.toUpperCase()) {
const adapterError = {
name: error.name,
message: error.message,
stack: error.stack,
request: req,
isAdapterError: true,
};
throw adapterError;
}
// Default response for other methods
return {
statusCode: 200,
data: { message: 'Mock response' },
headers: { 'content-type': 'application/json' },
};
});
// Also update individual method implementations
this.updateMethodImplementations();
});
this.setTimeout = this.createMock().mockImplementation((method, timeoutMs) => {
this.timeouts.set(method, timeoutMs);
// Configure the request method to timeout
this.request.mockImplementation(async (req) => {
if (req.method.toUpperCase() === method.toUpperCase() &&
this.timeouts.has(method)) {
await new Promise((resolve) => setTimeout(resolve, this.timeouts.get(method) + 10));
throw new Error(`Mock HTTP client timed out after ${this.timeouts.get(method)}ms`);
}
// Default response for other methods
return {
statusCode: 200,
data: { message: 'Mock response' },
headers: { 'content-type': 'application/json' },
};
});
// Also update individual method implementations
this.updateMethodImplementations();
});
this.reset = this.createMock().mockImplementation(() => {
this.timeouts.clear();
this.request.mockReset();
this.get.mockReset();
this.post.mockReset();
this.put.mockReset();
this.delete.mockReset();
this.patch.mockReset();
// Restore default implementations
this.request.mockImplementation(async (request) => {
return {
statusCode: 200,
data: { message: 'Mock response' },
headers: { 'content-type': 'application/json' },
};
});
this.updateMethodImplementations();
});
}
updateMethodImplementations() {
this.get.mockImplementation(async (url, headers) => {
return this.request({
url,
method: 'GET',
headers: headers || {},
});
});
this.post.mockImplementation(async (url, body, headers) => {
return this.request({
url,
method: 'POST',
headers: headers || {},
body,
});
});
this.put.mockImplementation(async (url, body, headers) => {
return this.request({
url,
method: 'PUT',
headers: headers || {},
body,
});
});
this.delete.mockImplementation(async (url, headers) => {
return this.request({
url,
method: 'DELETE',
headers: headers || {},
});
});
this.patch.mockImplementation(async (url, body, headers) => {
return this.request({
url,
method: 'PATCH',
headers: headers || {},
body,
});
});
}
createMock(implementation) {
if (!this.spyFn) {
throw new Error(`
🚨 SPY FUNCTION NOT INJECTED! 😡
To use spy functions like toHaveBeenCalled(), toHaveBeenCalledWith(), etc.
YOU MUST inject your spy function in the constructor:
// For Vitest:
const mockHttp = new MockHttpClient(vi.fn);
// For Jest:
const mockHttp = new MockHttpClient(jest.fn);
// For Jasmine:
const mockHttp = new MockHttpClient(jasmine.createSpy);
// Without spy (basic functionality only):
const mockHttp = new MockHttpClient();
DON'T FORGET AGAIN! 😤
`);
}
return this.spyFn(implementation);
}
}
/**
* MockSerializerRegistry - Framework Agnostic Mock
*
* This mock provides a testing-agnostic version of SerializerRegistry
* that can be used with both Vitest and Jest without conflicts.
*/
/**
* Creates a simple agnostic mock function without spy capabilities
*/
class MockSerializerRegistry {
constructor(spyFn) {
// Internal state to track configured serializers
this.serializers = new Map();
this.errorKeys = new Set();
this.timeoutMs = null;
this.spyFn = null;
this.spyFn = spyFn || null;
// Initialize mocks after spyFn is set
this.process = this.createMock().mockImplementation(async (meta, logger) => {
// Check for timeout first
if (this.timeoutMs !== null) {
await new Promise((resolve) => setTimeout(resolve, this.timeoutMs + 10));
throw new Error(`Mock serializer timed out after ${this.timeoutMs}ms.`);
}
const processedMeta = { ...meta };
// Process each field with its configured serializer
for (const [key, value] of Object.entries(processedMeta)) {
// Check for error simulation
if (this.errorKeys.has(key)) {
throw new Error(`Mock error for key '${key}'`);
}
// Check for serializer
const serializer = this.serializers.get(key);
if (serializer) {
try {
processedMeta[key] = serializer(value);
}
catch (error) {
logger.warn(`Mock serializer for key "${key}" failed.`, {
error: error instanceof Error ? error.message : String(error),
});
processedMeta[key] =
`[MOCK_SERIALIZER_ERROR: Failed to process key '${key}']`;
}
}
}
return processedMeta;
});
this.setSerializer = this.createMock().mockImplementation((key, serializer) => {
this.serializers.set(key, serializer);
});
this.setError = this.createMock().mockImplementation((key, error) => {
this.errorKeys.add(key);
});
this.setTimeout = this.createMock().mockImplementation((timeoutMs) => {
this.timeoutMs = timeoutMs;
});
this.reset = this.createMock().mockImplementation(() => {
this.serializers.clear();
this.errorKeys.clear();
this.timeoutMs = null;
this.process.mockReset();
this.process.mockImplementation(async (meta, logger) => {
return { ...meta };
});
});
}
createMock(implementation) {
if (!this.spyFn) {
throw new Error(`
🚨 SPY FUNCTION NOT INJECTED! 😡
To use spy functions like toHaveBeenCalled(), toHaveBeenCalledWith(), etc.
YOU MUST inject your spy function in the constructor:
// For Vitest:
const mockSerializer = new MockSerializerRegistry(vi.fn);
// For Jest:
const mockSerializer = new MockSerializerRegistry(jest.fn);
// For Jasmine:
const mockSerializer = new MockSerializerRegistry(jasmine.createSpy);
// Without spy (basic functionality only):
const mockSerializer = new MockSerializerRegistry();
DON'T FORGET AGAIN! 😤
`);
}
return this.spyFn(implementation);
}
}
/**
* FILE: src/redis/RedisConnectionManager.ts
* DESCRIPTION: Manages the lifecycle of the Redis client connection.
*/
// Type guard for single-node RedisClientType
function isRedisClientType(client) {
return (typeof client.ping === 'function' &&
!('commands' in client));
}
/**
* @class RedisConnectionManager
* Handles the state and lifecycle of a single native `node-redis` client.
* It abstracts away the complexities of connection states, retries, and events,
* providing a stable and predictable promise-based interface for connecting and disconnecting.
*/
class RedisConnectionManager {
/**
* Constructs a new RedisConnectionManager.
* @param {RedisClientOptions | RedisClusterOptions} options - The configuration options for the native `redis` client.
* @param {ILogger} logger - The logger instance for logging connection events.
*/
constructor(config, logger) {
this.connectionPromise = null;
this.connectionResolve = null;
this.connectionReject = null;
this.isConnectedAndReadyState = false;
this.isQuitState = false;
this.logger = logger;
this.instanceName = config.instanceName;
this.client = this.createNativeClient(config);
this.setupListeners();
}
/**
* Creates a native Redis client based on the instance configuration mode.
* @param config The configuration for the specific Redis instance.
* @returns A `NodeRedisClient` (either single-node or cluster).
*/
createNativeClient(config) {
switch (config.mode) {
case 'single':
case 'sentinel': {
// The reconnection strategy only applies to 'single' and 'sentinel' modes.
// It is defined here so TypeScript can correctly infer that 'config' has the 'retryOptions' property.
const reconnectStrategy = (retries) => {
const maxRetries = config.retryOptions?.maxRetries ?? 10;
if (retries > maxRetries) {
return new Error('Exceeded the maximum number of Redis connection retries.');
}
return Math.min(retries * 50, config.retryOptions?.retryDelay ?? 2000);
};
if (config.mode === 'single') {
return createClient({
url: config.url,
socket: {
reconnectStrategy,
},
});
}
else {
// An intermediate variable is created so that TypeScript correctly infers the overload.
const sentinelOptions = {
sentinels: config.sentinels,
name: config.name,
sentinelPassword: config.sentinelPassword,
socket: {
reconnectStrategy,
},
};
return createClient(sentinelOptions);
}
}
case 'cluster': {
// Reconnection in cluster mode is handled internally by the library.
// The variable is explicitly typed so that TypeScript uses the correct overload of `createClient`.
const clusterOptions = {
// Transforms the node configuration to the structure expected by the 'redis' library.
rootNodes: config.rootNodes.map((node) => ({
socket: { host: node.host, port: node.port },
})),
};
return createClient(clusterOptions);
}
default: {
const _exhaustiveCheck = config;
throw new Error(`Unsupported Redis mode: "${_exhaustiveCheck.mode}"`); // NOSONAR
}
}
}
/**
* Sets up all the necessary event listeners on the native Redis client
* to manage and report on the connection's lifecycle state.
* @private
*/
setupListeners() {
this.client.on('connect', () => this.logger.info(`Connection established.`));
this.client.on('ready', () => {
this.logger.info(`Client is ready.`);
this.isConnectedAndReadyState = true;
if (this.connectionResolve) {
this.connectionResolve();
this.connectionResolve = null;
this.connectionReject = null;
}
});
this.client.on('end', () => {
this.logger.warn(`Connection closed.`);
this.isConnectedAndReadyState = false;
});
this.client.on('error', (err) => {
this.logger.error(`Client Error.`, { error: err });
if (this.connectionReject) {
this.connectionReject(err);
this.connectionPromise = null;
this.connectionResolve = null;
this.connectionReject = null;
}
});
this.client.on('reconnecting', () => {
this.logger.info(`Client is reconnecting...`);
});
}
/**
* Initiates a connection to the Redis server.
* This method is idempotent; it will not attempt to reconnect if already connected
* or if a connection attempt is already in progress.
* @returns {Promise<void>} A promise that resolves when the client is connected and ready, or rejects on a connection error.
*/
connect() {
if (this.isQuitState) {
return Promise.reject(new Error('Client has been quit and cannot be reconnected.'));
}
if (this.isReady()) {
return Promise.resolve();
}
if (this.connectionPromise) {
return this.connectionPromise;
}
this.logger.info(`Attempting to connect...`);
this.connectionPromise = new Promise((resolve, reject) => {
this.connectionResolve = resolve;
this.connectionReject = reject;
this.client.connect().catch((err) => {
this.logger.error(`Immediate connection attempt failed.`, {
error: err,
});
if (this.connectionReject) {
this.connectionReject(err);
this.connectionPromise = null;
this.connectionResolve = null;
this.connectionReject = null;
}
});
});
return this.connectionPromise;
}
/**
* Ensures the client is connected and ready before proceeding.
* This is the primary method that should be awaited before executing a command.
* @returns {Promise<void>} A promise that resolves when the client is ready, or rejects if it can't connect.
*/
ensureReady() {
if (this.isQuitState) {
return Promise.reject(new Error('Client has been quit. Cannot execute commands.'));
}
if (!this.isReady() && !this.connectionPromise) {
this.logger.debug('ensureReady: Client not open, initiating connect.');
}
return this.connect();
}
/**
* Gracefully closes the connection to the Redis server by calling `quit()`.
* It also sets an internal state to prevent any further operations or reconnections.
* @returns {Promise<void>} A promise that resolves when the client has been successfully quit.
*/
async disconnect() {
if (this.isQuitState) {
this.logger.info('Quit already called. No action taken.');
return;
}
if (this.connectionReject) {
this.connectionReject(new Error('Connection aborted due to disconnect call.'));
this.connectionPromise = null;
this.connectionResolve = null;
this.connectionReject = null;
}
this.isQuitState = true;
this.isConnectedAndReadyState = false;
if (this.client.isOpen) {
this.logger.info('Attempting to quit client.');
try {
await this.client.quit();
}
catch (error) {
this.logger.error('Error during client.quit().', { error });
throw error;
}
}
else {
this.logger.info('Client was not open. Quit operation effectively complete.');
}
}
/**
* Retrieves the underlying native `node-redis` client instance.
* @returns {NodeRedisClient} The native client instance.
*/
getNativeClient() {
return this.client;
}
/**
* Checks if the client is currently connected and ready to accept commands.
* @returns {boolean} `true` if the client is ready, `false` otherwise.
*/
isReady() {
return this.isConnectedAndReadyState;
}
/**
* Performs a health check by sending a PING command to the server.
* @returns {Promise<boolean>} A promise that resolves to `true` if the server responds correctly, `false` otherwise.
*/
async isHealthy() {
if (this.isQuitState || !this.isReady()) {
return false;
}
try {
// By calling this.ping(), we reuse the logic that correctly handles
// single-node and cluster clients.
const pong = await this.ping();
this.logger.debug(`PING response: ${pong}`);
return pong === 'PONG';
}
catch (error) {
this.logger.error(`PING failed during health check.`, { error });
return false;
}
}
/**
* Checks if the disconnect (`quit`) process has been initiated for this client.
* @returns {boolean} `true` if `disconnect` has been called, `false` otherwise.
*/
isQuit() {
return this.isQuitState;
}
/**
* Executes the Redis PING command.
* Provides a fallback for cluster mode, as PING is not a standard cluster command.
*/
async ping(message) {
// First, we ensure the client is ready to receive commands.
await this.ensureReady();
// We use the type guard to check if it's a single-node or sentinel client.
if (isRedisClientType(this.client)) {
return this.client.ping(message);
}
// If it's a cluster client, we simulate the response as the library does.
return Promise.resolve(message || 'PONG');
}
/**
* Executes the Redis INFO command.
* Provides a fallback for cluster mode.
*/
async info(section) {
// We ensure the client is ready.
await this.ensureReady();
// Again, we use the type guard.
if (isRedisClientType(this.client)) {
return this.client.info(section);
}
// The INFO command does not exist in cluster mode.
return Promise.resolve('# INFO command is not supported in cluster mode.');
}
/**
* Executes the Redis EXISTS command.
* @param {string | string[]} keys - A single key or an array of keys to check.
* @returns {Promise<number>} A promise that resolves with the number of existing keys.
*/
async exists(keys) {
await this.ensureReady();
// The .exists() command is supported by both single-node and cluster clients.
return this.client.exists(keys);
}
/**
* Executes the Redis GET command.
* @param {string} key - The key to retrieve.
* @returns {Promise<string | null>} A promise that resolves with the value or null if not found.
*/
async get(key) {
await this.ensureReady();
return this.client.get(key);
}
/**
* Executes the Redis SET command.
* @param {string} key - The key to set.
* @param {string} value - The value to set.
* @param {number} [ttl] - Optional TTL in seconds.
* @returns {Promise<string>} A promise that resolves with 'OK' on success.
*/
async set(key, value, ttl) {
await this.ensureReady();
if (ttl) {
return this.client.setEx(key, ttl, value);
}
const result = await this.client.set(key, value);
return result || 'OK';
}
/**
* Executes the Redis DEL command.
* @param {string} key - The key to delete.
* @returns {Promise<number>} A promise that resolves with the number of keys deleted.
*/
async del(key) {
await this.ensureReady();
return this.client.del(key);
}
}
/**
* @file src/redis/BeaconRedis.ts
* @description Implementation of IBeaconRedis that wraps a native `redis` client.
* It centralizes command execution to add instrumentation (logging, metrics, etc.).
*/
/**
* The primary implementation of the `IBeaconRedis` interface.
* This class wraps a native `redis` client and uses a central logger
* to provide instrumentation for all commands. It delegates connection
* management and command execution to specialized classes.
* @implements {IBeaconRedis}
*/
class BeaconRedis {
/**
* Constructs a new BeaconRedis instance.
* @param {RedisInstanceConfig} config - The configuration specific to this Redis instance.
* @param {RedisConnectionManager} connectionManager - The manager for the client's connection lifecycle.
* @param {RedisCommandExecutor} commandExecutor - The executor for sending commands to Redis.
* @param {ILogger} logger - The pre-configured logger instance for this client.
*/
constructor(config, connectionManager, commandExecutor, logger) {
this.config = config;
this.logger = logger;
this.connectionManager = connectionManager;
this.commandExecutor = commandExecutor;
}
// --- Lifecycle and Management Methods ---
/**
* @inheritdoc
*/
getInstanceName() {
return this.config.instanceName;
}
/**
* @inheritdoc
*/
async connect() {
return this.connectionManager.ensureReady();
}
/**
* @inheritdoc
*/
async quit() {
return this.connectionManager.disconnect();
}
/**
* @inheritdoc
*/
updateConfig(newConfig) {
this.logger.info({ newConfig }, 'Dynamically updating Redis instance configuration...');
Object.assign(this.config, newConfig);
}
/**
* @inheritdoc
* @throws {Error} This method is not yet implemented.
*/
multi() {
// TODO: Implement a fully instrumented transaction class.
// This would need a more complex implementation to queue commands and log them on exec().
throw new Error('The multi() method is not yet implemented.');
}
/**
* A centralized method for executing and instrumenting any Redis command.
* It ensures the client is ready, executes the command, logs the outcome
* (success or failure) with timing information, and handles errors.
* @private
* @template T The expected return type of the command.
* @param {string} commandName - The name of the Redis command (e.g., 'GET', 'HSET').
* @param {() => Promise<T>} commandFn - A function that, when called, executes the native Redis command.
* @param {...RedisValue[]} params - The parameters passed to the original command, used for logging.
* @returns {Promise<T>} A promise that resolves with the result of the command.
* @throws The error from the native command is re-thrown after being logged.
*/
async _executeCommand(commandName, commandFn, ...params) {
const startTime = Date.now();
// Use a base logger with the source pre-set for this command.
const commandLogger = this.logger.withSource('redis');
try {
// 1. Ensure the client is connected and ready before executing.
await this.connectionManager.ensureReady();
// 2. Execute the command by calling the provided function.
const result = await commandFn();
const durationMs = Date.now() - startTime;
// 3. On success, log the execution details.
// Determine the log level from the instance's specific configuration.
const logLevel = this.config.logging?.onSuccess ?? 'debug';
const logPayload = {
command: commandName,
instance: this.getInstanceName(),
durationMs,
};
// Conditionally add command parameters and return value to the log payload.
if (this.config.logging?.logCommandValues) {
logPayload.params = params;
}
if (this.config.logging?.logReturnValue) {
logPayload.result = result;
}
// The log is sent to the central pipeline where serialization and masking occur.
commandLogger[logLevel](logPayload, `Redis command [${commandName}] executed successfully.`);
return result;
}
catch (error) {
const durationMs = Date.now() - startTime;
const errorLogLevel = this.config.logging?.onError ?? 'error';
// The error object will be serialized by the central SerializerRegistry.
commandLogger[errorLogLevel]({
command: commandName,
instance: this.getInstanceName(),
durationMs,
err: errorToJsonValue(error),
params: this.config.logging?.logCommandValues ? params : undefined,
}, `Redis command [${commandName}] failed.`);
throw error;
}
}
// --- Public Command Methods ---
// Each command now simply calls _executeCommand. The structure remains the same.
/**
* @inheritdoc
*/
async get(key) {
return this._executeCommand('GET', () => this.commandExecutor.get(key), key);
}
/**
* @inheritdoc
*/
async set(key, value, ttlSeconds) {
const options = ttlSeconds ? { EX: ttlSeconds } : undefined;
return this._executeCommand('SET', () => this.commandExecutor.set(key, value, options), key, value, ttlSeconds);
}
/**
* @inheritdoc
*/
async del(keys) {
return this._executeCommand('DEL', () => this.commandExecutor.del(keys), keys);
}
/**
* @inheritdoc
*/
async exists(keys) {
return this._executeCommand('EXISTS', () => this.commandExecutor.exists(keys), keys);
}
/**
* @inheritdoc
*/
async expire(key, seconds) {
return this._executeCommand('EXPIRE', () => this.commandExecutor.expire(key, seconds), key, seconds);
}
/**
* @inheritdoc
*/
async ttl(key) {
return this._executeCommand('TTL', () => this.commandExecutor.ttl(key), key);
}
/**
* @inheritdoc
*/
async incr(key) {
return this._executeCommand('INCR', () => this.commandExecutor.incr(key), key);
}
/**
* @inheritdoc
*/
async decr(key) {
return this._executeCommand('DECR', () => this.commandExecutor.decr(key), key);
}
/**
* @inheritdoc
*/
async incrBy(key, increment) {
return this._executeCommand('INCRBY', () => this.commandExecutor.incrBy(key, increment), key, increment);
}
/**
* @inheritdoc
*/
async decrBy(key, decrement) {
return this._executeCommand('DECRBY', () => this.commandExecutor.decrBy(key, decrement), key, decrement);
}
/**
* @inheritdoc
*/
async hGet(key, field) {
return this._executeCommand('HGET', async () => (await this.commandExecutor.hGet(key, field)) ?? null, key, field);
}
async hSet(key, fieldOrFields, value) {
if (typeof fieldOrFields === 'string') {
// Handle single field-value pair.
return this._executeCommand('HSET', () => this.commandExecutor.hSet(key, fieldOrFields, value), key, fieldOrFields, value);
}
// Handle object of field-value pairs.
return this._executeCommand('HSET', () => this.commandExecutor.hSet(key, fieldOrFields), key, fieldOrFields);
}
/**
* @inheritdoc
*/
async hGetAll(key) {
return this._executeCommand('HGETALL', () => this.commandExecutor.hGetAll(key), key);
}
/**
* @inheritdoc
*/
async hDel(key, fields) {
return this._executeCommand('HDEL', () => this.commandExecutor.hDel(key, fields), key, fields);
}
/**
* @inheritdoc
*/
async hExists(key, field) {
return this._executeCommand('HEXISTS', () => this.commandExecutor.hExists(key, field), key, field);
}
/**
* @inheritdoc
*/
async hIncrBy(key, field, increment) {
return this._executeCommand('HINCRBY', () => this.commandExecutor.hIncrBy(key, field, increment), key, field, increment);
}
async lPush(key, elementOrElements) {
return this._executeCommand('LPUSH', () => this.commandExecutor.lPush(key, elementOrElements), key, elementOrElements);
}
async rPush(key, elementOrElements) {
return this._executeCommand('RPUSH', () => this.commandExecutor.rPush(key, elementOrElements), key, elementOrElements);
}
/**
* @inheritdoc
*/
async lPop(key) {
return this._executeCommand('LPOP', () => this.commandExecutor.lPop(key), key);
}
/**
* @inheritdoc
*/
async rPop(key) {
return this._executeCommand('RPOP', () => this.commandExecutor.rPop(key), key);
}
/**
* @inheritdoc
*/
async lRange(key, start, stop) {
return this._executeCommand('LRANGE', () => this.commandExecutor.lRange(key, start, stop), key, start, stop);
}
/**
* @inheritdoc
*/
async lLen(key) {
return this._executeCommand('LLEN', () => this.commandExecutor.lLen(key), key);
}
/**
* @inheritdoc
*/
async lTrim(key, start, stop) {
return this._executeCommand('LTRIM', () => this.commandExecutor.lTrim(key, start, stop), key, start, stop);
}
async sAdd(key, memberOrMembers) {
return this._executeCommand('SADD', () => this.commandExecutor.sAdd(key, memberOrMembers), key, memberOrMembers);
}
/**
* @inheritdoc
*/
async sMembers(key) {
return this._executeCommand('SMEMBERS', () => this.commandExecutor.sMembers(key), key);
}
/**
* @inheritdoc
*/
async sIsMember(key, member) {
return this._executeCommand('SISMEMBER', () => this.commandExecutor.sIsMember(key, member), key, member);
}
async sRem(key, memberOrMembers) {
return this._executeCommand('SREM', () => this.commandExecutor.sRem(key, memberOrMembers), key, memberOrMembers);
}
/**
* @inheritdoc
*/
async sCard(key) {
return this._executeCommand('SCARD', () => this.commandExecutor.sCard(key), key);
}
async zAdd(key, scoreOrMembers, member) {
// Check if we are using the array overload for multiple members.
if (Array.isArray(scoreOrMembers)) {
return this._executeCommand('ZADD', () => this.commandExecutor.zAdd(key, scoreOrMembers), key, scoreOrMembers);
}
// Handle single score-member pair.
return this._executeCommand('ZADD', () => this.commandExecutor.zAdd(key, scoreOrMembers, member), key, scoreOrMembers, member);
}
/**
* @inheritdoc
*/
async zRange(key, min, max, options) {
return this._executeCommand('ZRANGE', () => this.commandExecutor.zRange(key, min, max, options), key, min, max, options);
}
/**
* @inheritdoc
*/
async zRangeWithScores(key, min, max, options) {
return this._executeCommand('ZRANGE_WITHSCORES', () => this.commandExecutor.zRangeWithScores(key, min, max, options), key, min, max, options);
}
/**
* @inheritdoc
*/
async zRem(key, members) {
return this._executeCommand('ZREM', () => this.commandExecutor.zRem(key, members), key, members);
}
/**
* @inheritdoc
*/
async zCard(key) {
return this._executeCommand('ZCARD', () => this.commandExecutor.zCard(key), key);
}
/**
* @inheritdoc
*/
async zScore(key, member) {
return this._executeCommand('ZSCORE', () => this.commandExecutor.zScore(key, member), key, member);
}
/**
* Subscribes the client to a channel to listen for messages.
* Note: This is a long-lived command. The initial subscription action is logged,
* but individual messages received by the listener are not logged by this wrapper.
* The listener itself should handle any required logging for received messages.
* @param {string} channel - The channel to subscribe to.
* @param {(message: string, channel: string) => void} listener - The function to call when a message is received.
* @returns {Promise<void>} A promise that resolves when the subscription is successful.
*/
async subscribe(channel, listener) {
return this._executeCommand('SUBSCRIBE', () => this.commandExecutor.subscribe(channel, listener), channel);
}
/**
* Unsubscribes the client from a channel, or all channels if none is specified.
* @param {string} [channel] - The optional channel to unsubscribe from.
* @returns {Promise<void>} A promise that resolves when the unsubscription is successful.
*/
async unsubscribe(channel) {
return this._executeCommand('UNSUBSCRIBE', () => this.commandExecutor.unsubscribe(channel), channel);
}
/**
* @inheritdoc
*/
async ping(message) {
return this._executeCommand('PING', () => this.connectionManager.ping(message), message);
}
/**
* @inheritdoc
*/
async info(section) {
return this._executeCommand('INFO', () => this.connectionManager.info(section), section);
}
/**
* Executes a Lua script on the server.
* @param {string} script - The Lua script to execute.
* @param {string[]} keys - An array of key names used by the script, accessible via the `KEYS` table in Lua.
* @param {string[]} args - An array of argument values for the script, accessible via the `ARGV` table in Lua.
* @returns {Promise<any>} A promise that resolves with the result of the script execution.
*/
async eval(script, keys, args) {
return this._executeCommand('EVAL', () => this.commandExecutor.eval(script, keys, args), script, keys, args);
}
}
/**
* @file src/redis/RedisCommandExecutor.ts
* @description A thin wrapper around the native `node-redis` client that directly executes commands.
* This class's sole responsibility is to pass commands to the underlying client.
* It does not contain any logic for instrumentation, connection management, or error handling.
*/
/**
* Executes Redis commands against a native `node-redis` client.
* This class acts as a direct pass-through to the client's methods,
* decoupling the command execution from the instrumentation and connection logic.
*/
class RedisCommandExecutor {
/**
* Constructs a new RedisCommandExecutor.
* @param {NodeRedisClient} client The native `node-redis` client (single-node or cluster) to execute commands on.
*/
constructor(client) {
this.client = client;
}
// --- String Commands ---
/**
* Executes the native GET command.
* @param {string} key The key to retrieve.
* @returns {Promise<string | null>} The value of the key, or null if it does not exist.
*/
get(key) {
return this.client.get(key);
}
/**
* Executes the native SET command.
* @param {string} key The key to set.
* @param {string} value The value to set.
* @param {RedisCommandOptions} [options] Optional SET options (e.g., EX, NX).
* @returns {Promise<string | null>} 'OK' if successful, or null.
*/
set(key, value, options) {
return this.client.set(key, value, options);
}
/**
* Executes the native DEL command.
* @param {string | string[]} keys The key or keys to delete.
* @returns {Promise<number>} The number of keys deleted.
*/
del(keys) {
return this.client.del(keys);
}
/**
* Executes the native EXISTS command.
* @param {string | string[]} keys The key or keys to check.
* @returns {Promise<number>} The number of keys that exist.
*/
exists(keys) {
return this.client.exists(keys);
}
/**
* Executes the native EXPIRE command.
* @param {string} key The key to set the expiration for.
* @param {number} seconds The time-to-live in seconds.
* @returns {Promise<boolean>} True if the timeout was set, false otherwise.
*/
expire(key, seconds) {
return this.client.expire(key, seconds);
}
/**
* Executes the native TTL command.
* @param {string} key The key to check.
* @returns {Promise<number>} The remaining time to live in seconds.
*/
ttl(key) {
return this.client.ttl(key);
}
/**
* Executes the native INCR command.
* @param {string} key The key to increment.
* @returns {Promise<number>} The value after the increment.
*/
incr(key) {
return this.client.incr(key);
}
/**
* Executes the native DECR command.
* @param {string} key The key to decrement.
* @returns {Promise<number>} The value after the decrement.
*/
decr(key) {
return this.client.decr(key);
}
/**
* Executes the native INCRBY command.
* @param {string} key The key to increment.
* @param {number} increment The amount to increment by.
* @returns {Promise<number>} The value after the increment.
*/
incrBy(key, increment) {
return this.client.incrBy(key, increment);
}
/**
* Executes the native DECRBY command.
* @param {string} key The key to decrement.
* @param {number} decrement The amount to decrement by.
* @returns {Promise<number>} The value after the decrement.
*/
decrBy(key, decrement) {
return this.client.decrBy(key, decrement);
}
// --- Hash Commands ---
/**
* Executes the native HGET command.
* @param {string} key The key of the hash.
* @param {string} field The field to retrieve.
* @returns {Promise<string | undefined>} The value of the field, or undefined if it does not exist.
*/
hGet(key, field) {
return this.client.hGet(key, field);
}
/**
* Executes the native HSET command.
* @param {string} key The key of the hash.
* @param {string | Record<string, RedisHashValue>} fieldOrFields The field to set or an object of field-value pairs.
* @param {RedisHashValue} [value] The value to set if a single field is provided.
* @returns {Promise<number>} The number of fields that were added.
*/
hSet(key, fieldOrFields, value) {
if (typeof fieldOrFields === 'string') {
return this.client.hSet(key, fieldOrFields, value);
}
// When fieldOrFields is an object, call the two-argument overload.
return this.client.hSet(key, fieldOrFields);
}
/**
* Executes the native HGETALL command.
* @param {string} key The key of the hash.
* @returns {Promise<Record<string, string>>} An object containing all fields and values.
*/
hGetAll(key) {
return this.client.hGetAll(key);
}
/**
* Executes the native HDEL command.
* @param {string} key The key of the hash.
* @param {string | string[]} fields The field or fields to delete.
* @returns {Promise<number>} The number of fields that were removed.
*/
hDel(key, fields) {
return this.client.hDel(key, fields);
}
/**
* Executes the native HEXISTS command.
* @param {string} key The key of the hash.
* @param {string} field The field to check.
* @returns {Promise<boolean>} True if the field exists, false otherwise.
*/
hExists(key, field) {
return this.client.hExists(key, field);
}
/**
* Executes the native HINCRBY command.
* @param {string} key The key of the hash.
* @param {string} field The field to increment.
* @param {number} increment The amount to increment by.
* @returns {Promise<number>} The value of the field after the increment.
*/
hIncrBy(key, field, increment) {
return this.client.hIncrBy(key, field, increment);
}
// --- List Commands ---
/**
* Executes the native LPUSH command.
* @param {string} key The key of the list.
* @param {RedisListElement | RedisListElement[]} elements The element or elements to prepend.
* @returns {Promise<number>} The length of the list after the operation.
*/
lPush(key, elements) {
return this.client.lPush(key, elements);
}
/**
* Executes the native RPUSH command.
* @param {string} key The key of the list.
* @param {RedisListElement | RedisListElement[]} elements The element or elements to append.
* @returns {Promise<number>} The length of the list after the operation.
*/
rPush(key, elements) {
return this.client.rPush(key, elements);
}
/**
* Executes the native LPOP command.
* @param {string} key The key of the list.
* @returns {Promise<string | null>} The value of the first element, or null if the list is empty.
*/
lPop(key) {
return this.client.lPop(key);
}
/**
* Executes the native RPOP command.
* @param {string} key The key of the list.
* @returns {Promise<string | null>} The value of the last element, or null if the list is empty.
*/
rPop(key) {
return this.client.rPop(key);
}
/**
* Executes the native LRANGE command.
* @param {string} key The key of the list.
* @param {number} start The starting index.
* @param {number} stop The ending index.
* @returns {Promise<string[]>} An array of elements in the specified range.
*/
lRange(key, start, stop) {
return this.client.lRange(key, start, stop);
}
/**
* Executes the native LLEN command.
* @param {string} key The key of the list.
* @returns {Promise<number>} The length of the list.
*/
lLen(key) {
return this.client.lLen(key);
}
/**
* Executes the native LTRIM command.
* @param {string} key The key of the list.
* @param {number} start The starting index.
* @param {number} stop The ending index.
* @returns {Promise<string>} 'OK'.
*/
lTrim(key, start, stop) {
return this.client.lTrim(key, start, stop);
}
// --- Set Commands ---
/**
* Executes the native SADD command.
* @param {string} key The key of the set.
* @param {RedisSetMember | RedisSetMember[]} members The member or members to add.
* @returns {Promise<number>} The number of members added to the set.
*/
sAdd(key, members) {
return this.client.sAdd(key, members);
}
/**
* Executes the native SMEMBERS command.
* @param {string} key The key of the set.
* @returns {Promise<string[]>} An array of all members in the set.
*/
sMembers(key) {
return this.client.sMembers(key);
}
/**
* Executes the native SISMEMBER command.
* @param {string} key The key of the set.
* @param {RedisSetMember} member The member to check for.
* @returns {Promise<boolean>} True if the member is in the set, false otherwise.
*/
sIsMember(key, member) {
return this.client.sIsMember(key, member);
}
/**
* Executes the native SREM command.
* @param {string} key The key of the set.
* @param {RedisSetMember | RedisSetMember[]} members The member or members to remove.
* @returns {Promise<number>} The number of members removed from the set.
*/
sRem(key, members) {
return this.client.sRem(key, members);
}
/**
* Executes the native SCARD command.
* @param {string} key The key of the set.
* @returns {Promise<number>} The cardinality of the set.
*/
sCard(key) {
return this.client.sCard(key);
}
// --- Sorted Set Commands ---
/**
* Executes the native ZADD command.
* @param {string} key The key of the sorted set.
* @param {number | RedisSortedSetMember[]} scoreOrMembers The score for a single member, or an array of member-score objects.
* @param {RedisValue} [member] The member to add if a single score is provided.
* @returns {Promise<number>} The number of elements added to the sorted set.
*/
zAdd(key, scoreOrMembers, member) {
if (Array.isArray(scoreOrMembers)) {
return this.client.zAdd(key, scoreOrMembers);
}
// For a single member, the native client expects a ZMember object or an array of them.
return this.client.zAdd(key, {
score: scoreOrMembers,
value: member,
});
}
/**
* Executes the native ZRANGE command.
* @param {string} key The key of the sorted set.
* @param {string | number} min The minimum index or score.
* @param {string | number} max The maximum index or score.
* @param {RedisCommandOptions} [options] Additional options (e.g., REV).
* @returns {Promise<string[]>} An array of members in the specified range.
*/
zRange(key, min, max, options) {
return this.client.zRange(key, min, max, options);
}
/**
* Executes the native ZRANGE command with the WITHSCORES option.
* @param {string} key The key of the sorted set.
* @param {string | number} min The minimum index or score.
* @param {string | number} max The maximum index or score.
* @param {RedisCommandOptions} [options] Additional options (e.g., REV).
* @returns {Promise<RedisZMember[]>} An array of members and their scores.
*/
zRangeWithScores(key, min, max, options) {
return this.client.zRangeWithScores(key, min, max, options);
}
/**
* Executes the native ZREM command.
* @param {string} key The key of the sorted set.
* @param {RedisValue | RedisValue[]} members The member or members to remove.
* @returns {Promise<number>} The number of members removed.
*/
zRem(key, members) {
return this.client.zRem(key, members);
}
/**
* Executes the native ZCARD command.
* @param {string} key The key of the sorted set.
* @returns {Promise<number>} The cardinality of the sorted set.
*/
zCard(key) {
return this.client.zCard(key);
}
/**
* Executes the native ZSCORE command.
* @param {string} key The key of the sorted set.
* @param {RedisValue} member The member whose score to retrieve.
* @returns {Promise<number | null>} The score of the member, or null if it does not exist.
*/
zScore(key, member) {
return this.client.zScore(key, member);
}
// --- Scripting and Pub/Sub Commands ---
/**
* Executes the native EVAL command.
* @param {string} script The Lua script to execute.
* @param {string[]} keys An array of key names.
* @param {string[]} args An array of argument values.
* @returns {Promise<any>} The result of the script execution.
*/
eval(script, keys, args) {
return this.client.eval(script, { keys, arguments: args });
}
/**
* Executes the native SUBSCRIBE command.
* @param {string} channel The channel to subscribe to.
* @param {(message: string, channel: string) => void} listener The callback for received messages.
* @returns {Promise<void>}
*/
subscribe(channel, listener) {
return this.client.subscribe(channel, listener);
}
/**
* Executes the native UNSUBSCRIBE command.
* @param {string} [channel] The channel to unsubscribe from. If omitted, unsubscribes from all.
* @returns {Promise<void>}
*/
unsubscribe(channel) {
if (channel) {
return this.client.unsubscribe(channel);
}
return this.client.unsubscribe();
}
/**
* Executes the native PUBLISH command.
* @param {string} channel The channel to publish to.
* @param {string} message The message to publish.
* @returns {Promise<number>} The number of clients that received the message.
*/
publish(channel, message) {
return this.client.publish(channel, message);
}
}
/**
* @file src/redis/RedisManager.ts
* @description Manages the lifecycle of multiple instrumented Redis client instances.
*/
/**
* Manages the creation, retrieval, and lifecycle of multiple `IBeaconRedis` instances
* based on the provided configuration. It acts as a central point of access for all
* Redis clients within an application.
*/
class RedisManager {
constructor(config, logger, contextManager) {
this.instances = new Map();
this.config = config;
this.logger = logger.child({ module: 'RedisManager' });
this.contextManager = contextManager;
}
init() {
this.logger.trace('Initializing RedisManager...');
// If no instances are configured, just log and return
if (!this.config ||
!this.config.instances ||
this.config.instances.length === 0) {
this.logger.trace('No Redis instances to initialize.');
return;
}
// Functional validation: Check if configuration is valid
const validateConfig = () => {
if (!this.config ||
!this.config.instances ||
this.config.instances.length === 0) {
throw new Error('Redis configuration is invalid: no instances configured. Please provide at least one Redis instance.');
}
};
// Functional validation: Check if default instance exists (if specified)
const validateDefaultInstance = () => {
if (this.config.default) {
const defaultExists = this.config.instances.some((instance) => instance.instanceName === this.config.default);
if (!defaultExists) {
throw new Error(`Redis configuration error: default instance "${this.config.default}" not found in configured instances. Available instances: ${this.config.instances.map((i) => i.instanceName).join(', ')}`);
}
}
};
// Functional instance creation with BeaconRedis
const createInstances = () => {
for (const instanceConfig of this.config.instances) {
// Create connection manager
const connectionManager = new RedisConnectionManager(instanceConfig, this.logger);
// Create command executor
const commandExecutor = new RedisCommandExecutor(connectionManager.getNativeClient());
// Create instrumented BeaconRedis instance
const beaconRedis = new BeaconRedis(instanceConfig, connectionManager, commandExecutor, this.logger);
this.instances.set(instanceConfig.instanceName, beaconRedis);
if (instanceConfig.instanceName === this.config.default) {
this.defaultInstance = beaconRedis;
}
}
};
// Functional fallback for default instance
const setDefaultFallback = () => {
if (!this.defaultInstance && this.instances.size > 0) {
const firstInstance = this.instances.values().next().value;
this.defaultInstance = firstInstance;
}
};
// Execute the functional pipeline
try {
validateConfig();
validateDefaultInstance();
createInstances();
setDefaultFallback();
}
catch (error) {
this.logger.error('RedisManager initialization failed', {
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
getInstance(name) {
const instanceName = name ?? this.defaultInstance?.getInstanceName();
if (!instanceName) {
throw new Error('A specific instance name was not provided and no default Redis instance is configured.');
}
const instance = this.instances.get(instanceName);
if (!instance) {
throw new Error(`Redis instance with name "${instanceName}" was not found. Please check that the name is spelled correctly in your configuration and code.`);
}
return instance;
}
/**
* Gracefully shuts down all managed Redis connections.
* It attempts to close all connections and waits for them to complete.
*/
async shutdown() {
this.logger.info('Closing all Redis connections...');
const shutdownPromises = Array.from(this.instances.values()).map((instance) => instance.quit());
await Promise.allSettled(shutdownPromises);
this.logger.info('All Redis connections have been closed.');
}
}
var RedisManager$1 = /*#__PURE__*/Object.freeze({
__proto__: null,
RedisManager: RedisManager
});
export { ClassicConsoleTransport, CompactConsoleTransport, ConsoleTransport, MaskingEngine, MockBrokerAdapter, MockHttpClient, MockSerializerRegistry, PrettyConsoleTransport, SanitizationEngine, SpyTransport, SyntropyLog, Transport, syntropyLog };
//# sourceMappingURL=index.mjs.map