Skip to content
Open
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
117 changes: 117 additions & 0 deletions ADRs/DR006_PostHog_Analytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# DR006: PostHog Analytics Integration

> **Amended by [DR007](./DR007_Analytics_Consent.md):** the `posthog.init` config
> and the "`Layout` slot unused" / "not via `Layout`" framing below predate the
> consent gate. PostHog now initialises opted-out and cookieless until the visitor
> consents, and the `Layout`/`footer.tsx` slots carry the consent UI. The `head`
> snippet mechanism for the PostHog **loader** (the decision recorded here) is
> unchanged.

## Context

The previous Mintlify docs site reported into PostHog via Mintlify's built-in
integration — a single key in `dev-docs/docs.json`:

```json
"posthog": { "apiKey": "phc_w5S82EA6htCdGahiKPNEskpaEr9PofM5YDKsw8JtfhUi" }
```

The migration to Vocs dropped this; the new site sent **no analytics** and
`vocs.config.ts` carried only a "wire PostHog later" TODO. We needed to restore
reliable event flow into the **same** PostHog project so historical data stays
continuous across the migration.

Two constraints shaped the decision:

1. **Same project, no break in continuity.** Reuse the existing project key
(`phc_…`, public/write-only — safe to ship to the browser) and PostHog's US
ingestion host (`https://us.i.posthog.com`), which is the host Mintlify's
integration defaulted to.
2. **Vocs is a client-side-routed SPA.** A plain analytics snippet captures the
initial page load only; in-app navigations between docs pages would be missed.

Vocs (on **v1.4.1** at the time of this decision) has **no dedicated analytics
feature**, and the hosted docs have no analytics guide (`vocs.dev/docs/guides/
analytics` 404s). The framework offers two documented extension points, confirmed
from the installed type defs / source rather than the website:

- **`head` config option** (`node_modules/vocs/_lib/config.d.ts`: *"Additional
tags to include in the `<head>` tag of the page HTML"*) — a `ReactElement`,
path-map, or `(params) => ReactElement` rendered into `<head>` at static-build
time. Already used in this repo for JSON-LD/SEO (see
[DR001](./DR001_SEO_Structured_Data.md)).
- **`layout.tsx` consumer components** — Vocs reads `rootDir/layout.tsx` for a
default `Layout` export (wraps the whole app, client-side) plus named exports
like `TopNavEnd` (`node_modules/vocs/_lib/vite/plugins/virtual-consumer-
components.js`). The repo already uses `TopNavEnd`; the default `Layout` slot is
unused.

## Decision

Inject the **standard PostHog browser snippet** via the Vocs **`head` option** —
*not* via `posthog-js` in a `Layout` component.

The snippet lives in its own module, **`docs/lib/analytics.ts`**, exporting
`analyticsHead(): ReactElement` (a `<script>` with the PostHog loader +
`posthog.init`). `vocs.config.ts` composes it with the existing SEO `head()` into
a single `Fragment`, since Vocs' `head` takes one value:

```ts
head: (params) =>
createElement(Fragment, null, analyticsHead(), seoHead(params)),
```

**`posthog.init` config that matters:**

- `api_host: 'https://us.i.posthog.com'` + the migrated `phc_…` key — same
project as Mintlify, so data is continuous.
- `capture_pageview: 'history_change'` — fires `$pageview` on
`pushState`/`popstate`, which is what makes SPA navigation tracking reliable.
Without it only the initial load would be counted.
- `person_profiles: 'identified_only'` — docs traffic is anonymous; don't create
a person profile per visitor.

**Why the `head` snippet over `posthog-js` in `Layout`:**

| | `head` snippet (chosen) | `Layout` + `posthog-js` |
| --- | --- | --- |
| Reliability | Loads async, independent of the app bundle — keeps reporting even if React fails to hydrate. Mirrors what Mintlify did. | Tied to bundle hydration. |
| Dependencies | None | Adds `posthog-js` (+ provider) to the build. |
| SPA pageviews | `capture_pageview: 'history_change'` | React effect / provider |
| Repo fit | Reuses the existing `head` injection pattern (DR001). | Would activate the unused `Layout` slot. |

Reliability of event flow was the priority, so the dependency-free, bundle-
independent snippet won.

**Why a separate module, not `structured-data.ts`:** that file is intentionally
single-purpose (SEO/JSON-LD per DR001). Keeping analytics in `analytics.ts` and
composing in config keeps each concern self-documenting and the snippet trivial to
find or remove.

## Consequences

- All built pages (~379 HTML files in `docs/dist`) carry the PostHog snippet, and
the SEO JSON-LD `head` from DR001 is unaffected — the two are composed, not
competing.
- The public key is committed in `docs/lib/analytics.ts`. This is by design for a
client-side analytics key; rotating the PostHog project means editing the
`POSTHOG_KEY`/`POSTHOG_HOST` constants there.
- The snippet string is the verbatim PostHog loader. To re-sync it with a newer
PostHog snippet, replace the `SNIPPET` body but keep the `posthog.init` options
above.
- **Node ≥ 22 is required to build** (`package.json` engines / `.nvmrc`). On older
Node the Vocs build fails with an unrelated `globSync` import error — not an
analytics issue.

### Verification

1. **Build:** `npm run docs:build` (Node ≥ 22).
2. **Snippet present on every page** (zsh-safe — avoid `--include`):
```bash
echo "with key: $(grep -rl 'phc_w5S82EA6' docs/dist | wc -l)"
echo "html total: $(find docs/dist -name '*.html' | wc -l)" # should match
```
3. **SPA config present:** `grep -o "capture_pageview:'history_change'" docs/dist/index.html`
4. **Live ingestion** (not verifiable from a static build): run `npm run docs:dev`
or check the deployed site, then watch PostHog's *Activity / live events*, or
the Network tab for requests to `us.i.posthog.com`.
105 changes: 105 additions & 0 deletions ADRs/DR007_Analytics_Consent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# DR007: Analytics Consent Gate

Amends [DR006](./DR006_PostHog_Analytics.md). DR006 restored PostHog event flow;
this record adds the consent gate around it. DR006's core mechanism (the PostHog
loader injected via Vocs' `head` option) stands unchanged — this layers opt-in
consent on top and, in doing so, activates the `Layout`/`footer.tsx` consumer
slots DR006 described as unused.

## Context

As shipped in DR006, PostHog captured `$pageview` on every navigation, ran
autocapture, and set its cookies on first load — unconditionally. That is product
analytics, **not** strictly-necessary error telemetry, so under GDPR / ePrivacy
(and equivalents — UK PECR, etc.) it requires **prior, opt-in consent**: nothing
non-essential may be captured or stored until the visitor actively agrees, reject
must be as easy as accept, and consent must be withdrawable as easily as it was
given. The DR006 build met none of these.

Two ways to comply:

1. **Geo-gate** — only prompt EU/EEA/UK visitors (needs edge/server geo-detection),
or run PostHog fully cookieless everywhere.
2. **Consent gate everywhere** — initialise opted-out and cookieless, capture
nothing until the visitor chooses, remember the choice.

## Decision

Take the **consent gate, applied globally** (not geo-gated), so the rule holds in
every region and needs no request-time geo lookup (the docs are statically
prerendered — see DR006).

**PostHog inits suppressed** (`docs/lib/analytics.ts`):

```ts
posthog.init(KEY, {
/* …DR006 options… */
opt_out_capturing_by_default: true, // capture nothing until opt-in
persistence: 'memory', // …and set no cookies until opt-in
})
```

`persistence: 'memory'` means the *only* thing stored before consent is the
visitor's own choice, under our own `localStorage` key `stable-analytics-consent`
(`CONSENT_KEY`) — a strictly-necessary value, exempt from consent. On opt-in we
switch to `persistence: 'localStorage+cookie'` and call `opt_in_capturing()`; on
opt-out, `opt_out_capturing()`.

**Honour a prior choice at load.** An inline bootstrap appended to the `head`
snippet reads `CONSENT_KEY` and, if `'granted'`, opts in *before React hydrates* —
so returning consenters are tracked immediately, not one render late. Wrapped in
`try/catch` so blocked storage can never break page load. (The PostHog stub queues
these calls until `array.js` loads, so calling them early is safe.)

**Prompt + withdraw — via the consumer slots DR006 left unused:**

- **`ConsentBanner`** (`docs/components/ConsentBanner.tsx`) mounts site-wide
through the **default `Layout` export** in `docs/layout.tsx` (a pass-through
wrapper Vocs renders around every page). It shows only when no choice is stored,
with **equal-weight Accept / Decline** — neither hidden nor de-emphasised.
- A **"Cookie preferences"** control in the **`footer.tsx`** consumer slot
re-opens the banner (via an `OPEN_CONSENT_EVENT` window event) so consent can be
changed or withdrawn as easily as it was granted.

Consent state and the PostHog calls live in `analytics.ts`
(`getConsent` / `grantConsent` / `denyConsent`); the React components only render
and dispatch. Keeps the single analytics seam authoritative (cf. DR006's
"separate module" rationale).

**Why this revises DR006.** DR006 chose the `head` snippet *over* `posthog-js` in
a `Layout` component, and noted the `Layout` slot was unused. That trade-off was
about how the **PostHog loader** ships, and is unchanged — the loader is still the
dependency-free `head` snippet. The consent **UI** is a separate concern and is
the natural use for the `Layout`/`footer` slots; no `posthog-js` dependency is
added (the components drive `window.posthog` directly).

## Consequences

- **Default state is no tracking.** A first-time or declining visitor produces no
PostHog events and no PostHog cookies. Expect EU analytics volume to drop to
consenting visitors only — this is the intended, compliant behaviour, not a
regression.
- **DR006's "snippet on every page" check no longer implies active tracking.** The
snippet is present everywhere, but capture is gated. To verify tracking, accept
the banner and watch for `us.i.posthog.com` requests (see Verification).
- **Cross-property consent is not shared.** The choice is stored per-origin in the
docs' own `localStorage`, so a visitor who accepted on another `stable.xyz`
property is still asked here. Unifying consent across `*.stable.xyz` would need a
shared parent-domain cookie + an agreed mechanism across hub/faucet/landing;
deferred until that pattern is settled.
- **Consent copy/styling is docs-local** and should be reconciled with the other
properties' banners once they land, for a consistent UX.
- Rotating or removing analytics still happens in `analytics.ts` (DR006); the
consent helpers and `CONSENT_KEY` live alongside the snippet there.

### Verification

1. **Build:** `npm run docs:build` (Node ≥ 22, per DR006).
2. **Opted out by default:** load a built page fresh (no `stable-analytics-consent`
in `localStorage`) → banner shows, and no request goes to `us.i.posthog.com`.
3. **Init is gated:** `grep -o "opt_out_capturing_by_default:true" docs/dist/index.html`
and `grep -o "persistence:'memory'" docs/dist/index.html`.
4. **Accept → tracking on:** click Accept → `$pageview` requests appear to
`us.i.posthog.com`; reload navigates are captured; the choice persists.
5. **Withdraw:** "Cookie preferences" in the footer re-opens the banner; Decline
stops capture.
4 changes: 3 additions & 1 deletion ADRs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,6 @@ Each ADR should follow this general structure:
- [DR002: i18n Parity & Translation Pipeline](./DR002_i18n_Sync_Pipeline.md) — en as source of truth; checker + CI gate + auto-draft translation engine to keep cn/ko in sync
- [DR003: Page filenames must not end in `index`](./DR003_Page_Filename_Index_Constraint.md) — Vocs strips a trailing `index` from any filename; `*-index.mdx` pages 404. Use `index.mdx` or a non-`index` suffix.
- [DR004: Translation LLM provider](./DR004_Translation_LLM_Provider.md) — swappable OpenAI-compatible seam (`llm.mjs`) defaulting to OpenRouter + a cheap model, optional review pass, structural + link guards; supersedes DR002 §5–6 internals.
- [DR005: Styleguide enforcement](./DR005_Styleguide_Enforcement.md) — mechanical rules in a single `RULES` source enforced by `verify-style.mjs`, surfaced on PRs as a sticky comment + inline applyable suggestions; judgment rules stay prose.
- [DR005: Styleguide enforcement](./DR005_Styleguide_Enforcement.md) — mechanical rules in a single `RULES` source enforced by `verify-style.mjs`, surfaced on PRs as a sticky comment + inline applyable suggestions; judgment rules stay prose.
- [DR006: PostHog Analytics Integration](./DR006_PostHog_Analytics.md) — restore Mintlify-era PostHog via the Vocs `head` option (snippet, not posthog-js); SPA pageviews via `capture_pageview: 'history_change'`
- [DR007: Analytics Consent Gate](./DR007_Analytics_Consent.md) — opt-in consent for the DR006 PostHog integration (GDPR/ePrivacy); inits opted-out + cookieless, site-wide banner via the `Layout` slot, withdraw via a footer link
67 changes: 67 additions & 0 deletions docs/components/ConsentBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Analytics consent banner (GDPR / ePrivacy).
*
* PostHog initialises opted-out and cookieless (docs/lib/analytics.ts); nothing
* is captured until the visitor makes a choice here. We show the prompt only
* when no choice is stored, so returning visitors aren't re-asked. Accept and
* Decline are equal-weight — neither is hidden or de-emphasised.
*
* Mounted once for the whole site by the default `Layout` export in
* docs/layout.tsx. The "Cookie preferences" footer link (docs/footer.tsx)
* re-opens it via the OPEN_CONSENT_EVENT so consent can be withdrawn or changed
* as easily as it was given.
*/
import { useEffect, useState } from 'react'
import { denyConsent, getConsent, grantConsent } from '../lib/analytics'

/** Footer link dispatches this to re-open the banner for an existing choice. */
export const OPEN_CONSENT_EVENT = 'stable:open-consent'

export function ConsentBanner() {
// `undefined` until mounted, so SSR and first client render agree (no banner)
// and we avoid a hydration mismatch; the effect decides whether to show it.
const [open, setOpen] = useState<boolean>()

useEffect(() => {
setOpen(getConsent() === null)
const reopen = () => setOpen(true)
window.addEventListener(OPEN_CONSENT_EVENT, reopen)
return () => window.removeEventListener(OPEN_CONSENT_EVENT, reopen)
}, [])

if (!open) return null

const accept = () => {
grantConsent()
setOpen(false)
}
const decline = () => {
denyConsent()
setOpen(false)
}

return (
<div className="stable-consent" role="dialog" aria-label="Analytics consent">
<p className="stable-consent-text">
We use cookies to measure how the docs are used so we can improve them.
Analytics stays off unless you accept.
</p>
<div className="stable-consent-actions">
<button
type="button"
className="stable-consent-btn stable-consent-decline"
onClick={decline}
>
Decline
</button>
<button
type="button"
className="stable-consent-btn stable-consent-accept"
onClick={accept}
>
Accept
</button>
</div>
</div>
)
}
25 changes: 25 additions & 0 deletions docs/footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Vocs renders the default export of `footer.tsx` as the `Footer` consumer
* component, appended inside the site footer on every page (see
* node_modules/vocs/_lib/app/components/Footer.js). We use it for a persistent
* "Cookie preferences" control so visitors can withdraw or change analytics
* consent as easily as they granted it — a GDPR/ePrivacy requirement.
*
* It dispatches OPEN_CONSENT_EVENT, which the always-mounted <ConsentBanner>
* (docs/components/ConsentBanner.tsx) listens for and re-opens.
*/
import { OPEN_CONSENT_EVENT } from './components/ConsentBanner'

export default function Footer() {
return (
<div className="stable-footer-consent">
<button
type="button"
className="stable-consent-link"
onClick={() => window.dispatchEvent(new CustomEvent(OPEN_CONSENT_EVENT))}
>
Cookie preferences
</button>
</div>
)
}
17 changes: 16 additions & 1 deletion docs/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { type ReactNode, useEffect, useState } from 'react'
import { ConsentBanner } from './components/ConsentBanner'

/* ----------------------------- Language label ---------------------------- */
/* The top-nav language dropdown is a Vocs `topNav` item (vocs.config.ts), whose
Expand Down Expand Up @@ -57,6 +58,20 @@ function installLangRelabel() {
schedule()
}

/* Vocs renders the default export as the `Layout` consumer component — a
pass-through wrapper around the entire app (every page, every layout variant),
rendered in vocs' Root outside DocsLayout/LandingLayout. We use it as the
single mount point for the site-wide analytics consent banner. Keep it a
transparent wrapper: render `children` unchanged, then the banner. */
export default function Layout({ children }: { children: ReactNode }) {
return (
<>
{children}
<ConsentBanner />
</>
)
}

/* Vocs renders the named `TopNavEnd` export at the end of the top navigation
(wired via virtual:consumer-components → <rootDir>/layout.tsx). We use it to
add a theme switcher to the nav, since Vocs only ships its own toggle in the
Expand Down
Loading