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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ src/

## Quality Standards

- Target **100% test coverage** — but only with meaningful tests, no padding
- **100% test coverage** on statements, branches, functions and lines — enforced by `coverage.thresholds` in `vitest.config.ts` (`npm run coverage`, CI's Node 20 job, fails below it) — but only with meaningful tests, no padding. Vitest 4 counts the implicit `else` of every `if` as a branch and binds `/* v8 ignore next */` to a single AST node (`next N` counts are ignored; use `/* v8 ignore else */` before an `if` for a truly unreachable else path)
- Every new feature **must** have corresponding tests
- Every new feature **must** be reflected in the docs (`docs/`)
- Don't write tests just to hit coverage numbers; each test should verify real behavior
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to this project are documented here. This project adheres to
- **Logged errors are cloned** — with `mask` configured, an `Error` argument is replaced by a masked clone like every other argument, and that clone is what transports receive as `nativeError`. It is a real `Error` with the source's prototype (no subclass constructor runs), so `instanceof`, JSON error detection and Sentry-style transports keep working, and the caller's instance is never modified. Without `mask`, errors pass through untouched as before.

### Fixed
- **Frozen or getter-based default LogObj** — a default log object passed as the second `Logger` argument (or via `getSubLogger`) that is frozen / has read-only properties, or exposes a value through a getter, made every log call throw (`Cannot assign to read only property`). The per-call clone now carries the evaluated value inside the copied descriptor; getters are read once per log and stored as plain values, like function fields.
- **Hostile source maps never throw** — a `.map` file that is valid JSON but structurally wrong (`"sections": [null]`, a non-string `mappings`, ...) made source-map resolution throw a `TypeError` out of the log call in development. Such maps now count as "no map" (cached like any other miss) and the transpiled position is kept.
- **Worker transport `flush()` after a failed spawn** — when `new Worker()` threw, every write already went inline, yet each later `flush()` rejected with the spawn error (and `logger.flush()` reported a transport error every time). It now resolves like the off-Node inline path.
- **`restoreConsole()` on a partial console** — a method the console did not have before `wrapConsole()` is put back to `undefined` instead of leaving tslog's forwarder installed.
- **Masking inside errors** — a secret in an error's message, in a property assigned to the error or down the `cause` chain no longer reaches the JSON line, the pretty error block or `nativeError` in plaintext. `mask.regex` covers the message and the `<name>: <message>` header of a V8 stack (frames are left alone, so a broad pattern cannot corrupt positions), `mask.keys`/`regex`/`paths` cover every other own property and the whole `cause` chain. `name`, `message` and `stack` are exempt from `mask.keys`, so `keys: ["name"]` does not blank every error, while `mask.paths` can still target them. (#214, #361)

## [5.1.0] - 2026-07-17
Expand Down
15 changes: 9 additions & 6 deletions src/core/logObj.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export function cloneError<T extends Error>(error: T): T {
/**
* 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.
* cloned; objects are rebuilt with the source's prototype and per-property enumerability as plain writable
* data properties (frozen sources stay loggable, accessors are read once); primitives pass through.
* Circular references are short-circuited with a shallow copy via a `seen` list.
*/
export function recursiveCloneAndExecuteFunctions<T>(source: T, seen: (object | Array<unknown>)[] = []): T {
Expand All @@ -74,10 +75,14 @@ export function recursiveCloneAndExecuteFunctions<T>(source: T, seen: (object |
return Object.getOwnPropertyNames(source).reduce(
(o, prop) => {
const descriptor = Object.getOwnPropertyDescriptor(source, prop);
/* v8 ignore else -- typing-only: getOwnPropertyDescriptor is typed `| undefined`, but for a key getOwnPropertyNames just reported on the same object it is only undefined when a Proxy ownKeys trap invents a ghost key — no supported default LogObj shape does that */
if (descriptor) {
Object.defineProperty(o, prop, descriptor);
const value = (source as Record<string, unknown>)[prop];
o[prop] = typeof value === "function" ? value() : recursiveCloneAndExecuteFunctions(value, seen);
const cloned = typeof value === "function" ? value() : recursiveCloneAndExecuteFunctions(value, seen);
// Define a plain data property holding the evaluated value; only enumerability is carried over (it
// decides whether the field is spread into the record). Copying the source descriptor and assigning
// afterwards threw in strict mode on a frozen/read-only source or a getter-only accessor.
Object.defineProperty(o, prop, { value: cloned, writable: true, enumerable: descriptor.enumerable, configurable: true });
}
return o;
},
Expand All @@ -94,9 +99,7 @@ export function recursiveCloneAndExecuteFunctions<T>(source: T, seen: (object |
* `seen` set prevents infinite loops on self-referential cause chains.
*/
export function toErrorObject(error: Error, deps: LogObjDeps, depth = 0, seen: Set<Error> = new Set()): IErrorObject {
if (!seen.has(error)) {
seen.add(error);
}
seen.add(error);

const errorObject: IErrorObject = {
nativeError: error,
Expand Down
38 changes: 25 additions & 13 deletions src/env/sourceMap.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ interface RawSourceMap {
}

interface SourceMapSection {
offset: { line: number; column: number };
/** Required by the spec; optional here because the raw JSON is untrusted (see parseRawMap). */
offset?: { line: number; column: number };
map?: RawSourceMap;
url?: string; // external sub-map url (relative to outer map)
}
Expand Down Expand Up @@ -158,6 +159,7 @@ function requireNodeModule<T>(name: string): T | undefined {
if (typeof getBuiltin === "function") {
try {
const resolved = getBuiltin(name) as T | undefined;
/* v8 ignore else -- defensive: the sole caller asks for "node:fs", which every runtime implementing getBuiltinModule resolves; the fall-through only guards the generic signature */
if (resolved != null) {
return resolved;
}
Expand All @@ -177,13 +179,16 @@ function requireNodeModule<T>(name: string): T | undefined {

type FsLike = { readFileSync: (path: string, encoding: "utf8") => string; existsSync: (path: string) => boolean };

let cachedFs: FsLike | null | undefined;
// `node:fs`, probed exactly once on first use. A failed probe (undefined) is cached too and never
// retried — the flag, not a sentinel value, records that the probe ran.
let cachedFs: FsLike | undefined;
let fsProbed = false;
function getFs(): FsLike | undefined {
if (cachedFs === undefined) {
/* v8 ignore next 3 -- defensive: node:fs is always resolvable on Node/Bun/Deno, the only runtimes this resolver is wired into */
cachedFs = requireNodeModule<FsLike>("node:fs") ?? null;
if (!fsProbed) {
fsProbed = true;
cachedFs = requireNodeModule<FsLike>("node:fs");
}
return cachedFs ?? undefined;
return cachedFs;
}

function dirnameOf(filePath: string): string {
Expand Down Expand Up @@ -275,10 +280,9 @@ function getParsedSourceMap(filePath: string): ParsedSourceMap | undefined {
// Defensive cap: a pathological process could load thousands of modules with source maps. Evict
// the oldest entry (FIFO — the cost of re-reading one file is negligible) to bound memory.
if (parsedMapCache.size >= PARSED_MAP_CACHE_LIMIT) {
const firstKey = parsedMapCache.keys().next().value;
if (firstKey !== undefined) {
parsedMapCache.delete(firstKey);
}
// The cache is non-empty here, so its first key always exists.
const [firstKey] = parsedMapCache.keys();
parsedMapCache.delete(firstKey);
}

const fs = getFs();
Expand All @@ -288,8 +292,15 @@ function getParsedSourceMap(filePath: string): ParsedSourceMap | undefined {
return undefined;
}

const loaded = loadRawSourceMap(filePath, fs);
const parsed = loaded != null ? parseRawMap(loaded.raw, loaded.mapDir, fs) : undefined;
// A map that is valid JSON but structurally hostile (`"sections": [null]`, `"mappings": 123`, ...) must
// degrade to "no map" — cached like any other miss — rather than throw a TypeError into the log call.
let parsed: ParsedSourceMap | undefined;
try {
const loaded = loadRawSourceMap(filePath, fs);
parsed = loaded != null ? parseRawMap(loaded.raw, loaded.mapDir, fs) : undefined;
} catch {
parsed = undefined;
}
parsedMapCache.set(filePath, parsed ?? null);
return parsed;
}
Expand All @@ -310,7 +321,8 @@ function parseRawMap(raw: RawSourceMap, mapDir: string, fs: FsLike, depth = 0):
if (depth >= MAX_SECTION_DEPTH) return undefined;
const sections: ParsedSection[] = [];
for (const section of raw.sections) {
/* v8 ignore next 2 -- `offset` is required by the spec; the ?? 0 guards malformed maps only */
// `offset` is required by the spec; a malformed section without one is anchored at 0:0 rather
// than throwing — this parse runs outside any try/catch, so a TypeError here would reach the log call.
const offsetLine = section.offset?.line ?? 0;
const offsetColumn = section.offset?.column ?? 0;
let subRaw = section.map;
Expand Down
15 changes: 9 additions & 6 deletions src/env/stackTrace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ const OWN_DIR_MARKER: string | undefined = (() => {
}
})();

/** A frame whose path begins with tslog's actual own directory is internal (location-based, name-independent). */
/* v8 ignore next 2 -- unreachable under the Node ESM runner where OWN_DIR_MARKER always resolves; live in the browser IIFE and user bundles where it is undefined */
const OWN_DIR_PATTERN: RegExp | undefined =
OWN_DIR_MARKER != null ? new RegExp(`^(?:file://)?${OWN_DIR_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\\\/]`, "i") : undefined;
/**
* A frame whose path begins with tslog's actual own directory is internal (location-based,
* name-independent). Kept as a spreadable list so {@link DEFAULT_IGNORE_PATTERNS} needs no
* conditional: empty in the browser IIFE and user bundles, where no marker resolves.
*/
/* v8 ignore next -- the empty arm is unreachable under the Node ESM runner where OWN_DIR_MARKER always resolves; live in the browser IIFE and user bundles where it is undefined */
const OWN_DIR_PATTERNS: RegExp[] =
OWN_DIR_MARKER != null ? [new RegExp(`^(?:file://)?${OWN_DIR_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\\\/]`, "i")] : [];

const DEFAULT_IGNORE_PATTERNS: RegExp[] = [
/(?:^|[\\/])node_modules[\\/].*tslog/i,
Expand All @@ -48,8 +52,7 @@ const DEFAULT_IGNORE_PATTERNS: RegExp[] = [
// The published bundle layout, so a frame in dist/esm or dist/cjs of *the tslog package* is internal.
// Anchored to `tslog/dist/...` rather than any bare `tslog/` substring.
/(?:^|[\\/])tslog[\\/]dist[\\/](?:esm|cjs)[\\/]/i,
/* v8 ignore next -- unreachable under the Node ESM runner where OWN_DIR_PATTERN is non-null; live in the browser IIFE and user bundles where it is undefined */
...(OWN_DIR_PATTERN != null ? [OWN_DIR_PATTERN] : []),
...OWN_DIR_PATTERNS,
// Runtime-chunk names from modern bundlers (Turbopack/Next.js dev). These are *generated* names —
// successful source-map remapping has already turned user frames into real `src/...` paths before
// this check runs, so only unremapped bundler-runtime frames still match.
Expand Down
16 changes: 6 additions & 10 deletions src/render/inspect.polyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,8 @@ export function inspect(obj: unknown, opts?: InspectOptions) {
stylize: stylizeNoColor,
};

if (opts != null) {
// got an "options" object
_extend(ctx, opts);
}
// Merge the caller's options (a missing or non-object value is a no-op inside _extend).
_extend(ctx, opts);
// set default options
if (isUndefined(ctx.showHidden)) ctx.showHidden = false;
if (isUndefined(ctx.depth)) ctx.depth = 2;
Expand Down Expand Up @@ -426,9 +424,9 @@ function reduceToSingleString(output: string[], base: string, braces: string[]):
return `${braces[0] + (base === "" ? "" : `${base}\n`)} ${output.join(",\n ")} ${braces[1]}`;
}

function _extend(origin: object, add: object): object {
function _extend(origin: object, add: unknown): object {
const typedOrigin = origin as { [key: string]: unknown };
// Don't do anything if add isn't an object
// Don't do anything if add isn't an object (covers the optional/null options of inspect and formatWithOptions)
if (!add || !isObject(add)) return origin;

const clonedAdd = { ...add } as { [key: string]: unknown };
Expand All @@ -448,10 +446,8 @@ export function formatWithOptions(inspectOptions: InspectOptions, ...args: unkno
stylize: stylizeNoColor,
};

if (inspectOptions != null) {
// got an "options" object
_extend(ctx, inspectOptions);
}
// Merge the caller's options (a missing or non-object value is a no-op inside _extend).
_extend(ctx, inspectOptions);

const first = args[0];
let a = 0;
Expand Down
15 changes: 7 additions & 8 deletions src/render/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,14 +719,13 @@ function renderPlannedLine<LogObj>(record: LogObj & ILogObjMeta, settings: ISett
let spreadSource: Record<string, unknown> | undefined;
const spreadShape = hasMessageKey ? undefined : getSpreadShapeHint(recordObj);
if (spreadShape !== undefined) {
const leading = recordObj["0"];
const trailing = recordObj["1"];
if (spreadShape === "object-first" && typeof leading === "object" && leading !== null) {
messageValue = trailing;
spreadSource = leading as Record<string, unknown>;
} else if (spreadShape === "message-first" && typeof trailing === "object" && trailing !== null) {
messageValue = leading;
spreadSource = trailing as Record<string, unknown>;
// The hint names which positional slot holds the plain object to spread; the other slot is the message.
const fieldsKey = spreadShape === "object-first" ? "0" : "1";
const fields = recordObj[fieldsKey];
/* v8 ignore else -- unreachable: toLogObj sets the hint only when that slot held a plain object, in the same pass that stores it there, and nothing between toLogObj and rendering rewrites positional values (middleware and masking run on the args BEFORE toLogObj); the typeof guard only protects the cast on a hand-built record */
if (typeof fields === "object" && fields !== null) {
messageValue = recordObj[fieldsKey === "0" ? "1" : "0"];
spreadSource = fields as Record<string, unknown>;
}
}
const spreading = spreadSource !== undefined;
Expand Down
15 changes: 8 additions & 7 deletions src/subpaths/presets/otel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,14 +565,15 @@ function looksLikeErrorObject(value: unknown): value is IErrorObject {
return candidate.nativeError instanceof Error && typeof candidate.name === "string" && Array.isArray(candidate.stack);
}

/** Best-effort raw stack STRING for one error, preferring the native `Error#stack`. */
/**
* Best-effort raw stack STRING for one error, preferring the native `Error#stack`. Every caller gates
* on {@link looksLikeErrorObject} / {@link isPlainErrorLike} first, so `nativeError` is always a real
* `Error` here (a JSON/worker round-tripped error object never passes those checks).
*/
function ownStackString(error: IErrorObject): string | undefined {
const native = error.nativeError;
if (native != null) {
const stack = safeStringProp(native, "stack");
if (stack !== undefined) {
return stack;
}
const stack = safeStringProp(error.nativeError, "stack");
if (stack !== undefined) {
return stack;
}
if (!Array.isArray(error.stack) || error.stack.length === 0) {
return undefined;
Expand Down
7 changes: 3 additions & 4 deletions src/subpaths/serializers/std.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,11 @@ function toErrorObject(error: Error, depth = 0, seen: Set<unknown> = new Set()):
return errorObject;
}

// `toError` returns an Error cause as-is (already checked against `seen`) and wraps anything else in
// a FRESH Error that cannot be in `seen` yet, so the raw-value check is the only one needed.
const causeValue = (error as { cause?: unknown }).cause;
if (causeValue != null && !seen.has(causeValue)) {
const normalizedCause = toError(causeValue);
if (!seen.has(normalizedCause)) {
errorObject.cause = toErrorObject(normalizedCause, depth + 1, seen);
}
errorObject.cause = toErrorObject(toError(causeValue), depth + 1, seen);
}

return errorObject;
Expand Down
Loading
Loading