Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
<img width="1509" alt="image" src="https://github.com/jacobra19/yakov.dev/assets/24259837/0063d377-bc07-48cd-b370-0216259ef924">

[Readership analytics: event contract, author opt-out, dashboard, and verification](docs/analytics.md)
157 changes: 157 additions & 0 deletions client-modules/posthog-analytics.js
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading