From 7e80327d0319cf6334f425ce368cba8c4bfa0925 Mon Sep 17 00:00:00 2001 From: Joshua Amaju Date: Thu, 10 Sep 2026 22:50:06 +0100 Subject: [PATCH 1/2] feat(logger): support pretty JSON output --- .changeset/chubby-ads-speak.md | 5 ++ packages/logger/README.md | 7 ++- packages/logger/example/pretty-json.ts | 21 +++++++ packages/logger/package.json | 1 + .../logger/src/internal/formatters/json.ts | 21 +++++-- packages/logger/src/internal/shared.ts | 49 +++++++++------- packages/logger/test/index.test.ts | 56 +++++++++++++++++++ 7 files changed, 132 insertions(+), 28 deletions(-) create mode 100644 .changeset/chubby-ads-speak.md create mode 100644 packages/logger/example/pretty-json.ts diff --git a/.changeset/chubby-ads-speak.md b/.changeset/chubby-ads-speak.md new file mode 100644 index 0000000..5edf094 --- /dev/null +++ b/.changeset/chubby-ads-speak.md @@ -0,0 +1,5 @@ +--- +"@jamx/logger": patch +--- + +Add support for pretty JSON formatting diff --git a/packages/logger/README.md b/packages/logger/README.md index 7670abc..b38bcd7 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -74,6 +74,9 @@ logger.log(Severity.Info, "charge succeeded", { }); ``` +`JsonFormatter` emits compact JSON by default. Pass an `indent` number or +string for readable output, for example `new JsonFormatter({ indent: 2 })`. + ## Convenience Logging API `CoreLogger` exposes the low-level `log(severity, message, meta)` method. @@ -169,7 +172,7 @@ Built-in transports: Built-in formatters: - `PrettyFormatter`: human-readable console output. -- `JsonFormatter`: JSON lines for structured output. +- `JsonFormatter`: structured JSON output, with optional indentation. - `TextFormatter`: compact text output. - `PrintfFormatter`: printf-style message interpolation with metadata values. @@ -235,6 +238,7 @@ Run examples from this package directory: ```bash pnpm run example:basic pnpm run example:structured +pnpm run example:pretty-json pnpm run example:writer-pretty pnpm run example:context pnpm run example:processor @@ -250,6 +254,7 @@ The example files live in `example/`: - `basic.ts`: named console logger with pretty output. - `structured.ts`: JSON output and structured metadata. +- `pretty-json.ts`: indented JSON output with nested metadata. - `writer-pretty.ts`: pretty output written to stderr with `WriterTransport`. - `context.ts`: inherited metadata with child loggers. - `processor.ts`: redaction and enrichment with a processor. diff --git a/packages/logger/example/pretty-json.ts b/packages/logger/example/pretty-json.ts new file mode 100644 index 0000000..1790e60 --- /dev/null +++ b/packages/logger/example/pretty-json.ts @@ -0,0 +1,21 @@ +import { + ConsoleTransport, + createNamedLogger, + JsonFormatter, + Severity, +} from "../src/index.js"; + +const logger = createNamedLogger({ + name: "api", + minSeverity: Severity.Debug, + transport: new ConsoleTransport(new JsonFormatter({ indent: 2 })), +}); + +logger.info("Request completed", { + requestId: "req_123", + durationMs: 18, + response: { + status: 200, + cached: false, + }, +}); diff --git a/packages/logger/package.json b/packages/logger/package.json index 43dcdc5..0dfce34 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -54,6 +54,7 @@ "dev": "tsx ./example/basic.ts", "example:basic": "tsx ./example/basic.ts", "example:structured": "tsx ./example/structured.ts", + "example:pretty-json": "tsx ./example/pretty-json.ts", "example:writer-pretty": "tsx ./example/writer-pretty.ts", "example:context": "tsx ./example/context.ts", "example:processor": "tsx ./example/processor.ts", diff --git a/packages/logger/src/internal/formatters/json.ts b/packages/logger/src/internal/formatters/json.ts index ea8c666..989bd06 100644 --- a/packages/logger/src/internal/formatters/json.ts +++ b/packages/logger/src/internal/formatters/json.ts @@ -1,13 +1,22 @@ import { Formatter, LogRecord } from "../core.js"; import { safeStringify } from "../shared.js"; +export interface JsonFormatterOptions { + indent?: number | string; +} + export class JsonFormatter implements Formatter { + constructor(private readonly options: JsonFormatterOptions = {}) {} + format(log: LogRecord): string { - return safeStringify({ - timestamp: log.timestamp.toISOString(), - severity: log.severityName, - message: log.message, - meta: log.meta, - }); + return safeStringify( + { + timestamp: log.timestamp.toISOString(), + severity: log.severityName, + message: log.message, + meta: log.meta, + }, + this.options.indent, + ); } } diff --git a/packages/logger/src/internal/shared.ts b/packages/logger/src/internal/shared.ts index 02b25a8..67e3b58 100644 --- a/packages/logger/src/internal/shared.ts +++ b/packages/logger/src/internal/shared.ts @@ -23,30 +23,37 @@ export function assertMeta( } } -export function safeStringify(value: unknown): string { +export function safeStringify( + value: unknown, + space?: string | number, +): string { const seen = new WeakSet(); - return JSON.stringify(value, (_, nestedValue: unknown) => { - if (nestedValue instanceof Error) { - return { - name: nestedValue.name, - message: nestedValue.message, - stack: nestedValue.stack, - }; - } - - if (typeof nestedValue === "bigint") { - return nestedValue.toString(); - } - - if (nestedValue && typeof nestedValue === "object") { - if (seen.has(nestedValue)) { - return "[Circular]"; + return JSON.stringify( + value, + (_, nestedValue: unknown) => { + if (nestedValue instanceof Error) { + return { + name: nestedValue.name, + message: nestedValue.message, + stack: nestedValue.stack, + }; + } + + if (typeof nestedValue === "bigint") { + return nestedValue.toString(); } - seen.add(nestedValue); - } + if (nestedValue && typeof nestedValue === "object") { + if (seen.has(nestedValue)) { + return "[Circular]"; + } + + seen.add(nestedValue); + } - return nestedValue; - }); + return nestedValue; + }, + space, + ); } diff --git a/packages/logger/test/index.test.ts b/packages/logger/test/index.test.ts index 1f930d2..05da96f 100644 --- a/packages/logger/test/index.test.ts +++ b/packages/logger/test/index.test.ts @@ -7,6 +7,7 @@ import { createNamedLogger, CompositeTransport, ConsoleTransport, + JsonFormatter, MemoryTransport, PrettyFormatter, PrintfFormatter, @@ -316,3 +317,58 @@ describe("PrintfFormatter", () => { expect(record.message).toBe("hello %s"); }); }); + +describe("JsonFormatter", () => { + it("formats compact JSON by default", () => { + const formatter = new JsonFormatter(); + + const output = formatter.format({ + message: "Ready", + severityName: "debug", + severity: Severity.Debug, + timestamp: new Date("2026-03-03T11:12:35.123Z"), + meta: {}, + }); + + expect(output).not.toContain("\n"); + }); + + it("formats structured logs as indented JSON", () => { + const formatter = new JsonFormatter({ indent: 2 }); + + const output = formatter.format({ + message: "Request completed", + severityName: "info", + severity: Severity.Info, + timestamp: new Date("2026-03-03T11:12:35.123Z"), + meta: { + requestId: "req_91f5", + durationMs: 18, + }, + }); + + expect(output).toBe(`{ + "timestamp": "2026-03-03T11:12:35.123Z", + "severity": "info", + "message": "Request completed", + "meta": { + "requestId": "req_91f5", + "durationMs": 18 + } +}`); + }); + + it("supports a custom indentation", () => { + const formatter = new JsonFormatter({ indent: "\t" }); + + const output = formatter.format({ + message: "Ready", + severityName: "debug", + severity: Severity.Debug, + timestamp: new Date("2026-03-03T11:12:35.123Z"), + meta: {}, + }); + + expect(output).toContain('\n\t"timestamp"'); + }); +}); From 82303ac3e875fd0fc29771f4e95d805904ec7d73 Mon Sep 17 00:00:00 2001 From: Joshua Amaju Date: Thu, 10 Sep 2026 23:04:37 +0100 Subject: [PATCH 2/2] ci: install pinned Playwright browser --- .github/workflows/check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index bc5a404..5c6be88 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -24,7 +24,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Install Playwright browsers - run: pnpx playwright install --with-deps + run: pnpm --filter @jamx/http exec playwright install --with-deps chromium - name: Run build run: pnpm build