Report visible engaged time, and finish the PostHog cutover - #1112
Merged
Merged
Conversation
blove
enabled auto-merge (squash)
September 19, 2026 02:34
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
…s bounces posthog-js fires $pageleave only from its pagehide/unload handler, never on visibilitychange, and PostHog derives session_duration from the span of a session's events. A visitor who lands, reads, clicks nothing and leaves the tab open therefore emits one $pageview, records zero seconds, and is scored as a bounce. Measured over 30 days: 13.6% of desktop and 17.8% of mobile homepage sessions. The tab-close lands in a rotated session - 67 orphan pageleave-only sessions, median gap 221 minutes - which is what rules out an ad blocker. The gap concentrated where nothing passive could fire. marketing:stage_progress is gated to 1024x720 and fired on 0.0% of mobile sessions; 91% of mobile sessions emitted nothing but the pageview, so mobile engagement was not measurable at all. marketing:engaged_time has no viewport gate. It counts visible time only, pausing while the tab is hidden, so it corrects the recorded duration rather than inflating it. That distinction is the whole design: the hidden-time tests are load-bearing, not incidental. 10s matches PostHog's own bounce cutoff so a genuine ten-second read stops being recorded as zero. All 14 assertions mutation-verified: hidden time counted, threshold dedupe removed, visible-run restarted, cleanup dropped, listener unregistered, and interval never installed each turn exactly the expected tests red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bounce series broke at 2026-09-19T02:05Z: the pageview fix deployed and autocapture_opt_out was flipped, verified by $autocapture events arriving and a soft navigation producing a $pageview. The prediction wording is now past tense, and the ~2 hour sliver between the pinned table window and the cutover is called out rather than left to be discovered. Replaces the "missing across every browser" framing with the actual mechanism. The uniformity across browsers was the symptom of a behavioural cause - tabs left open past the 30 minute idle timeout - not evidence of a transport problem, and the earlier draft read it the wrong way round. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aring Code review found that `startVisible: document.visibilityState === 'visible'` could be mutated to `true` with all 14 tests still green. That single argument IS the production implementation of "a tab nobody looked at reports nothing" - the exact inflation this design exists to prevent - and it was asserted only in the tracker unit test, the layer that cannot break it in production. A regression would let a background tab's throttled interval fire both thresholds for a page never seen. Three more surviving mutations closed: a leaked visibilitychange listener (which would emit engaged_time for every earlier page in the session, breaking the two-per-pageview bound), the [pathname] effect dependency, and `accumulatedMs +=` collapsing to `=` (invisible with one hide/show cycle, wrong from the second onward). All four now fail exactly their own test. Also stops the 1Hz interval once both thresholds have fired, rather than waking every second for the life of every page on the site. Docs: pin the orphan-session definition and window so the figure is re-derivable, and note that stage_progress is suppressed by prefers-reduced-motion as well as by viewport. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
blove
force-pushed
the
blove/posthog-cutover-and-pageleave
branch
from
September 19, 2026 15:17
38681f0 to
7557db2
Compare
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-on to #1110. Two things: the cutover that PR deferred is now done, and the zero-duration sessions it flagged now have an established root cause and a fix.
The cutover completed — 2026-09-19T02:05Z
autocapture_opt_out→falseandautocapture_web_vitals_opt_in→true, viaPATCH /api/projects/406826/. Verified through the public remote-config endpoint rather than the PATCH response, then behaviourally:$autocaptureevents now arrive (a click produced zero before), and a soft navigation emits a$pageviewfor the new path. All three$is_bouncebranches are live for the first time. The bounce series breaks at that instant and the docs say so.Root cause of the zero-duration sessions
Not a delivery failure, not an ad blocker, not a browser quirk — which is why it looked uniform across browsers.
posthog-js fires
$pageleaveonly from itspagehide/unloadhandler, never onvisibilitychange. PostHog derivessession_durationfrommax(timestamp) − min(timestamp). So a visitor who lands, reads, clicks nothing and leaves the tab open emits exactly one$pageview, records zero seconds, and is scored a bounce.Evidence: 67 orphan sessions carrying a
$pageleaveand no$pageview, every one preceded by a session from the same person, median gap 221 minutes, p75 1486 minutes. Those long gaps are the signature — the tab sat open for hours, the session rotated, and the eventual close landed somewhere else entirely.It concentrated exactly where no passive event could fire:
stage_progressmarketing:*marketing:stage_progressis gated to ≥1024×720 and non-reduced-motion, so 91% of mobile sessions emitted nothing but the pageview. Mobile engagement was not measurable at all.The fix
marketing:engaged_time, firing at 10s and 30s of visible time, on every viewport, at most twice per pageview.On whether this games the metric. 10s was chosen to match PostHog's own bounce cutoff, so this mechanically lowers the reported bounce rate. Stating that plainly rather than burying it. What makes it correction rather than inflation: it counts visible time only and pauses while hidden, a never-seen tab reports nothing, nothing fires at t=0, and it moves only
session_duration— notpage_screen_count, not autocapture. It records a fact that was always true and simply unrecorded.Review history worth knowing
The first review found that
startVisible: document.visibilityState === 'visible'could be mutated totruewith all 14 tests still green. That one argument is the "a never-seen tab reports nothing" property — the exact inflation this design exists to prevent — and it was asserted only in the unit test, the layer that cannot break in production. Closed, along with three other surviving mutations.Test Plan
nx test websitegreen;nx build websitegreen;nx lint website0 errors+=collapsed to=, cleanup dropped, listener unregistered, interval never installed,startVisibleforced true,[pathname]emptied$autocapturearriving, soft-nav$pageviewmarketing:engaged_timearrives withengaged_seconds10 and 30🤖 Generated with Claude Code