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 handed | What the snapshot keeps |
|---|---|
| A string | The string, cut to its byte ceiling |
A finite number, a boolean, null | Itself, as a value rather than as text |
NaN or an infinity | The text of it |
undefined, a symbol, a bigint | [undefined], [symbol], [bigint] |
| An error with an own message or stack | Its 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 copied | Ceiling |
|---|---|
| A string in an entry’s data | 1,024 bytes |
A stack string in an entry’s data | 4,096 bytes |
| A captured error’s name | 128 bytes |
| A captured error’s message | 1,024 bytes |
| A captured error’s stack | 4,096 bytes |
| The entry’s own message text | 4,096 bytes |
| Keys copied out of one entry’s data | 12 |
| Console arguments read per call | 4 |
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.