ehsan.blog
~/blog/one-bigint-in-your-log-data-deletes-every-other-field — zsh
cat one-bigint-in-your-log-data-deletes-every-other-field.md

One BigInt in your log data deletes every other field

·10 min read

You log four useful things:

ts
log.info('order placed', {
  route: '/checkout',
  userId: 42,
  total: 19.99,
  durationNs: end - start,
})

In your terminal it looks perfect. In production, the log line arrives like this:

json
{"level":"info","message":"order placed","data":"[unserializable data]"}

Not one field is missing. All of them are. The route is gone, the user id is gone, the total is gone. You still have the words “order placed”, which is exactly the part you already knew.

One BigInt in your log data deletes every other field next to it, and nothing throws or warns you on the way. I measured what actually happens, and the cause is a small rule in JavaScript that almost nobody is told about.

The short version

JSON.stringify cannot handle two kinds of values. Not “handles them badly” — it refuses, and throws an error:

js
JSON.stringify({ orderId: 10n })   // TypeError: Do not know how to serialize a BigInt

const req = { id: 1 }
req.self = req
JSON.stringify(req)                // TypeError: Converting circular structure to JSON

Those are real outputs from Node v22, not from memory. The BigInt rule is in the language spec itself — JSON.stringify on MDN states plainly that a BigInt value throws a TypeError, because JSON has no way to write a number that big.

Now think about where that JSON.stringify call lives. It lives inside your logger, because JSON is how logs get shipped anywhere. So when it throws, the logger is the thing holding the broken pieces — and a logger that crashes your checkout route because you asked it to print something would be a terrible logger. So it catches the error.

And once it has caught the error, what can it do? It has no partial result. JSON.stringify is all-or-nothing: it either gives you the whole string or it gives you an exception. There is no “here are the three fields that worked.”

So the logger writes down the only honest thing it can: "[unserializable data]", and your other three fields go in the bin with the bad one.

flowchart TD
  A["your object<br/>route, userId, total, durationNs"] --> B{"JSON.stringify"}
  B -->|"every value is JSON-safe"| C["full log line<br/>all 4 fields"]
  B -->|"one BigInt or one loop"| D["TypeError thrown"]
  D --> E["logger catches it<br/>so your route keeps working"]
  E --> F["no partial result exists<br/>data: '[unserializable data]'"]

There is no third branch. That is the whole bug.

Watching it happen

I ran this against the published @developerehsan/nextjs-logger (v1.0.1), one line per case, so you can see exactly which values survive:

ts
const log = createLogger('shop')
const cyc = { id: 1 }; cyc.self = cyc

log.info('t', { orderId: 'A-1', userId: 42, total: 19.99 })
log.info('t', { orderId: 10n,   userId: 42, total: 19.99, region: 'eu' })
log.info('t', { userId: 42, meta: { deep: { amount: 5n } } })
log.info('t', { userId: 42, req: cyc })
log.info('t', { userId: 42, tags: new Map([['a', 1]]) })
log.info('t', { userId: 42, note: undefined })
log.info('t', { userId: 42, ms: NaN })

The output, trimmed to the data field:

json
{"orderId":"A-1","userId":42,"total":19.99}
"[unserializable data]"
"[unserializable data]"
"[unserializable data]"
{"userId":42,"tags":{}}
{"userId":42}
{"userId":42,"ms":null}

Read that list carefully, because it splits into two very different failure modes.

Lines 2, 3 and 4 lost everything. One BigInt, even buried two levels deep inside meta.deep.amount, and the whole object is replaced. Depth does not save you. The bad value does not have to be near the top — it just has to be somewhere.

Lines 5, 6 and 7 lost one field each and kept the rest. A Map quietly became {}. An undefined value made its key vanish completely. NaN turned into null. Those are annoying, and worth knowing, but they are local damage. The BigInt and the circular reference are different in kind: they are not a bad field, they are a bad object.

That is the sentence to remember. Some values spoil themselves. Two values spoil everything around them.

Why this only hurts you in production

Here is the cruel part, and the reason this bug survives code review:

js
const data = { route: '/checkout', userId: 42, durationNs: end - start }

console.log(data)
// { route: '/checkout', userId: 42, durationNs: 922n }   ← perfect

JSON.stringify(data)
// TypeError: Do not know how to serialize a BigInt       ← broken

console.log does not use JSON. It uses Node’s inspector, which is happy to print a BigInt — it even adds the little n so you can tell it apart from a normal number. So on your laptop, staring at a pretty terminal, the field looks great: two back-to-back hrtime calls measured 922 ns apart on my machine, and console.log printed that number without complaint while JSON.stringify refused the exact same object.

The moment those same logs have to become JSON to travel somewhere — your log service, a file, an HTTP request — the same object stops working. Dev and prod disagree, and only prod is telling the truth.

Where BigInts come from when you never typed 10n

Almost nobody writes 10n by hand. BigInts arrive through the back door:

Timing code. process.hrtime.bigint() returns a BigInt, because nanoseconds overflow a normal JavaScript number. So the most natural way to time a request produces a BigInt:

ts
const start = process.hrtime.bigint()
await handleCheckout()
const end = process.hrtime.bigint()

log.info('checkout done', { userId, durationNs: end - start })  // whole line lost

You added timing to debug a slow route. The timing deleted the log you were debugging with.

Databases. Big integer columns — bigint, int8, anything counting past about nine quadrillion — are mapped to BigInt by several drivers and ORMs, because a normal JavaScript number cannot hold them without losing precision. So a row you pulled from the database and logged “just to see it” can carry one.

Circular references have their own back door: request objects, socket objects, and any tree where a child holds a reference back to its parent. You never wrote the loop, the framework did.

The fix, and the two bugs I hit writing it

The idea is simple: clean the object before handing it to the logger. Turn BigInts into strings, cut loops, leave everything else alone.

My first attempt was the version you will find on most Stack Overflow answers — a WeakSet of every object seen so far:

ts
function safe(value) {
  const seen = new WeakSet()
  return JSON.parse(JSON.stringify(value, (_key, v) => {
    if (typeof v === 'bigint') return v.toString()
    if (typeof v === 'object' && v !== null) {
      if (seen.has(v)) return '[circular]'
      seen.add(v)
    }
    return v
  }))
}

It fixes the crash. It also introduces a quieter bug, which I only caught because I tested a case that had nothing to do with loops:

ts
const user = { id: 7 }
safe({ author: user, reviewer: user })
// { author: { id: 7 }, reviewer: "[circular]" }

Nothing here is circular. The same object simply appears twice, which is completely normal — the same user as author and reviewer, the same config object passed to two fields. But “have I seen this object anywhere before” is the wrong question. The right question is “am I currently inside this object?” A loop means an object contains itself. Two siblings pointing at one object is just sharing.

So the fix is to track ancestors — the path you are currently standing in — and drop them again on the way back out:

ts
function safe(value, ancestors = []) {
  if (typeof value === 'bigint') return value.toString()
  if (typeof value !== 'object' || value === null) return value
  if (value instanceof Date || value instanceof Error) return value
  if (ancestors.includes(value)) return '[circular]'

  const path = [...ancestors, value]
  if (Array.isArray(value)) return value.map((v) => safe(v, path))

  const out = {}
  for (const [k, v] of Object.entries(value)) out[k] = safe(v, path)
  return out
}

That third line — the Date and Error one — is bug number two, and I only added it after watching my own fix make things worse.

A Date is an object, so my loop walked into it with Object.entries. A Date has no enumerable own properties, so it came out as {}. Same for Error. I had taken two values the logger already handled perfectly well and flattened them into empty braces:

plaintext
before my fix:  {"at":"1970-01-01T00:00:00.000Z"}
after my fix:   {"at":{}}

Dates and Errors already know how to turn themselves into something readable — Date has a toJSON method, and a decent logger has a dedicated error serializer that keeps name, message, stack and the cause chain. (If you have ever seen an error log as {}, that missing serializer is exactly why — I pulled that one apart in why error objects print as empty in JSON logs.) The correct move is to not touch them and let the layer below do its job.

Proof it works

The same cases as before, with safe() in front of them:

ts
log.info('t', safe({ route: '/checkout', userId: 42, durationNs: end - start }))
log.info('t', safe({ author: user, reviewer: user }))
log.info('t', safe({ route: '/c', req: cyc }))
log.info('t', safe({ userId: 42, at: new Date(0), amount: 5n, deep: { arr: [1n, 'x'] } }))
json
{"route":"/checkout","userId":42,"durationNs":"1062"}
{"author":{"id":7},"reviewer":{"id":7}}
{"route":"/c","req":{"id":1,"self":"[circular]"}}
{"userId":42,"at":"1970-01-01T00:00:00.000Z","amount":"5","deep":{"arr":["1","x"]}}

Every field survives. The loop is cut at exactly one place instead of taking the object down with it. The Date is still a real timestamp.

And here is the check I keep next to it, so a future edit cannot quietly bring back either bug:

ts
import assert from 'node:assert'

const user = { id: 7 }
const cyc = { id: 1 }; cyc.self = cyc

assert.deepStrictEqual(safe({ a: user, b: user }), { a: { id: 7 }, b: { id: 7 } })
assert.strictEqual(safe(cyc).self, '[circular]')
assert.strictEqual(safe({ n: 9n }).n, '9')
assert.deepStrictEqual(safe({ arr: [1n, 2] }).arr, ['1', 2])
assert.ok(safe({ at: new Date(0) }).at instanceof Date)
assert.ok(safe({ e: new Error('x') }).e instanceof Error)

Six lines. The first two are the two bugs above, frozen so they stay fixed.

Why strings, and not numbers

safe() turns a BigInt into a string, not a number, and that is deliberate.

The whole reason a value is a BigInt is that it is too big for a normal JavaScript number to hold exactly. Number(9007199254740993n) gives you 9007199254740992 — the last digit is simply wrong. If that BigInt is an order id, you have just logged a different order.

A string is the boring, correct answer. "9007199254740993" is still the same id, still searchable in your log tool, still copy-pasteable into a database query. You lose the ability to do maths on it inside your log viewer, which you were not going to do anyway.

The one place to be careful: if a field is sometimes a number and sometimes a string, some log tools will get confused about the field’s type. Pick one shape per field and stay with it — if durationNs is a string, keep it a string everywhere, or store milliseconds as a real number in a separate field if you want to chart it.

The rule worth keeping

When a log line comes back as one useless placeholder instead of your data, you are not looking at a logger bug. You are looking at JSON.stringify refusing an object, and the logger being polite about it.

Two values cause it: BigInt, and an object that contains itself. Both can be hiding anywhere inside the thing you logged, and neither shows up in console.log, which is why this always gets discovered in production.

Clean the value before you log it, track ancestors rather than everything seen, and leave Date and Error alone — they are already better at this than your helper is.

If you want the log line to keep the same shape every time as well as the same fields, the next step is validating that shape at the boundary, which I covered in how to stop your structured logs from silently drifting out of shape.

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.