Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# =============================================================================
Expand All @@ -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
Expand Down
41 changes: 41 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): 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 };
Expand Down
227 changes: 227 additions & 0 deletions src/core/error-parser.ts
Original file line number Diff line number Diff line change
@@ -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() || "<native>",
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: "<anonymous>",
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: "<anonymous>",
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<LoggingConfig, "errorStackEnabled" | "errorStackLines">,
): (error: Error) => Record<string, unknown> {
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),
};
};
}
3 changes: 3 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
export * from "./types";
export * from "./config";
export * from "./correlation";
export * from "./masking";
export * from "./error-parser";
export * from "./sampling";
Loading