diff --git a/.env.example b/.env.example index 06522f2..dd7b6ec 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,73 @@ LOG_HTTP_URL= # API key for HTTP transport authentication (Dynatrace Api-Token) LOG_HTTP_API_KEY= +# ----------------------------------------------------------------------------- +# Log Masking / Redaction +# ----------------------------------------------------------------------------- + +# Enable automatic masking of sensitive fields (true | false) +# When enabled, fields like password, token, apiKey are automatically redacted +LOG_MASK_ENABLED=true + +# Comma-separated list of additional field names to mask +# Leave empty to use default sensitive fields (password, token, apiKey, etc.) +# Example: LOG_MASK_FIELDS=customSecret,internalKey +LOG_MASK_FIELDS= + +# Pattern to replace masked values with (default: [REDACTED]) +LOG_MASK_PATTERN=[REDACTED] + +# ----------------------------------------------------------------------------- +# Request/Response Body Logging +# ----------------------------------------------------------------------------- + +# Enable logging of request bodies (true | false) +# WARNING: May log sensitive data - use with masking enabled +LOG_REQUEST_BODY=false + +# Enable logging of response bodies (true | false) +# WARNING: May increase log volume significantly +LOG_RESPONSE_BODY=false + +# Maximum body size to log in bytes (default: 10KB) +# Bodies larger than this will be truncated +LOG_BODY_MAX_SIZE=10240 + +# ----------------------------------------------------------------------------- +# Performance Metrics +# ----------------------------------------------------------------------------- + +# Enable performance tracking and slow request detection (true | false) +LOG_PERF_ENABLED=true + +# Threshold in milliseconds for slow request warnings (default: 500ms) +# Requests taking longer than this will be logged as warnings +LOG_PERF_THRESHOLD=500 + +# ----------------------------------------------------------------------------- +# Log Sampling (Production Volume Control) +# ----------------------------------------------------------------------------- + +# Enable log sampling for verbose levels (true | false) +# When enabled, debug/verbose/silly logs are sampled to reduce volume +# error/warn/info/http logs are NEVER sampled (always logged) +LOG_SAMPLING_ENABLED=false + +# Sampling rate for debug/verbose/silly logs (0.0 to 1.0) +# 0.1 = log 10% of debug messages, 1.0 = log all, 0.0 = log none +LOG_SAMPLING_RATE=0.1 + +# ----------------------------------------------------------------------------- +# Error Stack Parsing +# ----------------------------------------------------------------------------- + +# Enable enhanced error stack parsing (true | false) +# Provides structured, filtered stack traces excluding node_modules +LOG_ERROR_STACK_ENABLED=true + +# Maximum number of stack frames to include in parsed errors +LOG_ERROR_STACK_LINES=10 + # ============================================================================= # Per-Environment Overrides (Examples) # ============================================================================= @@ -62,6 +129,8 @@ LOG_HTTP_API_KEY= # LOG_CONSOLE_PRODUCTION=false # LOG_FILE_PRODUCTION=true # LOG_HTTP_PRODUCTION=true +# LOG_SAMPLING_ENABLED_PRODUCTION=true +# LOG_SAMPLING_RATE_PRODUCTION=0.1 # Staging overrides # LOG_LEVEL_STAGING=debug diff --git a/src/core/config.ts b/src/core/config.ts index 803ab6c..a759d99 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -32,20 +32,61 @@ function toInt(value: string | undefined, defaultValue: number): number { return isNaN(n) ? defaultValue : n; } +function toFloat(value: string | undefined, defaultValue: number): number { + if (value === undefined) return defaultValue; + const n = parseFloat(value); + return isNaN(n) ? defaultValue : n; +} + +function toArray(value: string | undefined, defaultValue: string[]): string[] { + if (value === undefined || value.trim() === "") return defaultValue; + return value + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + /** * Builds the logging configuration from environment variables. */ export function buildConfig(overrides?: Partial): LoggingConfig { const defaults: LoggingConfig = { + // Basic logging level: envVar("LOG_LEVEL", "info") as LogLevel, console: toBool(envVar("LOG_CONSOLE", "true"), true), + + // File transport file: toBool(envVar("LOG_FILE", "false"), false), filePath: envVar("LOG_FILE_PATH", "./logs/app.log"), fileMaxSize: toInt(envVar("LOG_FILE_MAXSIZE", "10485760"), 10 * 1024 * 1024), fileMaxFiles: toInt(envVar("LOG_FILE_MAXFILES", "5"), 5), + + // HTTP transport http: toBool(envVar("LOG_HTTP", "false"), false), httpUrl: envVar("LOG_HTTP_URL", ""), httpApiKey: envVar("LOG_HTTP_API_KEY", ""), + + // Log masking/redaction + maskEnabled: toBool(envVar("LOG_MASK_ENABLED", "true"), true), + maskFields: toArray(envVar("LOG_MASK_FIELDS", ""), []), + maskPattern: envVar("LOG_MASK_PATTERN", "[REDACTED]"), + + // Request body logging + logRequestBody: toBool(envVar("LOG_REQUEST_BODY", "false"), false), + logResponseBody: toBool(envVar("LOG_RESPONSE_BODY", "false"), false), + bodyMaxSize: toInt(envVar("LOG_BODY_MAX_SIZE", "10240"), 10 * 1024), + + // Performance metrics + perfEnabled: toBool(envVar("LOG_PERF_ENABLED", "true"), true), + perfThreshold: toInt(envVar("LOG_PERF_THRESHOLD", "500"), 500), + + // Log sampling + samplingEnabled: toBool(envVar("LOG_SAMPLING_ENABLED", "false"), false), + samplingRate: toFloat(envVar("LOG_SAMPLING_RATE", "0.1"), 0.1), + + // Error stack parsing + errorStackEnabled: toBool(envVar("LOG_ERROR_STACK_ENABLED", "true"), true), + errorStackLines: toInt(envVar("LOG_ERROR_STACK_LINES", "10"), 10), }; return { ...defaults, ...overrides }; diff --git a/src/core/error-parser.ts b/src/core/error-parser.ts new file mode 100644 index 0000000..c645aea --- /dev/null +++ b/src/core/error-parser.ts @@ -0,0 +1,227 @@ +/** + * Error stack parsing utilities. + * Parses and formats error stacks for better readability. + */ + +import type { LoggingConfig } from "./types"; + +export interface ParsedStackFrame { + /** Function or method name */ + functionName: string; + /** File path */ + fileName: string; + /** Line number */ + lineNumber: number | null; + /** Column number */ + columnNumber: number | null; + /** Whether this is a native/internal frame */ + isNative: boolean; + /** Whether this is from node_modules */ + isNodeModules: boolean; + /** Raw frame string */ + raw: string; +} + +export interface ParsedError { + /** Error name/type */ + name: string; + /** Error message */ + message: string; + /** Parsed stack frames */ + stack: ParsedStackFrame[]; + /** Cause chain (if Error has cause) */ + cause?: ParsedError; +} + +/** + * Parses a single stack frame line. + * Handles various formats: + * - at functionName (file:line:col) + * - at file:line:col + * - at functionName (native) + */ +function parseStackFrame(line: string): ParsedStackFrame | null { + const trimmed = line.trim(); + if (!trimmed.startsWith("at ")) { + return null; + } + + const raw = trimmed; + const content = trimmed.slice(3); // Remove "at " + + // Check for native + if (content.includes("(native)") || content === "native") { + return { + functionName: content.replace(/\s*\(native\)/, "").trim() || "", + fileName: "native", + lineNumber: null, + columnNumber: null, + isNative: true, + isNodeModules: false, + raw, + }; + } + + // Pattern: functionName (file:line:col) + const withParensMatch = content.match(/^(.+?)\s+\((.+):(\d+):(\d+)\)$/); + if (withParensMatch) { + const fileName = withParensMatch[2]!; + return { + functionName: withParensMatch[1]!.trim(), + fileName, + lineNumber: parseInt(withParensMatch[3]!, 10), + columnNumber: parseInt(withParensMatch[4]!, 10), + isNative: false, + isNodeModules: fileName.includes("node_modules"), + raw, + }; + } + + // Pattern: functionName (file:line) + const withParensNoColMatch = content.match(/^(.+?)\s+\((.+):(\d+)\)$/); + if (withParensNoColMatch) { + const fileName = withParensNoColMatch[2]!; + return { + functionName: withParensNoColMatch[1]!.trim(), + fileName, + lineNumber: parseInt(withParensNoColMatch[3]!, 10), + columnNumber: null, + isNative: false, + isNodeModules: fileName.includes("node_modules"), + raw, + }; + } + + // Pattern: file:line:col (anonymous) + const anonymousMatch = content.match(/^(.+):(\d+):(\d+)$/); + if (anonymousMatch) { + const fileName = anonymousMatch[1]!; + return { + functionName: "", + fileName, + lineNumber: parseInt(anonymousMatch[2]!, 10), + columnNumber: parseInt(anonymousMatch[3]!, 10), + isNative: false, + isNodeModules: fileName.includes("node_modules"), + raw, + }; + } + + // Pattern: file:line (anonymous) + const anonymousNoColMatch = content.match(/^(.+):(\d+)$/); + if (anonymousNoColMatch) { + const fileName = anonymousNoColMatch[1]!; + return { + functionName: "", + fileName, + lineNumber: parseInt(anonymousNoColMatch[2]!, 10), + columnNumber: null, + isNative: false, + isNodeModules: fileName.includes("node_modules"), + raw, + }; + } + + // Fallback - just use the content as function name + return { + functionName: content, + fileName: "unknown", + lineNumber: null, + columnNumber: null, + isNative: false, + isNodeModules: false, + raw, + }; +} + +/** + * Parses an Error object into structured data. + */ +export function parseError(error: Error, maxLines = 10): ParsedError { + const stack: ParsedStackFrame[] = []; + + if (error.stack) { + const lines = error.stack.split("\n").slice(1); // Skip first line (error message) + let count = 0; + for (const line of lines) { + if (count >= maxLines) break; + const frame = parseStackFrame(line); + if (frame) { + stack.push(frame); + count++; + } + } + } + + const parsed: ParsedError = { + name: error.name, + message: error.message, + stack, + }; + + // Handle Error cause chain (ES2022+) + if ("cause" in error && error.cause instanceof Error) { + parsed.cause = parseError(error.cause, maxLines); + } + + return parsed; +} + +/** + * Formats a parsed error for logging. + */ +export function formatParsedError(parsed: ParsedError, includeNodeModules = false): string { + const lines: string[] = [`${parsed.name}: ${parsed.message}`]; + + for (const frame of parsed.stack) { + if (!includeNodeModules && frame.isNodeModules) { + continue; + } + + let frameLine = ` at ${frame.functionName}`; + if (frame.fileName !== "unknown" && frame.fileName !== "native") { + frameLine += ` (${frame.fileName}`; + if (frame.lineNumber !== null) { + frameLine += `:${frame.lineNumber}`; + if (frame.columnNumber !== null) { + frameLine += `:${frame.columnNumber}`; + } + } + frameLine += ")"; + } + lines.push(frameLine); + } + + if (parsed.cause) { + lines.push(` Caused by: ${formatParsedError(parsed.cause, includeNodeModules)}`); + } + + return lines.join("\n"); +} + +/** + * Creates an error parser based on configuration. + */ +export function createErrorParser( + config: Pick, +): (error: Error) => Record { + if (!config.errorStackEnabled) { + return (error) => ({ + name: error.name, + message: error.message, + stack: error.stack, + }); + } + + return (error) => { + const parsed = parseError(error, config.errorStackLines); + return { + name: parsed.name, + message: parsed.message, + parsedStack: parsed.stack.filter((f) => !f.isNodeModules), + fullStack: parsed.stack, + cause: parsed.cause, + formatted: formatParsedError(parsed), + }; + }; +} diff --git a/src/core/index.ts b/src/core/index.ts index 858a979..7a8ebcc 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -4,3 +4,6 @@ export * from "./types"; export * from "./config"; export * from "./correlation"; +export * from "./masking"; +export * from "./error-parser"; +export * from "./sampling"; diff --git a/src/core/masking.ts b/src/core/masking.ts new file mode 100644 index 0000000..015e644 --- /dev/null +++ b/src/core/masking.ts @@ -0,0 +1,114 @@ +/** + * Log masking/redaction utilities. + * Automatically redacts sensitive fields from log metadata. + */ + +import type { LoggingConfig } from "./types"; + +/** Default sensitive field patterns (case-insensitive matching) */ +export const DEFAULT_MASK_FIELDS = [ + "password", + "passwd", + "secret", + "token", + "apikey", + "api_key", + "api-key", + "authorization", + "auth", + "bearer", + "credential", + "private", + "ssn", + "social_security", + "credit_card", + "creditcard", + "card_number", + "cardnumber", + "cvv", + "pin", + "otp", + "access_token", + "refresh_token", + "id_token", + "jwt", +]; + +/** Default mask replacement pattern */ +export const DEFAULT_MASK_PATTERN = "[REDACTED]"; + +/** + * Checks if a field name should be masked. + * Matches if the field name contains the pattern (case-insensitive). + */ +function shouldMaskField(fieldName: string, maskFields: string[]): boolean { + const lowerField = fieldName.toLowerCase(); + return maskFields.some((pattern) => { + const lowerPattern = pattern.toLowerCase(); + // Match if field name contains the sensitive pattern + // This ensures "password" matches "userPassword" but "id" doesn't match "id_token" + return lowerField.includes(lowerPattern); + }); +} + +/** + * Recursively masks sensitive fields in an object. + */ +export function maskObject( + obj: unknown, + maskFields: string[], + maskPattern: string, + depth = 0, + maxDepth = 10, +): unknown { + // Prevent infinite recursion + if (depth > maxDepth) { + return obj; + } + + if (obj === null || obj === undefined) { + return obj; + } + + if (typeof obj === "string") { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => maskObject(item, maskFields, maskPattern, depth + 1, maxDepth)); + } + + if (typeof obj === "object") { + const masked: Record = {}; + for (const [key, value] of Object.entries(obj)) { + // If value is an object/array, always recurse (don't mask entire containers) + if (typeof value === "object" && value !== null) { + masked[key] = maskObject(value, maskFields, maskPattern, depth + 1, maxDepth); + } else if (shouldMaskField(key, maskFields)) { + // Only mask primitive values (strings, numbers, booleans) + masked[key] = maskPattern; + } else { + masked[key] = value; + } + } + return masked; + } + + return obj; +} + +/** + * Creates a masking function based on configuration. + */ +export function createMasker( + config: Pick, +): (data: unknown) => unknown { + if (!config.maskEnabled) { + return (data) => data; + } + + const fields = config.maskFields.length > 0 ? config.maskFields : DEFAULT_MASK_FIELDS; + const pattern = config.maskPattern || DEFAULT_MASK_PATTERN; + + return (data: unknown) => maskObject(data, fields, pattern); +} diff --git a/src/core/sampling.ts b/src/core/sampling.ts new file mode 100644 index 0000000..13e98fc --- /dev/null +++ b/src/core/sampling.ts @@ -0,0 +1,136 @@ +/** + * Log sampling utilities. + * Reduces log volume by sampling verbose/debug logs in production. + */ + +import type { LoggingConfig, LogLevel } from "./types"; + +/** Log levels that are subject to sampling (high-volume levels) */ +const SAMPLED_LEVELS: LogLevel[] = ["debug", "verbose", "silly"]; + +/** Log levels that are never sampled (always logged) */ +const ALWAYS_LOG_LEVELS: LogLevel[] = ["error", "warn", "info", "http"]; + +export interface SamplingDecision { + /** Whether this log should be emitted */ + shouldLog: boolean; + /** Whether sampling was applied */ + wasSampled: boolean; + /** The sampling rate used (0-1) */ + rate: number; +} + +/** + * Deterministic sampler using message hash. + * This ensures the same message always gets the same sampling decision, + * preventing intermittent log appearance. + */ +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash); +} + +/** + * Determines if a log should be sampled out. + */ +export function shouldSampleLog( + level: LogLevel, + message: string, + config: Pick, +): SamplingDecision { + // If sampling is disabled, always log + if (!config.samplingEnabled) { + return { shouldLog: true, wasSampled: false, rate: 1.0 }; + } + + // Always log high-priority levels + if (ALWAYS_LOG_LEVELS.includes(level)) { + return { shouldLog: true, wasSampled: false, rate: 1.0 }; + } + + // Apply sampling to verbose levels + if (SAMPLED_LEVELS.includes(level)) { + const rate = Math.max(0, Math.min(1, config.samplingRate)); + + // Use deterministic sampling based on message hash + const hash = hashString(message); + const threshold = Math.floor(rate * 1000); + const shouldLog = hash % 1000 < threshold; + + return { shouldLog, wasSampled: true, rate }; + } + + // Default: log everything + return { shouldLog: true, wasSampled: false, rate: 1.0 }; +} + +/** + * Creates a sampling filter based on configuration. + */ +export function createSampler( + config: Pick, +): (level: LogLevel, message: string) => boolean { + if (!config.samplingEnabled) { + return () => true; + } + + return (level: LogLevel, message: string) => { + const decision = shouldSampleLog(level, message, config); + return decision.shouldLog; + }; +} + +/** + * Sampling statistics tracker for observability. + */ +export class SamplingStats { + private _total = 0; + private _sampled = 0; + private _dropped = 0; + + record(decision: SamplingDecision): void { + this._total++; + if (decision.wasSampled) { + this._sampled++; + if (!decision.shouldLog) { + this._dropped++; + } + } + } + + get total(): number { + return this._total; + } + + get sampled(): number { + return this._sampled; + } + + get dropped(): number { + return this._dropped; + } + + get dropRate(): number { + return this._sampled > 0 ? this._dropped / this._sampled : 0; + } + + toJSON(): Record { + return { + total: this._total, + sampled: this._sampled, + dropped: this._dropped, + dropRate: this.dropRate, + }; + } + + reset(): void { + this._total = 0; + this._sampled = 0; + this._dropped = 0; + } +} diff --git a/src/core/types.ts b/src/core/types.ts index 53d1702..a42ddb3 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -22,6 +22,28 @@ export interface LoggingConfig { http: boolean; httpUrl: string; httpApiKey: string; + + /** Log masking/redaction configuration */ + maskEnabled: boolean; + maskFields: string[]; + maskPattern: string; + + /** Request body logging configuration */ + logRequestBody: boolean; + logResponseBody: boolean; + bodyMaxSize: number; + + /** Performance metrics configuration */ + perfEnabled: boolean; + perfThreshold: number; + + /** Log sampling configuration */ + samplingEnabled: boolean; + samplingRate: number; + + /** Error stack parsing configuration */ + errorStackEnabled: boolean; + errorStackLines: number; } export interface LoggerMetadata { diff --git a/src/infra/logger.factory.ts b/src/infra/logger.factory.ts index fb6a127..2ec5e2d 100644 --- a/src/infra/logger.factory.ts +++ b/src/infra/logger.factory.ts @@ -6,21 +6,38 @@ import winston from "winston"; import { buildConfig } from "../core/config"; +import { createMasker } from "../core/masking"; +import { createSampler } from "../core/sampling"; import type { Logger, LoggerMetadata, LoggingConfig, LogLevel } from "../core/types"; import { createTransports } from "./transports"; /** * Wraps a Winston logger to implement our Logger interface. + * Includes masking and sampling functionality. */ class WinstonLogger implements Logger { + private readonly masker: (data: unknown) => unknown; + private readonly shouldSample: (level: LogLevel, message: string) => boolean; + constructor( private readonly winstonLogger: winston.Logger, private readonly metadata: LoggerMetadata = {}, - ) {} + private readonly config: LoggingConfig, + ) { + this.masker = createMasker(config); + this.shouldSample = createSampler(config); + } private logWithMeta(level: LogLevel, message: string, meta?: LoggerMetadata): void { - this.winstonLogger.log(level, message, { ...this.metadata, ...meta }); + // Apply sampling - skip log if sampled out + if (!this.shouldSample(level, message)) { + return; + } + + // Apply masking to metadata + const maskedMeta = this.masker({ ...this.metadata, ...meta }) as LoggerMetadata; + this.winstonLogger.log(level, message, maskedMeta); } error(message: string, meta?: LoggerMetadata): void { @@ -56,7 +73,7 @@ class WinstonLogger implements Logger { } child(meta: LoggerMetadata): Logger { - return new WinstonLogger(this.winstonLogger, { ...this.metadata, ...meta }); + return new WinstonLogger(this.winstonLogger, { ...this.metadata, ...meta }, this.config); } } @@ -76,5 +93,5 @@ export function createLogger( transports, }); - return new WinstonLogger(winstonLogger, defaultMeta); + return new WinstonLogger(winstonLogger, defaultMeta, config); } diff --git a/src/nest/constants.ts b/src/nest/constants.ts index d20d928..84ad813 100644 --- a/src/nest/constants.ts +++ b/src/nest/constants.ts @@ -4,3 +4,4 @@ export const LOGGING_MODULE_OPTIONS = Symbol("LOGGING_MODULE_OPTIONS"); export const LOGGER = Symbol("LOGGER"); +export const LOGGING_CONFIG = Symbol("LOGGING_CONFIG"); diff --git a/src/nest/interceptor.ts b/src/nest/interceptor.ts index 8ccf249..1ec1289 100644 --- a/src/nest/interceptor.ts +++ b/src/nest/interceptor.ts @@ -1,24 +1,64 @@ /** * CorrelationIdInterceptor - Automatically adds correlation ID to all requests. + * Includes request/response body logging and performance metrics. */ -import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"; +import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor } from "@nestjs/common"; import type { Observable } from "rxjs"; import { tap } from "rxjs/operators"; import { getCorrelationId, CORRELATION_ID_HEADER } from "../core/correlation"; +import { createErrorParser } from "../core/error-parser"; +import { createMasker } from "../core/masking"; +import type { LoggingConfig, LoggerMetadata } from "../core/types"; +import { LOGGING_CONFIG } from "./constants"; import { LoggingService } from "./service"; @Injectable() export class CorrelationIdInterceptor implements NestInterceptor { - constructor(private readonly loggingService: LoggingService) {} + private readonly masker: (data: unknown) => unknown; + private readonly errorParser: (error: Error) => Record; + + constructor( + private readonly loggingService: LoggingService, + @Inject(LOGGING_CONFIG) private readonly config: LoggingConfig, + ) { + this.masker = createMasker(config); + this.errorParser = createErrorParser(config); + } + + /** + * Truncates body to configured max size and masks sensitive fields. + */ + private processBody(body: unknown): unknown { + if (body === undefined || body === null) { + return undefined; + } + + // Mask sensitive fields + const masked = this.masker(body); + + // Truncate if too large + const serialized = JSON.stringify(masked); + if (serialized.length > this.config.bodyMaxSize) { + return { + _truncated: true, + _originalSize: serialized.length, + _maxSize: this.config.bodyMaxSize, + _preview: serialized.slice(0, 500) + "...", + }; + } + + return masked; + } intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest<{ headers: Record; method?: string; url?: string; + body?: unknown; logger?: ReturnType; }>(); @@ -42,32 +82,71 @@ export class CorrelationIdInterceptor implements NestInterceptor { const method = request.method ?? "UNKNOWN"; const url = request.url ?? "/"; - request.logger.info(`Incoming request: ${method} ${url}`, { + // Build request metadata + const requestMeta: LoggerMetadata = { method, url, correlationId, - }); + }; + + // Optionally include request body + if (this.config.logRequestBody && request.body) { + requestMeta.body = this.processBody(request.body); + } + + request.logger.info(`Incoming request: ${method} ${url}`, requestMeta); return next.handle().pipe( tap({ - next: () => { + next: (responseBody: unknown) => { const duration = Date.now() - startTime; - request.logger?.info(`Request completed: ${method} ${url}`, { + const isSlowRequest = this.config.perfEnabled && duration >= this.config.perfThreshold; + + // Build response metadata + const responseMeta: LoggerMetadata = { method, url, correlationId, duration, - }); + }; + + // Optionally include response body + if (this.config.logResponseBody && responseBody !== undefined) { + responseMeta.body = this.processBody(responseBody); + } + + // Add performance warning for slow requests + if (isSlowRequest) { + responseMeta.slowRequest = true; + responseMeta.perfThreshold = this.config.perfThreshold; + request.logger?.warn( + `Slow request detected: ${method} ${url} (${duration}ms)`, + responseMeta, + ); + } else { + request.logger?.info(`Request completed: ${method} ${url}`, responseMeta); + } }, error: (error: unknown) => { const duration = Date.now() - startTime; - request.logger?.error(`Request failed: ${method} ${url}`, { + + // Build error metadata + const errorMeta: LoggerMetadata = { method, url, correlationId, duration, - error: error instanceof Error ? error.message : String(error), - }); + }; + + // Use error parser for better stack traces + if (error instanceof Error) { + const parsedError = this.errorParser(error); + errorMeta.error = parsedError; + } else { + errorMeta.error = String(error); + } + + request.logger?.error(`Request failed: ${method} ${url}`, errorMeta); }, }), ); diff --git a/src/nest/module.ts b/src/nest/module.ts index bd6f95e..1cd6119 100644 --- a/src/nest/module.ts +++ b/src/nest/module.ts @@ -5,10 +5,11 @@ import { Module, Global } from "@nestjs/common"; import type { DynamicModule, Provider } from "@nestjs/common"; +import { buildConfig } from "../core/config"; import type { LoggingModuleOptions } from "../core/types"; import { createLogger } from "../infra/logger.factory"; -import { LOGGER, LOGGING_MODULE_OPTIONS } from "./constants"; +import { LOGGER, LOGGING_CONFIG, LOGGING_MODULE_OPTIONS } from "./constants"; import { CorrelationIdInterceptor } from "./interceptor"; import { LoggingService } from "./service"; @@ -34,6 +35,14 @@ export class LoggingModule { static register(options: LoggingModuleOptions = {}): DynamicModule { const { config, defaultMeta, isGlobal = true } = options; + // Build full config from env vars + overrides + const fullConfig = buildConfig(config); + + const configProvider: Provider = { + provide: LOGGING_CONFIG, + useValue: fullConfig, + }; + const loggerProvider: Provider = { provide: LOGGER, useFactory: () => createLogger(config, defaultMeta), @@ -47,8 +56,14 @@ export class LoggingModule { return { module: LoggingModule, global: isGlobal, - providers: [optionsProvider, loggerProvider, LoggingService, CorrelationIdInterceptor], - exports: [LOGGER, LoggingService, CorrelationIdInterceptor], + providers: [ + optionsProvider, + configProvider, + loggerProvider, + LoggingService, + CorrelationIdInterceptor, + ], + exports: [LOGGER, LOGGING_CONFIG, LoggingService, CorrelationIdInterceptor], }; } @@ -79,6 +94,14 @@ export class LoggingModule { }): DynamicModule { const { imports = [], inject = [], useFactory, isGlobal = true } = asyncOptions; + const configProvider: Provider = { + provide: LOGGING_CONFIG, + inject: [LOGGING_MODULE_OPTIONS], + useFactory: (options: LoggingModuleOptions) => { + return buildConfig(options.config); + }, + }; + const loggerProvider: Provider = { provide: LOGGER, inject: [LOGGING_MODULE_OPTIONS], @@ -97,8 +120,14 @@ export class LoggingModule { module: LoggingModule, global: isGlobal, imports, - providers: [optionsProvider, loggerProvider, LoggingService, CorrelationIdInterceptor], - exports: [LOGGER, LoggingService, CorrelationIdInterceptor], + providers: [ + optionsProvider, + configProvider, + loggerProvider, + LoggingService, + CorrelationIdInterceptor, + ], + exports: [LOGGER, LOGGING_CONFIG, LoggingService, CorrelationIdInterceptor], }; } } diff --git a/test/error-parser.test.ts b/test/error-parser.test.ts new file mode 100644 index 0000000..b6e43c7 --- /dev/null +++ b/test/error-parser.test.ts @@ -0,0 +1,257 @@ +/** + * Unit tests for error stack parsing functionality. + */ + +import { + parseError, + formatParsedError, + createErrorParser, + type ParsedStackFrame, +} from "../src/core/error-parser"; + +describe("Error Parser", () => { + describe("parseError()", () => { + test("parses error name and message", () => { + const error = new Error("Test error message"); + const parsed = parseError(error); + + expect(parsed.name).toBe("Error"); + expect(parsed.message).toBe("Test error message"); + }); + + test("parses TypeError correctly", () => { + const error = new TypeError("Cannot read property"); + const parsed = parseError(error); + + expect(parsed.name).toBe("TypeError"); + expect(parsed.message).toBe("Cannot read property"); + }); + + test("parses stack frames", () => { + const error = new Error("Test"); + const parsed = parseError(error); + + expect(parsed.stack).toBeInstanceOf(Array); + expect(parsed.stack.length).toBeGreaterThan(0); + }); + + test("respects maxLines parameter", () => { + const error = new Error("Test"); + const parsed = parseError(error, 2); + + expect(parsed.stack.length).toBeLessThanOrEqual(2); + }); + + test("parses stack frame details", () => { + const error = new Error("Test"); + const parsed = parseError(error); + + if (parsed.stack.length > 0) { + const frame = parsed.stack[0]!; + expect(frame).toHaveProperty("functionName"); + expect(frame).toHaveProperty("fileName"); + expect(frame).toHaveProperty("lineNumber"); + expect(frame).toHaveProperty("columnNumber"); + expect(frame).toHaveProperty("isNative"); + expect(frame).toHaveProperty("isNodeModules"); + expect(frame).toHaveProperty("raw"); + } + }); + + test("identifies node_modules frames", () => { + // Create error with artificial stack + const error = new Error("Test"); + const parsed = parseError(error); + + // At least some frames should be from node_modules in test environment + const hasNodeModulesCheck = parsed.stack.some( + (f: ParsedStackFrame) => typeof f.isNodeModules === "boolean", + ); + expect(hasNodeModulesCheck).toBe(true); + }); + + test("handles error without stack", () => { + const error = new Error("No stack"); + // Use Object.defineProperty to set stack to undefined (strict TS workaround) + Object.defineProperty(error, "stack", { value: undefined }); + const parsed = parseError(error); + + expect(parsed.name).toBe("Error"); + expect(parsed.message).toBe("No stack"); + expect(parsed.stack).toEqual([]); + }); + + test("parses error cause chain", () => { + const cause = new Error("Root cause"); + const error = new Error("Top level", { cause }); + const parsed = parseError(error); + + expect(parsed.cause).toBeDefined(); + expect(parsed.cause?.name).toBe("Error"); + expect(parsed.cause?.message).toBe("Root cause"); + }); + }); + + describe("formatParsedError()", () => { + test("formats error with name and message", () => { + const parsed = { + name: "Error", + message: "Test message", + stack: [], + }; + const formatted = formatParsedError(parsed); + + expect(formatted).toBe("Error: Test message"); + }); + + test("includes stack frames in output", () => { + const parsed = { + name: "Error", + message: "Test", + stack: [ + { + functionName: "testFunction", + fileName: "/app/src/test.ts", + lineNumber: 10, + columnNumber: 5, + isNative: false, + isNodeModules: false, + raw: "at testFunction (/app/src/test.ts:10:5)", + }, + ], + }; + const formatted = formatParsedError(parsed); + + expect(formatted).toContain("Error: Test"); + expect(formatted).toContain("testFunction"); + expect(formatted).toContain("/app/src/test.ts"); + expect(formatted).toContain("10"); + }); + + test("excludes node_modules frames by default", () => { + const parsed = { + name: "Error", + message: "Test", + stack: [ + { + functionName: "appFunction", + fileName: "/app/src/app.ts", + lineNumber: 10, + columnNumber: 5, + isNative: false, + isNodeModules: false, + raw: "", + }, + { + functionName: "libFunction", + fileName: "/app/node_modules/lib/index.js", + lineNumber: 20, + columnNumber: 3, + isNative: false, + isNodeModules: true, + raw: "", + }, + ], + }; + const formatted = formatParsedError(parsed, false); + + expect(formatted).toContain("appFunction"); + expect(formatted).not.toContain("libFunction"); + }); + + test("includes node_modules frames when requested", () => { + const parsed = { + name: "Error", + message: "Test", + stack: [ + { + functionName: "libFunction", + fileName: "/app/node_modules/lib/index.js", + lineNumber: 20, + columnNumber: 3, + isNative: false, + isNodeModules: true, + raw: "", + }, + ], + }; + const formatted = formatParsedError(parsed, true); + + expect(formatted).toContain("libFunction"); + }); + + test("formats cause chain", () => { + const parsed = { + name: "Error", + message: "Top level", + stack: [], + cause: { + name: "Error", + message: "Root cause", + stack: [], + }, + }; + const formatted = formatParsedError(parsed); + + expect(formatted).toContain("Top level"); + expect(formatted).toContain("Caused by:"); + expect(formatted).toContain("Root cause"); + }); + }); + + describe("createErrorParser()", () => { + test("returns simple format when disabled", () => { + const parser = createErrorParser({ + errorStackEnabled: false, + errorStackLines: 10, + }); + const error = new Error("Test"); + const result = parser(error); + + expect(result).toHaveProperty("name", "Error"); + expect(result).toHaveProperty("message", "Test"); + expect(result).toHaveProperty("stack"); + expect(result).not.toHaveProperty("parsedStack"); + }); + + test("returns parsed format when enabled", () => { + const parser = createErrorParser({ + errorStackEnabled: true, + errorStackLines: 5, + }); + const error = new Error("Test"); + const result = parser(error); + + expect(result).toHaveProperty("name", "Error"); + expect(result).toHaveProperty("message", "Test"); + expect(result).toHaveProperty("parsedStack"); + expect(result).toHaveProperty("fullStack"); + expect(result).toHaveProperty("formatted"); + }); + + test("filters node_modules from parsedStack", () => { + const parser = createErrorParser({ + errorStackEnabled: true, + errorStackLines: 10, + }); + const error = new Error("Test"); + const result = parser(error) as { parsedStack: ParsedStackFrame[] }; + + // parsedStack should not contain node_modules frames + const hasNodeModules = result.parsedStack.some((f) => f.isNodeModules); + expect(hasNodeModules).toBe(false); + }); + + test("includes node_modules in fullStack", () => { + const parser = createErrorParser({ + errorStackEnabled: true, + errorStackLines: 50, + }); + const error = new Error("Test"); + const result = parser(error) as { fullStack: ParsedStackFrame[] }; + + // fullStack may contain node_modules frames (depends on test environment) + expect(result.fullStack).toBeInstanceOf(Array); + }); + }); +}); diff --git a/test/interceptor.test.ts b/test/interceptor.test.ts index 708fb2c..3a68657 100644 --- a/test/interceptor.test.ts +++ b/test/interceptor.test.ts @@ -4,6 +4,7 @@ import type { CallHandler, ExecutionContext } from "@nestjs/common"; import { of, lastValueFrom } from "rxjs"; +import { buildConfig } from "../src/core/config"; import { CORRELATION_ID_HEADER } from "../src/core/correlation"; import { createLogger } from "../src/infra/logger.factory"; import { CorrelationIdInterceptor } from "../src/nest/interceptor"; @@ -18,6 +19,7 @@ describe("CorrelationIdInterceptor - Behavior", () => { headers: Record; method: string; url: string; + body?: unknown; logger?: any; }; let mockResponse: { @@ -28,6 +30,18 @@ describe("CorrelationIdInterceptor - Behavior", () => { // Create a real logger (with console disabled) const logger = createLogger({ console: false }); + // Create test config + const testConfig = buildConfig({ + console: false, + maskEnabled: true, + logRequestBody: false, + logResponseBody: false, + perfEnabled: true, + perfThreshold: 500, + errorStackEnabled: true, + errorStackLines: 10, + }); + // Create a mock LoggingService mockLoggingService = { withCorrelationId: jest.fn().mockImplementation((correlationId: string) => { @@ -35,7 +49,7 @@ describe("CorrelationIdInterceptor - Behavior", () => { }), } as unknown as LoggingService; - interceptor = new CorrelationIdInterceptor(mockLoggingService); + interceptor = new CorrelationIdInterceptor(mockLoggingService, testConfig); // Setup mock request mockRequest = { @@ -187,7 +201,10 @@ describe("CorrelationIdInterceptor - Behavior", () => { expect.objectContaining({ method: "GET", url: "/api/test", - error: "Test error", + error: expect.objectContaining({ + name: "Error", + message: "Test error", + }), }), ); }); diff --git a/test/masking.test.ts b/test/masking.test.ts new file mode 100644 index 0000000..159b332 --- /dev/null +++ b/test/masking.test.ts @@ -0,0 +1,186 @@ +/** + * Unit tests for log masking functionality. + */ + +import { + createMasker, + maskObject, + DEFAULT_MASK_FIELDS, + DEFAULT_MASK_PATTERN, +} from "../src/core/masking"; + +describe("Masking", () => { + describe("maskObject()", () => { + test("returns null/undefined unchanged", () => { + expect(maskObject(null, DEFAULT_MASK_FIELDS, DEFAULT_MASK_PATTERN)).toBeNull(); + expect(maskObject(undefined, DEFAULT_MASK_FIELDS, DEFAULT_MASK_PATTERN)).toBeUndefined(); + }); + + test("returns strings unchanged", () => { + expect(maskObject("test string", DEFAULT_MASK_FIELDS, DEFAULT_MASK_PATTERN)).toBe( + "test string", + ); + }); + + test("returns primitives unchanged", () => { + expect(maskObject(123, DEFAULT_MASK_FIELDS, DEFAULT_MASK_PATTERN)).toBe(123); + expect(maskObject(true, DEFAULT_MASK_FIELDS, DEFAULT_MASK_PATTERN)).toBe(true); + }); + + test("masks password fields", () => { + const input = { username: "user", password: "secret123" }; + const result = maskObject(input, DEFAULT_MASK_FIELDS, DEFAULT_MASK_PATTERN); + expect(result).toEqual({ username: "user", password: "[REDACTED]" }); + }); + + test("masks token fields", () => { + const input = { id: 1, accessToken: "abc123", refreshToken: "xyz789" }; + const result = maskObject(input, DEFAULT_MASK_FIELDS, DEFAULT_MASK_PATTERN); + expect(result).toEqual({ + id: 1, + accessToken: "[REDACTED]", + refreshToken: "[REDACTED]", + }); + }); + + test("masks authorization fields", () => { + const input = { authorization: "Bearer xyz", apiKey: "key123" }; + const result = maskObject(input, DEFAULT_MASK_FIELDS, DEFAULT_MASK_PATTERN); + expect(result).toEqual({ + authorization: "[REDACTED]", + apiKey: "[REDACTED]", + }); + }); + + test("masks nested objects", () => { + const input = { + user: { + name: "John", + credentials: { + password: "secret", + apiKey: "key", + }, + }, + }; + const result = maskObject(input, DEFAULT_MASK_FIELDS, DEFAULT_MASK_PATTERN); + expect(result).toEqual({ + user: { + name: "John", + credentials: { + password: "[REDACTED]", + apiKey: "[REDACTED]", + }, + }, + }); + }); + + test("masks arrays of objects", () => { + const input = [ + { username: "user1", password: "pass1" }, + { username: "user2", password: "pass2" }, + ]; + const result = maskObject(input, DEFAULT_MASK_FIELDS, DEFAULT_MASK_PATTERN); + expect(result).toEqual([ + { username: "user1", password: "[REDACTED]" }, + { username: "user2", password: "[REDACTED]" }, + ]); + }); + + test("uses custom mask pattern", () => { + const input = { password: "secret" }; + const result = maskObject(input, DEFAULT_MASK_FIELDS, "***HIDDEN***"); + expect(result).toEqual({ password: "***HIDDEN***" }); + }); + + test("uses custom mask fields", () => { + const input = { customField: "sensitive", password: "value" }; + const result = maskObject(input, ["customField"], DEFAULT_MASK_PATTERN); + expect(result).toEqual({ + customField: "[REDACTED]", + password: "value", // Not masked when not in custom fields + }); + }); + + test("handles deeply nested structures", () => { + const input = { + level1: { + level2: { + level3: { + level4: { + secret: "hidden", + }, + }, + }, + }, + }; + const result = maskObject(input, DEFAULT_MASK_FIELDS, DEFAULT_MASK_PATTERN); + expect(result).toEqual({ + level1: { + level2: { + level3: { + level4: { + secret: "[REDACTED]", + }, + }, + }, + }, + }); + }); + + test("prevents infinite recursion with max depth", () => { + // Create a structure that would be deep + let deep: any = { value: "test" }; + for (let i = 0; i < 15; i++) { + deep = { nested: deep }; + } + // Should not throw + expect(() => maskObject(deep, DEFAULT_MASK_FIELDS, DEFAULT_MASK_PATTERN)).not.toThrow(); + }); + }); + + describe("createMasker()", () => { + test("returns identity function when masking disabled", () => { + const masker = createMasker({ maskEnabled: false, maskFields: [], maskPattern: "" }); + const input = { password: "secret" }; + expect(masker(input)).toBe(input); // Same reference + }); + + test("returns masking function when enabled", () => { + const masker = createMasker({ + maskEnabled: true, + maskFields: [], + maskPattern: "[HIDDEN]", + }); + const input = { password: "secret" }; + const result = masker(input); + expect(result).toEqual({ password: "[HIDDEN]" }); + }); + + test("uses custom fields when provided", () => { + const masker = createMasker({ + maskEnabled: true, + maskFields: ["mySecret"], + maskPattern: "***", + }); + const input = { mySecret: "value", password: "other" }; + const result = masker(input); + expect(result).toEqual({ + mySecret: "***", + password: "other", + }); + }); + }); + + describe("DEFAULT_MASK_FIELDS", () => { + test("includes common sensitive field names", () => { + expect(DEFAULT_MASK_FIELDS).toContain("password"); + expect(DEFAULT_MASK_FIELDS).toContain("token"); + expect(DEFAULT_MASK_FIELDS).toContain("apikey"); + expect(DEFAULT_MASK_FIELDS).toContain("authorization"); + expect(DEFAULT_MASK_FIELDS).toContain("secret"); + expect(DEFAULT_MASK_FIELDS).toContain("ssn"); + expect(DEFAULT_MASK_FIELDS).toContain("credit_card"); + expect(DEFAULT_MASK_FIELDS).toContain("cvv"); + }); + }); +}); diff --git a/test/sampling.test.ts b/test/sampling.test.ts new file mode 100644 index 0000000..b0506e8 --- /dev/null +++ b/test/sampling.test.ts @@ -0,0 +1,229 @@ +/** + * Unit tests for log sampling functionality. + */ + +import { shouldSampleLog, createSampler, SamplingStats } from "../src/core/sampling"; + +describe("Sampling", () => { + describe("shouldSampleLog()", () => { + test("always logs when sampling disabled", () => { + const config = { samplingEnabled: false, samplingRate: 0.1 }; + + expect(shouldSampleLog("debug", "test", config).shouldLog).toBe(true); + expect(shouldSampleLog("silly", "test", config).shouldLog).toBe(true); + expect(shouldSampleLog("verbose", "test", config).shouldLog).toBe(true); + }); + + test("always logs error level regardless of sampling", () => { + const config = { samplingEnabled: true, samplingRate: 0.0 }; + + expect(shouldSampleLog("error", "test", config).shouldLog).toBe(true); + expect(shouldSampleLog("error", "test", config).wasSampled).toBe(false); + }); + + test("always logs warn level regardless of sampling", () => { + const config = { samplingEnabled: true, samplingRate: 0.0 }; + + expect(shouldSampleLog("warn", "test", config).shouldLog).toBe(true); + }); + + test("always logs info level regardless of sampling", () => { + const config = { samplingEnabled: true, samplingRate: 0.0 }; + + expect(shouldSampleLog("info", "test", config).shouldLog).toBe(true); + }); + + test("always logs http level regardless of sampling", () => { + const config = { samplingEnabled: true, samplingRate: 0.0 }; + + expect(shouldSampleLog("http", "test", config).shouldLog).toBe(true); + }); + + test("samples debug level when enabled", () => { + const config = { samplingEnabled: true, samplingRate: 0.5 }; + const decision = shouldSampleLog("debug", "test", config); + + expect(decision.wasSampled).toBe(true); + expect(decision.rate).toBe(0.5); + }); + + test("samples verbose level when enabled", () => { + const config = { samplingEnabled: true, samplingRate: 0.5 }; + const decision = shouldSampleLog("verbose", "test", config); + + expect(decision.wasSampled).toBe(true); + }); + + test("samples silly level when enabled", () => { + const config = { samplingEnabled: true, samplingRate: 0.5 }; + const decision = shouldSampleLog("silly", "test", config); + + expect(decision.wasSampled).toBe(true); + }); + + test("deterministic sampling - same message gives same result", () => { + const config = { samplingEnabled: true, samplingRate: 0.5 }; + const message = "consistent-test-message"; + + const results = new Set(); + for (let i = 0; i < 10; i++) { + results.add(shouldSampleLog("debug", message, config).shouldLog); + } + + // Should always be the same result + expect(results.size).toBe(1); + }); + + test("different messages may have different sampling decisions", () => { + const config = { samplingEnabled: true, samplingRate: 0.5 }; + + // With many different messages, we should see some variation + const decisions = []; + for (let i = 0; i < 100; i++) { + decisions.push(shouldSampleLog("debug", `message-${i}`, config).shouldLog); + } + + const logged = decisions.filter(Boolean).length; + // With 50% rate and 100 messages, expect roughly 30-70 to be logged + expect(logged).toBeGreaterThan(20); + expect(logged).toBeLessThan(80); + }); + + test("rate of 0 drops all sampled logs", () => { + const config = { samplingEnabled: true, samplingRate: 0.0 }; + + // Test many messages + const logged = []; + for (let i = 0; i < 100; i++) { + logged.push(shouldSampleLog("debug", `msg-${i}`, config).shouldLog); + } + + // All should be dropped + expect(logged.every((x) => !x)).toBe(true); + }); + + test("rate of 1 logs all sampled logs", () => { + const config = { samplingEnabled: true, samplingRate: 1.0 }; + + // Test many messages + const logged = []; + for (let i = 0; i < 100; i++) { + logged.push(shouldSampleLog("debug", `msg-${i}`, config).shouldLog); + } + + // All should be logged + expect(logged.every((x) => x)).toBe(true); + }); + + test("clamps rate to 0-1 range", () => { + const configHigh = { samplingEnabled: true, samplingRate: 1.5 }; + const configLow = { samplingEnabled: true, samplingRate: -0.5 }; + + // Rate > 1 should be treated as 1 + expect(shouldSampleLog("debug", "test", configHigh).rate).toBe(1.0); + + // Rate < 0 should be treated as 0 + expect(shouldSampleLog("debug", "test", configLow).rate).toBe(0.0); + }); + }); + + describe("createSampler()", () => { + test("returns always-true function when disabled", () => { + const sampler = createSampler({ samplingEnabled: false, samplingRate: 0.0 }); + + expect(sampler("debug", "test")).toBe(true); + expect(sampler("silly", "test")).toBe(true); + }); + + test("returns sampling function when enabled", () => { + const sampler = createSampler({ samplingEnabled: true, samplingRate: 0.5 }); + + // Should return boolean + expect(typeof sampler("debug", "test")).toBe("boolean"); + }); + + test("sampler always allows non-sampled levels", () => { + const sampler = createSampler({ samplingEnabled: true, samplingRate: 0.0 }); + + expect(sampler("error", "test")).toBe(true); + expect(sampler("warn", "test")).toBe(true); + expect(sampler("info", "test")).toBe(true); + }); + }); + + describe("SamplingStats", () => { + test("starts with zero counters", () => { + const stats = new SamplingStats(); + + expect(stats.total).toBe(0); + expect(stats.sampled).toBe(0); + expect(stats.dropped).toBe(0); + expect(stats.dropRate).toBe(0); + }); + + test("records total logs", () => { + const stats = new SamplingStats(); + + stats.record({ shouldLog: true, wasSampled: false, rate: 1.0 }); + stats.record({ shouldLog: true, wasSampled: false, rate: 1.0 }); + + expect(stats.total).toBe(2); + }); + + test("records sampled logs", () => { + const stats = new SamplingStats(); + + stats.record({ shouldLog: true, wasSampled: true, rate: 0.5 }); + stats.record({ shouldLog: false, wasSampled: true, rate: 0.5 }); + stats.record({ shouldLog: true, wasSampled: false, rate: 1.0 }); + + expect(stats.sampled).toBe(2); + }); + + test("records dropped logs", () => { + const stats = new SamplingStats(); + + stats.record({ shouldLog: false, wasSampled: true, rate: 0.5 }); + stats.record({ shouldLog: true, wasSampled: true, rate: 0.5 }); + + expect(stats.dropped).toBe(1); + }); + + test("calculates drop rate correctly", () => { + const stats = new SamplingStats(); + + // 2 sampled, 1 dropped = 50% drop rate + stats.record({ shouldLog: false, wasSampled: true, rate: 0.5 }); + stats.record({ shouldLog: true, wasSampled: true, rate: 0.5 }); + + expect(stats.dropRate).toBe(0.5); + }); + + test("toJSON returns stats object", () => { + const stats = new SamplingStats(); + + stats.record({ shouldLog: true, wasSampled: true, rate: 0.5 }); + stats.record({ shouldLog: false, wasSampled: true, rate: 0.5 }); + + const json = stats.toJSON(); + expect(json).toEqual({ + total: 2, + sampled: 2, + dropped: 1, + dropRate: 0.5, + }); + }); + + test("reset clears all counters", () => { + const stats = new SamplingStats(); + + stats.record({ shouldLog: true, wasSampled: true, rate: 0.5 }); + stats.record({ shouldLog: false, wasSampled: true, rate: 0.5 }); + stats.reset(); + + expect(stats.total).toBe(0); + expect(stats.sampled).toBe(0); + expect(stats.dropped).toBe(0); + }); + }); +}); diff --git a/test/transports.test.ts b/test/transports.test.ts index 465f193..5abbde5 100644 --- a/test/transports.test.ts +++ b/test/transports.test.ts @@ -2,13 +2,13 @@ * Unit tests for transport creation. */ -import type { LoggingConfig } from "../src/core/types"; +import { buildConfig } from "../src/core/config"; import { createTransports } from "../src/infra/transports"; describe("Transports", () => { describe("createTransports()", () => { test("returns a silent fallback transport when all transports disabled", () => { - const config: LoggingConfig = { + const config = buildConfig({ level: "info", console: false, file: false, @@ -18,7 +18,7 @@ describe("Transports", () => { http: false, httpUrl: "", httpApiKey: "", - }; + }); const transports = createTransports(config); // Fallback silent transport to prevent Winston errors @@ -27,7 +27,7 @@ describe("Transports", () => { }); test("includes Console transport when console is true", () => { - const config: LoggingConfig = { + const config = buildConfig({ level: "info", console: true, file: false, @@ -37,7 +37,7 @@ describe("Transports", () => { http: false, httpUrl: "", httpApiKey: "", - }; + }); const transports = createTransports(config); expect(transports.length).toBe(1); @@ -45,7 +45,7 @@ describe("Transports", () => { }); test("includes DailyRotateFile transport when file is true", () => { - const config: LoggingConfig = { + const config = buildConfig({ level: "info", console: false, file: true, @@ -55,7 +55,7 @@ describe("Transports", () => { http: false, httpUrl: "", httpApiKey: "", - }; + }); const transports = createTransports(config); expect(transports.length).toBe(1); @@ -63,7 +63,7 @@ describe("Transports", () => { }); test("includes Http transport when http is true and httpUrl is set", () => { - const config: LoggingConfig = { + const config = buildConfig({ level: "info", console: false, file: false, @@ -73,7 +73,7 @@ describe("Transports", () => { http: true, httpUrl: "https://logs.example.com/api/logs", httpApiKey: "test-api-key", - }; + }); const transports = createTransports(config); expect(transports.length).toBe(1); @@ -81,7 +81,7 @@ describe("Transports", () => { }); test("does not include Http transport when httpUrl is empty", () => { - const config: LoggingConfig = { + const config = buildConfig({ level: "info", console: false, file: false, @@ -91,7 +91,7 @@ describe("Transports", () => { http: true, httpUrl: "", // Empty URL httpApiKey: "test-api-key", - }; + }); const transports = createTransports(config); // Only fallback transport since no valid transports configured @@ -100,7 +100,7 @@ describe("Transports", () => { }); test("includes multiple transports when enabled", () => { - const config: LoggingConfig = { + const config = buildConfig({ level: "debug", console: true, file: true, @@ -110,7 +110,7 @@ describe("Transports", () => { http: true, httpUrl: "https://logs.example.com/api", httpApiKey: "key", - }; + }); const transports = createTransports(config); expect(transports.length).toBe(3); @@ -122,7 +122,7 @@ describe("Transports", () => { }); test("respects log level in transports", () => { - const config: LoggingConfig = { + const config = buildConfig({ level: "error", console: true, file: false, @@ -132,7 +132,7 @@ describe("Transports", () => { http: false, httpUrl: "", httpApiKey: "", - }; + }); const transports = createTransports(config); expect(transports.length).toBe(1); @@ -143,7 +143,7 @@ describe("Transports", () => { describe("HTTP Transport URL Parsing", () => { test("handles HTTPS URLs correctly", () => { - const config: LoggingConfig = { + const config = buildConfig({ level: "info", console: false, file: false, @@ -153,14 +153,14 @@ describe("Transports", () => { http: true, httpUrl: "https://secure.example.com:8443/logs/ingest", httpApiKey: "secret-key", - }; + }); const transports = createTransports(config); expect(transports.length).toBe(1); }); test("handles HTTP URLs correctly", () => { - const config: LoggingConfig = { + const config = buildConfig({ level: "info", console: false, file: false, @@ -170,14 +170,14 @@ describe("Transports", () => { http: true, httpUrl: "http://localhost:3000/logs", httpApiKey: "dev-key", - }; + }); const transports = createTransports(config); expect(transports.length).toBe(1); }); test("handles URLs with query parameters", () => { - const config: LoggingConfig = { + const config = buildConfig({ level: "info", console: false, file: false, @@ -187,7 +187,7 @@ describe("Transports", () => { http: true, httpUrl: "https://api.example.com/logs?env=test&version=1", httpApiKey: "api-key", - }; + }); const transports = createTransports(config); expect(transports.length).toBe(1);