Skip to content

Report visible engaged time, and finish the PostHog cutover - #1112

Merged
blove merged 3 commits into
mainfrom
blove/posthog-cutover-and-pageleave
Sep 19, 2026
Merged

blove merged 3 commits into
mainfrom
blove/posthog-cutover-and-pageleave

Conversation

@blove

@blove blove commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

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_outfalse and autocapture_web_vitals_opt_intrue, via PATCH /api/projects/406826/. Verified through the public remote-config endpoint rather than the PATCH response, then behaviourally: $autocapture events now arrive (a click produced zero before), and a soft navigation emits a $pageview for the new path. All three $is_bounce branches 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 $pageleave only from its pagehide/unload handler, never on visibilitychange. PostHog derives session_duration from max(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 $pageleave and 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:

Device Sessions Zero-duration Had stage_progress Had any marketing:* Median duration
Desktop 176 13.6% 11.9% 44.3% 11.0s
Mobile 45 17.8% 0.0% 8.9% 3.0s

marketing:stage_progress is 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 — not page_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 to true with 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 website green; nx build website green; nx lint website 0 errors
  • 19 assertions, every one mutation-verified — hidden time counted, threshold dedupe removed, visible-run restarted, += collapsed to =, cleanup dropped, listener unregistered, interval never installed, startVisible forced true, [pathname] emptied
  • Cutover verified in production: remote config, $autocapture arriving, soft-nav $pageview
  • After deploy: confirm marketing:engaged_time arrives with engaged_seconds 10 and 30
  • After deploy: confirm mobile sessions now emit it — the signal that was 0%

🤖 Generated with Claude Code

@blove
blove enabled auto-merge (squash) September 19, 2026 02:34
@vercel

vercel Bot commented Sep 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
threadplane Ready Ready Preview Sep 19, 2026 2:37am UTC

Request Review

@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

blove and others added 3 commits September 19, 2026 08:17
…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
blove force-pushed the blove/posthog-cutover-and-pageleave branch from 38681f0 to 7557db2 Compare September 19, 2026 15:17
@github-actions

github-actions Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@blove
blove merged commit a93977f into main Sep 19, 2026
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant