Skip to content

feat(analytics): capture $pageleave and send $pathname - #60

Merged
scttbnsn merged 1 commit into
devfrom
feat/analytics-pageleave-pathname
Aug 26, 2026
Merged

feat(analytics): capture $pageleave and send $pathname#60
scttbnsn merged 1 commit into
devfrom
feat/analytics-pageleave-pathname

Conversation

@scttbnsn

Copy link
Copy Markdown
Collaborator

Two properties the analytics contract never sent, both costing real data and neither changing the privacy posture.

What's wrong today

Measured against the shared PostHog project over the first eleven days of live traffic across all five instrumented sites:

  • 208 of 432 sessions (48%) record zero duration, so the reported bounce rate is really "one pageview and no second event."
  • PostHog's built-in Web analytics Page, Entry page and Exit page tables return zero rows. Not few rows, none.

Why

capture_pageleave was false, so a session's last recorded timestamp is its last pageview. A five minute read of one page scores as zero seconds.

Flipping the option alone fixes nothing, which is the part worth reviewing carefully. sanitizeEvent in frontend/lib/posthog-privacy.ts only accepted $pageview, cta activated, and $web_vitals; anything else fell through to the closing return null. Every $pageleave posthog-js emitted would have been dropped silently, no error, no ingestion warning. This PR adds a $pageleave branch that rebuilds the event the same way $pageview does.

posthog-js gates the event on capture_pageleave === true || ('if_capture_pageview' && capture_pageview) (posthog-core.ts, _shouldCapturePageleave). capture_pageview is false here because pageviews are captured by hand in instrumentation-client.ts, so the option has to be an explicit true rather than the default.

$pathname was never sent, and it's the property those page tables actually read.

Privacy

Nothing opens up. cookieless_mode, person_profiles, persistence, disable_persistence, respect_dnt, save_referrer, and save_campaign_params are all untouched.

$pathname is set to the already-sanitized path value, never the raw pathname, so it can't carry a route outside ALLOWED_ROUTES and adds no information the event wasn't already sending. A regression test asserts the two never diverge and that an unlisted route still collapses to /_other.

Tests

npm run test:posthog passes, 17 tests. New cases: $pageleave rebuilds correctly through sanitizeEvent from PostHog's own automatic properties, $pageleave requires and forwards the cookieless server-hash fields the same way $pageview does, and $pathname never diverges from path across both event types. posthog-source.test.mjs now also asserts capture_pageleave: true verbatim in instrumentation-client.ts. npm run check (biome + tsc) and npm run test:scene also pass.

No CHANGELOG entry: this repo has no tagged-release ceremony.

Part of X16 in the ops execution plan. The same change already shipped for portwing (CodesWhat/portwing#214); drydock, careerrat, and sockguard are still to come.

Measured over the shared PostHog project, 208 of 432 sessions across
the five instrumented sites record zero duration, and PostHog's
built-in Web analytics Page/Entry page/Exit page tables return zero
rows. capture_pageleave was false, so a session's last recorded
timestamp is its last pageview, and a five-minute read of one page
scores as zero seconds.

Flipping the option alone fixes nothing: sanitizeEvent allowlisted
only $pageview, cta activated, and $web_vitals, so every $pageleave
posthog-js emitted would have been dropped silently with no error and
no ingestion warning. This adds a $pageleave branch that rebuilds the
event the same way $pageview does. capture_pageview is false here
(pageviews are captured by hand), so posthog-js's
_shouldCapturePageleave gate needs an explicit true rather than the
default.

$pathname is the property PostHog's page tables actually key off, and
it was never sent. It's bound to the already-sanitized `path` value,
never the raw pathname, so it can't carry a route outside
ALLOWED_ROUTES and adds no information the event wasn't already
sending. A regression test asserts the two never diverge.

No privacy option changes: cookieless_mode, person_profiles,
persistence, disable_persistence, respect_dnt, save_referrer, and
save_campaign_params are untouched.

Part of X16 in the ops execution plan.
@scttbnsn

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
codeswhat-website Ready Ready Preview Aug 26, 2026 4:11pm

@scttbnsn scttbnsn closed this Aug 26, 2026
@scttbnsn scttbnsn reopened this Aug 26, 2026
@scttbnsn

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@scttbnsn
scttbnsn merged commit f50fadb into dev Aug 26, 2026
6 checks passed
@scttbnsn
scttbnsn deleted the feat/analytics-pageleave-pathname branch August 26, 2026 19:01
scttbnsn added a commit that referenced this pull request Aug 28, 2026
…o production (#61)

* fix(analytics): forward $raw_user_agent and $host for cookieless ingestion (#52)

PostHog's cookieless server-hash step reads $raw_user_agent and $host
straight off event.properties and drops the event with a
cookieless_missing_user_agent/cookieless_missing_host ingestion warning
if either is absent. createCommonProperties rebuilt an allowlisted
properties object that dropped both, so every event was silently
discarded at ingestion. Forward them through; never add $ip, which
PostHog's capture service fills in server-side from the connection.

Co-authored-by: scttbnsn <80784472+scttbnsn@users.noreply.github.com>

* chore(config): remove the stale Cursor rules (#54)

* fix(analytics): promote cookieless ingestion fix to production (#53)

PostHog's cookieless server-hash step reads $raw_user_agent and $host
straight off event.properties and drops the event with a
cookieless_missing_user_agent/cookieless_missing_host ingestion warning
if either is absent. createCommonProperties rebuilt an allowlisted
properties object that dropped both, so every event was silently
discarded at ingestion. Forward them through; never add $ip, which
PostHog's capture service fills in server-side from the connection.

Co-authored-by: biggest-littlest <zap_inane.2p@icloud.com>

* chore(config): drop the stale Cursor rules folder

* docs(config): drop dangling .cursorrules references

---------

Co-authored-by: biggest-littlest <zap_inane.2p@icloud.com>

* docs(readme): describe what this repo is and how it deploys (#55)

* chore(gitignore): ignore the root .vercel link and history-backup bundles

* feat(analytics): capture $pageleave and send $pathname (#60)

Measured over the shared PostHog project, 208 of 432 sessions across
the five instrumented sites record zero duration, and PostHog's
built-in Web analytics Page/Entry page/Exit page tables return zero
rows. capture_pageleave was false, so a session's last recorded
timestamp is its last pageview, and a five-minute read of one page
scores as zero seconds.

Flipping the option alone fixes nothing: sanitizeEvent allowlisted
only $pageview, cta activated, and $web_vitals, so every $pageleave
posthog-js emitted would have been dropped silently with no error and
no ingestion warning. This adds a $pageleave branch that rebuilds the
event the same way $pageview does. capture_pageview is false here
(pageviews are captured by hand), so posthog-js's
_shouldCapturePageleave gate needs an explicit true rather than the
default.

$pathname is the property PostHog's page tables actually key off, and
it was never sent. It's bound to the already-sanitized `path` value,
never the raw pathname, so it can't carry a route outside
ALLOWED_ROUTES and adds no information the event wasn't already
sending. A regression test asserts the two never diverge.

No privacy option changes: cookieless_mode, person_profiles,
persistence, disable_persistence, respect_dnt, save_referrer, and
save_campaign_params are untouched.

Part of X16 in the ops execution plan.

* fix(seo): repair JSON-LD logo 404 and double-slash base URLs

- fix(seo): point Organization.logo at /icon-512x512.png; the referenced
  /logos/codeswhat-logo-green.png never existed, so crawlers got a 404
- fix(seo): strip trailing slashes from BASE_URL and reuse it in robots.ts
  and sitemap.ts, so a NEXT_PUBLIC_SITE_URL set with a trailing slash can't
  emit //sitemap.xml-style URLs
- chore(seo): disallow /studio/ in robots.txt; the capture pages already
  404 in production but the exclusion shouldn't depend on that guard
- chore(seo): 308 the stable *.vercel.app production aliases to
  codeswhat.com instead of serving duplicate content
- fix(api): stop forwarding EmailOctopus error detail to subscribe clients;
  log it server-side and return a fixed message

* build(deps): bump next to ^16.2.11 to clear all 35 Dependabot alerts

One-line range bump; npm resolves next 16.3.3, which also pulls the
patched transitive versions: postcss 8.5.23, nanoid 3.3.18, sharp 0.35.4.
npm audit now reports zero vulnerabilities. No code changes needed: the
app has no middleware, rewrites, server actions, CSP nonces, or
next/image usage, so none of the fixed CVEs required app-side work.

* build(deps): regenerate next-env.d.ts for next 16.3

- build(deps): pick up the root-params.d.ts reference next 16.3 adds
- ci(hooks): pass --no-errors-on-unmatched to the biome pre-commit job so
  committing only biome-ignored files (like next-env.d.ts) doesn't fail

* docs(roadmap): track web-analytics table coverage follow-ups (ops X37)

* chore: ignore .claude/ with a tracked line (#59)

It was covered only by .git/info/exclude, which protects one clone and nobody else's. Without a tracked line, `git add -A` in the parent stages a nested worktree as an embedded gitlink and `git clean -ffd` deletes it.

* docs(roadmap): point acquisition-data item at the ops analytics standard

* docs(roadmap): pageleave ratio is structural; note the bot-detection canary caveat

---------

Co-authored-by: biggest-littlest <zap_inane.2p@icloud.com>
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.

3 participants