Logging an object runs code, or loses data

Reading the value of a property runs whatever getter sits behind it, so a capture that refuses to run one has to lose data instead. Here is the trade.

Two overlapping glass cubes hold suspended shards, with a short orange prism entering the open cube

Reading the value of a property is a function call. A property can be an accessor, and reading it runs the getter, so a tool that reports what your object contained has already run your code. A tool that refuses to run it has to lose data instead. Both are defensible; only one is usually declared.

A property is not a slot, and reading it is not free

cart.total looks like a lookup, and sometimes it is one. The object may hold that property as data, in which case reading it copies a value and nothing else happens. Or it may hold a getter, in which case reading it calls a function somebody wrote, which can hit the network, throw, count its callers, or answer differently the second time.

The two cases are indistinguishable at the call site, and no tool can ask whether a property is safe to read by reading it. What it can ask for is the descriptor. Object.getOwnPropertyDescriptor returns a record carrying either a value field or a get field, and that answer arrives without the property being read. Our widget’s capture takes that route and accepts a property only when the descriptor carries value, so nothing on your object runs because we looked at it.

Two consequences matter before you decide what to log. The descriptor is an own descriptor, so a field living on the prototype is not copied even though your code treats it as part of the object. And a property that exists only as an accessor is invisible to the capture: absent rather than empty.

The same mechanism has a second face on the host page’s globals rather than on your data, where a script that works out what to instrument by reading a property has already caused the side effect it was inspecting for. That is one reason a third-party script leaves something behind on a wrapped global instead of reading it back.

What a snapshot will not read, it cannot report

The refusal costs something immediately, and the cost shows up in the first line you log.

A snapshot keeps an object in structured form only when it carries its own message or stack as a string, which is the shape of an error. Everything else is recorded as the literal text [object]. Not a summary of its keys and not a type name: the same eight characters for every object on the page.

What the snapshot is handedWhat the snapshot keeps
A stringThe string, cut to its byte ceiling
A finite number, a boolean, nullItself, as a value rather than as text
NaN or an infinityThe text of it
undefined, a symbol, a bigint[undefined], [symbol], [bigint]
An error with an own message or stackIts name, message and stack, each cut to its own ceiling
Any other object, array, function or proxy[object]

That is the snapshot, and a console line then does something narrower with it. The call is read up to its first four arguments, a fifth is not read at all, and each snapshot is flattened to text and joined to the others by a space to become the entry’s message. A scalar arrives there as its own text. An error contributes the text of its message alone, or its name when the message is empty: no stack, and no structured field beside the line. The uncaught-exception path reads the same snapshot but stores the stack on the entry, which is why a crash arrives with one and a logged error never does.

Errors earn their place in that table by carrying data properties, and the exception is narrower than it looks. A native TypeError keeps message on the instance and name on its prototype, so the own-property rule finds no name and the snapshot falls back to the generic one. A subclass whose constructor assigns this.name is stored under the name you gave it, which is a decent argument for assigning it.

A snapshot stops being your object

The other half of the trade is the half worth copying into your own tooling: what the capture keeps is a copy, and a copy has a time attached to it.

A test in our widget suite builds an object with an own message, an own stack, and an accessor named secret whose getter increments a counter. It snapshots the object, asserts the result is frozen, asserts the counter still reads zero, then assigns a new value to message on the original and asserts the stored copy did not move. A second test runs the same snapshot against a proxy whose getOwnPropertyDescriptor trap throws on every call: the result is [object], no exception escapes into the host page, and the counter is still zero.

A reference has none of those properties. A tool that keeps the object and serializes it when the payload is sent reports what the object became, which is rarely what it held at the moment you logged it, and that gap is the window you were inspecting. Retaining host references was considered here and rejected, together with unconditional deep serialization, on the ground that getters, proxies, mutation and object size make the main-thread and memory cost of a walk impossible to bound. A capture with no ceiling on its own cost eventually becomes the slow thing on somebody’s page, which is the host isolation contract these rules exist to keep.

The ceilings are per field, and they are in bytes

What is copiedCeiling
A string in an entry’s data1,024 bytes
A stack string in an entry’s data4,096 bytes
A captured error’s name128 bytes
A captured error’s message1,024 bytes
A captured error’s stack4,096 bytes
The entry’s own message text4,096 bytes
Keys copied out of one entry’s data12
Console arguments read per call4

Bytes, not characters, and the counter charges what UTF-8 charges: one for ASCII, two or three for most of the rest, four for anything built from a surrogate pair. It also stops before the character that would cross the line, so a cut never leaves half a character behind. A line of emoji reaches its ceiling four times sooner than the same number of ASCII characters.

Structure is bounded the same way. An entry’s data is copied one key at a time up to twelve keys, and the thirteenth is not copied. A nested object is refused except under a single reserved key whose contract is closed and scalar, and even there only one level further down. Anything the rules will not copy sets a flag on the entry recording that something was dropped.

That flag is what makes the trade declarable rather than silent. A key that disappears without a trace is indistinguishable from a key that was never set, and the rule that an uncopyable value must mark its entry was written after a diagnostic went missing from every stored row while the tests stayed green. The marker travels inside the stored entry rather than as a badge beside each line in a viewer, so it tells the next system that the record is incomplete rather than a reader mid-scroll.

Log the field, not the object

What you have to design around fits in one line. Log the object and the entry holds [object]; no care at read time recovers what it contained.

console.log("checkout", cart) stores the word and the literal. console.log("checkout", cart.id, cart.total, cart.currency) stores four readable values joined by spaces in one field, three of them the ones you would have gone hunting for, each cut only at its own ceiling. One line of legible text is the whole prize, and it is worth more than a line that reads checkout [object].

This is a worse interface than the one where you hand over an object and something clever works out what mattered. It is also the only one that can promise it ran none of your getters, and that promise is what lets the capture stay on by default instead of being switched on once the bug is already reproducing.

The consequence lands later and on somebody else’s screen. When a report arrives with its console attached, the evidence capture on that ticket is whatever the widget was allowed to keep, assembled out of lines written weeks earlier by somebody who had no idea a stranger would read them. Whether that record answers the question was decided at the call site, and it was decided in favour of whoever logged the field.

Common questions

Why does a captured console log show [object] instead of my object?

A console argument is snapshotted and then flattened to text, and the snapshot refuses any object that does not carry its own message or stack string, which is the shape of an error. Every other object, array, function or proxy reaches the stored line as the literal text [object].

Does reading a property value run code on the page?

Reading a property runs code whenever that property is an accessor, because a getter is an ordinary function the object's author wrote. It can throw, count its callers or answer differently each time, and nothing at the call site shows which kind of property it is.

How can a logging tool copy an object without invoking a getter?

A property descriptor states whether a property holds a value or a pair of functions, and asking for the descriptor does not read the property. Copying only the descriptor's value field skips every accessor, so no getter runs.

What should I log so the value survives capture?

Log the scalar fields you would actually read while debugging, one argument each, rather than the object holding them. Strings, finite numbers, booleans and null each reach the stored line as their own text, up to their ceiling; an object reaches it as the literal [object].

Does a snapshot change if the object is mutated afterwards?

A snapshot is a frozen copy taken during the call, so assigning a new value to the original property later leaves the stored copy alone. A tool holding a reference instead would report the object's final state, not its state at the log.

Why not serialize the whole object deeply instead?

Deep serialization has no bound a host page can rely on. Getters run arbitrary code, proxies intercept every step of the walk, the size is unknown before it starts, and the object can mutate while the walk is running.

The Notarus team

The people who build Notarus. We write up the decisions behind the product when the reasoning is worth more than the changelog line.

Keep reading

Reports with the evidence attached.

Start free, one script tag.

<script src="…/widget.js" data-key="pb_pub_…">