Count non-consenting visitors with PostHog cookieless mode - #8146
Conversation
…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>
WalkthroughPostHog 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 ChangesConsent-aware analytics capture
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
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (5)
apps/blog/src/instrumentation-client.tsapps/docs/src/instrumentation-client.tsapps/site/src/instrumentation-client.tspackages/ui/src/lib/consent.tspnpm-workspace.yaml
| posthog.register(SUPER_PROPERTIES); | ||
| // Returning visitor whose stored consent is already available at init. | ||
| if (hasAnalyticsConsent()) posthog.opt_in_capturing(); | ||
| }, |
There was a problem hiding this comment.
🗄️ 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.tsRepository: 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 240Repository: 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:
- 1: https://posthog.com/docs/references/posthog-js.md
- 2: https://posthog.com/docs/references/posthog-js
- 3: https://posthog.com/docs/libraries/js/usage
- 4: https://posthog.com/docs/privacy/data-collection
- 5: https://posthog.com/docs/libraries/js/config
- 6: https://posthog.com/docs/libraries/js
🏁 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("---")
PYRepository: 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:
- 1: https://posthog.com/docs/references/posthog-js-1.391.2
- 2: https://posthog.com/docs/references/posthog-js-1.376.4
- 3: https://posthog.com/docs/privacy/data-collection
- 4: https://posthog.com/docs/privacy/data-collection.md
- 5: https://github.com/PostHog/posthog-js/blob/e463240ff4fa82431949774081353397a7ab3fa3/src/posthog-core.ts
- 6: https://posthog.com/docs/references/posthog-js/types/PostHogConfig
🏁 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 "---" }
'
doneRepository: 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("---")
PYRepository: 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 40Repository: 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"
doneRepository: 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"
doneRepository: 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-L28apps/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.
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_cookielesssentinel and no device storage of any kind. PostHog's servers derive the visitor id ashash(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 existingopt_out_capturing_by_default: true, undecided visitors are captured cookielessly instead of not at all.^1.351.3to^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.granted/denied/pendingviaisUserActionCompleted), so ignoring the banner is no longer converted into a stored explicit opt-out write before the visitor decides anything.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
Privacy notes for review
__ph_opt_in_out_<token>=0, which records the visitor's own choice and identifies nobody.Measurement implications
Verification done
Against a local production-config build of the site app, per consent state:
consent=pending, capturing,distinct_id=$posthog_cookieless,$cookieless_mode=true,site_namepresent, zero storage writesconsent=denied, still capturing cookielessly, only the opt-out flag stored; a previously stored identity is purged on rejectionconsent=granted, fresh UUID unlinked to the cookieless person, no cookieless flag, persistence enabledAlso 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