diff --git a/.changeset/brown-glasses-thank.md b/.changeset/brown-glasses-thank.md new file mode 100644 index 00000000000..0445b02de0b --- /dev/null +++ b/.changeset/brown-glasses-thank.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Handle BigInt values safely and consistently across JSON diagnostics and logger formats. diff --git a/packages/effect/src/Formatter.ts b/packages/effect/src/Formatter.ts index 843b5b88bc8..fb73d19c79c 100644 --- a/packages/effect/src/Formatter.ts +++ b/packages/effect/src/Formatter.ts @@ -250,9 +250,9 @@ function safeToString(input: any): string { * Uses `JSON.stringify` internally with a replacer that tracks the current * object ancestry. Circular references are replaced with `undefined`, which * omits them from object output. `Redactable` values are automatically redacted - * before serialization. Values not supported by JSON otherwise follow standard - * `JSON.stringify` behavior. The `space` parameter controls indentation and - * defaults to `0`. + * before serialization. `BigInt` values are stringified with an `n` suffix. + * Values not supported by JSON otherwise follow standard `JSON.stringify` + * behavior. The `space` parameter controls indentation and defaults to `0`. * * **Gotchas** * @@ -300,6 +300,9 @@ export function formatJson(input: unknown, options?: { input, function(this: unknown, _key: string, value: unknown) { const redacted = redact(value) + if (typeof redacted === "bigint") { + return format(redacted) + } if (typeof redacted !== "object" || redacted === null) { return redacted } diff --git a/packages/effect/src/Inspectable.ts b/packages/effect/src/Inspectable.ts index e2c06932a2c..ddf3e270108 100644 --- a/packages/effect/src/Inspectable.ts +++ b/packages/effect/src/Inspectable.ts @@ -187,7 +187,7 @@ export const toStringUnknown = (u: unknown, whitespace: number | string | undefi return u } try { - return typeof u === "object" ? formatJson(u, { space: whitespace }) : String(u) + return typeof u === "object" ? formatJson(u, { space: whitespace }) : format(u, { space: whitespace }) } catch { return String(u) } diff --git a/packages/effect/src/internal/effect.ts b/packages/effect/src/internal/effect.ts index 370b36ec222..99c3632ff8a 100644 --- a/packages/effect/src/internal/effect.ts +++ b/packages/effect/src/internal/effect.ts @@ -6302,7 +6302,6 @@ export const formatLogSpan = (self: [label: string, timestamp: number], now: num /** @internal */ export const structuredMessage = (u: unknown): unknown => { switch (typeof u) { - case "bigint": case "function": case "symbol": { return String(u) diff --git a/packages/effect/test/Formatter.test.ts b/packages/effect/test/Formatter.test.ts index a8d586153ea..a862bff56d6 100644 --- a/packages/effect/test/Formatter.test.ts +++ b/packages/effect/test/Formatter.test.ts @@ -2,6 +2,7 @@ import { describe, it } from "@effect/vitest" import { Context, Effect, + Inspectable, Option, Redactable, Redacted, @@ -256,12 +257,30 @@ describe("Formatter", () => { strictEqual(formatJson({ left: shared, right: shared }), `{"left":{"a":1},"right":{"a":1}}`) }) + it("should stringify BigInt values", () => { + strictEqual(formatJson(123n), `"123n"`) + strictEqual(formatJson({ value: 123n }), `{"value":"123n"}`) + strictEqual(formatJson([1n, 2n]), `["1n","2n"]`) + }) + it("should redact sensitive data", () => { strictEqual(formatJson(data), `{"secret":"[REDACTED]"}`) strictEqual(formatJson({ a: data }), `{"a":{"secret":"[REDACTED]"}}`) }) }) + describe("Inspectable.toStringUnknown", () => { + it("should stringify BigInt values", () => { + strictEqual(Inspectable.toStringUnknown(123n), `123n`) + strictEqual( + Inspectable.toStringUnknown({ value: 123n }), + `{ + "value": "123n" +}` + ) + }) + }) + describe("SchemaIssue reportInput", () => { const formatIssue = SchemaIssue.makeFormatterDefault() diff --git a/packages/effect/test/Logger.test.ts b/packages/effect/test/Logger.test.ts index 5871e875217..3d54139c300 100644 --- a/packages/effect/test/Logger.test.ts +++ b/packages/effect/test/Logger.test.ts @@ -90,6 +90,27 @@ describe("Logger", () => { assert.ok(!output.includes("annotation=\"\\\"value with spaces\\\"\"")) })) + it.effect("formats BigInt messages consistently", () => + Effect.gen(function*() { + const simple: Array = [] + const logFmt: Array = [] + const structured: Array = [] + const json: Array = [] + const loggers = [ + Logger.formatSimple.pipe(Logger.map((output) => void simple.push(output))), + Logger.formatLogFmt.pipe(Logger.map((output) => void logFmt.push(output))), + Logger.formatStructured.pipe(Logger.map((output) => void structured.push(output.message))), + Logger.formatJson.pipe(Logger.map((output) => void json.push(JSON.parse(output).message))) + ] + + yield* Effect.logInfo(123n, { value: 123n }).pipe(Effect.provide(Logger.layer(loggers))) + + assert.include(simple[0], `message=123n message="{\\"value\\":123n}"`) + assert.include(logFmt[0], `message=123n message="{\\"value\\":123n}"`) + assert.deepStrictEqual(structured, [[123n, { value: 123n }]]) + assert.deepStrictEqual(json, [["123n", { value: "123n" }]]) + })) + it.effect("annotateLogsScoped applies annotations only while scoped", () => Effect.gen(function*() { const annotations: Array> = []