diff --git a/README.md b/README.md
index e78b4ec..fc92af0 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,3 @@
+
+[Readership analytics: event contract, author opt-out, dashboard, and verification](docs/analytics.md)
diff --git a/client-modules/posthog-analytics.js b/client-modules/posthog-analytics.js
new file mode 100644
index 0000000..1a0a11e
--- /dev/null
+++ b/client-modules/posthog-analytics.js
@@ -0,0 +1,157 @@
+import posthog from 'posthog-js/dist/module.no-external';
+import siteConfig from '@generated/docusaurus.config';
+
+const {posthog: config} = siteConfig.customFields;
+const AUTHOR_OPT_OUT = 'yakov.analytics.disabled';
+const PRODUCTION_HOSTS = ['yakov.dev', 'www.yakov.dev'];
+let initialized = false;
+let currentPath;
+let pendingPageview;
+let cleanup = () => {};
+
+function isExcluded() {
+ if (!config.enabled || !PRODUCTION_HOSTS.includes(window.location.hostname)) {
+ return true;
+ }
+ try {
+ return window.localStorage.getItem(AUTHOR_OPT_OUT) === 'true';
+ } catch {
+ // If preferences cannot be read, avoid collecting author traffic by mistake.
+ return true;
+ }
+}
+
+function cleanUrl(value) {
+ try {
+ const url = new URL(value);
+ return url.origin + url.pathname;
+ } catch {
+ return '';
+ }
+}
+
+function initialize() {
+ if (initialized) return;
+ posthog.init(config.token, {
+ api_host: config.apiHost,
+ ui_host: 'https://us.posthog.com',
+ defaults: '2026-05-30',
+ capture_pageview: false,
+ capture_pageleave: false,
+ autocapture: false,
+ rageclick: false,
+ disable_session_recording: true,
+ disable_surveys: true,
+ capture_performance: false,
+ capture_exceptions: false,
+ advanced_disable_flags: true,
+ disable_external_dependency_loading: true,
+ person_profiles: 'never',
+ persistence: 'localStorage',
+ before_send(event) {
+ if (isExcluded()) return null;
+ // Keep campaign properties, but never send arbitrary URL queries or hashes.
+ for (const key of [
+ '$current_url', '$referrer', '$initial_current_url', '$initial_referrer',
+ '$session_entry_url', '$session_entry_referrer',
+ ]) {
+ if (event?.properties?.[key]) {
+ event.properties[key] = cleanUrl(event.properties[key]);
+ }
+ }
+ return event;
+ },
+ });
+ initialized = true;
+}
+
+function trackPage(pathname) {
+ if (isExcluded()) return;
+ initialize();
+
+ const article = document.querySelector('article h1')?.closest('article');
+ const properties = {
+ $current_url: window.location.origin + pathname,
+ $pathname: pathname,
+ $title: document.title,
+ site: 'yakov.dev',
+ environment: 'production',
+ analytics_version: 1,
+ page_path: pathname,
+ page_type: article ? 'article' : 'listing',
+ article_title: article?.querySelector('h1')?.textContent ?? null,
+ referrer_host: document.referrer ? new URL(document.referrer).hostname : '$direct',
+ };
+ const capture = (event, extra = {}) => {
+ if (!isExcluded()) posthog.capture(event, {...properties, ...extra});
+ };
+ capture('$pageview');
+
+ const onOutbound = (event) => {
+ if (event.type === 'auxclick' && event.button !== 1) return;
+ const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
+ if (!link) return;
+ const destination = new URL(link.href, window.location.href);
+ if (!['http:', 'https:'].includes(destination.protocol)
+ || PRODUCTION_HOSTS.includes(destination.hostname)
+ || destination.origin === window.location.origin) return;
+ capture('outbound_link_clicked', {
+ destination_url: cleanUrl(destination.href),
+ destination_host: destination.hostname,
+ link_location: link.closest('article') ? 'article' : link.closest('footer') ? 'footer' : 'navigation',
+ });
+ };
+ document.addEventListener('click', onOutbound);
+ document.addEventListener('auxclick', onOutbound);
+
+ const depths = new Set();
+ const onScroll = () => {
+ if (!article || document.visibilityState !== 'visible') return;
+ const bounds = article.getBoundingClientRect();
+ if (bounds.height <= 0) return;
+ const percent = Math.min(100, 100 * (window.innerHeight - bounds.top) / bounds.height);
+ for (const depth of [50, 90]) {
+ if (percent >= depth && !depths.has(depth)) {
+ depths.add(depth);
+ capture('article_scroll_depth', {scroll_depth_percent: depth});
+ }
+ }
+ };
+ window.addEventListener('scroll', onScroll, {passive: true});
+
+ let visibleMs = 0;
+ let visibleSince = document.visibilityState === 'visible' ? performance.now() : null;
+ let engaged = false;
+ const updateVisibleTime = () => {
+ const now = performance.now();
+ if (visibleSince !== null) visibleMs += now - visibleSince;
+ visibleSince = document.visibilityState === 'visible' ? now : null;
+ if (article && !engaged && visibleMs >= 30_000) {
+ engaged = true;
+ capture('article_engaged', {engaged_seconds: 30});
+ }
+ };
+ const interval = article ? window.setInterval(updateVisibleTime, 1000) : null;
+ document.addEventListener('visibilitychange', updateVisibleTime);
+
+ cleanup = () => {
+ updateVisibleTime();
+ window.clearInterval(interval);
+ document.removeEventListener('visibilitychange', updateVisibleTime);
+ window.removeEventListener('scroll', onScroll);
+ document.removeEventListener('click', onOutbound);
+ document.removeEventListener('auxclick', onOutbound);
+ };
+}
+
+// Docusaurus calls this once on hydration and after each committed navigation.
+// Owning pageviews here avoids automatic SDK/history listeners double-counting.
+export function onRouteDidUpdate({location}) {
+ if (typeof window === 'undefined' || location.pathname === currentPath) return;
+ window.clearTimeout(pendingPageview);
+ cleanup();
+ cleanup = () => {};
+ currentPath = location.pathname;
+ // Helmet updates the document title after the route commits.
+ pendingPageview = window.setTimeout(() => trackPage(location.pathname), 0);
+}
diff --git a/docs/analytics-dashboard.json b/docs/analytics-dashboard.json
new file mode 100644
index 0000000..535478e
--- /dev/null
+++ b/docs/analytics-dashboard.json
@@ -0,0 +1,287 @@
+{
+ "projectId": 621292,
+ "dashboardId": 2120504,
+ "url": "https://us.posthog.com/project/621292/dashboard/2120504",
+ "insights": [
+ {
+ "name": "Daily readers and pageviews",
+ "description": "Anonymous unique readers and total pageviews per day. Production only; labeled verification traffic excluded.",
+ "query": {
+ "kind": "TrendsQuery",
+ "dateRange": {
+ "date_from": "-30d"
+ },
+ "interval": "day",
+ "properties": [
+ {
+ "key": "environment",
+ "type": "event",
+ "operator": "exact",
+ "value": [
+ "production"
+ ]
+ },
+ {
+ "key": "utm_source",
+ "type": "event",
+ "operator": "is_not",
+ "value": [
+ "analytics-verification"
+ ]
+ }
+ ],
+ "series": [
+ {
+ "kind": "EventsNode",
+ "event": "$pageview",
+ "math": "dau",
+ "name": "Readers"
+ },
+ {
+ "kind": "EventsNode",
+ "event": "$pageview",
+ "math": "total",
+ "name": "Pageviews"
+ }
+ ]
+ }
+ },
+ {
+ "name": "Most-read pages",
+ "description": "Pageviews by pathname. Production only; labeled verification traffic excluded.",
+ "query": {
+ "kind": "TrendsQuery",
+ "dateRange": {
+ "date_from": "-30d"
+ },
+ "interval": "day",
+ "properties": [
+ {
+ "key": "environment",
+ "type": "event",
+ "operator": "exact",
+ "value": [
+ "production"
+ ]
+ },
+ {
+ "key": "utm_source",
+ "type": "event",
+ "operator": "is_not",
+ "value": [
+ "analytics-verification"
+ ]
+ }
+ ],
+ "series": [
+ {
+ "kind": "EventsNode",
+ "event": "$pageview",
+ "math": "total"
+ }
+ ],
+ "breakdownFilter": {
+ "breakdowns": [
+ {
+ "property": "page_path",
+ "type": "event"
+ }
+ ],
+ "breakdown_path_cleaning": true
+ },
+ "trendsFilter": {
+ "display": "ActionsTable"
+ }
+ }
+ },
+ {
+ "name": "Referral sources",
+ "description": "Incoming document referrer domains. Production only; labeled verification traffic excluded.",
+ "query": {
+ "kind": "TrendsQuery",
+ "dateRange": {
+ "date_from": "-30d"
+ },
+ "interval": "day",
+ "properties": [
+ {
+ "key": "environment",
+ "type": "event",
+ "operator": "exact",
+ "value": [
+ "production"
+ ]
+ },
+ {
+ "key": "utm_source",
+ "type": "event",
+ "operator": "is_not",
+ "value": [
+ "analytics-verification"
+ ]
+ }
+ ],
+ "series": [
+ {
+ "kind": "EventsNode",
+ "event": "$pageview",
+ "math": "total"
+ }
+ ],
+ "breakdownFilter": {
+ "breakdowns": [
+ {
+ "property": "referrer_host",
+ "type": "event"
+ }
+ ]
+ },
+ "trendsFilter": {
+ "display": "ActionsTable"
+ }
+ }
+ },
+ {
+ "name": "Engaged article visits",
+ "description": "Visits reaching 30 seconds of visible article time, by article path. Production only; QA excluded.",
+ "query": {
+ "kind": "TrendsQuery",
+ "dateRange": {
+ "date_from": "-30d"
+ },
+ "interval": "day",
+ "properties": [
+ {
+ "key": "environment",
+ "type": "event",
+ "operator": "exact",
+ "value": [
+ "production"
+ ]
+ },
+ {
+ "key": "utm_source",
+ "type": "event",
+ "operator": "is_not",
+ "value": [
+ "analytics-verification"
+ ]
+ }
+ ],
+ "series": [
+ {
+ "kind": "EventsNode",
+ "event": "article_engaged",
+ "math": "total"
+ }
+ ],
+ "breakdownFilter": {
+ "breakdowns": [
+ {
+ "property": "page_path",
+ "type": "event"
+ }
+ ],
+ "breakdown_path_cleaning": true
+ },
+ "trendsFilter": {
+ "display": "ActionsTable"
+ }
+ }
+ },
+ {
+ "name": "Article scroll depth",
+ "description": "50% and 90% article depth milestones, once per threshold per visit. Production only; QA excluded.",
+ "query": {
+ "kind": "TrendsQuery",
+ "dateRange": {
+ "date_from": "-30d"
+ },
+ "interval": "day",
+ "properties": [
+ {
+ "key": "environment",
+ "type": "event",
+ "operator": "exact",
+ "value": [
+ "production"
+ ]
+ },
+ {
+ "key": "utm_source",
+ "type": "event",
+ "operator": "is_not",
+ "value": [
+ "analytics-verification"
+ ]
+ }
+ ],
+ "series": [
+ {
+ "kind": "EventsNode",
+ "event": "article_scroll_depth",
+ "math": "total"
+ }
+ ],
+ "breakdownFilter": {
+ "breakdowns": [
+ {
+ "property": "scroll_depth_percent",
+ "type": "event"
+ }
+ ]
+ },
+ "trendsFilter": {
+ "display": "ActionsBarValue"
+ }
+ }
+ },
+ {
+ "name": "Outbound destinations",
+ "description": "Outbound HTTP(S) clicks by destination host. Production only; QA excluded.",
+ "query": {
+ "kind": "TrendsQuery",
+ "dateRange": {
+ "date_from": "-30d"
+ },
+ "interval": "day",
+ "properties": [
+ {
+ "key": "environment",
+ "type": "event",
+ "operator": "exact",
+ "value": [
+ "production"
+ ]
+ },
+ {
+ "key": "utm_source",
+ "type": "event",
+ "operator": "is_not",
+ "value": [
+ "analytics-verification"
+ ]
+ }
+ ],
+ "series": [
+ {
+ "kind": "EventsNode",
+ "event": "outbound_link_clicked",
+ "math": "total"
+ }
+ ],
+ "breakdownFilter": {
+ "breakdowns": [
+ {
+ "property": "destination_host",
+ "type": "event"
+ }
+ ]
+ },
+ "trendsFilter": {
+ "display": "ActionsTable"
+ }
+ }
+ }
+ ]
+}
diff --git a/docs/analytics.md b/docs/analytics.md
new file mode 100644
index 0000000..dcd92af
--- /dev/null
+++ b/docs/analytics.md
@@ -0,0 +1,158 @@
+# Readership analytics
+
+PostHog project: [yakov.dev / 621292](https://us.posthog.com/project/621292).
+The browser sends events directly to `https://us.i.posthog.com`. There are no
+Vercel Functions, ingestion rewrites, or reverse proxies. The checked-in `phc_`
+token is the public project ingestion token, not a personal API key.
+
+## Readership views
+
+The [yakov.dev readership dashboard](https://us.posthog.com/project/621292/dashboard/2120504)
+contains daily readers/pageviews, most-read pages, referral sources, engaged
+article visits, scroll milestones, and outbound destinations. Views use the
+last 30 days in the project's UTC timezone, require `environment=production`,
+and exclude `utm_source=analytics-verification`. Browser IDs approximate
+readers; different devices/profiles or cleared storage count separately.
+The starter dashboard remains untouched and may include QA traffic. Reproducible
+query definitions are saved in [analytics-dashboard.json](analytics-dashboard.json).
+
+## Collection contract
+
+| Event | When | Additional properties |
+| --- | --- | --- |
+| `$pageview` | Initial hydration and each committed pathname change, including back/forward | `page_type`, `article_title` |
+| `outbound_link_clicked` | HTTP(S) link to a different site; includes middle clicks | `destination_url`, `destination_host`, `link_location` |
+| `article_engaged` | An article accumulates 30 seconds in a visible tab, once per visit | `engaged_seconds: 30` |
+| `article_scroll_depth` | The viewport reaches 50% or 90% of the article, once per threshold per visit | `scroll_depth_percent` |
+
+All events include `site: yakov.dev`, `environment: production`,
+`analytics_version: 1`, `page_path`, `page_type`, `article_title` (null outside
+articles), `referrer_host`, and PostHog's browser/session/referral/UTM properties.
+`referrer_host` is the document's incoming referrer, not the preceding SPA route.
+The SDK stores anonymous browser IDs in local storage; no person profiles or
+identification calls are used. This is not cookieless tracking.
+
+`$current_url`, referrer URLs, and outbound URLs exclude query strings and hashes.
+UTM properties remain available for campaign attribution. No link text, form
+values, or copied text is collected. Automatic interaction capture, pageviews,
+pageleaves, replay, surveys, performance capture, exception capture, remote
+feature configuration, and external SDK bundle loading are disabled.
+
+The Docusaurus route lifecycle owns pageviews. Hash and query-only changes are
+not new readership views. Revisiting a pathname after another page is a new
+view. Timers and event listeners are removed on pathname changes. Visible time
+measures an opportunity to read, not proof of attention; scroll depth measures
+the article reached, not comprehension. Listing pages do not emit article events.
+
+## Development, previews, and the author
+
+Collection requires **both** a production build (and, when set, Vercel's
+`VERCEL_ENV=production`) and the runtime hostname `yakov.dev` or `www.yakov.dev`.
+Localhost, Vercel previews, and development builds do not initialize PostHog.
+Do not add a query-string override to bypass these guards.
+
+Before browsing as the author, run this in the production browser console and
+reload:
+
+```js
+localStorage.setItem('yakov.analytics.disabled', 'true');
+location.reload();
+```
+
+Repeat in each browser/profile and for each production origin you use. The
+preference persists until storage is cleared. To resume collection, remove the
+key and reload. There is no IP-based author detection; unmarked author visits
+are indistinguishable from readers. The exclusion applies to PostHog only;
+the retained Google/Vercel trackers keep their existing behavior.
+
+## Existing trackers and CSP ownership
+
+Baseline audit on 2026-09-21 at `https://www.yakov.dev/`:
+
+- HTTP response: 200 from Vercel, HSTS present, no CSP response header.
+- Rendered meta CSP: `connect-src 'self' https://*.vercel-insights.com`.
+- Browser DOM loads Google gtag (`G-44N96DYZYC`) and
+ `/_vercel/insights/script.js`; no PostHog script in the deployed baseline.
+- That effective policy excludes Google Analytics collection. The existing
+ production failure is tracked by [#20](https://github.com/jacobra19/yakov.dev/issues/20).
+- A real Chromium network audit confirmed `connect-src` violations for
+ `https://www.google-analytics.com/g/collect` and Google's fallback
+ `https://www.google.com/g/collect`. Both analytics scripts returned HTTP 200.
+ Loading a script alone is not evidence that collection succeeded.
+- Vercel's `/_vercel/insights/view` returned HTTP 200 in the same production
+ browser audit. Vercel delivery was working; GA delivery was blocked.
+
+This change adds only `https://us.i.posthog.com` to `connect-src`. SDK code is
+bundled with the site, and optional remote features are disabled. Both Google
+Analytics and Vercel Analytics are retained until replacement production data
+is verified. No tracker is silently removed or repaired here.
+
+[#20](https://github.com/jacobra19/yakov.dev/issues/20) still owns GA collection
+repair/removal. [#21](https://github.com/jacobra19/yakov.dev/issues/21) still owns
+moving CSP to HTTP headers and wider header hardening. When that moves, remove
+the meta policy and carry the PostHog ingestion origin into the effective
+header policy; multiple policies intersect rather than override each other.
+
+## Verification and rollout
+
+```sh
+yarn validate:content
+yarn build
+yarn playwright test e2e/analytics.spec.ts --project desktop
+```
+
+Default analytics tests load the real built site through an intercepted production
+hostname and decode the SDK's requests. They never send events to PostHog.
+They check initial/SPA/back pageviews, hash/query exclusion, referral properties,
+outbound destinations, engagement thresholds, hidden-tab time, listener cleanup,
+URL sanitization, author opt-out, and localhost/preview exclusion.
+
+The final production build and content validation passed. The full desktop and
+mobile suite passed 12 tests; 10 were intentionally skipped (desktop-only
+behavior checks and opt-in live ingestion). The live ingestion test passed
+separately when explicitly enabled.
+
+To repeat the production CSP/network audit:
+
+```sh
+node scripts/audit-analytics.mjs https://www.yakov.dev/
+```
+
+On 2026-09-21, the explicitly enabled ingestion test sent the locally built site
+through the production hostname in an isolated browser and let that browser
+send directly to PostHog. All ingestion responses were HTTP 200. PostHog queries
+confirmed exactly six QA events: two `$pageview`, two `article_scroll_depth`
+(50 and 90), one `article_engaged` (`engaged_seconds=30`), and one
+`outbound_link_clicked` (GitHub destination). Paths, article title, QA UTM source,
+and `$recording_status=disabled` matched the contract. Readership views returned
+zero after excluding that QA traffic. This verifies ingestion from the production
+**build**, not deployment of this change to the live website.
+
+To intentionally send another labeled QA session (never enabled in CI):
+
+```sh
+POSTHOG_VERIFY_LIVE=1 yarn playwright test e2e/analytics.spec.ts --project desktop -g 'live ingestion'
+```
+
+After deployment, use a fresh browser profile without the author opt-out:
+
+1. Inspect the main document's response headers and meta CSP together.
+2. Open `/?utm_source=analytics-verification&utm_medium=qa`, then navigate to an
+ article without reloading. Confirm exactly one `$pageview` for each pathname.
+3. Scroll the article and leave it visible for 30 seconds; follow an outbound link.
+4. Check that PostHog ingestion requests succeed and no PostHog CSP violations,
+ recorder assets, `$snapshot`, `$autocapture`, or duplicate pageviews appear.
+5. In PostHog's live events, confirm the four event names, paths, titles, incoming
+ referrer, UTM attribution, outbound destination, and engagement properties.
+6. Keep verification visits out of readership views by excluding
+ `utm_source=analytics-verification`. The test profile must remain separate
+ from normal reading because campaign attribution persists.
+7. Compare several days of PostHog data with the retained trackers before
+ deciding which trackers to remove. Ad blockers and browser delivery failures
+ can still cause undercounting with direct browser ingestion.
+
+Running `npx -y @posthog/wizard@latest self-driving` reached the self-driving
+onboarding screen. That command configures paid GitHub-connected agents, not
+just analytics. It was canceled per the author's instruction; no paid agents
+were enabled. SDK configuration follows the current
+[PostHog JavaScript configuration reference](https://posthog.com/docs/libraries/js/config).
diff --git a/docusaurus.config.js b/docusaurus.config.js
index 75bbca0..7a3b430 100644
--- a/docusaurus.config.js
+++ b/docusaurus.config.js
@@ -19,6 +19,16 @@ const config = {
// For GitHub pages deployment, it is often '//'
baseUrl: '/',
+ customFields: {
+ posthog: {
+ // Public ingestion token, not a personal API key. Project: yakov.dev (621292).
+ token: 'phc_zM49DU8YmX4SeCX2TTTSmdB52CXu7fcCS7B4QCwkgjJy',
+ apiHost: 'https://us.i.posthog.com',
+ enabled: process.env.NODE_ENV === 'production'
+ && (!process.env.VERCEL_ENV || process.env.VERCEL_ENV === 'production'),
+ },
+ },
+
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
// organizationName: 'facebook', // Usually your GitHub org/user name.
@@ -141,11 +151,16 @@ const config = {
metadata: [
{
'http-equiv': 'Content-Security-Policy',
- "content": "connect-src 'self' https://*.vercel-insights.com"
+ // Only add direct PostHog ingestion here. GA repair is tracked by #20;
+ // moving the effective policy to HTTP headers is tracked by #21.
+ "content": "connect-src 'self' https://*.vercel-insights.com https://us.i.posthog.com"
},
],
}),
- clientModules: [require.resolve('./client-modules/vercel-analytics.js')],
+ clientModules: [
+ require.resolve('./client-modules/vercel-analytics.js'),
+ require.resolve('./client-modules/posthog-analytics.js'),
+ ],
};
module.exports = config;
diff --git a/e2e/analytics.spec.ts b/e2e/analytics.spec.ts
new file mode 100644
index 0000000..c7d4297
--- /dev/null
+++ b/e2e/analytics.spec.ts
@@ -0,0 +1,163 @@
+import {gunzipSync} from 'node:zlib';
+import {expect, test, type Page} from '@playwright/test';
+
+type AnalyticsEvent = {event: string; properties: Record};
+const production = 'https://www.yakov.dev';
+const articlePath = '/recursion-in-react-simplified';
+
+// Serve the actual production bundle at its allowed hostname without deploying.
+// All third-party requests are intercepted, so this suite never pollutes analytics.
+async function captureAnalytics(page: Page, origin = production, live = false) {
+ const events: AnalyticsEvent[] = [];
+ const requests: string[] = [];
+ // Simulate a reader: the SDK deliberately drops automated browser traffic.
+ // These overrides are test-only; production keeps the SDK's bot filtering.
+ await page.addInitScript(() => {
+ Object.defineProperty(navigator, 'webdriver', {get: () => false});
+ Object.defineProperty(navigator, 'userAgentData', {get: () => undefined});
+ });
+ await page.route('**/*', async (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ if (url.origin === origin) {
+ if (url.pathname.startsWith('/_vercel/')) return route.fulfill({body: ''});
+ const response = await route.fetch({url: `http://127.0.0.1:3000${url.pathname}${url.search}`});
+ return route.fulfill({response});
+ }
+ requests.push(request.url());
+ if (url.hostname === 'us.i.posthog.com' && request.method() === 'POST') {
+ const buffer = request.postDataBuffer()!;
+ const body = buffer[0] === 0x1f && buffer[1] === 0x8b
+ ? gunzipSync(buffer).toString() : buffer.toString();
+ const payload = JSON.parse(body);
+ events.push(...(Array.isArray(payload) ? payload : payload.batch ?? [payload]));
+ if (live) return route.continue();
+ return route.fulfill({json: {status: 1}});
+ }
+ return route.fulfill({body: '', contentType: 'application/javascript'});
+ });
+ return {events, requests};
+}
+
+test.describe('PostHog readership analytics', () => {
+ test.beforeEach(({}, testInfo) => {
+ test.skip(testInfo.project.name !== 'desktop', 'Analytics behavior is viewport-independent');
+ });
+
+ test('one pageview on load, navigation and back; none for hashes or query changes', async ({page}) => {
+ const {events, requests} = await captureAnalytics(page);
+ await page.goto(`${production}/?utm_source=newsletter&utm_medium=email&private=secret`, {
+ referer: 'https://example.com/newsletter?private=secret',
+ });
+ await expect.poll(() => events.filter(e => e.event === '$pageview').length).toBe(1);
+ expect(events[0].properties).toMatchObject({
+ page_path: '/', page_type: 'listing', environment: 'production',
+ $current_url: `${production}/`, referrer_host: 'example.com',
+ utm_source: 'newsletter', utm_medium: 'email', $process_person_profile: false,
+ });
+ await page.getByRole('link', {name: 'Read more about Recursion in React simplified'}).click();
+ await expect.poll(() => events.filter(e => e.event === '$pageview').length).toBe(2);
+ expect(events.filter(e => e.event === '$pageview')[1].properties).toMatchObject({
+ page_path: articlePath, page_type: 'article', article_title: 'Recursion in React simplified',
+ });
+ await page.evaluate(() => { location.hash = 'example'; });
+ await page.evaluate(() => { history.pushState({}, '', '?sort=recent#example'); window.dispatchEvent(new PopStateEvent('popstate')); });
+ await page.waitForTimeout(3500);
+ expect(events.filter(e => e.event === '$pageview')).toHaveLength(2);
+ await page.getByRole('navigation').getByRole('link', {name: /My Site Logo/}).click();
+ await expect.poll(() => events.filter(e => e.event === '$pageview').length).toBe(3);
+ await page.goBack();
+ await expect.poll(() => events.filter(e => e.event === '$pageview').length).toBe(4);
+ expect(events.filter(e => e.event === '$pageview').map(e => e.properties.page_path))
+ .toEqual(['/', articlePath, '/', articlePath]);
+ expect(events.some(e => ['$snapshot', '$autocapture', '$pageleave'].includes(e.event))).toBe(false);
+ expect(requests.some(url => /recorder|surveys|\/flags/.test(url))).toBe(false);
+ expect(JSON.stringify(events)).not.toContain('private=secret');
+ });
+
+ test('outbound destinations and article milestones are captured once per visit', async ({page}) => {
+ const {events} = await captureAnalytics(page);
+ await page.clock.install();
+ await page.goto(`${production}${articlePath}`);
+ await expect(page.getByRole('heading', {level: 1})).toBeVisible();
+ await page.clock.runFor(3500);
+ await page.getByRole('navigation').getByRole('link', {name: /GitHub/}).click();
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ await page.clock.runFor(35_000);
+ await expect.poll(() => events.filter(e => e.event === 'article_engaged').length).toBe(1);
+ expect(events.find(e => e.event === 'outbound_link_clicked')?.properties).toMatchObject({
+ destination_host: 'github.com', destination_url: 'https://github.com/jacobra19/yakov.dev',
+ page_path: articlePath, link_location: 'navigation',
+ });
+ expect(events.filter(e => e.event === 'article_scroll_depth').map(e => e.properties.scroll_depth_percent)).toEqual([50, 90]);
+ await page.evaluate(() => window.scrollTo(0, 0));
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ await page.clock.runFor(35_000);
+ expect(events.filter(e => e.event === 'article_engaged')).toHaveLength(1);
+ expect(events.filter(e => e.event === 'article_scroll_depth')).toHaveLength(2);
+ });
+
+ test('hidden time is excluded and old article timers are cleaned up on navigation', async ({page}) => {
+ const {events} = await captureAnalytics(page);
+ await page.clock.install();
+ await page.goto(`${production}${articlePath}`);
+ await page.clock.runFor(10_000);
+ await page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', {configurable: true, value: 'hidden'});
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ await page.clock.runFor(60_000);
+ expect(events.filter(e => e.event === 'article_engaged')).toHaveLength(0);
+ await page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', {configurable: true, value: 'visible'});
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ await page.getByRole('navigation').getByRole('link', {name: /My Site Logo/}).click();
+ await page.clock.runFor(40_000);
+ expect(events.filter(e => e.event === 'article_engaged')).toHaveLength(0);
+ });
+
+ for (const origin of ['http://127.0.0.1:3000', 'https://yakov-preview.vercel.app']) {
+ test(`excludes ${origin}`, async ({page}) => {
+ const {events, requests} = await captureAnalytics(page, origin);
+ await page.goto(`${origin}${articlePath}`);
+ await expect(page.getByRole('heading', {level: 1})).toBeVisible();
+ await page.waitForTimeout(3500);
+ expect(events).toHaveLength(0);
+ expect(requests.some(url => url.includes('posthog.com'))).toBe(false);
+ });
+ }
+
+ test('author opt-out prevents initialization, including after navigation', async ({page}) => {
+ const {events, requests} = await captureAnalytics(page);
+ await page.addInitScript(() => localStorage.setItem('yakov.analytics.disabled', 'true'));
+ await page.goto(production);
+ await page.getByRole('link', {name: 'Read more about Recursion in React simplified'}).click();
+ await page.waitForTimeout(3500);
+ expect(events).toHaveLength(0);
+ expect(requests.some(url => url.includes('posthog.com'))).toBe(false);
+ });
+
+ test('live ingestion verification (explicit opt-in)', async ({page}, testInfo) => {
+ test.skip(process.env.POSTHOG_VERIFY_LIVE !== '1', 'Sends labeled QA events to the real PostHog project');
+ test.setTimeout(60_000);
+ const {events} = await captureAnalytics(page, production, true);
+ const responses: number[] = [];
+ page.on('response', response => {
+ if (new URL(response.url()).hostname === 'us.i.posthog.com') responses.push(response.status());
+ });
+ await page.goto(`${production}/?utm_source=analytics-verification&utm_medium=qa`);
+ await expect.poll(() => responses.length, {timeout: 10_000}).toBeGreaterThan(0);
+ await page.getByRole('link', {name: 'Read more about Recursion in React simplified'}).click();
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ await expect.poll(() => events.some(e => e.event === 'article_engaged'), {timeout: 40_000}).toBe(true);
+ await page.getByRole('navigation').getByRole('link', {name: /GitHub/}).click();
+ await expect.poll(() => events.some(e => e.event === 'outbound_link_clicked')).toBe(true);
+ await page.waitForTimeout(1000);
+ expect(responses.every(status => status === 200)).toBe(true);
+ expect(events.filter(e => e.event === '$pageview')).toHaveLength(2);
+ await testInfo.attach('posthog-ingestion', {
+ body: JSON.stringify({responses, events}, null, 2), contentType: 'application/json',
+ });
+ });
+});
diff --git a/package.json b/package.json
index 2dc4dc5..5d869d9 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"@mdx-js/react": "^3.0.0",
"@vercel/analytics": "^1.0.1",
"clsx": "^2.0.0",
+ "posthog-js": "^1.434.5",
"prism-react-renderer": "^2.0.0",
"raw-loader": "^4.0.2",
"react": "^18.0.0",
diff --git a/scripts/audit-analytics.mjs b/scripts/audit-analytics.mjs
new file mode 100644
index 0000000..c690a68
--- /dev/null
+++ b/scripts/audit-analytics.mjs
@@ -0,0 +1,50 @@
+import {chromium, devices} from '@playwright/test';
+
+// Read-only production audit. Use a fresh browser context and omit query strings
+// from output so analytics IDs and campaign parameters do not enter the report.
+const target = process.argv[2] ?? 'https://www.yakov.dev/';
+const browser = await chromium.launch();
+const context = await browser.newContext({...devices['Desktop Chrome']});
+const page = await context.newPage();
+const analytics = /google-analytics\.com|googletagmanager\.com|vercel-insights\.com|\/_vercel\/insights\/|posthog\.com/;
+const safeUrl = value => {
+ try { const url = new URL(value); return url.origin + url.pathname; }
+ catch { return value; }
+};
+const requests = [];
+page.on('response', response => {
+ if (analytics.test(response.url())) {
+ requests.push({url: safeUrl(response.url()), status: response.status()});
+ }
+});
+page.on('requestfailed', request => {
+ if (analytics.test(request.url())) {
+ requests.push({url: safeUrl(request.url()), failure: request.failure()?.errorText});
+ }
+});
+await page.addInitScript(() => {
+ // Simulate a reader so tracker bot filters do not hide collection requests.
+ Object.defineProperty(navigator, 'webdriver', {get: () => false});
+ Object.defineProperty(navigator, 'userAgentData', {get: () => undefined});
+ window.analyticsPolicyViolations = [];
+ document.addEventListener('securitypolicyviolation', event => {
+ window.analyticsPolicyViolations.push({directive: event.effectiveDirective, blockedURI: event.blockedURI});
+ });
+});
+try {
+ const response = await page.goto(target);
+ await page.waitForTimeout(10_000);
+ const dom = await page.evaluate(() => ({
+ metaPolicies: [...document.querySelectorAll('meta[http-equiv="Content-Security-Policy"]')].map(el => el.content),
+ violations: window.analyticsPolicyViolations,
+ }));
+ console.log(JSON.stringify({
+ auditedAt: new Date().toISOString(), url: page.url(), status: response.status(),
+ cspHeader: response.headers()['content-security-policy'] ?? null,
+ metaPolicies: dom.metaPolicies,
+ violations: dom.violations.map(violation => ({...violation, blockedURI: safeUrl(violation.blockedURI)})),
+ requests,
+ }, null, 2));
+} finally {
+ await browser.close();
+}
diff --git a/yarn.lock b/yarn.lock
index b1c5565..34fb746 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3593,6 +3593,32 @@ __metadata:
languageName: node
linkType: hard
+"@posthog/browser-common@npm:^0.9.0":
+ version: 0.9.0
+ resolution: "@posthog/browser-common@npm:0.9.0"
+ dependencies:
+ "@posthog/core": "npm:^1.54.2"
+ "@posthog/types": "npm:^1.412.1"
+ checksum: 10c0/39bf601ceeeca5bfab72dcf46355ad70cc4c136dd6dec7d981db0e2c5959174841e5f696a2b4059be6299cb84afcdca1a9737cdccc3fd08248a9f874f09a0310
+ languageName: node
+ linkType: hard
+
+"@posthog/core@npm:^1.54.2, @posthog/core@npm:^1.55.1":
+ version: 1.55.1
+ resolution: "@posthog/core@npm:1.55.1"
+ dependencies:
+ "@posthog/types": "npm:^1.412.3"
+ checksum: 10c0/085574da07f29eeb4d59c8327f9bebc0c0a9e538148ffe7bc8ebab97d3cfc977a8aa7d08bd232f754642245bf0c6736dced187e072f60641c6997623676fdc1c
+ languageName: node
+ linkType: hard
+
+"@posthog/types@npm:^1.412.1, @posthog/types@npm:^1.412.3, @posthog/types@npm:^1.412.4":
+ version: 1.412.4
+ resolution: "@posthog/types@npm:1.412.4"
+ checksum: 10c0/06479892bee8b0509a3e45af60944bbc7d2d949160f675250e9fe7232378f98bb08ab5388361581e20e33344d905451928e7ee3677b564b795c78b7de78d1032
+ languageName: node
+ linkType: hard
+
"@sideway/address@npm:^4.1.5":
version: 4.1.5
resolution: "@sideway/address@npm:4.1.5"
@@ -4265,6 +4291,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/trusted-types@npm:^2.0.7":
+ version: 2.0.7
+ resolution: "@types/trusted-types@npm:2.0.7"
+ checksum: 10c0/4c4855f10de7c6c135e0d32ce462419d8abbbc33713b31d294596c0cc34ae1fa6112a2f9da729c8f7a20707782b0d69da3b1f8df6645b0366d08825ca1522e0c
+ languageName: node
+ linkType: hard
+
"@types/unist@npm:*, @types/unist@npm:^2.0.0":
version: 2.0.6
resolution: "@types/unist@npm:2.0.6"
@@ -5686,6 +5719,13 @@ __metadata:
languageName: node
linkType: hard
+"core-js@npm:^3.49.0":
+ version: 3.50.0
+ resolution: "core-js@npm:3.50.0"
+ checksum: 10c0/1bfcfb1dbdf45b0b1428a9fdcb03697d1fc52c7e9bc336f804a86ff2c2705a301dca7d9d6636d09d49d60298183273b9321681baeaf330c78db5393bc73f3f6a
+ languageName: node
+ linkType: hard
+
"core-util-is@npm:~1.0.0":
version: 1.0.3
resolution: "core-util-is@npm:1.0.3"
@@ -6262,6 +6302,18 @@ __metadata:
languageName: node
linkType: hard
+"dompurify@npm:^3.4.13":
+ version: 3.4.15
+ resolution: "dompurify@npm:3.4.15"
+ dependencies:
+ "@types/trusted-types": "npm:^2.0.7"
+ dependenciesMeta:
+ "@types/trusted-types":
+ optional: true
+ checksum: 10c0/406b5f0f9c9dcbc80016367d33e73585b6a6f440815228d7e9cbe8abe71058183b1f4cb4207b79b8473637de4a106fc055eb6da271ef9db353d29679b891ecbc
+ languageName: node
+ linkType: hard
+
"domutils@npm:^2.5.2, domutils@npm:^2.8.0":
version: 2.8.0
resolution: "domutils@npm:2.8.0"
@@ -6869,6 +6921,13 @@ __metadata:
languageName: node
linkType: hard
+"fflate@npm:^0.4.8":
+ version: 0.4.9
+ resolution: "fflate@npm:0.4.9"
+ checksum: 10c0/a74e599dc8592766d810e1af5c476627480132d1ded41bad3a8bff5ccebdaec048c0a3504f2578792b1fa26db5c2603473fb76e4983e76d0635f5cfc188f8613
+ languageName: node
+ linkType: hard
+
"file-loader@npm:^6.2.0":
version: 6.2.0
resolution: "file-loader@npm:6.2.0"
@@ -9646,6 +9705,7 @@ __metadata:
"@playwright/test": "npm:^1.52.0"
"@vercel/analytics": "npm:^1.0.1"
clsx: "npm:^2.0.0"
+ posthog-js: "npm:^1.434.5"
prism-react-renderer: "npm:^2.0.0"
raw-loader: "npm:^4.0.2"
react: "npm:^18.0.0"
@@ -11083,6 +11143,44 @@ __metadata:
languageName: node
linkType: hard
+"posthog-js@npm:^1.434.5":
+ version: 1.434.5
+ resolution: "posthog-js@npm:1.434.5"
+ dependencies:
+ "@posthog/browser-common": "npm:^0.9.0"
+ "@posthog/core": "npm:^1.55.1"
+ "@posthog/types": "npm:^1.412.4"
+ core-js: "npm:^3.49.0"
+ dompurify: "npm:^3.4.13"
+ fflate: "npm:^0.4.8"
+ preact: "npm:^10.29.3"
+ query-selector-shadow-dom: "npm:^1.0.1"
+ web-vitals: "npm:^6.2.1"
+ web-vitals-soft-navs: "npm:web-vitals@6.2.1"
+ peerDependencies:
+ "@types/react": ">=16.8.0"
+ react: ">=16.8.0"
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ react:
+ optional: true
+ checksum: 10c0/3ac92dbb42ffbf560153d82546059410404e172b0160d228e956786dbe2e275c25c9e0f55628daa4bcc78d751b0484566f0efde60b9ba59a3997b7f3e50190a9
+ languageName: node
+ linkType: hard
+
+"preact@npm:^10.29.3":
+ version: 10.29.8
+ resolution: "preact@npm:10.29.8"
+ peerDependencies:
+ preact-render-to-string: ">=5"
+ peerDependenciesMeta:
+ preact-render-to-string:
+ optional: true
+ checksum: 10c0/505460352139e0ce5b9c971b69d8d9f3b73ea0f316e3ff1c0b3db9a1abe206c9dff6ba385eb796253c38fcc08b5ade70cd8da3d9d6c9e9aec1a2fd59348253ae
+ languageName: node
+ linkType: hard
+
"pretty-error@npm:^4.0.0":
version: 4.0.0
resolution: "pretty-error@npm:4.0.0"
@@ -11236,6 +11334,13 @@ __metadata:
languageName: node
linkType: hard
+"query-selector-shadow-dom@npm:^1.0.1":
+ version: 1.0.1
+ resolution: "query-selector-shadow-dom@npm:1.0.1"
+ checksum: 10c0/f36de03f170ff1da69c3eecfa7f8b01e450a46dd266c921e17f36076ec59862eee00179489f30cb17c118bb56e868436578c01ea66f671fb358750d6ae474125
+ languageName: node
+ linkType: hard
+
"queue-microtask@npm:^1.2.2":
version: 1.2.3
resolution: "queue-microtask@npm:1.2.3"
@@ -13275,6 +13380,20 @@ __metadata:
languageName: node
linkType: hard
+"web-vitals-soft-navs@npm:web-vitals@6.2.1":
+ version: 6.2.1
+ resolution: "web-vitals@npm:6.2.1"
+ checksum: 10c0/6f5cfb5875f6164898ea1097067a8ab29ed6fbd9ad38ac7ea519c110bf1d38a441a5b1e38d18f3afd17c62f9aaa88b6bac3b719bd8e29660f6d939bc12640508
+ languageName: node
+ linkType: hard
+
+"web-vitals@npm:^6.2.1":
+ version: 6.2.2
+ resolution: "web-vitals@npm:6.2.2"
+ checksum: 10c0/dda70c450470680decfe6cb4e04550b28843e697b1ed7634e51fb1e14b2c4056daffa66b593ab3f8134a8f214c885a4913353b34836273f72d73036a561a12dc
+ languageName: node
+ linkType: hard
+
"webpack-bundle-analyzer@npm:^4.10.2":
version: 4.10.2
resolution: "webpack-bundle-analyzer@npm:4.10.2"