diff --git a/README.md b/README.md index e78b4ec..fc92af0 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ image + +[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"