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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ All notable changes to this project are documented here. This project adheres to
- **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)
- **No ANSI colors in transport output** — on an interactive terminal the pretty line handed to attached transports carried the console's ANSI escape codes, so a `fileTransport` (or an HTTP / ring-buffer sink) on a pretty logger wrote color codes into the log. A transport's `"pretty"` line is now always plain text; the console stays colored. (#375)

## [5.1.0] - 2026-07-17

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,8 @@ interface Transport<LogObj> {
}
```

`line` is the record rendered in the transport's `format` (the logger's `type` when unset; `json` for `hidden`). A `"pretty"` line is always plain text: the ANSI colors a TTY console gets never reach a file, HTTP or buffer sink.

`attachTransport` accepts a full `Transport` object **or** a bare function, and **returns a detach function**:

```typescript
Expand Down
3 changes: 3 additions & 0 deletions RECIPES.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,9 @@ log.info("ready");
// the buffered file output is flushed when the `await using` scope ends
```

Prefer a human-readable file? Use `format: "pretty"`: transport lines are always plain text, so the file
never picks up the console's ANSI colors.

Built-in exit safety: the file transport registers guarded exit hooks by default (`exitHooks: false`
opts out) — an async flush on `beforeExit` and a synchronous drain on `exit`, so even a bare
`process.exit(0)` or an uncaught exception does not lose the buffered tail. fs errors (disk full,
Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { Logger, createLogger, log } from "tslog"; // class + typed-custom-level
- Custom levels install real methods: `createLogger({ customLevels: { AUDIT: 7 } }).audit(...)` (typed) or `log.addLevel("NOTICE", 3.5).notice(...)`; names resolve case-insensitively.
- `log.runInContext({ requestId }, fn)` — AsyncLocalStorage correlation: fields attach to every log inside `fn`, across awaits. `log.getContext()` reads them. Auto-resolves on Node/Deno/Bun; on Cloudflare Workers pass `contextStorage: new AsyncLocalStorage()` (from "node:async_hooks", needs the nodejs_als or nodejs_compat flag).
- `log.attachTransport(t)` extra sink; `log.use(mw)` middleware; `log.flush()` awaits async transport writes + each transport's `flush()`; `await using` disposes owned transports (a child never disposes inherited ones).
- Custom transport: a bare `(record) => void|Promise<void>` or `{ write(record, line), minLevel?, format?, flush? }` (per-sink level/format; errors isolated per transport). Middleware: `log.use((ctx) => ctx)` — mutate `ctx.args`/`ctx.meta`, return `null` to drop the log.
- Custom transport: a bare `(record) => void|Promise<void>` or `{ write(record, line), minLevel?, format?, flush? }` (per-sink level/format; a `"pretty"` `line` is always plain text, never ANSI-colored; errors isolated per transport). Middleware: `log.use((ctx) => ctx)` — mutate `ctx.args`/`ctx.meta`, return `null` to drop the log.
- `log.setMinLevel("DEBUG")` — switch the level at runtime; `log.isLevelEnabled("TRACE")` — guard expensive log-argument construction.
- `log.if(condition)` — returns `log` when truthy, a no-op when falsy: `log.if(!ok).warn("failed", { id })`. Gates a single call; args are still evaluated (use `isLevelEnabled` to skip expensive construction).
- `Logger.fromEnv(overrides?)` — build from env vars: `TSLOG_LEVEL` → `minLevel`, `TSLOG_TYPE` → `type`, `TSLOG_NAME` → `name`; explicit `overrides` win. `defineConfig({ ... })` — typed helper for sharing settings across loggers.
Expand Down
20 changes: 17 additions & 3 deletions src/core/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,10 @@ export function json<LogObj>(): FormatStage<LogObj> {
* meta markup, error rendering, and ANSI handling, so all runtimes share one implementation.
*
* This stage produces the plain-text pretty line used by attached transports and per-transport
* `format: "pretty"`. The live console (which may add browser CSS `%c` styling) is still driven by the
* provider's `transportFormatted` from the core.
* `format: "pretty"`. The line is always rendered WITHOUT ANSI styling, whatever `pretty.style` says:
* `style` follows the console (an interactive TTY), while transports write to files, HTTP endpoints and
* buffers where escape codes are noise. The live console (ANSI, or browser CSS `%c` styling) is still
* driven by the provider's `transportFormatted` from the core.
*
* @param provider - the runtime environment provider (Node, browser, or universal).
* @example
Expand All @@ -128,10 +130,22 @@ export function pretty<LogObj>(provider: EnvironmentProvider): FormatStage<LogOb
return (record, settings) => {
const meta = record[settings.meta.property] as unknown as IMeta | undefined;
const maskedArgs = getMaskedArgs(record, settings.meta.property);
return provider.prettyFormatLine(maskedArgs, meta, settings);
return provider.prettyFormatLine(maskedArgs, meta, unstyled(settings));
};
}

/**
* A view of `settings` with pretty styling off, for the {@link pretty} stage. Built per call (not cached)
* so later `logger.settings.pretty` changes are always honored; `inspectOptions` is copied because
* `prettyFormatLine` writes `colors` into it, and the console path must keep its own value.
*/
function unstyled<LogObj>(settings: ISettings<LogObj>): ISettings<LogObj> {
if (settings.pretty.style === false) {
return settings;
}
return { ...settings, pretty: { ...settings.pretty, style: false, inspectOptions: { ...settings.pretty.inspectOptions } } };
}

/**
* Format stage that prefixes the output of an inner pretty/json stage with the record's ISO timestamp.
*
Expand Down
5 changes: 3 additions & 2 deletions src/env/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ export interface EnvironmentProvider {
* Build the plain-text pretty log line (meta markup + inspected args + rendered errors) as a string,
* WITHOUT writing it anywhere. This is the runtime-agnostic "prettyFormat path" the format pipeline's
* `pretty()` stage delegates to so attached transports and per-transport `format: "pretty"` get a
* pretty line. ANSI styling follows `settings.pretty.style`; browser CSS `%c` styling is NOT applied
* here (that is exclusive to the live console via {@link transportFormatted}).
* pretty line. ANSI styling follows `settings.pretty.style` (the `pretty()` stage passes it off, so
* transport lines are always plain; the `tslog` CLI passes it through); browser CSS `%c` styling is NOT
* applied here (that is exclusive to the live console via {@link transportFormatted}).
*
* @param maskedArgs - the masked log arguments (errors are split out and rendered into the line).
* @param meta - the record's {@link IMeta} block (drives the meta markup and the log-level method).
Expand Down
60 changes: 59 additions & 1 deletion tests/41_issue_fixes.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createUniversalEnvironment } from "../src/env/environment.universal.js";
import { Logger } from "../src/index.js";
import type { IMeta } from "../src/interfaces.js";
import { consoleSupportsCssStyling, isWorkerEnvironment } from "../src/internal/environment.js";
import { buildPrettyMeta } from "../src/internal/metaFormatting.js";
import { inspect } from "../src/render/inspect.polyfill.js";
import { getConsoleLogStripped, mockConsoleLog } from "./helper.js";
import { fileTransport } from "../src/subpaths/transports/file.js";
import { getConsoleLog, getConsoleLogStripped, mockConsoleLog } from "./helper.js";

// Regression tests for fixed GitHub issues. Each assertion fails against the pre-fix code.

Expand Down Expand Up @@ -160,3 +164,57 @@ describe("#262: Web Workers are treated as CSS-capable consoles", () => {
expect(consoleSupportsCssStyling()).toBe(false);
});
});

describe("#375: pretty transport lines carry no ANSI escapes, even when the console is styled", () => {
// `pretty.style: true` is what a logger resolves to on an interactive TTY.
const styled = () => new Logger({ type: "pretty", stack: { capture: "off" }, pretty: { style: true, passObjectsNatively: false } });

test("an attached transport gets a plain line while the console stays colored", () => {
mockConsoleLog(true, false);
const logger = styled();
const lines: string[] = [];
logger.attachTransport({ write: (_record, line) => lines.push(line) });
logger.error("payment failed", { orderId: 7 }, new Error("card declined"));

expect(lines).toHaveLength(1);
expect(lines[0]).not.toContain("\u001b");
expect(lines[0]).toContain("ERROR");
expect(lines[0]).toContain("payment failed");
expect(lines[0]).toContain("orderId: 7");
expect(lines[0]).toContain("card declined");
expect(getConsoleLog()).toContain("\u001b[");

// Rendering the plain transport line must not switch the logger's own console styling off.
mockConsoleLog(true, false);
logger.info("again", { a: 1 });
expect(logger.settings.pretty.style).toBe(true);
expect(logger.settings.pretty.inspectOptions.colors).toBe(true);
expect(getConsoleLog()).toContain("\u001b[33m1\u001b[39m");
});

test("an explicit per-transport format: 'pretty' is plain too", () => {
const logger = new Logger({ type: "hidden", stack: { capture: "off" }, pretty: { style: true } });
const lines: string[] = [];
logger.attachTransport({ format: "pretty", write: (_record, line) => lines.push(line) });
logger.info("hello", { a: 1 });

expect(lines[0]).toContain("hello");
expect(lines[0]).not.toContain("\u001b");
});

test("fileTransport writes an uncolored pretty file (the reported setup)", async () => {
const dir = await mkdtemp(join(tmpdir(), "tslog-375-"));
const path = join(dir, "app.log");
mockConsoleLog(true, false);
const logger = styled();
const file = fileTransport({ path, exitHooks: false });
logger.attachTransport(file);
logger.info("written to disk", { a: 1 });
await file[Symbol.asyncDispose]();

const contents = await readFile(path, "utf8");
await rm(dir, { recursive: true, force: true });
expect(contents).toContain("written to disk");
expect(contents).not.toContain("\u001b");
});
});
Loading