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
5 changes: 5 additions & 0 deletions .changeset/chubby-ads-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@jamx/logger": patch
---

Add support for pretty JSON formatting
2 changes: 1 addition & 1 deletion .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion packages/logger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions packages/logger/example/pretty-json.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
1 change: 1 addition & 0 deletions packages/logger/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 15 additions & 6 deletions packages/logger/src/internal/formatters/json.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
49 changes: 28 additions & 21 deletions packages/logger/src/internal/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>();

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,
);
}
56 changes: 56 additions & 0 deletions packages/logger/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createNamedLogger,
CompositeTransport,
ConsoleTransport,
JsonFormatter,
MemoryTransport,
PrettyFormatter,
PrintfFormatter,
Expand Down Expand Up @@ -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"');
});
});
Loading