Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## Unreleased

### Added

- **Docs-site analytics for search, code copies, 404s, docs section, and live-demo connectivity** (`docs/src/components/DocsTracking.astro` (new), `docs/src/components/{PostHog,Footer,LiveDemo}.astro`): the site tracked its own CTAs but nothing a reader did on the way to one, so the questions that decide what to write next — what people search for and *don't* find, which snippets get copied, which dead links keep getting followed — had no data behind them. `docs_search` fires a second after the query settles rather than once per keystroke, carrying `query` and `result_count` read off Pagefind's own results message (the rendered list is capped at its page size, so counting the DOM would under-report); `result_count: 0` is the event worth having. `code_copied` (`page`, `language`) watches Expressive Code's copy buttons from the document rather than re-binding every code block on every navigation — the hero's install chip is not an EC block and keeps its own `hero_install_copied`. `docs_404` (`path`, `referrer`) turns broken inbound links into a list instead of a hunch. A `doc_section` property (the first path segment, `home` for `/`) puts every event in a docs area without each tracker carrying its own copy; it's stamped at capture time by a `before_send` hook in `posthog.init()` rather than `register()`, because a queued `register()` replays only after init has already captured the first hard-load `$pageview` — which would then carry the previous visit's persisted value — and `history_change` navigations update the URL before capture fires, so reading `location` in the hook is always current. `live_demo_connected` fires once per mount when the hero's SSE feed comes up rather than on its first row — named for what it measures (the demo backend answered), since a quiet minute on the repo is not a disengaged reader. The three site-wide trackers share one new `DocsTracking.astro` rendered from the footer (like `MermaidZoom` / `ScrollHints`) and delegate from `document`, since Pagefind, Expressive Code, and the 404 route all own their own markup — some of it created after page load.

## [0.1.0] - 2026-08-19

The first public release. Everything below shipped in it — the sections are grouped the way Keep a Changelog asks for, but since there is no previous release to compare against, a reader upgrading from nothing can treat the whole file as "Added". The date is the intended cut date; correct it if tagging slips, and move anything merged in between up from `## Unreleased`.
Expand Down
88 changes: 88 additions & 0 deletions docs/src/components/DocsTracking.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
// PostHog tracking for the three site-wide surfaces with no component of their
// own: Starlight's Pagefind search, Expressive Code's copy buttons, and the 404
// page. All three are owned by third-party markup (some of it created after
// page load), so the handlers are delegated from `document` and hang off
// `window` — removed before re-adding, the same idempotence the per-element
// trackers get from `el.__whPh*Handler`. Rendered once per page from
// Footer.astro; no markup, no styles.
---

<script is:inline>
// Pagefind's UI is light DOM inside <site-search>, so one delegated `input`
// listener reaches it. The valuable event is the query the reader settled on,
// not the seven prefixes they typed on the way there — hence the idle
// debounce — and a result_count of 0 is the whole point: a search that found
// nothing is a docs gap.
function whCaptureSearch(input, retries) {
var query = (input.value || '').trim();
if (!query || query === window.__whPhLastSearch) return;
var root = document.getElementById('starlight__search');
// Pagefind renders the results list only once a search has settled; while
// one is in flight the drawer holds just the "Searching…" message. That
// in-flight window can outlast the idle debounce (cold first search: the
// wasm + index load happens then), and no further `input` event will
// re-arm the timer — so re-check a few times instead of dropping the
// search on the floor.
var list = root && root.querySelector('.pagefind-ui__results');
if (!list) {
if (retries > 0) {
window.__whPhSearchTimer = setTimeout(function() { whCaptureSearch(input, retries - 1); }, 400);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
return;
}
// "12 results for x" / "No results for x" — the rendered list is capped at
// Pagefind's page size, so only the message carries the real total, and the
// count (when there is one) always leads the string.
var msg = root.querySelector('.pagefind-ui__message');
var count = msg && msg.textContent.trim().match(/^([\d,]+)\b/);
window.__whPhLastSearch = query;
window.posthog?.capture('docs_search', {
query: query,
result_count: count ? Number(count[1].replace(/,/g, '')) : list.children.length
});
Comment thread
jfwoods marked this conversation as resolved.
Resolved
}

// Starlight builds the 404 route from src/content/docs/404.md; the .wh-404
// block that page renders is the marker. Keyed on the requested path so the
// DOMContentLoaded + astro:page-load pair fires it once per visit.
function whCapture404() {
if (!document.querySelector('.wh-404')) return;
if (window.__whPh404Path === location.pathname) return;
window.__whPh404Path = location.pathname;
window.posthog?.capture('docs_404', {
path: location.pathname,
referrer: document.referrer
});
}

function setupDocsTracking() {
document.removeEventListener('input', window.__whPhSearchHandler);
window.__whPhSearchHandler = function(e) {
if (!e.target.classList?.contains('pagefind-ui__search-input')) return;
clearTimeout(window.__whPhSearchTimer);
window.__whPhSearchTimer = setTimeout(function() { whCaptureSearch(e.target, 3); }, 1000);
};
document.addEventListener('input', window.__whPhSearchHandler);

// Expressive Code binds its own click handler to `.expressive-code .copy
// button`; we watch the same clicks from the document rather than re-walk
// every code block on every navigation. The hero's install chip is not an
// EC block, so hero_install_copied stays its own event.
document.removeEventListener('click', window.__whPhCodeCopyHandler);
window.__whPhCodeCopyHandler = function(e) {
var btn = e.target.closest?.('.expressive-code .copy button');
if (!btn) return;
var pre = btn.closest('figure')?.querySelector('pre');
window.posthog?.capture('code_copied', {
page: location.pathname,
language: pre?.getAttribute('data-language') || null
});
};
document.addEventListener('click', window.__whPhCodeCopyHandler);

whCapture404();
}
document.addEventListener('DOMContentLoaded', setupDocsTracking);
document.addEventListener('astro:page-load', setupDocsTracking);
</script>
9 changes: 6 additions & 3 deletions docs/src/components/Footer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "../config/outbound";
import { pageText } from "../config/trademarks";
import CloudCta from "./CloudCta.astro";
import DocsTracking from "./DocsTracking.astro";
import Logo from "./Logo.astro";
import MermaidZoom from "./MermaidZoom.astro";
import ReadingProgress from "./ReadingProgress.astro";
Expand Down Expand Up @@ -185,11 +186,13 @@ const trademarkSource = pageText(entry.data, entry.body ?? "");
)
}

{/* Click-to-zoom for Mermaid diagrams + horizontal-scroll affordances —
rendered once per page from here (Footer is on every page);
self-contained scripts + styles, no markup. */}
{/* Click-to-zoom for Mermaid diagrams + horizontal-scroll affordances, and the
site-wide PostHog trackers (search / code copy / 404) — rendered once per
page from here (Footer is on every page); self-contained scripts + styles,
no markup. */}
<MermaidZoom />
<ScrollHints />
<DocsTracking />

<script is:inline>
function setupFooterLinkTracking() {
Expand Down
22 changes: 18 additions & 4 deletions docs/src/components/LiveDemo.astro
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ const DEMO_HOST = "stats.wavehouse.dev";
const MAX_ROWS = 6;
const fmtInt = (n: number) => Number(n).toLocaleString("en-US");

// window.posthog comes from the head snippet (PostHog.astro), never an
// import, so it's typed at the call site.
const posthog = () =>
(window as { posthog?: { capture: (event: string, props?: object) => void } }).posthog;

// Backfill rows come back RFC3339 from ClickHouse, but live SSE rows carry
// the producer's payload as ingested — a zone-less ClickHouse-style
// "YYYY-MM-DD HH:MM:SS[.fff]" (UTC by the gh_events contract), which
Expand Down Expand Up @@ -423,11 +428,20 @@ const DEMO_HOST = "stats.wavehouse.dev";
return true;
}

// Once per mount, on connect rather than on the first row, and named for
// what it measures — the demo backend answered — not reader engagement:
// the stream is subscribed on load, below the fold or not. A reconnect
// isn't a second visit either.
let connected = false;
function setStream(state: string) {
const live = state === "live";
const bad = state === "error" || state === "closed";
stream.dataset.state = live ? "live" : bad ? "down" : "connecting";
streamLabel.textContent = live ? "live" : bad ? "offline" : `${state}…`;
if (live && !connected) {
connected = true;
posthog()?.capture("live_demo_connected");
}
}

// Live tail first, so an event landing while the backfill is in flight
Expand Down Expand Up @@ -508,10 +522,10 @@ const DEMO_HOST = "stats.wavehouse.dev";
// --- CTA analytics, same shape as the hero buttons' tracking.
root.querySelectorAll<HTMLAnchorElement>("[data-ph-demo]").forEach((el) => {
el.addEventListener("click", () => {
(window as { posthog?: { capture: (e: string, p: object) => void } }).posthog?.capture(
"live_demo_cta_clicked",
{ target: el.getAttribute("data-ph-demo"), href: el.href },
);
posthog()?.capture("live_demo_cta_clicked", {
target: el.getAttribute("data-ph-demo"),
href: el.href,
});
});
});
}
Expand Down
13 changes: 12 additions & 1 deletion docs/src/components/PostHog.astro
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,18 @@ const apiHost =
capture_pageview: 'history_change',
// Surface broken docs JS (LiveDemo SSE, copy buttons, theme scripts) in
// PostHog error tracking — volume on a docs site is negligible.
capture_exceptions: true
capture_exceptions: true,
// doc_section = first path segment ("home" for /), stamped at capture
// time so every event can be sliced by docs area. A before_send hook
// instead of register(): a queued register() replays only after init has
// already captured the first hard-load $pageview (which would then carry
// the previous visit's persisted value), and history_change navigations
// update the URL before capture fires, so reading location here is
// always current.
before_send: (e) => {
if (e && e.properties) e.properties.doc_section = location.pathname.split('/')[1] || 'home';
return e;
}
})
}
</script>
6 changes: 4 additions & 2 deletions docs/src/content/docs/404.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ head:

<!-- The visible page is the .wh-404 block below; Starlight's default title
panel is hidden for this page via body:has(.wh-404) in global.css (the
custom <h1> here replaces it in the accessibility tree). Plain .md, not
.mdx, so raw HTML passes through untouched. -->
custom <h1> here replaces it in the accessibility tree), and
DocsTracking.astro uses .wh-404 as the marker that fires the docs_404
PostHog event — rename the class in all three places or not at all.
Plain .md, not .mdx, so raw HTML passes through untouched. -->
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

<div class="wh-404 not-content">
<p class="wh-404__code" aria-hidden="true">404</p>
Expand Down