From e90396c67833fe7b3f32f5476d111c103599bb9a Mon Sep 17 00:00:00 2001 From: Eugene Terehov Date: Fri, 11 Sep 2026 14:09:33 +0300 Subject: [PATCH] Keep ANSI colors out of pretty transport lines --- CHANGELOG.md | 1 + README.md | 2 ++ RECIPES.md | 3 ++ llms.txt | 2 +- src/core/pipeline.ts | 20 ++++++++++-- src/env/environment.ts | 5 +-- tests/41_issue_fixes.test.ts | 60 +++++++++++++++++++++++++++++++++++- 7 files changed, 86 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e3e241..0903473c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `: ` 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 diff --git a/README.md b/README.md index fa1e2960..aa6ae166 100644 --- a/README.md +++ b/README.md @@ -532,6 +532,8 @@ interface Transport { } ``` +`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 diff --git a/RECIPES.md b/RECIPES.md index cf9291d0..7f76d835 100644 --- a/RECIPES.md +++ b/RECIPES.md @@ -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, diff --git a/llms.txt b/llms.txt index 6fee7a80..b595ca1f 100644 --- a/llms.txt +++ b/llms.txt @@ -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` 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` 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. diff --git a/src/core/pipeline.ts b/src/core/pipeline.ts index edc1f8c3..12ca7a96 100644 --- a/src/core/pipeline.ts +++ b/src/core/pipeline.ts @@ -117,8 +117,10 @@ export function json(): FormatStage { * 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 @@ -128,10 +130,22 @@ export function pretty(provider: EnvironmentProvider): FormatStage { 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(settings: ISettings): ISettings { + 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. * diff --git a/src/env/environment.ts b/src/env/environment.ts index 428ded34..40661354 100644 --- a/src/env/environment.ts +++ b/src/env/environment.ts @@ -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). diff --git a/tests/41_issue_fixes.test.ts b/tests/41_issue_fixes.test.ts index 111532f6..2cf32a85 100644 --- a/tests/41_issue_fixes.test.ts +++ b/tests/41_issue_fixes.test.ts @@ -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. @@ -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"); + }); +});