From 647c7042596db3b25672b2568dc71f5a4842d0ab Mon Sep 17 00:00:00 2001 From: Eugene Terehov Date: Thu, 2 Jul 2026 08:46:18 +0200 Subject: [PATCH 01/66] Restructure the logger into core, env and render modules Replace the flat settings and overwrite hooks with grouped settings (mask/json/pretty/stack/meta), one log pipeline (mask -> logObj -> meta -> middleware -> format -> transports) and a middleware chain. Entry points now inject their runtime environment instead of sharing a module-level singleton, the default output type is environment-aware, and JSON output is flat and fields-first. Adds async-context correlation on top of AsyncLocalStorage, path/regex/censor masking that survives shared references and cycles, runtime level changes with optional browser persistence, and Logger.fromEnv. Every env read is guarded per property so Deno without --allow-env can import and log. --- src/BaseLogger.ts | 1532 +++++++----------------------- src/core/asyncContext.ts | 112 +++ src/core/config.ts | 49 + src/core/fromEnv.ts | 36 + src/core/levelPersistence.ts | 64 ++ src/core/levels.ts | 75 ++ src/core/logObj.ts | 148 +++ src/core/masking.ts | 730 ++++++++++++++ src/core/meta.ts | 114 +++ src/core/pipeline.ts | 222 +++++ src/core/settings.ts | 271 ++++++ src/core/transports.ts | 240 +++++ src/env/environment.browser.ts | 294 ++++++ src/env/environment.node.ts | 261 +++++ src/env/environment.ts | 102 ++ src/env/environment.universal.ts | 323 +++++++ src/env/shared.ts | 498 ++++++++++ src/env/stackTrace.ts | 131 +++ src/formatTemplate.ts | 6 +- src/index.browser.ts | 122 ++- src/index.node.ts | 185 ++++ src/index.ts | 142 +-- src/index.universal.ts | 195 ++++ src/interfaces.ts | 566 +++++++++-- src/internal/environment.ts | 87 ++ src/internal/errorUtils.ts | 102 +- src/internal/metaFormatting.ts | 20 +- src/internal/nativeConsole.ts | 41 + src/render/inspect.polyfill.ts | 543 +++++++++++ src/render/inspect.ts | 50 + src/render/json.ts | 557 +++++++++++ src/render/styles.ts | 136 +++ 32 files changed, 6500 insertions(+), 1454 deletions(-) create mode 100644 src/core/asyncContext.ts create mode 100644 src/core/config.ts create mode 100644 src/core/fromEnv.ts create mode 100644 src/core/levelPersistence.ts create mode 100644 src/core/levels.ts create mode 100644 src/core/logObj.ts create mode 100644 src/core/masking.ts create mode 100644 src/core/meta.ts create mode 100644 src/core/pipeline.ts create mode 100644 src/core/settings.ts create mode 100644 src/core/transports.ts create mode 100644 src/env/environment.browser.ts create mode 100644 src/env/environment.node.ts create mode 100644 src/env/environment.ts create mode 100644 src/env/environment.universal.ts create mode 100644 src/env/shared.ts create mode 100644 src/env/stackTrace.ts create mode 100644 src/index.node.ts create mode 100644 src/index.universal.ts create mode 100644 src/internal/nativeConsole.ts create mode 100644 src/render/inspect.polyfill.ts create mode 100644 src/render/inspect.ts create mode 100644 src/render/json.ts create mode 100644 src/render/styles.ts diff --git a/src/BaseLogger.ts b/src/BaseLogger.ts index a3afed6..5a8d861 100644 --- a/src/BaseLogger.ts +++ b/src/BaseLogger.ts @@ -1,1035 +1,344 @@ -import { formatTemplate } from "./formatTemplate.js"; -import type { IErrorObject, ILogObj, ILogObjMeta, IMeta, IMetaStatic, ISettings, ISettingsParam, IStackFrame, TLogLevel } from "./interfaces.js"; -import { DefaultLogLevels } from "./interfaces.js"; -import { consoleSupportsCssStyling, isBrowserEnvironment, safeGetCwd } from "./internal/environment.js"; -import { collectErrorCauses, toError } from "./internal/errorUtils.js"; -import type { InspectOptions } from "./internal/InspectOptions.interface.js"; -import { jsonStringifyRecursive } from "./internal/jsonStringifyRecursive.js"; +import type { AsyncContextFields, AsyncContextStore } from "./core/asyncContext.js"; +import { createAsyncContextStore } from "./core/asyncContext.js"; +import { DEFAULT_PERSIST_LEVEL_KEY, readPersistedLevel, writePersistedLevel } from "./core/levelPersistence.js"; +import { resolveLogLevelId as resolveLevelId, validateCustomLevel } from "./core/levels.js"; +import { type LogObjDeps, recursiveCloneAndExecuteFunctions, toLogObj } from "./core/logObj.js"; +import { MaskingEngine } from "./core/masking.js"; +import { attachMaskedArgs, resolveFormatter, runMiddleware } from "./core/pipeline.js"; +import { normalizeSettings, resolveLogLevelId, validateSettingsParam } from "./core/settings.js"; +import { attachTransport, dispatchToTransports, flushAll } from "./core/transports.js"; +import type { EnvironmentProvider } from "./env/environment.js"; +import type { + ILogObj, + ILogObjMeta, + IMeta, + ISettings, + ISettingsParam, + LogContext, + LogMiddleware, + TLogFormat, + TLogLevelName, + Transport, + TransportFn, +} from "./interfaces.js"; import { buildPrettyMeta } from "./internal/metaFormatting.js"; -import { buildStackTrace, clampIndex, findFirstExternalFrameIndex, getDefaultIgnorePatterns } from "./internal/stackTrace.js"; -import { formatWithOptions } from "./internal/util.inspect.polyfill.js"; -import { urlToObject } from "./urlToObj.js"; +import { nativeConsoleMethod } from "./internal/nativeConsole.js"; +import { renderJson } from "./render/json.js"; -type RuntimeName = "browser" | "node" | "deno" | "bun" | "worker" | "unknown"; - -interface RuntimeInfo { - name: RuntimeName; - version?: string; - hostname?: string; - userAgent?: string; -} - -export function createLoggerEnvironment(): LoggerEnvironment { - const runtimeInfo = detectRuntimeInfo(); - const meta: RuntimeMetaStatic = createRuntimeMeta(runtimeInfo); - const usesBrowserStack = runtimeInfo.name === "browser" || runtimeInfo.name === "worker"; - const callerIgnorePatterns = usesBrowserStack - ? [...getDefaultIgnorePatterns(), /node_modules[\\/].*tslog/i] - : [...getDefaultIgnorePatterns(), /node:(?:internal|vm)/i, /\binternal[\\/]/i]; - - let cachedCwd: string | null | undefined; - - const environment: LoggerEnvironment & { - __resetWorkingDirectoryCacheForTests?: () => void; - } = { - getMeta( - logLevelId: number, - logLevelName: string, - stackDepthLevel: number, - hideLogPositionForPerformance: boolean, - name?: string, - parentNames?: string[], - extraIgnorePatterns?: RegExp[], - ): IMeta { - return Object.assign({}, meta, { - name, - parentNames, - date: new Date(), - logLevelId, - logLevelName, - path: !hideLogPositionForPerformance ? environment.getCallerStackFrame(stackDepthLevel, new Error(), extraIgnorePatterns) : undefined, - }) as RuntimeMeta; - }, - getCallerStackFrame(stackDepthLevel: number, error: Error = new Error(), extraIgnorePatterns?: RegExp[]): IStackFrame { - const frames = buildStackTrace(error, (line) => parseStackLine(line)); - if (frames.length === 0) { - return {}; - } - - // Allow callers (e.g. wrapper/custom loggers) to register additional frame patterns to skip, - // so auto-detection lands on their caller rather than the wrapper itself. - const ignorePatterns = - extraIgnorePatterns != null && extraIgnorePatterns.length > 0 ? [...callerIgnorePatterns, ...extraIgnorePatterns] : callerIgnorePatterns; - const autoIndex = findFirstExternalFrameIndex(frames, ignorePatterns); - const useManualIndex = Number.isFinite(stackDepthLevel) && stackDepthLevel >= 0; - const resolvedIndex = useManualIndex ? clampIndex(stackDepthLevel, frames.length) : clampIndex(autoIndex, frames.length); - /* v8 ignore next -- defensive: clampIndex always yields a valid index for a non-empty frames array */ - return frames[resolvedIndex] ?? {}; - }, - getErrorTrace(error: Error): IStackFrame[] { - return buildStackTrace(error, (line) => parseStackLine(line)); - }, - isError(value: unknown): value is Error { - return isNativeError(value); - }, - isBuffer(value: unknown): boolean { - return typeof Buffer !== "undefined" && typeof Buffer.isBuffer === "function" ? Buffer.isBuffer(value) : false; - }, - prettyFormatLogObj(maskedArgs: unknown[], settings: ISettings): { args: unknown[]; errors: string[] } { - return maskedArgs.reduce( - (result: { args: unknown[]; errors: string[] }, arg) => { - if (environment.isError(arg)) { - result.errors.push(environment.prettyFormatErrorObj(arg as Error, settings)); - } else { - result.args.push(arg); - } - return result; - }, - { args: [], errors: [] }, - ); - }, - prettyFormatErrorObj(error: Error, settings: ISettings): string { - const stackLines = formatStackFrames(environment.getErrorTrace(error), settings); - const causeSections = collectErrorCauses(error).map((cause, index) => { - const header = `Caused by (${index + 1}): ${cause.name ?? "Error"}${cause.message ? `: ${cause.message}` : ""}`; - const frames = formatStackFrames( - buildStackTrace(cause, (line) => parseStackLine(line)), - settings, - ); - return [header, ...frames].join("\n"); - }); - - const placeholderValuesError = { - errorName: ` ${error.name} `, - errorMessage: formatErrorMessage(error), - errorStack: [...stackLines, ...causeSections].join("\n"), - }; - - return formatTemplate(settings, settings.prettyErrorTemplate, placeholderValuesError); - }, - transportFormatted(logMetaMarkup: string, logArgs: unknown[], logErrors: string[], logMeta: IMeta | undefined, settings: ISettings): void { - const prettyLogs = settings.stylePrettyLogs !== false; - const logErrorsStr = (logErrors.length > 0 && logArgs.length > 0 ? "\n" : "") + logErrors.join("\n"); - const sanitizedMetaMarkup = stripAnsi(logMetaMarkup); - const metaMarkupForText = prettyLogs ? logMetaMarkup : sanitizedMetaMarkup; - const log = getPrettyLogMethod(logMeta?.logLevelName, settings.prettyLogLevelMethod); - - if (shouldUseCss(prettyLogs)) { - settings.prettyInspectOptions.colors = false; - const formattedArgs = formatWithOptionsSafe(settings.prettyInspectOptions, logArgs); - const cssMeta = logMeta != null ? buildCssMetaOutput(settings, logMeta) : { text: sanitizedMetaMarkup, styles: [] }; - const hasCssMeta = cssMeta.text.length > 0 && cssMeta.styles.length > 0; - const metaOutput = hasCssMeta ? cssMeta.text : sanitizedMetaMarkup; - const output = metaOutput + formattedArgs + logErrorsStr; - - if (hasCssMeta) { - log(output, ...cssMeta.styles); - } else { - log(output); - } - return; - } - - settings.prettyInspectOptions.colors = prettyLogs; - const formattedArgs = formatWithOptionsSafe(settings.prettyInspectOptions, logArgs); - log(metaMarkupForText + formattedArgs + logErrorsStr); - }, - transportJSON(json: LogObj & ILogObjMeta): void { - console.log(jsonStringifyRecursive(json)); - }, - }; - - if (getNodeEnv() === "test") { - environment.__resetWorkingDirectoryCacheForTests = () => { - cachedCwd = undefined; - }; - } - - return environment; - - function parseStackLine(line?: string): IStackFrame | undefined { - return usesBrowserStack ? parseBrowserStackLine(line) : parseServerStackLine(line); - } - - function parseServerStackLine(rawLine?: string): IStackFrame | undefined { - if (typeof rawLine !== "string" || rawLine.length === 0) { - return undefined; - } - - const trimmedLine = rawLine.trim(); - if (!trimmedLine.includes(" at ") && !trimmedLine.startsWith("at ")) { - return undefined; - } - - const line = trimmedLine.replace(/^at\s+/, ""); - let method: string | undefined; - let location = line; - - const methodMatch = line.match(/^(.*?)\s+\((.*)\)$/); - if (methodMatch) { - method = methodMatch[1]; - location = methodMatch[2]; - } - - const sanitizedLocation = location.replace(/^\(/, "").replace(/\)$/, ""); - const withoutQuery = sanitizedLocation.replace(/\?.*$/, ""); - - let fileLine: string | undefined; - let fileColumn: string | undefined; - let filePathCandidate = withoutQuery; - - const segments = withoutQuery.split(":"); - if (segments.length >= 3 && /^\d+$/.test(segments[segments.length - 1])) { - fileColumn = segments.pop(); - fileLine = segments.pop(); - filePathCandidate = segments.join(":"); - } else if (segments.length >= 2 && /^\d+$/.test(segments[segments.length - 1])) { - fileLine = segments.pop(); - filePathCandidate = segments.join(":"); - } - - let normalizedPath = filePathCandidate.replace(/^file:\/\//, ""); - const cwd = getWorkingDirectory(); - if (cwd != null && normalizedPath.startsWith(cwd)) { - normalizedPath = normalizedPath.slice(cwd.length); - normalizedPath = normalizedPath.replace(/^[\\/]/, ""); - } - - if (normalizedPath.length === 0) { - normalizedPath = filePathCandidate; - } - - const normalizedPathWithoutLine = normalizeFilePath(normalizedPath); - const effectivePath = normalizedPathWithoutLine.length > 0 ? normalizedPathWithoutLine : normalizedPath; - const pathSegments = effectivePath.split(/\\|\//); - const fileName = pathSegments[pathSegments.length - 1]; - const fileNameWithLine = fileName && fileLine ? `${fileName}:${fileLine}` : undefined; - const filePathWithLine = effectivePath && fileLine ? `${effectivePath}:${fileLine}` : undefined; - - return { - fullFilePath: sanitizedLocation, - fileName, - fileNameWithLine, - fileColumn, - fileLine, - filePath: effectivePath, - filePathWithLine, - method, - }; - } - - function parseBrowserStackLine(line?: string): IStackFrame | undefined { - const href = (globalThis as { location?: { origin?: string } }).location?.origin; - /* v8 ignore next 3 -- defensive: buildStackTrace only ever feeds non-null lines into the parser */ - if (line == null) { - return undefined; - } - - const match = line.match(BROWSER_PATH_REGEX); - if (!match) { - return undefined; - } - - const filePath = match[1]?.replace(/\?.*$/, ""); - /* v8 ignore next 3 -- defensive: the regex requires capture group 1 to match, so filePath is never null here */ - if (filePath == null) { - return undefined; - } +export * from "./interfaces.js"; - const pathParts = filePath.split("/"); - const fileLine = match[2]; - const fileColumn = match[3]; - const fileName = pathParts[pathParts.length - 1]; +/** + * The core logging pipeline (BC11 — no module-level environment singleton). + * + * `BaseLogger` owns the runtime-agnostic pipeline: settings normalization, the `log()` method, + * `attachTransport`, and `getSubLogger`. Everything that depends on the runtime (stack parsing, meta + * assembly, inspect, console transports, CSS styling) is supplied through the injected + * {@link EnvironmentProvider}, and the masking / log-object / meta engines live in `core/*`. + * + * Each entry point injects its own provider: + * - `index.node.ts` -> `createNodeEnvironment()` + * - `index.browser.ts` -> `createBrowserEnvironment()` + * - `index.universal.ts` -> `selectEnvironment()` (universal provider) + * + * The constructor takes `(settings?, logObj?, environment, callerFrame=NaN)`. `callerFrame` (M1.14, + * renamed from `stackDepthLevel`) is the manual stack-frame index; `NaN` means auto-detect. + */ +export class BaseLogger { + public readonly runtime: EnvironmentProvider; + public settings: ISettings; + private readonly maxErrorCauseDepth = 5; + private readonly captureStackForMeta: boolean; + private readonly maskingEngine: MaskingEngine; + private readonly logObjDeps: LogObjDeps; + // Async context store (M2.13). Created lazily on first `runInContext`/`getContext`/`log` that needs it, so + // merely constructing a logger never resolves `AsyncLocalStorage`. Shared with sub-loggers (see getSubLogger) + // so a context entered on a parent propagates to its children. + private asyncContextStore?: AsyncContextStore; - return { - fullFilePath: href ? `${href}${filePath}` : filePath, - fileName, - fileNameWithLine: fileName && fileLine ? `${fileName}:${fileLine}` : undefined, - fileColumn, - fileLine, - filePath, - filePathWithLine: fileLine ? `${filePath}:${fileLine}` : undefined, - method: undefined, + constructor( + settings: ISettingsParam | undefined, + private logObj: LogObj | undefined, + environment: EnvironmentProvider, + private callerFrame: number = Number.NaN, + ) { + validateSettingsParam(settings); + this.runtime = environment; + // Normalize into the fully-populated settings object. The engine reads this live (never a copy), + // so post-construction mutations (e.g. tests setting mask.keys/mask.placeholder) take effect. + this.settings = normalizeSettings(settings); + + this.maskingEngine = new MaskingEngine(this.settings, { + isError: (value): value is Error => this.runtime.isError(value), + isBuffer: (value) => this.runtime.isBuffer(value), + }); + this.logObjDeps = { + isError: (value): value is Error => this.runtime.isError(value), + isBuffer: (value) => this.runtime.isBuffer(value), + getErrorTrace: (error) => this.runtime.getErrorTrace(error), + maxErrorCauseDepth: this.maxErrorCauseDepth, }; - } - function formatStackFrames(frames: IStackFrame[], settings: ISettings): string[] { - return frames.map((stackFrame) => formatTemplate(settings, settings.prettyErrorStackTemplate, { ...stackFrame }, true)); - } - - function formatErrorMessage(error: Error): string { - return Object.getOwnPropertyNames(error) - .filter((key) => key !== "stack" && key !== "cause") - .reduce((result, key) => { - const value = (error as unknown as Record)[key]; - if (typeof value === "function") { - return result; + // Opt-in browser log-level persistence (M4.6): when `persistLevel` is set, seed `minLevel` from + // localStorage so a level flipped in the devtools console survives a reload. Off-browser / when no value + // is stored this is a guarded no-op (readPersistedLevel returns undefined), leaving the normalized level. + if (this.settings.persistLevel === true) { + const persisted = readPersistedLevel(this.settings.persistLevelKey ?? DEFAULT_PERSIST_LEVEL_KEY); + if (persisted != null) { + // The stored token is either a numeric id ("2") or a level name ("WARN"); resolve numeric strings to + // a number first, then fall back to name resolution (which also covers custom levels). + const asNumber = Number(persisted); + const token: number | TLogLevelName = persisted.trim() !== "" && Number.isFinite(asNumber) ? asNumber : (persisted as TLogLevelName); + const resolved = resolveLevelId(token, this.settings.customLevels); + if (resolved != null) { + this.settings.minLevel = resolved; } - result.push(stringifyFallback(value)); - return result; - }, []) - .join(", "); - } - - function shouldUseCss(prettyLogs: boolean): boolean { - return prettyLogs && (runtimeInfo.name === "browser" || runtimeInfo.name === "worker") && consoleSupportsCssStyling(); - } - - function stripAnsi(value: string): string { - return value.replace(ANSI_REGEX, ""); - } - - function getPrettyLogMethod( - logLevelName: string | undefined, - prettyLogLevelMethod: Record void> | undefined, - ): (...args: unknown[]) => void { - if (logLevelName && prettyLogLevelMethod?.[logLevelName]) { - return prettyLogLevelMethod[logLevelName]; - } - if (prettyLogLevelMethod?.["*"]) { - return prettyLogLevelMethod["*"]; - } - return console.log; - } - - function buildCssMetaOutput(settings: ISettings, metaValue: IMeta | undefined): { text: string; styles: string[] } { - /* v8 ignore next 3 -- defensive: the sole caller only invokes this when logMeta is non-null */ - if (metaValue == null) { - return { text: "", styles: [] }; - } - - const { template, placeholders } = buildPrettyMeta(settings, metaValue); - const parts: string[] = []; - const styles: string[] = []; - let lastIndex = 0; - const placeholderRegex = /{{(.+?)}}/g; - let match: RegExpExecArray | null; - - // biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop - while ((match = placeholderRegex.exec(template)) != null) { - if (match.index > lastIndex) { - parts.push(template.slice(lastIndex, match.index)); - } - - const key = match[1]; - const rawValue = placeholders[key] != null ? String(placeholders[key]) : ""; - const tokens = collectStyleTokens(settings.prettyLogStyles?.[key as keyof typeof settings.prettyLogStyles], rawValue); - const css = tokensToCss(tokens); - - if (css.length > 0) { - parts.push(`%c${rawValue}%c`); - styles.push(css, ""); - } else { - parts.push(rawValue); - } - - lastIndex = placeholderRegex.lastIndex; - } - - if (lastIndex < template.length) { - parts.push(template.slice(lastIndex)); - } - - return { - text: parts.join(""), - styles, - }; - } - - function collectStyleTokens(style: unknown, value: string): string[] { - if (style == null) { - return []; - } - - if (typeof style === "string") { - return [style]; - } - - if (Array.isArray(style)) { - return style.flatMap((token) => collectStyleTokens(token, value)); - } - - if (typeof style === "object") { - const normalizedValue = value.trim(); - const nextStyle = (style as Record)[normalizedValue] ?? (style as Record)["*"]; - if (nextStyle == null) { - return []; } - return collectStyleTokens(nextStyle, value); } - return []; - } - - function tokensToCss(tokens: string[]): string { - const seen = new Set(); - const cssParts: string[] = []; - for (const token of tokens) { - const css = styleTokenToCss(token); - if (css != null && css.length > 0 && !seen.has(css)) { - seen.add(css); - cssParts.push(css); - } - } - return cssParts.join("; "); + this.captureStackForMeta = this._shouldCaptureStack(); } - function styleTokenToCss(token: string): string | undefined { - const color = COLOR_TOKENS[token]; - if (color != null) { - return `color: ${color}`; - } - - const background = BACKGROUND_TOKENS[token]; - if (background != null) { - return `background-color: ${background}`; + /** + * Set this logger's minimum level at runtime (M4.6). Accepts a numeric id, the + * {@link import("./interfaces.js").LogLevel} enum, or a level name (a default like `"WARN"` or a + * registered custom level like `"NOTICE"`), resolved the same way `minLevel` is. An unknown level name is + * ignored (the level is left unchanged). Works on every runtime. + * + * When {@link import("./interfaces.js").ISettingsParam.persistLevel} is enabled and running in a browser, + * the new level is also written to `localStorage` (guarded; a no-op off-browser) so it survives a reload. + * + * @returns this logger, for chaining. + * @example logger.setMinLevel("WARN"); // or logger.setMinLevel(4) + */ + public setMinLevel(level: number | TLogLevelName): this { + const resolved = resolveLevelId(level, this.settings.customLevels); + if (resolved == null) { + return this; } - - switch (token) { - case "bold": - return "font-weight: bold"; - case "dim": - return "opacity: 0.75"; - case "italic": - return "font-style: italic"; - case "underline": - return "text-decoration: underline"; - case "overline": - return "text-decoration: overline"; - case "inverse": - return "filter: invert(1)"; - case "hidden": - return "visibility: hidden"; - case "strikethrough": - return "text-decoration: line-through"; - /* v8 ignore next 2 -- defensive: an unknown style token throws earlier during ANSI rendering, so it never reaches the CSS path */ - default: - return undefined; + this.settings.minLevel = resolved; + if (this.settings.persistLevel === true) { + writePersistedLevel(resolved, this.settings.persistLevelKey ?? DEFAULT_PERSIST_LEVEL_KEY); } + return this; } - function getWorkingDirectory(): string | undefined { - if (cachedCwd === undefined) { - cachedCwd = safeGetCwd() ?? null; + /** + * Logs a message with a custom log level. + * @param logLevelId - Log level ID e.g. 0 + * @param logLevelName - Log level name e.g. silly + * @param args - Multiple log attributes that should be logged out. + * @return LogObject with meta property, when log level is >= minLevel + */ + public log(logLevelId: number, logLevelName: string, ...args: unknown[]): (LogObj & ILogObjMeta) | undefined { + // Below-minLevel short-circuit: allocation-free, the args are never touched. + if (logLevelId < this.settings.minLevel) { + return; } - return cachedCwd ?? undefined; - } - - function shouldCaptureHostname(): boolean { - return runtimeInfo.name === "node" || runtimeInfo.name === "deno" || runtimeInfo.name === "bun"; - } - - function shouldCaptureRuntimeVersion(): boolean { - return runtimeInfo.name === "node" || runtimeInfo.name === "deno" || runtimeInfo.name === "bun"; - } - function createRuntimeMeta(info: RuntimeInfo): RuntimeMetaStatic { - if (info.name === "browser" || info.name === "worker") { - return { - runtime: info.name, - browser: info.userAgent, - }; - } + const resolvedArgs = this._resolveLogArguments(args); + const logArgs = [...this.settings.prefix, ...resolvedArgs]; - const metaStatic: RuntimeMetaStatic = { - runtime: info.name, + // Middleware chain (replaces the v4 overwrite.* hooks): runs in registration order over a mutable + // context. Any middleware may rewrite args/level/meta or drop the log entirely (return null/false). + let context: LogContext | null = { + logLevelId, + logLevelName, + args: logArgs, + settings: this.settings, + meta: {}, }; - - if (shouldCaptureRuntimeVersion()) { - metaStatic.runtimeVersion = info.version ?? "unknown"; - } - - if (shouldCaptureHostname()) { - metaStatic.hostname = info.hostname ?? "unknown"; - } - - return metaStatic; - } - - function formatWithOptionsSafe(options: InspectOptions, args: unknown[]): string { - try { - return formatWithOptions(options, ...args); - } catch { - return args.map(stringifyFallback).join(" "); - } - } - - function stringifyFallback(value: unknown): string { - if (typeof value === "string") { - return value; - } - - try { - return JSON.stringify(value); - /* v8 ignore next 3 -- defensive: only reached for values JSON.stringify rejects (e.g. BigInt) while the primary inspect path has already failed */ - } catch { - return String(value); - } - } - - function normalizeFilePath(value: string): string { - if (typeof value !== "string" || value.length === 0) { - return value; - } - - const replaced = value.replace(/\\+/g, "\\").replace(/\\/g, "/"); - const hasRootDoubleSlash = replaced.startsWith("//"); - const hasLeadingSlash = replaced.startsWith("/") && !hasRootDoubleSlash; - const driveMatch = replaced.match(/^[A-Za-z]:/); - const drivePrefix = driveMatch ? driveMatch[0] : ""; - const withoutDrive = drivePrefix ? replaced.slice(drivePrefix.length) : replaced; - - const segments = withoutDrive.split("/"); - const normalizedSegments: string[] = []; - for (const segment of segments) { - if (segment === "" || segment === ".") { - continue; + if (this.settings.middleware.length > 0) { + context = runMiddleware(context, this.settings.middleware); + if (context == null) { + return; } - if (segment === "..") { - if (normalizedSegments.length > 0) { - normalizedSegments.pop(); + } + const effectiveLevelId = context.logLevelId; + const effectiveLevelName = context.logLevelName; + + // Mask / normalize the (possibly middleware-rewritten) args through the masking engine. + const maskedArgs: unknown[] = this.maskingEngine.mask(context.args); + + // Execute default LogObj functions for every log (e.g. requestId), then build the flat log object. + const thisLogObj: LogObj | undefined = this.logObj != null ? recursiveCloneAndExecuteFunctions(this.logObj) : undefined; + const logObj: LogObj = toLogObj(maskedArgs, this.settings.argumentsArrayName, this.logObjDeps, thisLogObj); + + // Attach the runtime _meta block (incl. v: 5 via the JSON renderer) to produce the finished record. + const record: LogObj & ILogObjMeta = this._addMetaToLogObj(logObj, effectiveLevelId, effectiveLevelName); + + // Any middleware-stashed meta fields are merged onto the record's _meta block so a later format stage + // (or a transport) can read trace/correlation data the middleware attached. + const recordMeta = record[this.settings.meta.property] as IMeta | undefined; + if (recordMeta != null) { + // Auto-attach the active async context's fields (M2.13) FIRST, so explicit middleware-stashed meta + // (set this call) takes precedence over inherited context fields on a key collision. + if (this.settings.meta.attachContext) { + const activeContext = this.asyncContextStore?.getStore(); + if (activeContext != null) { + for (const key of Object.keys(activeContext)) { + (recordMeta as unknown as Record)[key] = activeContext[key]; + } } - continue; } - normalizedSegments.push(segment); - } - - let normalized = normalizedSegments.join("/"); - if (hasRootDoubleSlash) { - normalized = `//${normalized}`; - } else if (hasLeadingSlash) { - normalized = `/${normalized}`; - } else if (drivePrefix !== "") { - normalized = `${drivePrefix}${normalized.length > 0 ? `/${normalized}` : ""}`; - } - - if (normalized.length === 0) { - return value; - } - - return normalized; - } - - function detectRuntimeInfo(): RuntimeInfo { - if (isBrowserEnvironment()) { - const navigatorObj = (globalThis as { navigator?: { userAgent?: string } }).navigator; - return { - name: "browser", - userAgent: navigatorObj?.userAgent, - }; - } - - const globalScope = globalThis as { - importScripts?: unknown; - navigator?: { userAgent?: string }; - }; - - if (typeof globalScope.importScripts === "function") { - return { - name: "worker", - userAgent: globalScope.navigator?.userAgent, - }; - } - - const globalAny = globalThis as { - process?: { versions?: Record; version?: string; env?: Record }; - Deno?: { version?: { deno?: string }; env?: { get?: (key: string) => string | undefined }; hostname?: () => string }; - Bun?: { version?: string; env?: Record }; - location?: { hostname?: string }; - }; - - if (globalAny.Bun != null) { - const bunVersion = globalAny.Bun.version; - return { - name: "bun", - version: bunVersion != null ? `bun/${bunVersion}` : undefined, - hostname: getEnvironmentHostname(globalAny.process, globalAny.Deno, globalAny.Bun, globalAny.location), - }; - } - - if (globalAny.Deno != null) { - const denoHostname = resolveDenoHostname(globalAny.Deno); - const denoVersion = globalAny.Deno?.version?.deno; - return { - name: "deno", - version: denoVersion != null ? `deno/${denoVersion}` : undefined, - hostname: denoHostname ?? getEnvironmentHostname(globalAny.process, globalAny.Deno, globalAny.Bun, globalAny.location), - }; - } - - if (globalAny.process?.versions?.node != null || globalAny.process?.version != null) { - return { - name: "node", - version: globalAny.process?.versions?.node ?? globalAny.process?.version, - hostname: getEnvironmentHostname(globalAny.process, globalAny.Deno, globalAny.Bun, globalAny.location), - }; - } - - if (globalAny.process != null) { - return { - name: "node", - version: "unknown", - hostname: getEnvironmentHostname(globalAny.process, globalAny.Deno, globalAny.Bun, globalAny.location), - }; - } - - return { - name: "unknown", - }; - } - - function getEnvironmentHostname( - nodeProcess?: { env?: Record }, - deno?: { env?: { get?: (key: string) => string | undefined } }, - bun?: { env?: Record }, - location?: { hostname?: string }, - ): string | undefined { - const processHostname = nodeProcess?.env?.HOSTNAME ?? nodeProcess?.env?.HOST ?? nodeProcess?.env?.COMPUTERNAME; - if (processHostname != null && processHostname.length > 0) { - return processHostname; + for (const key of Object.keys(context.meta)) { + (recordMeta as unknown as Record)[key] = context.meta[key]; + } } - const bunHostname = bun?.env?.HOSTNAME ?? bun?.env?.HOST ?? bun?.env?.COMPUTERNAME; - if (bunHostname != null && bunHostname.length > 0) { - return bunHostname; - } + // Expose the masked args to the pretty format stage (tslog inspects the ARGS for pretty output, not + // the reshaped record). Non-enumerable, so it never leaks into JSON output or spreads. + attachMaskedArgs(record, maskedArgs); - try { - const denoEnvGet = deno?.env?.get; - if (typeof denoEnvGet === "function") { - const value = denoEnvGet("HOSTNAME"); - if (value != null && value.length > 0) { - return value; - } + // Single console path (no overwrite branching): pretty -> live console transport (with browser CSS); + // json -> the flat fields-first line; hidden -> no console output (transports still run). + if (this.settings.type === "pretty") { + const { args: prettyArgs, errors: prettyErrors } = this.runtime.prettyFormatLogObj(maskedArgs, this.settings); + const metaMarkup = buildPrettyMeta(this.settings, recordMeta).text; + this.runtime.transportFormatted(metaMarkup, prettyArgs, prettyErrors, recordMeta, this.settings); + } else if (this.settings.type === "json") { + try { + nativeConsoleMethod("log")(renderJson(record, this.settings)); + /* v8 ignore next 3 -- defensive: guards against a console.log implementation that itself throws */ + } catch { + // never let the console sink crash logging } - } catch { - // ignore permission or access issues } - if (location?.hostname != null && location.hostname.length > 0) { - return location.hostname; + // Attached transports: each gated by its own minLevel and formatted per its own `format` (lazily, + // shared across transports that request the same format), every transport isolated in try/catch. + if (this.settings.attachedTransports.length > 0) { + const defaultFormat: TLogFormat = this.settings.type === "json" ? "json" : "pretty"; + dispatchToTransports(this.settings.attachedTransports, record, effectiveLevelId, defaultFormat, (rec, format) => + resolveFormatter(format, this.runtime)(rec, this.settings), + ); } - return undefined; + return record; } - function resolveDenoHostname(deno?: { hostname?: () => string }): string | undefined { - try { - if (typeof deno?.hostname === "function") { - const value = deno.hostname(); - if (value != null && value.length > 0) { - return value; - } - } - } catch { - // ignore inability to resolve hostname via Deno APIs - } - const locationHostname = (globalThis as { location?: { hostname?: string } }).location?.hostname; - if (locationHostname != null && locationHostname.length > 0) { - return locationHostname; + /** + * Whether a log at `level` would be emitted by this logger (E4) — i.e. its resolved id is `>=` + * `this.settings.minLevel`. Accepts a numeric id, the {@link import("./interfaces.js").LogLevel} + * enum, or a level name (a default like `"WARN"` or a registered custom level like `"NOTICE"`), resolved + * the same way `minLevel` is. An unknown level name returns `false`. Use this to guard expensive payload + * construction before logging. + * + * @example + * if (logger.isLevelEnabled("DEBUG")) logger.debug({ snapshot: serializeExpensiveState() }); + */ + public isLevelEnabled(level: number | TLogLevelName): boolean { + const resolved = resolveLevelId(level, this.settings.customLevels); + if (resolved == null) { + return false; } - return undefined; + return resolved >= this.settings.minLevel; } - function getNodeEnv(): string | undefined { - const globalProcess = (globalThis as { process?: { env?: Record } })?.process; - return globalProcess?.env?.NODE_ENV; + /** + * Append a {@link LogMiddleware} to this logger's chain. Middleware run in registration order on every + * log before the record is built, and may enrich/rewrite the {@link LogContext} or drop the log (return + * `null`/`false`). The replacement for the removed `overwrite.*` hooks. + * + * @returns this logger, for chaining. + * @example + * logger.use((ctx) => { ctx.meta.traceId = getTraceId(); return ctx; }); + */ + public use(middleware: LogMiddleware): this { + this.settings.middleware.push(middleware); + return this; } - function isNativeError(value: unknown): value is Error { - if (value instanceof Error) { - return true; - } - - if (value != null && typeof value === "object") { - const objectTag = Object.prototype.toString.call(value); - if (/\[object .*Error\]/.test(objectTag)) { - return true; - } - - const name = (value as { name?: unknown }).name; - if (typeof name === "string" && name.endsWith("Error")) { - return true; - } + /** + * Lazily resolve this logger's {@link AsyncContextStore} (M2.13), creating it on first use so merely + * constructing a logger never touches `AsyncLocalStorage`. Sub-loggers share their parent's store. + */ + private _getAsyncContextStore(): AsyncContextStore { + if (this.asyncContextStore == null) { + // Prefer the runtime provider's resolver (Node resolves via createRequire); fall back to the core + // global/builtin probe. Either yields a graceful no-op store where AsyncLocalStorage is unavailable. + this.asyncContextStore = this.runtime.createAsyncContextStore != null ? this.runtime.createAsyncContextStore() : createAsyncContextStore(); } - - return false; - } -} - -// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes -const ANSI_REGEX = /\u001b\[[0-9;]*m/g; - -const COLOR_TOKENS: Record = { - black: "#000000", - red: "#ef5350", - green: "#66bb6a", - yellow: "#fdd835", - blue: "#42a5f5", - magenta: "#ab47bc", - cyan: "#26c6da", - white: "#fafafa", - blackBright: "#424242", - redBright: "#ff7043", - greenBright: "#81c784", - yellowBright: "#ffe082", - blueBright: "#64b5f6", - magentaBright: "#ce93d8", - cyanBright: "#4dd0e1", - whiteBright: "#ffffff", -}; - -const BACKGROUND_TOKENS: Record = { - bgBlack: "#000000", - bgRed: "#ef5350", - bgGreen: "#66bb6a", - bgYellow: "#fdd835", - bgBlue: "#42a5f5", - bgMagenta: "#ab47bc", - bgCyan: "#26c6da", - bgWhite: "#fafafa", - bgBlackBright: "#424242", - bgRedBright: "#ff7043", - bgGreenBright: "#81c784", - bgYellowBright: "#ffe082", - bgBlueBright: "#64b5f6", - bgMagentaBright: "#ce93d8", - bgCyanBright: "#4dd0e1", - bgWhiteBright: "#ffffff", -}; -interface LoggerEnvironment { - getMeta: ( - logLevelId: number, - logLevelName: string, - stackDepthLevel: number, - hideLogPositionForPerformance: boolean, - name?: string, - parentNames?: string[], - extraIgnorePatterns?: RegExp[], - ) => IMeta; - getCallerStackFrame: (stackDepthLevel: number, error?: Error, extraIgnorePatterns?: RegExp[]) => IStackFrame; - getErrorTrace: (error: Error) => IStackFrame[]; - isError: (value: unknown) => value is Error; - isBuffer: (value: unknown) => boolean; - prettyFormatLogObj: ( - maskedArgs: unknown[], - settings: ISettings, - ) => { - args: unknown[]; - errors: string[]; - }; - prettyFormatErrorObj: (error: Error, settings: ISettings) => string; - transportFormatted: (logMetaMarkup: string, logArgs: unknown[], logErrors: string[], logMeta: IMeta | undefined, settings: ISettings) => void; - transportJSON: (json: LogObj & ILogObjMeta) => void; -} - -type RuntimeMetaStatic = IMetaStatic & { - runtimeVersion?: string; - hostname?: string; - browser?: string; -}; - -type RuntimeMeta = IMeta & { - runtimeVersion?: string; - hostname?: string; - browser?: string; -}; - -const BROWSER_PATH_REGEX = /(?:(?:file|https?|global code|[^@]+)@)?(?:file:)?((?:\/[^:/]+){2,})(?::(\d+))?(?::(\d+))?/; - -const runtime = createLoggerEnvironment(); - -export const loggerEnvironment = runtime; - -export * from "./interfaces.js"; - -/** - * Resolve a {@link TLogLevel} (number, enum, or level name like "WARN") to its numeric id. - * Unknown names fall back to `undefined` so the caller can apply its own default. - */ -function resolveLogLevelId(level: TLogLevel | undefined): number | undefined { - if (level == null) { - return undefined; - } - if (typeof level === "number") { - return level; + return this.asyncContextStore; } - const mapped = (DefaultLogLevels as unknown as Record)[level]; - return typeof mapped === "number" ? mapped : undefined; -} -/** Whether to emit developer diagnostics. Off in production and when explicitly disabled via TSLOG_DISABLE_WARNINGS. */ -function devWarningsEnabled(): boolean { - const env = (globalThis as { process?: { env?: Record } })?.process?.env; - if (env?.TSLOG_DISABLE_WARNINGS != null) { - return false; - } - return env?.NODE_ENV !== "production"; -} - -// Callers gate on devWarningsEnabled() before building a message, so this only emits. -function emitConfigWarning(message: string): void { - try { - console.warn(`tslog: ${message}`); - /* v8 ignore next 3 -- defensive: guards against a console.warn implementation that itself throws */ - } catch { - // never let a diagnostic crash logging + /** + * Run `fn` with `ctx` as the active async context (M2.13). For the (possibly async) duration of `fn`, the + * fields in `ctx` are attached onto every log's `_meta` (unless `meta.attachContext` is `false`) and are + * readable via {@link getContext} — across `await`, timers, promise chains, and nested `runInContext` + * calls (nested contexts inherit and shallow-merge over the parent). Returns whatever `fn` returns. + * + * On runtimes without `AsyncLocalStorage` (browsers/edge) this gracefully degrades: `fn` still runs, but + * no context is propagated. Never throws on account of an unavailable store. + * + * @example + * await logger.runInContext({ requestId: req.id }, async () => { + * logger.info("handling"); // _meta.requestId === req.id + * await doWork(); // still in context after the await + * }); + */ + public runInContext(ctx: AsyncContextFields, fn: () => T): T { + return this._getAsyncContextStore().run(ctx, fn); } -} -const KNOWN_PRETTY_PLACEHOLDERS = new Set([ - "yyyy", - "mm", - "dd", - "hh", - "MM", - "ss", - "ms", - "dateIsoStr", - "rawIsoStr", - "logLevelName", - "name", - "nameWithDelimiterPrefix", - "nameWithDelimiterSuffix", - "fullFilePath", - "filePathWithLine", - "fileNameWithLine", - "fileName", - "filePath", - "fileLine", - "fileColumn", -]); - -/** - * Validate user-provided settings and warn (once, in development only) about likely mistakes. - * This is a best-effort developer aid; it never throws and never changes behavior. - */ -function validateSettingsParam(settings: ISettingsParam | undefined): void { - if (settings == null || !devWarningsEnabled()) { - return; + /** + * Return the fields of the currently active async context (set by an enclosing {@link runInContext}), or + * `undefined` when there is none / the runtime has no `AsyncLocalStorage`. The otel preset can consume + * this as its trace-context getter, e.g. `otelFormat({ getSpanContext: () => logger.getContext() })`. + */ + public getContext(): AsyncContextFields | undefined { + return this._getAsyncContextStore().getStore(); } - // Out-of-range / unknown minLevel. - if (settings.minLevel != null) { - const resolved = resolveLogLevelId(settings.minLevel); - if (resolved == null) { - emitConfigWarning(`unknown minLevel ${JSON.stringify(settings.minLevel)}; expected a number 0-6 or a level name like "WARN".`); - } else if (typeof settings.minLevel === "number" && (resolved < 0 || resolved > 6)) { - emitConfigWarning(`minLevel ${resolved} is outside the default range 0-6; no default log method will be filtered as you might expect.`); - } + /** + * Register an additive custom log level (M2.14) at runtime: maps `name → id` for this logger so a + * subsequent `log(id, name, ...)` emits the right `logLevelId`/`logLevelName` and a string `minLevel` + * (e.g. `"NOTICE"`) resolves against it. The canonical seven names keep working; a name colliding with a + * default level throws. Mutates this logger's resolved settings and returns `this` for chaining. + * + * @example logger.addLevel("NOTICE", 3.5).log(3.5, "NOTICE", "heads up"); + */ + public addLevel(name: string, id: number): this { + validateCustomLevel(name, id); + this.settings.customLevels[name] = id; + return this; } - // Unknown template placeholders (typos like {{loglevelname}}). - if (typeof settings.prettyLogTemplate === "string") { - const placeholderRegex = /{{\s*(.+?)\s*}}/g; - let match: RegExpExecArray | null; - // biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop - while ((match = placeholderRegex.exec(settings.prettyLogTemplate)) != null) { - const key = match[1]; - if (!KNOWN_PRETTY_PLACEHOLDERS.has(key)) { - emitConfigWarning( - `prettyLogTemplate references unknown placeholder "{{${key}}}"; check the spelling (e.g. "{{logLevelName}}") or add it via overwrite.addPlaceholders.`, - ); - } - } + /** + * Attaches an external output sink (e.g. a log service, file system, database). Accepts a full + * {@link Transport} or a bare {@link TransportFn} (which is wrapped into a `Transport` with no flush). + * + * @param transport - the transport (or plain function) to attach. + * @returns a detach function that removes this transport on first call (idempotent thereafter). + */ + public attachTransport(transport: Transport | TransportFn): () => void { + return attachTransport(this.settings.attachedTransports, transport); } -} -export class BaseLogger { - public readonly runtime: LoggerEnvironment = runtime; - public settings: ISettings; - private readonly maxErrorCauseDepth = 5; - private readonly captureStackForMeta: boolean; - private maskKeysCache?: { - source: string[]; - caseInsensitive: boolean; - normalized: (string | number)[]; - signature: string; - }; - // not needed yet - //private subLoggers: BaseLogger[] = []; - - constructor( - settings?: ISettingsParam, - private logObj?: LogObj, - private stackDepthLevel: number = Number.NaN, - ) { - validateSettingsParam(settings); - this.settings = { - type: settings?.type ?? "pretty", - name: settings?.name, - parentNames: settings?.parentNames, - minLevel: resolveLogLevelId(settings?.minLevel) ?? 0, - argumentsArrayName: settings?.argumentsArrayName, - internalFramePatterns: settings?.internalFramePatterns, - hideLogPositionForProduction: settings?.hideLogPositionForProduction ?? false, - prettyLogTemplate: - settings?.prettyLogTemplate ?? - "{{yyyy}}.{{mm}}.{{dd}} {{hh}}:{{MM}}:{{ss}}:{{ms}}\t{{logLevelName}}\t{{filePathWithLine}}{{nameWithDelimiterPrefix}}\t", - prettyErrorTemplate: settings?.prettyErrorTemplate ?? "\n{{errorName}} {{errorMessage}}\nerror stack:\n{{errorStack}}", - prettyErrorStackTemplate: settings?.prettyErrorStackTemplate ?? " • {{fileName}}\t{{method}}\n\t{{filePathWithLine}}", - prettyErrorParentNamesSeparator: settings?.prettyErrorParentNamesSeparator ?? ":", - prettyErrorLoggerNameDelimiter: settings?.prettyErrorLoggerNameDelimiter ?? "\t", - stylePrettyLogs: settings?.stylePrettyLogs ?? true, - prettyLogTimeZone: settings?.prettyLogTimeZone ?? "UTC", - prettyLogStyles: settings?.prettyLogStyles ?? { - logLevelName: { - "*": ["bold", "black", "bgWhiteBright", "dim"], - SILLY: ["bold", "white"], - TRACE: ["bold", "whiteBright"], - DEBUG: ["bold", "green"], - INFO: ["bold", "blue"], - WARN: ["bold", "yellow"], - ERROR: ["bold", "red"], - FATAL: ["bold", "redBright"], - }, - dateIsoStr: "white", - filePathWithLine: "white", - name: ["white", "bold"], - nameWithDelimiterPrefix: ["white", "bold"], - nameWithDelimiterSuffix: ["white", "bold"], - errorName: ["bold", "bgRedBright", "whiteBright"], - fileName: ["yellow"], - fileNameWithLine: "white", - }, - prettyLogLevelMethod: settings?.prettyLogLevelMethod ?? {}, - prettyInspectOptions: settings?.prettyInspectOptions ?? { - colors: true, - compact: false, - depth: Infinity, - }, - metaProperty: settings?.metaProperty ?? "_meta", - maskPlaceholder: settings?.maskPlaceholder ?? "[***]", - maskValuesOfKeys: settings?.maskValuesOfKeys ?? ["password"], - maskValuesOfKeysCaseInsensitive: settings?.maskValuesOfKeysCaseInsensitive ?? false, - maskValuesRegEx: settings?.maskValuesRegEx, - prefix: [...(settings?.prefix ?? [])], - attachedTransports: [...(settings?.attachedTransports ?? [])], - overwrite: { - mask: settings?.overwrite?.mask, - toLogObj: settings?.overwrite?.toLogObj, - addMeta: settings?.overwrite?.addMeta, - includeDefaultMetaInAddMeta: settings?.overwrite?.includeDefaultMetaInAddMeta ?? false, - addPlaceholders: settings?.overwrite?.addPlaceholders, - formatMeta: settings?.overwrite?.formatMeta, - formatLogObj: settings?.overwrite?.formatLogObj, - transportFormatted: settings?.overwrite?.transportFormatted, - transportJSON: settings?.overwrite?.transportJSON, - }, - }; - - this.captureStackForMeta = this._shouldCaptureStack(); + /** + * Await every attached transport's `flush()`, so buffered output is written before the process exits. + * Transports without a `flush` are skipped; a failing flush is isolated and never rejects this promise. + */ + public async flush(): Promise { + await flushAll(this.settings.attachedTransports); } /** - * Logs a message with a custom log level. - * @param logLevelId - Log level ID e.g. 0 - * @param logLevelName - Log level name e.g. silly - * @param args - Multiple log attributes that should be logged out. - * @return LogObject with meta property, when log level is >= minLevel + * Async disposer (`await using`): flushes and disposes every attached transport. Lets a logger be used + * with explicit resource management so buffered transports are drained on scope exit. */ - public log(logLevelId: number, logLevelName: string, ...args: unknown[]): (LogObj & ILogObjMeta) | undefined { - if (logLevelId < this.settings.minLevel) { - return; - } - const resolvedArgs = this._resolveLogArguments(args); - const logArgs = [...this.settings.prefix, ...resolvedArgs]; - const maskedArgs: unknown[] = - this.settings.overwrite?.mask != null - ? this.settings.overwrite?.mask(logArgs) - : this.settings.maskValuesOfKeys != null && this.settings.maskValuesOfKeys.length > 0 - ? this._mask(logArgs) - : logArgs; - // execute default LogObj functions for every log (e.g. requestId) - const thisLogObj: LogObj | undefined = this.logObj != null ? this._recursiveCloneAndExecuteFunctions(this.logObj) : undefined; - const logObj: LogObj = - this.settings.overwrite?.toLogObj != null ? this.settings.overwrite?.toLogObj(maskedArgs, thisLogObj) : this._toLogObj(maskedArgs, thisLogObj); - const logObjWithMeta: LogObj & ILogObjMeta = - this.settings.overwrite?.addMeta != null - ? this.settings.overwrite.addMeta( - logObj, - logLevelId, - logLevelName, - // Optionally hand the default meta to the custom handler so it can extend rather than replace it. - this.settings.overwrite.includeDefaultMetaInAddMeta - ? this.runtime.getMeta( - logLevelId, - logLevelName, - this.stackDepthLevel, - !this.captureStackForMeta, - this.settings.name, - this.settings.parentNames, - this.settings.internalFramePatterns, - ) - : undefined, - ) - : this._addMetaToLogObj(logObj, logLevelId, logLevelName); - const logMeta = logObjWithMeta?.[this.settings.metaProperty] as IMeta | undefined; - - // overwrite no matter what, should work for any type (pretty, json, ...) - let logMetaMarkup: string | undefined; - let logArgsAndErrorsMarkup: { args: unknown[]; errors: string[] } | undefined; - if (this.settings.overwrite?.formatMeta != null) { - logMetaMarkup = this.settings.overwrite?.formatMeta(logObjWithMeta?.[this.settings.metaProperty]); - } - if (this.settings.overwrite?.formatLogObj != null) { - logArgsAndErrorsMarkup = this.settings.overwrite?.formatLogObj(maskedArgs, this.settings); - } - - if (this.settings.type === "pretty") { - logMetaMarkup = logMetaMarkup ?? this._prettyFormatLogObjMeta(logObjWithMeta?.[this.settings.metaProperty]); - logArgsAndErrorsMarkup = logArgsAndErrorsMarkup ?? runtime.prettyFormatLogObj(maskedArgs, this.settings); - } - - if (logMetaMarkup != null && logArgsAndErrorsMarkup != null) { - if (this.settings.overwrite?.transportFormatted != null) { - const transport = this.settings.overwrite.transportFormatted; - const declaredParams = transport.length; - if (declaredParams < 4) { - transport(logMetaMarkup, logArgsAndErrorsMarkup.args, logArgsAndErrorsMarkup.errors); - } else if (declaredParams === 4) { - transport(logMetaMarkup, logArgsAndErrorsMarkup.args, logArgsAndErrorsMarkup.errors, logMeta); - } else { - transport(logMetaMarkup, logArgsAndErrorsMarkup.args, logArgsAndErrorsMarkup.errors, logMeta, this.settings); - } - } else { - runtime.transportFormatted(logMetaMarkup, logArgsAndErrorsMarkup.args, logArgsAndErrorsMarkup.errors, logMeta, this.settings); - } - } else { - // overwrite transport no matter what, hide only with default transport - if (this.settings.overwrite?.transportJSON != null) { - this.settings.overwrite.transportJSON(logObjWithMeta); - } else if (this.settings.type !== "hidden") { - runtime.transportJSON(logObjWithMeta); - } - } - - if (this.settings.attachedTransports != null && this.settings.attachedTransports.length > 0) { - this.settings.attachedTransports.forEach((transportLogger) => { - try { - transportLogger(logObjWithMeta); - } catch (transportError) { - // A failing transport must never take down logging or prevent other transports from running. - try { - console.error("tslog: attached transport threw an error", transportError); - /* v8 ignore next 3 -- defensive: guards against a console.error implementation that itself throws */ - } catch { - // ignore secondary failures while reporting the transport error - } - } - }); - } - - return logObjWithMeta; + public async [Symbol.asyncDispose](): Promise { + await flushAll(this.settings.attachedTransports, true); } /** - * Attaches external Loggers, e.g. external log services, file system, database - * - * @param transportLogger - External logger to be attached. Must implement all log methods. + * Sync disposer (`using`) — best-effort (E1). Triggers each attached transport's flush+dispose but, being + * synchronous, cannot await them: a transport whose `flush`/`[Symbol.asyncDispose]` is async is kicked off + * and left to settle on its own (the rejection is isolated and swallowed by `flushAll`). For guaranteed + * draining of buffered/async transports prefer `await using` (the {@link Symbol.asyncDispose} path) or an + * explicit `await logger.flush()`. Provided so a logger also works under synchronous `using` scopes. */ - public attachTransport(transportLogger: (transportLogger: LogObj & ILogObjMeta) => void): void { - this.settings.attachedTransports.push(transportLogger); + public [Symbol.dispose](): void { + void flushAll(this.settings.attachedTransports, true); } /** @@ -1038,12 +347,41 @@ export class BaseLogger { * @param settings - Overwrite settings inherited from parent logger * @param logObj - Overwrite logObj for sub-logger */ + /** + * Alias for {@link getSubLogger} (E2) — matches the pino/bunyan/winston `child(...)` convention that + * AI models and many ecosystems reach for by reflex. Same inheritance semantics (settings/levels/context + * are inherited and merged); both methods are kept. + * + * @param settings - Overwrite settings inherited from parent logger + * @param logObj - Overwrite logObj for sub-logger + */ + public child(settings?: ISettingsParam, logObj?: LogObj): BaseLogger { + return this.getSubLogger(settings, logObj); + } + public getSubLogger(settings?: ISettingsParam, logObj?: LogObj): BaseLogger { - const subLoggerSettings: ISettings = { + // Pass the merged settings to the constructor, which re-runs normalizeSettings so the grouped + // fields (mask/json/attachedTransports/middleware) are re-resolved from the parent's resolved values + // plus any sub-logger overrides. We hand a partial param shape; the constructor fully populates it. + // Merge additive custom levels (M2.14) so a sub-logger inherits the parent's and may extend them; used + // below to resolve a string `minLevel` that names a custom level. + const mergedCustomLevels: Record = { ...this.settings.customLevels, ...settings?.customLevels }; + + const subLoggerSettings: ISettingsParam = { ...this.settings, ...settings, - // resolve a level-name minLevel (e.g. "WARN") back to its numeric id for the resolved settings - minLevel: resolveLogLevelId(settings?.minLevel) ?? this.settings.minLevel, + // resolve a level-name minLevel (e.g. "WARN" or a custom "NOTICE") back to its numeric id + minLevel: resolveLogLevelId(settings?.minLevel, mergedCustomLevels) ?? this.settings.minLevel, + customLevels: mergedCustomLevels, + // Deep-merge each grouped object so a sub-logger overriding ONE field of a group keeps the parent's + // other resolved defaults for that group (per-group shallow merge over the parent's resolved values). + pretty: { ...this.settings.pretty, ...settings?.pretty }, + json: { ...this.settings.json, ...settings?.json }, + mask: { ...this.settings.mask, ...settings?.mask }, + stack: { ...this.settings.stack, ...settings?.stack }, + meta: { ...this.settings.meta, ...settings?.meta }, + attachedTransports: [...this.settings.attachedTransports, ...(settings?.attachedTransports ?? [])], + middleware: [...this.settings.middleware, ...(settings?.middleware ?? [])], // collect parent names in Array parentNames: this.settings?.parentNames != null && this.settings?.name != null @@ -1057,53 +395,18 @@ export class BaseLogger { const subLogger: BaseLogger = new ( this.constructor as new ( - subLoggerSettings?: ISettingsParam, - logObj?: LogObj, - stackDepthLevel?: number, + subLoggerSettings: ISettingsParam | undefined, + logObj: LogObj | undefined, + environment: EnvironmentProvider, + callerFrame?: number, ) => this - )(subLoggerSettings, logObj ?? this.logObj, this.stackDepthLevel); - //this.subLoggers.push(subLogger); - return subLogger; - } - - private _mask(args: unknown[]): unknown[] { - const maskKeys = this._getMaskKeys(); - return args?.map((arg) => { - return this._recursiveCloneAndMaskValuesOfKeys(arg, maskKeys); - }); - } - - private _getMaskKeys(): (string | number)[] { - const maskKeys = this.settings.maskValuesOfKeys ?? []; - const signature = maskKeys.map(String).join("|"); - if (this.settings.maskValuesOfKeysCaseInsensitive === true) { - if (this.maskKeysCache?.source === maskKeys && this.maskKeysCache.caseInsensitive === true && this.maskKeysCache.signature === signature) { - return this.maskKeysCache.normalized; - } - - const normalized = maskKeys.map((key) => (typeof key === "string" ? key.toLowerCase() : String(key).toLowerCase())); - this.maskKeysCache = { - source: maskKeys, - caseInsensitive: true, - normalized, - signature, - }; - return normalized; + )(subLoggerSettings, logObj ?? this.logObj, this.runtime, this.callerFrame); + // Share the async context store (M2.13) with the child so a context entered on the parent (or on any + // ancestor) propagates to sub-logger calls. Only shared once the parent has actually materialized one. + if (this.asyncContextStore != null) { + subLogger.asyncContextStore = this.asyncContextStore; } - - if (this.maskKeysCache?.source === maskKeys && this.maskKeysCache.caseInsensitive === false && this.maskKeysCache.signature === signature) { - return this.maskKeysCache.normalized; - } - - // Property names returned by Object.getOwnPropertyNames are always strings, so normalize numeric mask keys to strings to make them match. - const normalized = maskKeys.map((key) => (typeof key === "string" ? key : String(key))); - this.maskKeysCache = { - source: maskKeys, - caseInsensitive: false, - normalized, - signature, - }; - return normalized; + return subLogger; } private _resolveLogArguments(args: unknown[]): unknown[] { @@ -1117,195 +420,38 @@ export class BaseLogger { return args; } - private _recursiveCloneAndMaskValuesOfKeys(source: T, keys: (number | string)[], seen: unknown[] = []): T { - if (seen.includes(source)) { - return { ...source } as T; - } - if (typeof source === "object" && source !== null) { - seen.push(source); - } - - if (runtime.isError(source) || runtime.isBuffer(source)) { - return source as T; - } else if (source instanceof Map) { - return new Map(source) as T; - } else if (source instanceof Set) { - return new Set(source) as T; - } else if (Array.isArray(source)) { - return source.map((item) => this._recursiveCloneAndMaskValuesOfKeys(item, keys, seen)) as unknown as T; - } else if (source instanceof Date) { - return new Date(source.getTime()) as T; - } else if (source instanceof URL) { - return urlToObject(source) as T; - } else if (source !== null && typeof source === "object") { - const baseObject = runtime.isError(source) ? this._cloneError(source as unknown as Error) : Object.create(Object.getPrototypeOf(source)); - return Object.getOwnPropertyNames(source).reduce((o, prop) => { - const lookupKey = - this.settings?.maskValuesOfKeysCaseInsensitive !== true - ? (prop as string) - : typeof prop === "string" - ? prop.toLowerCase() - : String(prop).toLowerCase(); - o[prop] = keys.includes(lookupKey) - ? this.settings.maskPlaceholder - : (() => { - try { - return this._recursiveCloneAndMaskValuesOfKeys((source as Record)[prop], keys, seen); - } catch { - return null; - } - })(); - return o; - }, baseObject) as T; - } else { - if (typeof source === "string") { - let modifiedSource: string = source; - // Escape "$" so that a maskPlaceholder containing "$1", "$&", etc. is inserted literally - // instead of being interpreted as a String.replace substitution pattern (which could leak parts of the secret). - const placeholder = (this.settings?.maskPlaceholder || "").replace(/\$/g, "$$$$"); - for (const regEx of this.settings?.maskValuesRegEx || []) { - modifiedSource = modifiedSource.replace(regEx, placeholder); - } - return modifiedSource as unknown as T; - } - return source; - } - } - - private _recursiveCloneAndExecuteFunctions(source: T, seen: (object | Array)[] = []): T { - if (this.isObjectOrArray(source) && seen.includes(source)) { - return this.shallowCopy(source); - } - - if (this.isObjectOrArray(source)) { - seen.push(source); - } - - if (Array.isArray(source)) { - return source.map((item) => this._recursiveCloneAndExecuteFunctions(item, seen)) as unknown as T; - } else if (source instanceof Date) { - return new Date(source.getTime()) as unknown as T; - } else if (this.isObject(source)) { - return Object.getOwnPropertyNames(source).reduce( - (o, prop) => { - const descriptor = Object.getOwnPropertyDescriptor(source, prop); - if (descriptor) { - Object.defineProperty(o, prop, descriptor); - const value = (source as Record)[prop]; - o[prop] = typeof value === "function" ? value() : this._recursiveCloneAndExecuteFunctions(value, seen); - } - return o; - }, - Object.create(Object.getPrototypeOf(source)), - ) as T; - } else { - return source; - } - } - - private isObjectOrArray(value: unknown): value is object | unknown[] { - return typeof value === "object" && value !== null; - } - - private isObject(value: unknown): value is object { - return typeof value === "object" && !Array.isArray(value) && value !== null; - } - - private shallowCopy(source: T): T { - if (Array.isArray(source)) { - return [...source] as unknown as T; - } else { - return { ...source } as unknown as T; - } - } - - private _toLogObj(args: unknown[], clonedLogObj: LogObj = {} as LogObj): LogObj { - args = args?.map((arg) => (runtime.isError(arg) ? this._toErrorObject(arg as Error) : arg)); - if (this.settings.argumentsArrayName == null) { - if (args.length === 1 && !Array.isArray(args[0]) && runtime.isBuffer(args[0]) !== true && !(args[0] instanceof Date)) { - clonedLogObj = typeof args[0] === "object" && args[0] != null ? { ...args[0], ...clonedLogObj } : { 0: args[0], ...clonedLogObj }; - } else { - clonedLogObj = { ...clonedLogObj, ...args }; - } - } else { - clonedLogObj = { - ...clonedLogObj, - [this.settings.argumentsArrayName]: args, - }; - } - return clonedLogObj; - } - - private _cloneError(error: T): T { - const cloned = new (error.constructor as { new (): T })(); - - Object.getOwnPropertyNames(error).forEach((key) => { - (cloned as Record)[key] = (error as Record)[key]; - }); - - return cloned; - } - - private _toErrorObject(error: Error, depth = 0, seen: Set = new Set()): IErrorObject { - if (!seen.has(error)) { - seen.add(error); - } - - const errorObject: IErrorObject = { - nativeError: error, - name: error.name ?? "Error", - message: error.message, - stack: runtime.getErrorTrace(error), - }; - - if (depth >= this.maxErrorCauseDepth) { - return errorObject; - } - - const causeValue = (error as { cause?: unknown }).cause; - if (causeValue != null) { - const normalizedCause = toError(causeValue); - if (!seen.has(normalizedCause)) { - errorObject.cause = this._toErrorObject(normalizedCause, depth + 1, seen); - } - } - - return errorObject; - } - private _addMetaToLogObj(logObj: LogObj, logLevelId: number, logLevelName: string): LogObj & ILogObjMeta & ILogObj { return { ...logObj, - [this.settings.metaProperty]: runtime.getMeta( + [this.settings.meta.property]: this.runtime.getMeta( logLevelId, logLevelName, - this.stackDepthLevel, + this.callerFrame, !this.captureStackForMeta, this.settings.name, this.settings.parentNames, - this.settings.internalFramePatterns, + this.settings.stack.internalFramePatterns, ), }; } private _shouldCaptureStack(): boolean { - if (this.settings.hideLogPositionForProduction) { + const capture = this.settings.stack.capture; + if (capture === "off") { return false; } - if (this.settings.type === "json") { + if (capture === "full" || capture === "lazy") { return true; } - - const template = this.settings.prettyLogTemplate ?? ""; - const stackPlaceholders = /{{\s*(file(Name|Path|Line|PathWithLine|NameWithLine)|fullFilePath)\s*}}/; - if (stackPlaceholders.test(template)) { + // "auto": for json the type-driven default already resolved to "off" (so we never reach here with + // json under auto). For pretty/hidden, capture only when the template references a code-position + // placeholder so the cost is paid solely when it is actually rendered. + if (this.settings.type === "json") { return true; } - return false; - } - - private _prettyFormatLogObjMeta(logObjMeta?: IMeta): string { - return buildPrettyMeta(this.settings, logObjMeta).text; + const template = this.settings.pretty.template ?? ""; + const stackPlaceholders = /{{\s*(file(Name|Path|Line|PathWithLine|NameWithLine)|fullFilePath)\s*}}/; + return stackPlaceholders.test(template); } } diff --git a/src/core/asyncContext.ts b/src/core/asyncContext.ts new file mode 100644 index 0000000..280172d --- /dev/null +++ b/src/core/asyncContext.ts @@ -0,0 +1,112 @@ +/** + * Async context store (M2.13) — the runtime-agnostic seam behind `logger.runInContext(ctx, fn)` and the + * automatic attach of the active context onto every log's `_meta`. + * + * On Node, Deno, and Bun this is backed by `node:async_hooks`' `AsyncLocalStorage`, so a context set with + * `runInContext` propagates across `await`, `setTimeout`, promise chains, and nested calls. On runtimes + * without `AsyncLocalStorage` (browsers, most edge runtimes) the store degrades to a graceful NO-OP: `run` + * still invokes the function, but no context is propagated (`getStore()` returns `undefined`). + * + * IMPORTANT (sideEffects:false / tree-shaking): this module must NEVER statically import `node:async_hooks` + * at the top level. The constructor — itself only invoked lazily, on first `runInContext` — resolves the + * `AsyncLocalStorage` class through guarded global/builtin probes that work without an ESM `import`. Merely + * importing this module pulls in no Node built-in. + */ + +/** A read-only, free-form bag of context fields attached to the active async scope (e.g. `requestId`, `traceId`). */ +export type AsyncContextFields = Record; + +/** + * The minimal store contract `BaseLogger` consumes. Implemented either by an `AsyncLocalStorage`-backed + * store (Node/Deno/Bun) or by the no-op store (everywhere else). Both share the same call shape so the + * core pipeline never branches on the runtime. + */ +export interface AsyncContextStore { + /** Run `fn` with `ctx` as the active context for the (possibly async) duration of `fn`; returns `fn`'s result. */ + run(ctx: AsyncContextFields, fn: () => T): T; + /** The context active for the current async scope, or `undefined` when none is set (or on a no-op store). */ + getStore(): AsyncContextFields | undefined; + /** Whether this store actually propagates context (`true` for ALS-backed; `false` for the no-op fallback). */ + readonly enabled: boolean; +} + +/** The structural subset of `AsyncLocalStorage` we rely on, so we can type the probed class without `node:async_hooks`. */ +interface AsyncLocalStorageLike { + run(store: T, fn: () => R): R; + getStore(): T | undefined; +} +type AsyncLocalStorageCtor = new () => AsyncLocalStorageLike; + +/** + * Best-effort, side-effect-free resolution of the `AsyncLocalStorage` constructor across server runtimes, + * without a top-level `node:async_hooks` import (which would break `sideEffects:false`). + * + * Probe order: + * 1. `globalThis.AsyncLocalStorage` — exposed directly by some runtimes/polyfills. + * 2. `process.getBuiltinModule("node:async_hooks")` — the synchronous, import-free builtin accessor + * (Node 22+, Bun, Deno) added precisely for cases like this. + * + * Returns `undefined` when no implementation is reachable, so the caller can fall back to the no-op store. + */ +export function resolveAsyncLocalStorage(): AsyncLocalStorageCtor | undefined { + const fromGlobal = (globalThis as { AsyncLocalStorage?: unknown }).AsyncLocalStorage; + if (typeof fromGlobal === "function") { + return fromGlobal as AsyncLocalStorageCtor; + } + + try { + const getBuiltinModule = (globalThis as { process?: { getBuiltinModule?: (id: string) => unknown } }).process?.getBuiltinModule; + if (typeof getBuiltinModule === "function") { + const mod = getBuiltinModule("node:async_hooks") as { AsyncLocalStorage?: unknown } | undefined; + if (typeof mod?.AsyncLocalStorage === "function") { + return mod.AsyncLocalStorage as AsyncLocalStorageCtor; + } + } + } catch { + // ignore — runtime forbids builtin access or has no async_hooks; fall through to the no-op store. + } + + return undefined; +} + +/** The shared, allocation-free no-op store: runs the function but never propagates context. */ +const NOOP_STORE: AsyncContextStore = { + run: (_ctx, fn) => fn(), + getStore: () => undefined, + enabled: false, +}; + +/** + * Build an {@link AsyncContextStore} for the current runtime. + * + * If `ctor` (an `AsyncLocalStorage` constructor, typically from {@link resolveAsyncLocalStorage} or a + * provider that resolved it through `createRequire`) is supplied/resolvable, returns an ALS-backed store + * that nests correctly (the parent's fields are inherited and shallow-merged under a child `run`). Otherwise + * returns the singleton no-op store. Never throws. + */ +export function createAsyncContextStore(ctor: AsyncLocalStorageCtor | undefined = resolveAsyncLocalStorage()): AsyncContextStore { + if (ctor == null) { + return NOOP_STORE; + } + + let als: AsyncLocalStorageLike; + try { + als = new ctor(); + } catch { + // The runtime advertised an AsyncLocalStorage but it cannot be instantiated — degrade to no-op. + return NOOP_STORE; + } + + return { + enabled: true, + run(ctx: AsyncContextFields, fn: () => T): T { + // Nested contexts inherit the parent's fields, with the new context's fields taking precedence. + const parent = als.getStore(); + const merged: AsyncContextFields = parent != null ? { ...parent, ...ctx } : ctx; + return als.run(merged, fn); + }, + getStore(): AsyncContextFields | undefined { + return als.getStore(); + }, + }; +} diff --git a/src/core/config.ts b/src/core/config.ts new file mode 100644 index 0000000..5e9bc2a --- /dev/null +++ b/src/core/config.ts @@ -0,0 +1,49 @@ +import type { ISettingsParam } from "../interfaces.js"; + +/** + * A typed configuration error (E6). Thrown by {@link import("./settings.js").validateSettingsParam} for a + * hard misconfiguration when the opt-in `strictConfig: true` setting is set (default `false` keeps the + * warn-only behavior). Carries a stable {@link code}, the offending {@link setting} path, and a + * human-readable {@link suggestion} so editors/agents and `catch` blocks can act on it programmatically. + * + * @example + * try { + * new Logger({ strictConfig: true, minLevel: "LOUD" as never }); + * } catch (e) { + * if (e instanceof TslogConfigError) console.error(e.code, e.setting, e.suggestion); + * } + */ +export class TslogConfigError extends Error { + /** Stable machine-readable error code (e.g. `"UNKNOWN_MIN_LEVEL"`). */ + public readonly code: string; + /** The dotted settings path that triggered the error (e.g. `"minLevel"` or `"pretty.template"`). */ + public readonly setting: string; + /** A short, actionable hint describing how to fix the configuration. */ + public readonly suggestion: string; + + constructor(args: { code: string; setting: string; message: string; suggestion: string }) { + super(`tslog: ${args.message}`); + this.name = "TslogConfigError"; + this.code = args.code; + this.setting = args.setting; + this.suggestion = args.suggestion; + // Restore the prototype chain so `instanceof TslogConfigError` holds when targeting older runtimes. + Object.setPrototypeOf(this, TslogConfigError.prototype); + } +} + +/** + * Identity helper (E5) for authoring a tslog settings object with full editor/agent autocomplete and + * type-checking on the grouped settings shape. Returns its input unchanged — it exists purely so a + * standalone config object is checked against {@link ISettingsParam} at its definition site. + * + * @example + * import { defineConfig } from "tslog"; + * export const logConfig = defineConfig({ type: "json", minLevel: "INFO", mask: { keys: ["password"] } }); + * const logger = new Logger(logConfig); + * + * @typeParam LogObj - Shape of your structured log object; inferred from the settings when omitted. + */ +export function defineConfig(settings: ISettingsParam): ISettingsParam { + return settings; +} diff --git a/src/core/fromEnv.ts b/src/core/fromEnv.ts new file mode 100644 index 0000000..42d7d1e --- /dev/null +++ b/src/core/fromEnv.ts @@ -0,0 +1,36 @@ +import type { ISettingsParam, TLogLevel } from "../interfaces.js"; +import { safeEnvGet } from "../internal/environment.js"; + +/** + * Build an {@link ISettingsParam} from the environment (E3): reads `TSLOG_LEVEL` → `minLevel`, + * `TSLOG_TYPE` → `type` (only the three valid values are accepted; anything else is ignored), and + * `TSLOG_NAME` → `name`. `NO_COLOR`/`FORCE_COLOR` are already honored downstream by `normalizeSettings`. + * The returned object is then shallow-merged under caller `overrides` by {@link Logger.fromEnv}. + * + * Exported so the per-entry `Logger.fromEnv` statics share one env-reading implementation. + */ +export function settingsFromEnv(overrides?: ISettingsParam): ISettingsParam { + const fromEnv: ISettingsParam = {}; + + // Each read is individually guarded (safeEnvGet): on Deno the permission check fires per property + // GET, so an unguarded read would throw NotCapable without --allow-env. + const level = safeEnvGet("TSLOG_LEVEL"); + if (level != null && level !== "") { + // A numeric string becomes a numeric id; otherwise it's a level name resolved by normalizeSettings. + const numeric = Number(level); + fromEnv.minLevel = (Number.isNaN(numeric) ? level : numeric) as TLogLevel; + } + + const type = safeEnvGet("TSLOG_TYPE"); + if (type === "json" || type === "pretty" || type === "hidden") { + fromEnv.type = type; + } + + const name = safeEnvGet("TSLOG_NAME"); + if (name != null && name !== "") { + fromEnv.name = name; + } + + // Caller overrides win over env-derived settings. + return { ...fromEnv, ...overrides }; +} diff --git a/src/core/levelPersistence.ts b/src/core/levelPersistence.ts new file mode 100644 index 0000000..26adbba --- /dev/null +++ b/src/core/levelPersistence.ts @@ -0,0 +1,64 @@ +/** + * Opt-in browser log-level persistence (M4.6). + * + * Reads/writes a logger's `minLevel` from `localStorage` so verbosity can be flipped live from the devtools + * console and survive a page reload. Everything here is: + * - GUARDED: every `localStorage` access is wrapped in `try/catch` (Safari private mode and disabled storage + * throw on access), so a hostile environment never crashes logging. + * - NO-OP off-browser: when there is no usable `localStorage` (Node/Bun/Deno/workers), reads return + * `undefined` and writes do nothing — keeping Node behavior byte-for-byte unchanged. + * - SIDE-EFFECT FREE at import time: nothing runs until a function is called, so `sideEffects: false` holds. + */ + +/** Default `localStorage` key used to persist the active log level. */ +export const DEFAULT_PERSIST_LEVEL_KEY = "tslog:level"; + +/** + * Resolve a usable `localStorage` object, or `undefined` when none is available (off-browser) or access throws + * (private mode / blocked storage). Never throws. + */ +function getLocalStorage(): Storage | undefined { + try { + // `localStorage` may be undefined (Node), or a getter that throws (privacy modes / sandboxed iframes). + const storage = (globalThis as { localStorage?: Storage }).localStorage; + return storage != null && typeof storage.getItem === "function" ? storage : undefined; + /* v8 ignore next 3 -- defensive: some engines throw merely on touching the property */ + } catch { + return undefined; + } +} + +/** + * Read the persisted level token (a numeric id like `"2"` or a name like `"WARN"`) from `localStorage`, or + * `undefined` when persistence is unavailable / the key is unset. Caller resolves the token to a numeric id. + */ +export function readPersistedLevel(key: string = DEFAULT_PERSIST_LEVEL_KEY): string | undefined { + const storage = getLocalStorage(); + if (storage == null) { + return undefined; + } + try { + const value = storage.getItem(key); + return value == null || value === "" ? undefined : value; + /* v8 ignore next 3 -- defensive: getItem can throw in restricted contexts */ + } catch { + return undefined; + } +} + +/** + * Persist the given numeric level id to `localStorage`. NO-OP (and never throws) when persistence is + * unavailable. Stored as the numeric id so it round-trips deterministically regardless of custom-level names. + */ +export function writePersistedLevel(levelId: number, key: string = DEFAULT_PERSIST_LEVEL_KEY): void { + const storage = getLocalStorage(); + if (storage == null) { + return; + } + try { + storage.setItem(key, String(levelId)); + /* v8 ignore next 3 -- defensive: setItem can throw (quota / private mode) */ + } catch { + // never let persistence crash logging + } +} diff --git a/src/core/levels.ts b/src/core/levels.ts new file mode 100644 index 0000000..c3d74b4 --- /dev/null +++ b/src/core/levels.ts @@ -0,0 +1,75 @@ +import type { TLogLevel } from "../interfaces.js"; +import { LogLevel } from "../interfaces.js"; + +/** + * Canonical default level table: id -> name. The seven names are stable across v5; additive custom + * levels (M2.14) extend this map per-logger rather than mutating it. + */ +export const DEFAULT_LOG_LEVEL_NAMES: Readonly> = Object.freeze({ + 0: "SILLY", + 1: "TRACE", + 2: "DEBUG", + 3: "INFO", + 4: "WARN", + 5: "ERROR", + 6: "FATAL", +}); + +/** Reverse lookup name -> id, used to resolve a string `minLevel` like "WARN". */ +const NAME_TO_ID: Readonly> = Object.freeze( + Object.fromEntries(Object.entries(DEFAULT_LOG_LEVEL_NAMES).map(([id, name]) => [name, Number(id)])), +); + +/** + * Resolve a {@link TLogLevel} (number, {@link LogLevel} enum, or a level name like "WARN") + * to its numeric id. Returns `undefined` for unknown names so the caller can apply its own default. + * + * @param customLevels - optional additive name→id map (M2.14) consulted, case-insensitively, before the + * default table so a string `minLevel` referring to a custom level resolves correctly. + */ +export function resolveLogLevelId(level: TLogLevel | undefined, customLevels?: Record): number | undefined { + if (level == null) { + return undefined; + } + if (typeof level === "number") { + return level; + } + // Additive custom levels win when present (so e.g. minLevel: "NOTICE" resolves), looked up by exact + // then upper-cased name so callers may use either casing for their custom level names. + if (customLevels != null) { + const fromCustom = customLevels[level] ?? customLevels[level.toUpperCase()]; + if (typeof fromCustom === "number") { + return fromCustom; + } + } + // Prefer the explicit name table, then fall back to the enum (kept for source compatibility). + const fromTable = NAME_TO_ID[level] ?? NAME_TO_ID[level.toUpperCase()]; + if (typeof fromTable === "number") { + return fromTable; + } + const fromEnum = (LogLevel as unknown as Record)[level.toUpperCase()]; + return typeof fromEnum === "number" ? fromEnum : undefined; +} + +/** + * Validate an additive custom level (M2.14): the name must be a non-empty string that does not collide + * (case-insensitively) with one of the canonical seven, and the id must be a finite number (fractional ids + * such as `3.5` are allowed so a level can slot between two defaults). Throws a `RangeError`/`TypeError` on + * violation so misconfiguration fails fast at `addLevel`/normalize time. + */ +export function validateCustomLevel(name: string, id: number): void { + if (typeof name !== "string" || name.length === 0) { + throw new TypeError(`tslog: custom level name must be a non-empty string (got ${JSON.stringify(name)}).`); + } + if (typeof id !== "number" || !Number.isFinite(id)) { + throw new TypeError(`tslog: custom level id for "${name}" must be a finite number (got ${JSON.stringify(id)}).`); + } + if (NAME_TO_ID[name.toUpperCase()] != null) { + throw new RangeError(`tslog: custom level "${name}" collides with a canonical level name; the seven default names are reserved.`); + } +} + +/** Resolve a numeric id back to its canonical name, or `undefined` if it is not a default level. */ +export function logLevelName(id: number): string | undefined { + return DEFAULT_LOG_LEVEL_NAMES[id]; +} diff --git a/src/core/logObj.ts b/src/core/logObj.ts new file mode 100644 index 0000000..7d1f522 --- /dev/null +++ b/src/core/logObj.ts @@ -0,0 +1,148 @@ +import type { IErrorObject, IStackFrame } from "../interfaces.js"; +import { safeErrorString, safeGetCause, toError } from "../internal/errorUtils.js"; + +/** + * Runtime-agnostic dependencies the log-object builders need. The monolith reached for the + * module-level `runtime` singleton (`runtime.isError`, `runtime.isBuffer`, `runtime.getErrorTrace`); + * extracting those reads into explicit parameters keeps this module free of any environment import + * so it works identically on Node, browsers, Deno, and Bun. + */ +export interface LogObjDeps { + /** Recognizes native errors (incl. cross-realm and Error-like objects). Mirrors `runtime.isError`. */ + isError: (value: unknown) => value is Error; + /** Recognizes Node Buffers (always `false` where Buffer is unavailable). Mirrors `runtime.isBuffer`. */ + isBuffer: (value: unknown) => boolean; + /** Parses an error into stack frames. Mirrors `runtime.getErrorTrace`. */ + getErrorTrace: (error: Error) => IStackFrame[]; + /** Maximum depth to follow the `error.cause` chain before stopping. */ + maxErrorCauseDepth: number; +} + +/** True for any non-null object or array. */ +export function isObjectOrArray(value: unknown): value is object | unknown[] { + return typeof value === "object" && value !== null; +} + +/** True for plain (non-array) non-null objects. */ +export function isObject(value: unknown): value is object { + return typeof value === "object" && !Array.isArray(value) && value !== null; +} + +/** Returns a one-level copy of an array or object, preserving its container kind. */ +export function shallowCopy(source: T): T { + if (Array.isArray(source)) { + return [...source] as unknown as T; + } else { + return { ...source } as unknown as T; + } +} + +/** + * Clones an error by re-instantiating its constructor and copying every own property across. + * Used so masking/cloning never mutates the caller's original error instance. + */ +export function cloneError(error: T): T { + const cloned = new (error.constructor as { new (): T })(); + + Object.getOwnPropertyNames(error).forEach((key) => { + (cloned as Record)[key] = (error as Record)[key]; + }); + + return cloned; +} + +/** + * Deeply clones a value while executing any zero-purpose field that is a function (e.g. a `requestId` + * generator on the default LogObj), so every log gets a freshly evaluated value. Arrays and Dates are + * cloned; objects are rebuilt preserving prototype and property descriptors; primitives pass through. + * Circular references are short-circuited with a shallow copy via a `seen` list. + */ +export function recursiveCloneAndExecuteFunctions(source: T, seen: (object | Array)[] = []): T { + if (isObjectOrArray(source) && seen.includes(source)) { + return shallowCopy(source); + } + + if (isObjectOrArray(source)) { + seen.push(source); + } + + if (Array.isArray(source)) { + return source.map((item) => recursiveCloneAndExecuteFunctions(item, seen)) as unknown as T; + } else if (source instanceof Date) { + return new Date(source.getTime()) as unknown as T; + } else if (isObject(source)) { + return Object.getOwnPropertyNames(source).reduce( + (o, prop) => { + const descriptor = Object.getOwnPropertyDescriptor(source, prop); + if (descriptor) { + Object.defineProperty(o, prop, descriptor); + const value = (source as Record)[prop]; + o[prop] = typeof value === "function" ? value() : recursiveCloneAndExecuteFunctions(value, seen); + } + return o; + }, + Object.create(Object.getPrototypeOf(source)), + ) as T; + } else { + return source; + } +} + +/** + * Converts a native error into a serializable {@link IErrorObject}, following the `error.cause` chain + * up to `deps.maxErrorCauseDepth` levels. Non-Error causes are normalized via {@link toError}, and a + * `seen` set prevents infinite loops on self-referential cause chains. + */ +export function toErrorObject(error: Error, deps: LogObjDeps, depth = 0, seen: Set = new Set()): IErrorObject { + if (!seen.has(error)) { + seen.add(error); + } + + const errorObject: IErrorObject = { + nativeError: error, + name: safeErrorString(error, "name", "Error"), + message: safeErrorString(error, "message", ""), + stack: deps.getErrorTrace(error), + }; + + if (depth >= deps.maxErrorCauseDepth) { + return errorObject; + } + + const causeValue = safeGetCause(error); + if (causeValue != null) { + const normalizedCause = toError(causeValue); + if (!seen.has(normalizedCause)) { + errorObject.cause = toErrorObject(normalizedCause, deps, depth + 1, seen); + } + } + + return errorObject; +} + +/** + * Builds the final log object from the (already masked) call arguments. + * + * - Every Error argument is replaced by its serializable {@link IErrorObject} form. + * - When `argumentsArrayName` is set, all args are stored under that single key. + * - Otherwise, a lone object/array/primitive argument is merged into the cloned default LogObj + * (objects spread directly; a non-mergeable single value is keyed under `"0"`), while multiple + * args are spread index-keyed (`0`, `1`, …). The cloned default LogObj always wins on key + * collisions, matching the monolith's `{ ...args[0], ...clonedLogObj }` ordering. + */ +export function toLogObj(args: unknown[], argumentsArrayName: string | undefined, deps: LogObjDeps, clonedLogObj: LogObj = {} as LogObj): LogObj { + args = args?.map((arg) => (deps.isError(arg) ? toErrorObject(arg as Error, deps) : arg)); + if (argumentsArrayName == null) { + if (args.length === 1 && !Array.isArray(args[0]) && deps.isBuffer(args[0]) !== true && !(args[0] instanceof Date)) { + clonedLogObj = typeof args[0] === "object" && args[0] != null ? { ...args[0], ...clonedLogObj } : { 0: args[0], ...clonedLogObj }; + } else { + clonedLogObj = { ...clonedLogObj, ...args }; + } + } else { + clonedLogObj = { + ...clonedLogObj, + [argumentsArrayName]: args, + }; + } + return clonedLogObj; +} diff --git a/src/core/masking.ts b/src/core/masking.ts new file mode 100644 index 0000000..e700405 --- /dev/null +++ b/src/core/masking.ts @@ -0,0 +1,730 @@ +import type { ISettings } from "../interfaces.js"; +import { urlToObject } from "../urlToObj.js"; + +/** + * Runtime predicates the masking engine needs but must not import directly, so the core stays + * runtime-agnostic. They are supplied by the active {@link import("../env/environment.js").EnvironmentProvider}. + */ +export interface MaskingPredicates { + isError: (value: unknown) => value is Error; + isBuffer: (value: unknown) => boolean; +} + +/** + * A single compiled path matcher (M2.3). `segments` is the dotted path split once into its parts; + * a `"*"` segment matches any single path segment. {@link match} compares against a path described + * by the live `segmentStack` so no per-node string is allocated during the recursive walk. + */ +interface CompiledPath { + segments: string[]; +} + +interface MaskPathsCache { + /** The exact `settings.mask.paths` array the cache was built from (identity-compared). */ + source: string[]; + /** A `join("|")` fingerprint so an in-place mutation of `source` still invalidates the cache. */ + signature: string; + /** The compiled matchers, returned/used verbatim while the source is unchanged. */ + compiled: CompiledPath[]; +} + +/** + * Per-invocation masking state threaded through the recursive walk. Bundled into one object so the + * recursion signature stays small while carrying the key set, the escaped placeholder, the cycle + * guard, the compiled path matchers, and the live dotted-path {@link segmentStack}. + */ +interface MaskContext { + keySet: Set; + escapedPlaceholder: string; + /** + * Memoized masked clones: source object → its (possibly still-filling) masked clone. A repeat visit — + * a shared reference, DAG, or cycle — returns the SAME masked clone, so a secret masked on the first + * encounter can never leak through a second path to the same object. + * + * When path masking is active, completed clones are NOT reused across positions (path censoring is + * position-dependent); the memo then only breaks true cycles via {@link inProgress}. + */ + seen: WeakMap; + /** + * Objects currently being filled on the recursion path. Only tracked when path masking is active: + * a revisit of an in-progress object is a true cycle (return the in-progress clone), while a revisit + * of a completed object is a shared reference at a DIFFERENT path position and must be re-processed + * so `mask.paths` censors exactly the configured positions — never more, never less. + */ + inProgress?: WeakSet; + /** + * Clones created at a path-inert position (see {@link MaskingEngine.isPathInert}). Only tracked when + * path masking is active. An inert clone revisited at an inert position CAN be reused — no path can + * match in either subtree, so position no longer matters. Without this, every shared reference under + * `mask.paths` re-walks its subtree, which is exponential on diamond-shaped object graphs. + */ + inertClones?: WeakSet; + /** Pre-seeded guard objects from the legacy v4-compat `seen` parameter; these short-circuit to a shallow copy. */ + legacySeen?: WeakSet; + /** Compiled `mask.paths`; empty when path masking is disabled (skips all path bookkeeping). */ + paths: CompiledPath[]; + /** The longest compiled path's segment count; below that depth a path can still match (see isPathInert). */ + maxPathDepth: number; + /** The mask regexes, normalized to global flags once per invocation (see toGlobalRegex). */ + regexes: RegExp[]; + /** The path segments from the current root down to the node being visited. Mutated in place. */ + segmentStack: string[]; + /** + * Depth counter suspending path matching while inside `Map`/`Set` contents: Map entries are not + * addressable path segments, so `mask.paths` neither descends into nor passes through them + * (`mask.keys`/`mask.regex` still apply inside). + */ + pathsSuspended: number; +} + +interface MaskKeysCache { + /** The exact `settings.mask.keys` array the cache was built from (identity-compared). */ + source: string[]; + caseInsensitive: boolean; + /** Stringified, optionally lower-cased copy of {@link source}. Returned to callers verbatim. */ + normalized: (string | number)[]; + /** A `String(k).join("|")` fingerprint so an in-place mutation of `source` still invalidates the cache. */ + signature: string; +} + +/** + * The masking engine (M1.5–M1.7, M2.3). Owns the recursive clone-and-mask pass that redacts the values of + * configured keys (`mask.keys`), substrings matching `mask.regex`, and leaves whose dotted path + * matches `mask.paths` (`*` = any one segment), plus the normalized mask-keys and compiled-paths caches. + * + * It is constructed with the *live* {@link ISettings} object (read on every call, never snapshotted, so + * post-construction mutations of `mask.keys` / `mask.placeholder` take effect) and the runtime's + * {@link MaskingPredicates}, keeping the core free of runtime imports. + * + * Behavior preserved from the v4 monolith: Error/Buffer pass-through, Date/URL cloning, the + * `$`-escape fix for the placeholder, numeric mask-key normalization, and getter-only robustness + * (a throwing getter yields `null` rather than aborting the mask). v5 improvements per contract: + * a zero-clone fast path, a memoizing `WeakMap` cycle/shared-reference guard (a repeat visit returns + * the same MASKED clone, never an unmasked copy), masking inside `Map`/`Set` contents, mask regexes + * always applied globally, `Set.has` key matching, and a single placeholder `$`-escape per invocation. + */ +export class MaskingEngine { + private maskKeysCache?: MaskKeysCache; + private maskPathsCache?: MaskPathsCache; + /** Per-source-RegExp cache of the global-flagged variant used for string masking. */ + private globalRegexCache = new WeakMap(); + + constructor( + private readonly settings: ISettings, + private readonly predicates: MaskingPredicates, + ) {} + + /** + * Normalize and mask every argument in `args`. + * + * Fast path (M1.5): when there are no mask keys AND no mask regexes AND no `mask.paths`, nothing can be + * *masked*, so we skip the deep masking clone. We still expand any `URL` instances into plain objects, + * because in v4 that normalization lived inside the mask pass and therefore ran for every log under the old + * default; with the v5 default `mask.keys: []` (BC5) a bare `URL` would otherwise render as an opaque + * `{}` (its own properties are non-enumerable). {@link normalizeUrls} returns the args untouched (no + * allocation) when no URL is present, so the common case stays cheap. Both the pretty and JSON formatters + * consume this result, so URLs render consistently regardless of masking. + */ + public mask(args: unknown[]): unknown[] { + const hasKeys = this.settings.mask.keys != null && this.settings.mask.keys.length > 0; + const hasRegex = this.settings.mask.regex != null && this.settings.mask.regex.length > 0; + const compiledPaths = this.getMaskPaths(); + const hasPaths = compiledPaths.length > 0; + if (!hasKeys && !hasRegex && !hasPaths) { + return this.normalizeUrls(args, new WeakSet()); + } + + const ctx = this.createContext(compiledPaths); + // Each top-level argument is the root of its own path (`""` so its own children are ""). + return args?.map((arg) => this.recurse(arg, ctx)); + } + + /** + * Recursively expand `URL` instances into plain objects, allocating a copy only along paths that actually + * contain a URL (so a log with no URL incurs no clone). Containers handled specially by the masking + * recursion (Map/Set/Date/Buffer/Error) are left as-is here — they have well-defined inspect output and v4 + * did not URL-expand inside them. Non-data properties (getters) are not invoked. A `WeakSet` guards cycles. + */ + private normalizeUrls(source: T, seen: WeakSet): T { + if (source instanceof URL) { + return urlToObject(source) as T; + } + if (source == null || typeof source !== "object") { + return source; + } + // Defensive: predicate checks (isError/isBuffer) and `seen` bookkeeping may read properties, which a + // hostile Proxy can throw from. URL normalization is best-effort — never crash logging over it. + try { + if ( + source instanceof Date || + source instanceof Map || + source instanceof Set || + this.predicates.isError(source) || + this.predicates.isBuffer(source) || + ArrayBuffer.isView(source) + ) { + return source; + } + if (seen.has(source)) { + return source; + } + seen.add(source); + } catch { + return source; + } + + if (Array.isArray(source)) { + let copy: unknown[] | undefined; + for (let i = 0; i < source.length; i++) { + const next = this.normalizeUrls(source[i], seen); + if (next !== source[i] && copy == null) { + copy = source.slice(0, i); + } + if (copy != null) { + copy.push(next); + } + } + return (copy ?? source) as unknown as T; + } + + // Defensive: a hostile object (e.g. a throwing Proxy) can throw from any trap. URL normalization is a + // best-effort convenience, never a reason to crash logging — pass such objects through untouched. + let keys: string[]; + try { + keys = Object.getOwnPropertyNames(source); + } catch { + return source; + } + + let copy: Record | undefined; + for (const key of keys) { + let value: unknown; + try { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (descriptor == null || !("value" in descriptor)) { + continue; + } + value = (source as Record)[key]; + } catch { + // a throwing getter/trap on this property — leave the original in place, keep walking + continue; + } + if (value instanceof URL || (value != null && typeof value === "object")) { + const next = this.normalizeUrls(value, seen); + if (next !== value && copy == null) { + copy = { ...(source as Record) }; + } + if (copy != null) { + copy[key] = next; + } + } + } + return (copy ?? source) as T; + } + + /** + * Return the normalized (stringified, lower-cased when case-insensitive) mask keys, cached by identity + * and signature of `settings.mask.keys`. The same array reference is returned across calls while + * the source is unchanged, so callers may rely on a stable result. + */ + public getMaskKeys(): (string | number)[] { + const maskKeys = this.settings.mask.keys ?? []; + const signature = maskKeys.map(String).join("|"); + const caseInsensitive = this.settings.mask.caseInsensitive === true; + + if (this.maskKeysCache?.source === maskKeys && this.maskKeysCache.caseInsensitive === caseInsensitive && this.maskKeysCache.signature === signature) { + return this.maskKeysCache.normalized; + } + + // Property names returned by Object.getOwnPropertyNames are always strings, so normalize numeric mask + // keys to strings to make them match. Lower-case them as well when matching case-insensitively. + const normalized = caseInsensitive + ? maskKeys.map((key) => (typeof key === "string" ? key.toLowerCase() : String(key).toLowerCase())) + : maskKeys.map((key) => (typeof key === "string" ? key : String(key))); + + this.maskKeysCache = { + source: maskKeys, + caseInsensitive, + normalized, + signature, + }; + return normalized; + } + + /** + * Recursively clone `source`, masking the values of any property whose (optionally lower-cased) name is + * in `keys`, applying `mask.regex` to string values, and censoring any leaf whose dotted path matches + * `settings.mask.paths`. + * + * Mirrors the v4 monolith signature so existing tests can call it directly; `keys` may be the raw, + * already-normalized array from {@link getMaskKeys}. The cycle guard is a `WeakSet` (callers that pass a + * legacy `seen` array are honored for source compatibility — its current members seed the guard). + */ + public recursiveCloneAndMaskValuesOfKeys(source: T, keys: (number | string)[], seen?: unknown[] | WeakSet): T { + const paths = this.getMaskPaths(); + const ctx: MaskContext = { + keySet: this.buildKeySet(keys), + escapedPlaceholder: this.escapePlaceholder(), + seen: new WeakMap(), + inProgress: paths.length > 0 ? new WeakSet() : undefined, + inertClones: paths.length > 0 ? new WeakSet() : undefined, + legacySeen: this.toLegacySeen(seen), + paths, + maxPathDepth: maxSegments(paths), + regexes: this.normalizedRegexes(), + segmentStack: [], + pathsSuspended: 0, + }; + return this.recurse(source, ctx); + } + + /** Build the lookup `Set` for O(1) `has` checks. Keys are already stringified/lower-cased by {@link getMaskKeys}. */ + private buildKeySet(keys: (number | string)[]): Set { + const set = new Set(); + for (const key of keys) { + set.add(typeof key === "string" ? key : String(key)); + } + return set; + } + + /** + * Normalize the legacy v4-compat `seen` parameter into a `WeakSet` of pre-visited objects. Members + * short-circuit to a shallow copy on encounter (the historical contract for caller-seeded guards); + * the engine's own cycle/shared-reference handling uses the memoizing `MaskContext.seen` map instead. + */ + private toLegacySeen(seen?: unknown[] | WeakSet): WeakSet | undefined { + if (seen instanceof WeakSet) { + return seen; + } + if (!Array.isArray(seen) || seen.length === 0) { + return undefined; + } + const guard = new WeakSet(); + for (const entry of seen) { + if (entry !== null && typeof entry === "object") { + guard.add(entry as object); + } + } + return guard; + } + + /** + * Escape every `$` in the placeholder so a placeholder like `"$1"`, `"$&"`, etc. is inserted literally by + * `String.replace` instead of being interpreted as a substitution pattern (which could leak the secret). + * A nullish placeholder is treated as an empty replacement. Computed once per mask invocation. + */ + private escapePlaceholder(): string { + return (this.settings.mask.placeholder || "").replace(/\$/g, "$$$$"); + } + + /** Assemble a fresh {@link MaskContext} for a top-level {@link mask} pass. */ + private createContext(compiledPaths: CompiledPath[]): MaskContext { + const maskKeys = this.getMaskKeys(); + return { + keySet: this.buildKeySet(maskKeys), + escapedPlaceholder: this.escapePlaceholder(), + seen: new WeakMap(), + inProgress: compiledPaths.length > 0 ? new WeakSet() : undefined, + inertClones: compiledPaths.length > 0 ? new WeakSet() : undefined, + paths: compiledPaths, + maxPathDepth: maxSegments(compiledPaths), + regexes: this.normalizedRegexes(), + segmentStack: [], + pathsSuspended: 0, + }; + } + + /** The mask regexes normalized to global matching, computed once per mask invocation. */ + private normalizedRegexes(): RegExp[] { + const regexes = this.settings.mask.regex ?? []; + if (regexes.length === 0) { + return regexes; + } + return regexes.map((regEx) => this.toGlobalRegex(regEx)); + } + + /** + * Whether the current position is "path-inert": no `mask.paths` pattern can match anywhere at or below + * it. Matching requires the segment stack length to EQUAL a compiled path's length, so once the stack + * is at least as deep as the longest path — or while inside Map/Set contents (pathsSuspended) — path + * censoring can no longer occur and clones become position-independent (safe to memo-reuse). + */ + private isPathInert(ctx: MaskContext): boolean { + return ctx.pathsSuspended > 0 || ctx.segmentStack.length >= ctx.maxPathDepth; + } + + /** + * Return the compiled {@link CompiledPath} matchers for `settings.mask.paths`, cached by identity and + * signature of the source array (mirrors the mask-keys cache so an in-place mutation still invalidates). + * Empty/omitted paths yield an empty array, preserving the normalize-only fast path. + */ + public getMaskPaths(): CompiledPath[] { + const paths = this.settings.mask?.paths ?? []; + if (paths.length === 0) { + return []; + } + const signature = paths.join("|"); + if (this.maskPathsCache?.source === paths && this.maskPathsCache.signature === signature) { + return this.maskPathsCache.compiled; + } + + const compiled: CompiledPath[] = []; + for (const path of paths) { + // A path is split once into its dotted segments; empty strings produce no usable matcher. + if (typeof path !== "string" || path.length === 0) { + continue; + } + compiled.push({ segments: path.split(".") }); + } + + this.maskPathsCache = { source: paths, signature, compiled }; + return compiled; + } + + /** + * True when the current {@link MaskContext.segmentStack} (the dotted path to the node being visited) + * matches at least one compiled path. A `"*"` segment matches any single segment; matching requires + * the same number of segments (each compiled path targets a leaf at a fixed depth). + */ + private matchesPath(ctx: MaskContext): boolean { + // Inside Map/Set contents there are no addressable path segments — never match there. + if (ctx.pathsSuspended > 0) { + return false; + } + const stack = ctx.segmentStack; + for (const compiled of ctx.paths) { + const segments = compiled.segments; + if (segments.length !== stack.length) { + continue; + } + let matched = true; + for (let i = 0; i < segments.length; i++) { + const pattern = segments[i]; + if (pattern !== "*" && pattern !== stack[i]) { + matched = false; + break; + } + } + if (matched) { + return true; + } + } + return false; + } + + /** + * Resolve the replacement for a path-matched value per `settings.mask.censor`: + * a function result, the `"hash"` correlation token, a literal string, or the mask placeholder when + * censor is omitted. `"remove"` is handled by the caller (the property is dropped) and never reaches here. + */ + private censorValue(value: unknown, ctx: MaskContext): unknown { + const censor = this.settings.mask?.censor; + if (typeof censor === "function") { + return censor(value, ctx.segmentStack.join(".")); + } + if (censor === "hash") { + return this.hashToken(value); + } + if (typeof censor === "string") { + return censor; + } + return this.settings.mask.placeholder; + } + + /** + * Build the SHORT, stable, **non-cryptographic** correlation token for a matched value (M4.3): + * `"[