SIGN IN SIGN UP

feat(logs): capture console calls made before the logs script loads (#4036)

* Buffer console entries at init and replay them once remote config confirms capture

Remote config's captureConsoleLogs verdict now persists to a hidden key.
On the next visit that key does not start capture directly, since a cached
true would have no stop path if remote config later says disabled. It only
gates a small console recorder: entries buffer locally from init, replay
through the console pipeline when remote config confirms capture, and get
dropped with the console restored when it does not.

Rebuilt on top of the core-pipeline refactor, replacing the pre-refactor
commits on this branch.

* Keep recording until the logs script initializes and hand it the raw buffer

The entrypoint's stringifyArgsSafely now serializes replayed entries, so the
core-side serializer is gone and the gap between remote config and script
load no longer loses console calls. The recorder patches through the layered
patch helper with __rrweb_original__ set, checks is_capturing before
buffering, and drops the buffer after 30s if nothing settles.

* fix(logs): buffer early console calls and replay them once capture is confirmed

Also fixes `logs.captureConsoleLogs` passed to `init()` never enabling console
autocapture: the extension is constructed before init applies user config, so
the opt-in is now read in setup.

* fix(logs): complete the remote kill switch for console capture

Drop console records already queued when the server disables capture, and
honour a persisted "disabled" verdict so the window does not re-open on every
page load until remote config speaks again.

* fix(replay): let either patch copy splice past the other's wrapper

rrweb ships its own copy of `patch` that walks only `__rrweb_layer__`, so
stopping session recording while the logs recorder sat above it left the rrweb
wrapper in the chain. Publish the layer under both marker names.

* fix(logs): fall back to the persisted verdict when no fresh one arrives

A failed remote-config fetch now behaves like a response without a logs key,
matching session recording. Teach rrweb's patch copy to walk posthog layers so
already-released clients get the cross-library teardown fix too.

* revert(logs): let a local captureConsoleLogs opt-in stand

The server reports captureConsoleLogs false for every project that has not
turned it on, so it cannot tell "not enabled" from "turned off" and must not
revoke an opt-in the caller made in init. Matches the logs spec, which states
there is no remote kill switch for logs. The remote flag still withdraws the
persisted hint, which is the only thing it granted.

* test(logs): pin the invariants the kill-switch revert left uncovered

A persisted "disabled" must not start a recorder, the hint teardown must drop
its buffer, and rrweb's own suite now fails if its walk stops recognising
posthog layers. Adds @posthog/rrweb-plugin-console-record to the changeset —
it inlines the patched helper.

* fix(logs): drop console records already captured when the user opts out

Opting out released the pre-load buffer but left records the logs script had
already mirrored to flush. Programmatic captureLog and logger keep their own
queue, which consent gates separately.

* docs(logs): correct stale comments and drop redundant ones

The rrweb walk comment described the opposite of what it does, the entrypoint
had stopped deriving its console levels from the shared tuple, and several
comments restated the line beneath them. Also drops a patch test that killed
no mutant its neighbour did not.

* fix(logs): stop an in-flight batch discarding records captured after a clear

Clearing the console queue on opt-out reset the bookkeeping that tells the
queue advance how much was already accounted for, so a batch awaiting its
response sliced off records captured after a later opt-in. Core now credits
the cleared count the way a FIFO eviction does.

* chore(changeset): merge the two @posthog/core entries

* fix(logs): apply the in-flight credit to reset() as well as opt-out

reset() runs on every identity change, and it cleared the logs queues the same
unaccounted way opt-out used to. Core no longer zeroes the credit a batch in
flight still needs; the flush loop already zeroes it per batch.

* chore(logs): share the console marker walk and declare the rrweb-utils dep

The lifecycle rebuild reintroduced a second copy of the marker walk in the logs
entrypoint, so the shared module's docstring was claiming a guarantee it did
not have. Three tests imported @posthog/rrweb-utils through a jest alias only,
which typed the layer contract they exist to pin as `any`.

* fix(logs): record console during the first remote-enable load and retire flushes across clearQueue

Start the pre-load recorder when remote config is the first thing to enable capture, and give the logs queue a generation so a batch in flight cannot re-send or advance past records a clearQueue purged.

* fix(logs): scope the queue generation to each batch and keep a hint-started recorder withdrawable

Capturing the generation once per flush retired a batch assembled after a mid-flush clear, leaving it sent but never advanced. Starting a recorder no longer adopts one that setup() started from the persisted hint.

* fix(logs): keep the early console buffer once remote config has granted capture

A second remote-config result — session recording requests one when its persisted copy is stale — withdrew a hint-started recorder even though the grant had already happened and the handover was in flight, dropping the early lines it exists to preserve. Withdrawal now applies only while nothing has granted capture, which makes _recorderStartedByHintOnly redundant.

* chore(changeset): drop rrweb packages changesets bumps automatically

Only @posthog/rrweb-utils and posthog-js changed; the other rrweb packages pick up a dependency bump via updateInternalDependencies.

* chore(logs): tighten comments and drop test casts

* fix(logs): record the new private names and stop probing a console marker in e2e

The pre-load recorder marks its console wrapper the way the lazy bundle does, so __rrweb_original__ no longer tells the two apart. Spy on captureConsoleLog and captureBufferedConsoleLog and poll for the record instead, which holds whether the call goes live or through the buffer.

---------

Co-authored-by: Anna Garcia <anna.g@posthog.com>
K
Kimaswa Emmanuel Yusufu committed
718beee86aa31026beef9af2c13b049e9b847721
Parent: 712223a
Committed by GitHub <noreply@github.com> on 8/27/2026, 11:47:02 AM