Skip to content

Count non-consenting visitors with PostHog cookieless mode - #8146

Merged
ankur-arch merged 3 commits into
mainfrom
analytics/posthog-cookieless-before-consent
Aug 12, 2026
Merged

Count non-consenting visitors with PostHog cookieless mode#8146
ankur-arch merged 3 commits into
mainfrom
analytics/posthog-cookieless-before-consent

Conversation

@ankur-arch

@ankur-arch ankur-arch commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

What this does

Restores a trustworthy top-line visitor metric by counting visitors who reject analytics cookies or never interact with the banner, using PostHog's cookieless server hash mode. Nothing is stored on the visitor's device before consent, and the June 22 consent gating (#7971) stays intact. Visitors who accept analytics get exactly the same full tracking as today.

Do not merge before the two rollout steps below.

Why

Since #7971, PostHog only records visitors who accept analytics cookies. Measured traffic dropped about 60% overnight while Google Search Console clicks moved single digits, so the current PostHog visitor number cannot represent total traffic. With this change every visitor is counted, and consent decides only how much detail we get.

How it works

Before any consent decision, and after a rejection: events are captured with the $posthog_cookieless sentinel and no device storage of any kind. PostHog's servers derive the visitor id as hash(team_id, daily_salt, ip, user_agent, host). The salt rotates daily and is deleted after the day is processed, so the hash cannot be reversed and visitors cannot be recognized across days. The IP is discarded after hashing.

After accepting analytics: the SDK switches to normal persistent tracking (same as production today). The pre-consent cookieless person is not linked to the post-consent person.

Changes:

  • cookieless_mode: "on_reject" in the PostHog init of docs, site, and blog. Combined with our existing opt_out_capturing_by_default: true, undecided visitors are captured cookielessly instead of not at all.
  • posthog-js ^1.351.3 to ^1.415.7. Our installed version predates the SDK behavior (fix(cookieless): start in cookieless mode when opt_out_capturing_by_default is set PostHog/posthog-js#3362) that makes undecided visitors capture cookielessly. The lockfile also drops @opentelemetry/* entries because newer posthog-js no longer depends on them.
  • The shared CookieYes helper is now tri-state (granted/denied/pending via isUserActionCompleted), so ignoring the banner is no longer converted into a stored explicit opt-out write before the visitor decides anything.
  • Super-properties (site_name, environment) are re-registered after consent transitions because the SDK resets its state when switching modes and later events silently lost them (verified, then fixed and re-verified).

Rollout requirements

  1. Enable "Cookieless server hash mode" in Project Settings > Web analytics (choose Enabled, which is the stateful mode). Without it, cookieless events are dropped at ingestion; I verified a test event was dropped with the setting off. My API access could not flip it (project admin required in the UI). Safe to enable ahead of deploy: events without the cookieless flag are untouched by this setting, and production sends none until this PR deploys.
  2. Privacy sign-off (see below). The change is reversible: revert the PR and disable the project setting to return exactly to today's behavior.

Privacy notes for review

  • Before consent nothing is written to or read from the device: no cookies, no localStorage, no sessionStorage (verified in-browser across all four consent states). After an explicit rejection the only write is the opt-out flag __ph_opt_in_out_<token>=0, which records the visitor's own choice and identifies nobody.
  • IP address and user agent are processed transiently on PostHog's servers as hash inputs. PostHog states the resulting hash is not personal data because the salt is deleted; whether we agree is a call for privacy review, since the IP is still processed briefly and our project is on PostHog Cloud US (data processed in the US, DPA in place).
  • Session replay and surveys stay disabled for non-consenting visitors. Feature flags work but responses are not cached on-device before consent.

Measurement implications

  • Daily unique visitors become trustworthy again for everyone. This is the metric to use for top-line reporting.
  • Weekly and monthly uniques will overcount cookieless visitors, because the daily salt rotation makes the same person a new hash each day. A daily visitor who never consents counts up to 30 times in a monthly unique count. Report monthly traffic as average daily uniques or as consented monthly uniques plus cookieless daily average, not raw monthly uniques.
  • GeoIP is not available on cookieless events (IP is stripped before enrichment), so country breakdowns only cover consenting visitors.
  • Expect a step up in measured visitors at deploy (roughly back toward pre-June levels). Annotate the deploy date in PostHog alongside the June 22 annotation; numbers on either side are not comparable.

Verification done

Against a local production-config build of the site app, per consent state:

State Result
No decision consent=pending, capturing, distinct_id=$posthog_cookieless, $cookieless_mode=true, site_name present, zero storage writes
Rejected consent=denied, still capturing cookielessly, only the opt-out flag stored; a previously stored identity is purged on rejection
Accepted consent=granted, fresh UUID unlinked to the cookieless person, no cookieless flag, persistence enabled
Returning stored decision applies from init in both directions, UUID stable across reloads for accepters

Also verified server-side: a cookieless event was dropped at ingestion with the project setting off (expected until rollout step 1), and a consented test event arrived in the project. Unit-tested the tri-state consent mapping (7/7 pass). CookieYes does not render on localhost, so the final banner click-through should be checked once on the Vercel preview.

Summary by CodeRabbit

  • New Features
    • Analytics consent now supports three states: granted, denied, and pending.
    • Pending visitors remain in a privacy-preserving state until they make an explicit choice.
    • Analytics metadata is consistently applied across the blog, documentation, and site.
  • Bug Fixes
    • Improved consent transition handling so analytics behavior updates correctly after visitors grant or deny permission.
    • Enabled cookieless analytics capture when consent is rejected or undecided while maintaining opt-out defaults.

…s mode

Since PostHog became consent-gated on CookieYes (#7971, June 22), visitors
who reject analytics or never touch the banner disappear from PostHog
entirely, which cut measured traffic by roughly 60% and made the top-line
visitor metric unusable. This turns on PostHog's cookieless mode so those
visitors are counted again without weakening the June consent behavior.

- Set cookieless_mode: "on_reject" in docs/site/blog PostHog init. Together
  with the existing opt_out_capturing_by_default: true, visitors with no
  consent decision and visitors who rejected analytics are captured
  cookielessly: events carry the $posthog_cookieless sentinel plus
  $cookieless_mode: true, and PostHog's servers derive the visitor id from
  a salted daily hash. Nothing is stored on the device.
- Bump posthog-js ^1.351.3 -> ^1.415.7. The installed 1.364.4 predates the
  SDK change (PostHog/posthog-js#3362, v1.369.4) that makes undecided
  visitors capture cookielessly, plus several cookieless fixes after it.
- Make the CookieYes consent helper tri-state (granted/denied/pending) via
  isUserActionCompleted so a visitor who merely ignored the banner is not
  converted into a stored explicit opt-out before making any decision.
- Re-register site_name/environment super-properties after consent
  transitions; the SDK resets its state on opt-in/opt-out in cookieless
  mode and events after the transition lost those properties otherwise.

Requires "Cookieless server hash mode" enabled in PostHog project settings
(Project Settings > Web analytics) before deploy, otherwise cookieless
events are dropped at ingestion (verified: they currently are).

Consent behavior per state, verified against a local build:
- no decision yet: cookieless capture, no cookies/localStorage writes
- rejected: cookieless capture, only the opt-out flag "0" stored
- accepted: full PostHog analytics, unchanged from today
- returning visitors: stored decision applies from init

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
blog Ready Ready Preview Aug 12, 2026 11:41am
docs Ready Ready Preview Aug 12, 2026 11:41am
eclipse Ready Ready Preview Aug 12, 2026 11:41am
site Ready Ready Preview Aug 12, 2026 11:41am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

PostHog consent handling now supports granted, denied, and pending states. The blog, docs, and site apps use shared metadata and cookieless capture settings. The workspace updates posthog-js to version ^1.415.7.

Changes

Consent-aware analytics capture

Layer / File(s) Summary
Tri-state consent contract
packages/ui/src/lib/consent.ts
CookieYes consent now exposes granted, denied, or pending through getAnalyticsConsentStatus(). Consent callbacks emit the new status values.
Cookieless PostHog integration
apps/blog/src/instrumentation-client.ts, apps/docs/src/instrumentation-client.ts, apps/site/src/instrumentation-client.ts, pnpm-workspace.yaml
The applications share SUPER_PROPERTIES, configure cookieless capture, and apply opt-in or opt-out only for decided consent states. The workspace updates posthog-js to ^1.415.7.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Visitor
  participant CookieYes
  participant ConsentUtility
  participant PostHog
  Visitor->>CookieYes: set or review analytics consent
  CookieYes->>ConsentUtility: provide consent state
  ConsentUtility->>PostHog: emit granted, denied, or pending
  PostHog->>PostHog: opt in, opt out, or preserve current capture
Loading

Possibly related PRs

  • prisma/web#7971: Introduced the shared PostHog consent-gating logic and CookieYes consent utilities extended by this pull request.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: counting non-consenting visitors through PostHog cookieless mode.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch analytics/posthog-cookieless-before-consent

Comment @coderabbitai help to get the list of available commands.

@ankur-arch
ankur-arch marked this pull request as ready for review August 12, 2026 11:38
@ankur-arch
ankur-arch merged commit 063138f into main Aug 12, 2026
13 of 16 checks passed
@ankur-arch ankur-arch self-assigned this Aug 12, 2026
@ankur-arch
ankur-arch deleted the analytics/posthog-cookieless-before-consent branch August 12, 2026 11:43

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/blog/src/instrumentation-client.ts`:
- Around line 25-28: In the instrumentation initialization callback, move
register(SUPER_PROPERTIES) to after the conditional opt_in_capturing() call so
properties persist when hasAnalyticsConsent() is true. Apply this change in
apps/blog/src/instrumentation-client.ts lines 25-28,
apps/docs/src/instrumentation-client.ts lines 25-28, and
apps/site/src/instrumentation-client.ts lines 24-27.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: dbe42c53-6632-44de-9831-65f5f0cc62da

📥 Commits

Reviewing files that changed from the base of the PR and between 786e411 and e5637fc.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (5)
  • apps/blog/src/instrumentation-client.ts
  • apps/docs/src/instrumentation-client.ts
  • apps/site/src/instrumentation-client.ts
  • packages/ui/src/lib/consent.ts
  • pnpm-workspace.yaml

Comment on lines +25 to 28
posthog.register(SUPER_PROPERTIES);
// Returning visitor whose stored consent is already available at init.
if (hasAnalyticsConsent()) posthog.opt_in_capturing();
},

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 6 'loaded:|opt_in_capturing|register\(SUPER_PROPERTIES\)' \
  apps/blog/src/instrumentation-client.ts \
  apps/docs/src/instrumentation-client.ts \
  apps/site/src/instrumentation-client.ts

Repository: prisma/web

Length of output: 7191


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- PostHog dependency declarations ---'
rg -n -C 3 '"posthog-js"|posthog-js@|posthog-js/' \
  package.json pnpm-lock.yaml yarn.lock package-lock.json apps 2>/dev/null || true

printf '%s\n' '--- Instrumentation imports and consent helpers ---'
rg -n -C 8 'from "posthog-js"|from '\''posthog-js'\''|function hasAnalyticsConsent|const hasAnalyticsConsent|onAnalyticsConsentChange' \
  apps/blog/src/instrumentation-client.ts \
  apps/docs/src/instrumentation-client.ts \
  apps/site/src/instrumentation-client.ts \
  apps/blog apps/docs apps/site 2>/dev/null | head -n 240

Repository: prisma/web

Length of output: 25171


🌐 Web query:

PostHog JavaScript SDK opt_in_capturing register super properties reset behavior source

💡 Result:

In the PostHog JavaScript SDK, the interaction between reset behavior, consent, and super properties is critical for accurate data tracking [1][2]. Reset and Consent Behavior When you call posthog.reset, the SDK clears the user's identity (setting a new random distinct_id), removes cached data, and clears the consent state [1][2]. Crucially, because reset clears consent, it returns the instance to the default consent state [1][2]. If you have configured the SDK with opt_out_capturing_by_default: true, the default state is "opted out" [1][2]. Consequently, if you call posthog.reset after posthog.opt_in_capturing, the SDK will silently stop capturing data [1][2]. The recommended pattern is to always call posthog.reset before calling posthog.opt_in_capturing [1][2]. Super Properties Behavior Super properties are properties set using posthog.register that are automatically included with every event [3][2]. - Persistence: Super properties are stored in persistent storage (e.g., cookies or local storage) [3][2]. - Reset Impact: Calling posthog.reset clears these stored super properties, as it clears the instance's cached data and persistence [1][2]. - Updates: If you register the same property multiple times, the new value overwrites the old one [3][2]. To remove a specific super property without a full reset, use posthog.unregister('property_name') [3]. Summary of Best Practices To maintain proper state and consent, follow this sequence: 1. Initialize with appropriate defaults (e.g., opt_out_capturing_by_default: true) [4][5]. 2. Call posthog.reset to clear previous identity and super properties [1][2][6]. 3. Call posthog.opt_in_capturing to establish consent [1][2][4]. 4. Call posthog.register to set your desired super properties for the new session [3][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json
import urllib.request

urls = [
    "https://raw.githubusercontent.com/PostHog/posthog-js/v1.415.7/src/posthog-core.ts",
    "https://raw.githubusercontent.com/PostHog/posthog-js/v1.415.7/src/posthog-surveys.ts",
    "https://raw.githubusercontent.com/PostHog/posthog-js/v1.415.7/src/posthog.ts",
]
for url in urls:
    print(f"--- {url} ---")
    try:
        text = urllib.request.urlopen(url, timeout=15).read().decode()
    except Exception as exc:
        print(f"FETCH_ERROR {exc}")
        continue
    lines = text.splitlines()
    for i, line in enumerate(lines):
        if any(token in line for token in (
            "opt_in_capturing", "opt_out_capturing", "register(", "reset(",
            "clear_opt_in_out_capturing", "clear_opt_in_out_capturing",
        )):
            lo = max(0, i - 8)
            hi = min(len(lines), i + 18)
            print("\n".join(f"{n+1}:{lines[n]}" for n in range(lo, hi)))
            print("---")
PY

Repository: prisma/web

Length of output: 857


🌐 Web query:

github PostHog posthog-js v1.415.7 opt_in_capturing implementation

💡 Result:

In the PostHog JavaScript SDK (posthog-js), the opt_in_capturing method is used to enable data capturing and persistence (cookies/localStorage) for the current PostHog instance [1][2]. This is typically used in conjunction with a consent management platform (CMP) or a manual consent banner to comply with privacy regulations [3]. Key Implementation Details: 1. Recommended Pattern: The standard approach is to initialize PostHog with opt_out_capturing_by_default: true in your config [3][4]. This ensures no data is captured until the user provides explicit consent [3]. When the user grants consent, you then call posthog.opt_in_capturing [3]. 2. Method Signature: The method accepts an optional configuration object to customize the opt-in behavior [1][2]: posthog.opt_in_capturing(options?: { captureEventName?: EventName | null | false; captureProperties?: Properties; }) 3. Customization Options: - captureEventName: By default, calling this method captures an $opt_in event [1][2]. You can set this to a custom string to use a different event name, or set it to false if you wish to opt the user in without sending an event [1][2]. - captureProperties: You can provide a dictionary of properties to be attached to the opt-in event [1][2]. 4. Persistence: Calling opt_in_capturing automatically enables persistence (re-enabling cookies/localStorage) [1][5]. The SDK manages this state based on your instance configuration, such as the opt_out_capturing_persistence_type setting [6]. Example Usage: // Simple opt-in posthog.opt_in_capturing; // Opt-in with custom event and properties posthog.opt_in_capturing({ captureEventName: 'Privacy Accepted', captureProperties: { source: 'banner' } }); // Opt-in without sending an event posthog.opt_in_capturing({ captureEventName: false }); When a user withdraws or denies consent, you should call posthog.opt_out_capturing to stop further data collection and disable persistence [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for file in src/posthog-core.ts src/posthog.ts; do
  url="https://raw.githubusercontent.com/PostHog/posthog-js/v1.415.7/$file"
  echo "--- $url ---"
  curl -fsSLk "$url" |
    nl -ba |
    awk '
      /opt_in_capturing|opt_out_capturing|register[[:space:]]*\(|reset[[:space:]]*\(|clear_opt_in_out_capturing/ {
        start = $1 - 10
        if (start < 1) start = 1
        end = $1 + 22
        print "lines " start "-" end
        show = 1
      }
      show && $1 >= start && $1 <= end { print }
      show && $1 > end { show = 0; print "---" }
    '
done

Repository: prisma/web

Length of output: 378


🌐 Web query:

site:github.com/PostHog/posthog-js/blob/v1.415.7/src/posthog-core.ts "opt_in_capturing"

💡 Result:

The requested GitHub path appears to be unavailable: tag v1.415.7 or src/posthog-core.ts returns 404 Not Found. ()

The opt_in_capturing API is documented as enabling event capture and has corresponding status-check methods such as has_opted_in_capturing(). (app.unpkg.com)

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import io
import json
import ssl
import tarfile
import urllib.request

ctx = ssl._create_unverified_context()
meta_url = "https://registry.npmjs.org/posthog-js/1.415.7"
with urllib.request.urlopen(meta_url, context=ctx, timeout=20) as response:
    meta = json.load(response)

tarball_url = meta["dist"]["tarball"]
print("--- package ---")
print(meta["name"], meta["version"], tarball_url)

with urllib.request.urlopen(tarball_url, context=ctx, timeout=30) as response:
    data = response.read()

with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
    names = archive.getnames()
    candidates = [
        name for name in names
        if name.endswith((".js", ".ts", ".d.ts")) and
        any(token in name.lower() for token in ("posthog", "index"))
    ]
    print("--- candidate files ---")
    print("\n".join(candidates[:80]))

    terms = (
        "opt_in_capturing", "opt_out_capturing", "register(", "reset(",
        "clear_opt_in_out_capturing", "super_properties",
    )
    for name in candidates:
        text = archive.extractfile(name).read().decode("utf-8", errors="replace")
        lines = text.splitlines()
        hits = [i for i, line in enumerate(lines) if any(term in line for term in terms)]
        if hits:
            print(f"--- {name} ---")
            shown = set()
            for i in hits:
                for j in range(max(0, i - 5), min(len(lines), i + 12)):
                    if j not in shown:
                        print(f"{j+1}:{lines[j]}")
                        shown.add(j)
                print("---")
PY

Repository: prisma/web

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -euo pipefail

log=/tmp/coderabbit-shell-logs/shell-output-wHF0v2
printf '%s\n' '--- opt-in/opt-out matches ---'
rg -n -o '.{0,500}opt_in_capturing.{0,1200}' "$log" | head -n 20

printf '%s\n' '--- reset and super-property matches near consent methods ---'
rg -n -o '.{0,500}(clear_opt_in_out_capturing|clearPersistence|reset\(\)|super_properties|register\().{0,1200}' "$log" | head -n 40

Repository: prisma/web

Length of output: 4761


🏁 Script executed:

#!/bin/bash
set -euo pipefail

log=/tmp/coderabbit-shell-logs/shell-output-wHF0v2
for range in '660,715' '515,610' '250,345' '1210,1335'; do
  echo "--- lines $range ---"
  sed -n "${range}p" "$log"
done

Repository: prisma/web

Length of output: 17644


🏁 Script executed:

#!/bin/bash
set -euo pipefail

log=/tmp/coderabbit-shell-logs/shell-output-wHF0v2
printf '%s\n' '--- opt-in implementation continuation ---'
sed -n '683,735p' "$log"

printf '%s\n' '--- cookieless predicate and initialization state ---'
rg -n -o '.{0,700}(_inCookielessMode|COOKIELESS_ON_REJECT|cookieless_mode|opt_out_capturing_by_default).{0,1200}' "$log" | head -n 40

printf '%s\n' '--- all three callback orderings ---'
for file in apps/blog/src/instrumentation-client.ts apps/docs/src/instrumentation-client.ts apps/site/src/instrumentation-client.ts; do
  echo "--- $file ---"
  sed -n '1,45p' "$file"
done

Repository: prisma/web

Length of output: 13642


Register properties after initial opt-in.

When hasAnalyticsConsent() is true, posthog.opt_in_capturing() resets persistence and removes SUPER_PROPERTIES. Move posthog.register(SUPER_PROPERTIES) after the opt-in in all three instrumentation files.

📍 Affects 3 files
  • apps/blog/src/instrumentation-client.ts#L25-L28 (this comment)
  • apps/docs/src/instrumentation-client.ts#L25-L28
  • apps/site/src/instrumentation-client.ts#L24-L27
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/blog/src/instrumentation-client.ts` around lines 25 - 28, In the
instrumentation initialization callback, move register(SUPER_PROPERTIES) to
after the conditional opt_in_capturing() call so properties persist when
hasAnalyticsConsent() is true. Apply this change in
apps/blog/src/instrumentation-client.ts lines 25-28,
apps/docs/src/instrumentation-client.ts lines 25-28, and
apps/site/src/instrumentation-client.ts lines 24-27.

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