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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,11 @@ CRON_SECRET=
# IPs, or delivery ids. Dedicated key — do NOT reuse SESSION_SECRET,
# CRON_SECRET, or the webhook secret. Optional: unset → hashes are null.
SECURITY_EVENT_HASH_KEY=

# Public client analytics configuration (optional — silent no-ops unset).
# VITE_POSTHOG_* power the PostHog product analytics integration;
# VITE_GA_MEASUREMENT_ID enables GA4 acquisition analytics. Both are
# public, publishable identifiers — never secrets.
VITE_POSTHOG_PROJECT_TOKEN=
VITE_POSTHOG_HOST=
VITE_GA_MEASUREMENT_ID=
143 changes: 143 additions & 0 deletions src/analytics/ga4.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/* Google Analytics 4 acquisition-analytics facade — the typed, app-facing
half of the GA4 integration. All validation/sanitization lives in
ga4Model.mjs so it can be unit-tested directly; this module only owns the
gtag.js lifecycle and the typed capture entry points.

Contract (enforced by the model + this file):
- page_view, login_start, login and select_content only — nothing else;
- send_page_view stays false: every page_view is manual and sanitized
(origin + normalized pathname — never location.href, never query/hash);
- the only query data ever read is the five utm_* campaign fields;
- official telemetry is emitted only on devledger.site (localhost runs in
debug mode); a fork on another hostname emits nothing;
- no measurement ID, blocked gtag.js, or any failure → silent no-op;
- no User-ID, no identify-equivalent, no Google Signals/ads features. */

import {
ga4CampaignFields,
ga4PagePath,
ga4PageViewParams,
isGA4DebugHost,
isGA4ProductionHost,
isValidMeasurementId,
validateGA4Event,
} from './ga4Model.mjs';

export {
GA4_EVENT_PROPERTIES,
GA4_UTM_TO_CAMPAIGN_FIELD,
ga4CampaignFields,
ga4PageLocation,
ga4PagePath,
ga4PageTitle,
ga4PageViewParams,
isGA4DebugHost,
isGA4ProductionHost,
isValidMeasurementId,
sanitizeCampaignValue,
validateGA4Event,
} from './ga4Model.mjs';

export type GA4EventName = 'login_start' | 'login' | 'select_content';

export interface GA4EventProps {
login_start: { method: 'github' };
login: { method: 'github' };
select_content: { content_type: 'product_cta'; item_id: string };
}

type Gtag = (...args: unknown[]) => void;

declare global {
interface Window {
dataLayer?: unknown[];
gtag?: Gtag;
}
}

const GTAG_SCRIPT_ORIGIN = 'https://www.googletagmanager.com';

let initStarted = false;
let enabled = false;
let lastPagePath: string | null = null;

function measurementId(): string | null {
const id = import.meta.env.VITE_GA_MEASUREMENT_ID;
return isValidMeasurementId(id) ? id : null;
}

function gtag(): Gtag | null {
return typeof window !== 'undefined' && typeof window.gtag === 'function'
? window.gtag
: null;
}

// Initializes gtag.js once: defines dataLayer/gtag (events queue safely even
// before the script arrives), configures the stream with automatic
// page_view measurement OFF, then injects the async tag. Never throws —
// analytics must never break the app. main.tsx calls this once at boot.
export function initGoogleAnalytics(): void {
if (initStarted || typeof window === 'undefined') return;
initStarted = true;
const id = measurementId();
const host = window.location.host;
if (!id || (!isGA4ProductionHost(host) && !isGA4DebugHost(host))) return;
try {
window.dataLayer = window.dataLayer || [];
window.gtag = function gtagShim(...args: unknown[]) {
window.dataLayer!.push(args);
};
window.gtag('js', new Date());
window.gtag('config', id, {
send_page_view: false,
debug_mode: isGA4DebugHost(host),
// Ads/identity features stay off — acquisition reporting only.
allow_google_signals: false,
allow_ad_personalization_signals: false,
});
const script = document.createElement('script');
script.async = true;
script.src = `${GTAG_SCRIPT_ORIGIN}/gtag/js?id=${encodeURIComponent(id)}`;
document.head.appendChild(script);
enabled = true;
} catch {
enabled = false;
}
}

// The only sanctioned way to emit a GA4 event. Validation runs first; an
// invalid name or property bag is dropped silently.
export function captureGoogleEvent<E extends GA4EventName>(
name: E,
props: GA4EventProps[E],
): void {
const clean = validateGA4Event(name, props as Record<string, unknown>);
const fn = gtag();
if (!clean || !enabled || !fn) return;
try {
fn('event', name, clean);
} catch {
/* analytics is best-effort — never surface to the app */
}
}

// Manual sanitized page_view — the caller never supplies URL data. The
// pathname is normalized (trailing slashes collapse, query/hash discarded)
// and deduped so StrictMode remounts and hash/query-only history entries
// cannot double-count. Recognized utm_* fields are lifted into GA4's
// documented campaign_* dimensions via gtag('set'); page_location itself
// stays clean.
export function captureGooglePageview(): void {
const fn = gtag();
if (!enabled || !fn || typeof window === 'undefined') return;
const pathname = ga4PagePath(window.location.pathname);
if (pathname === lastPagePath) return;
lastPagePath = pathname;
try {
const campaign = ga4CampaignFields(window.location.search);
if (campaign) fn('set', campaign);
fn('event', 'page_view', ga4PageViewParams(window.location, document.title));
} catch {
/* analytics is best-effort — never surface to the app */
}
}
168 changes: 168 additions & 0 deletions src/analytics/ga4Model.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/* Google Analytics 4 acquisition-analytics model — the pure, testable core.
The typed facade lives in ga4.ts.

GA4 is the acquisition/marketing layer only (traffic sources, landing
pages, campaigns, login funnel). Product behavior stays in PostHog; the
two integrations are deliberately independent — this module shares the
PostHog model's philosophy but none of its code.

Hard rules enforced here, in one place:

1. Only the event names in GA4_EVENT_PROPERTIES may be emitted, each with
ONLY the properties its schema lists.
2. page_view is never caller-constructed — the facade derives a sanitized
page_location itself (origin + normalized pathname; query and fragment
are always discarded).
3. The only query parameters ever read are the five documented utm_*
campaign fields, mapped to GA4's campaign_* dimensions after strict
value sanitization. Arbitrary query params (token, code, state, ...)
can never become event or campaign data.
4. Official telemetry is emitted only on the canonical production host —
a fork carrying a configured measurement ID to another hostname emits
nothing. Localhost runs in GA4 debug mode only.
5. No measurement ID configured → every entry point is a silent no-op. */

// ── Event allowlist ───────────────────────────────────────────────────────

// Keep GA4 tiny: page_view (manual) + the acquisition funnel
// (CTA → login start → login). Nothing from the PostHog product taxonomy.
export const GA4_EVENT_PROPERTIES = {
// Google recommended `login` event — method only, never any identity.
login: { method: ['github'] },
// Funnel step: the GitHub OAuth button was activated.
login_start: { method: ['github'] },
// Google recommended `select_content` — used for the blog/product CTA.
// item_id carries a slug-like identifier only (validated below).
select_content: { content_type: ['product_cta'], item_id: null },
};

// Slug-shaped item identifiers (article slug, cta id). No free text.
const ITEM_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,99}$/;

// Returns the event's sanitized property object, or null when the event or
// any property is outside the allowlist. Exact key-set match.
export function validateGA4Event(name, props) {
const schema = GA4_EVENT_PROPERTIES[name];
if (!schema || !props || typeof props !== 'object') return null;
const allowed = Object.keys(schema);
const keys = Object.keys(props);
if (keys.length !== allowed.length || !keys.every((k) => allowed.includes(k))) {
return null;
}
const clean = {};
for (const [key, spec] of Object.entries(schema)) {
const value = props[key];
if (typeof value !== 'string') return null;
if (spec === null) {
if (!ITEM_ID_PATTERN.test(value)) return null;
} else if (!spec.includes(value)) {
return null;
}
clean[key] = value;
}
return clean;
}

// ── Host gating ───────────────────────────────────────────────────────────

// Official GA4 telemetry exists only on the canonical public origin. Local
// dev hosts may initialize for QA but run with debug_mode so they never
// pollute production reports.
export function isGA4ProductionHost(host) {
return host === 'devledger.site';
}

export function isGA4DebugHost(host) {
return (
host === 'localhost' ||
host === '127.0.0.1' ||
host === '::1' ||
host.startsWith('localhost:') ||
host.startsWith('127.0.0.1:')
);
}

// A measurement ID must look like one before it is ever used — guards a
// malformed or secret-bearing value from being injected into the tag URL.
export function isValidMeasurementId(id) {
return typeof id === 'string' && /^G-[A-Z0-9]{4,20}$/.test(id);
}

// ── Sanitized page identity ───────────────────────────────────────────────

// Canonical page path: strips trailing slashes to the non-slash canonical
// form, rejects anything that is not a plain path (query/hash/encoding or
// exotic characters collapse to '/') — same canonical convention the
// PostHog integration uses, implemented independently.
export function ga4PagePath(pathname) {
if (typeof pathname !== 'string' || !pathname.startsWith('/')) return '/';
if (/[?#]/.test(pathname)) return '/';
const clean = pathname.replace(/\/+$/, '') || '/';
if (!/^\/[A-Za-z0-9\-._~/]*$/.test(clean)) return '/';
return clean;
}

// page_location is rebuilt from origin + sanitized pathname — never
// location.href, so query strings and fragments cannot survive.
export function ga4PageLocation(locationLike) {
const origin =
typeof locationLike?.origin === 'string' ? locationLike.origin : '';
return origin + ga4PagePath(locationLike?.pathname);
}

// Document titles are static editorial strings; sanitized defensively
// (control characters stripped, capped) before they may ride an event.
export function ga4PageTitle(title) {
if (typeof title !== 'string') return '';
return title.replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300);
}

// The complete manual page_view parameter set.
export function ga4PageViewParams(locationLike, title) {
return {
page_location: ga4PageLocation(locationLike),
page_title: ga4PageTitle(title),
};
}

// ── Campaign (UTM) extraction ─────────────────────────────────────────────

// The only recognized inbound parameters, mapped to GA4's documented
// campaign_* fields. Everything else in the query string is ignored.
export const GA4_UTM_TO_CAMPAIGN_FIELD = {
utm_source: 'campaign_source',
utm_medium: 'campaign_medium',
utm_campaign: 'campaign_name',
utm_term: 'campaign_term',
utm_content: 'campaign_content',
};

// Conservative campaign value: slug-ish text only — letters, digits,
// underscore, hyphen, dot and space. Anything else (URL delimiters,
// markup, encodings, scheme-like values, control characters) is rejected.
export function sanitizeCampaignValue(value) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed || trimmed.length > 100) return null;
if (!/^[A-Za-z0-9][A-Za-z0-9_.\- ]*$/.test(trimmed)) return null;
return trimmed;
}

// Parse the five utm_* parameters out of a query string into GA4 campaign
// fields. Returns null when nothing valid is present. Unrelated params
// (token, code, state, ...) are never touched.
export function ga4CampaignFields(search) {
if (typeof search !== 'string' || !search.startsWith('?')) return null;
let params;
try {
params = new URLSearchParams(search);
} catch {
return null;
}
const fields = {};
for (const [utm, field] of Object.entries(GA4_UTM_TO_CAMPAIGN_FIELD)) {
const value = sanitizeCampaignValue(params.get(utm));
if (value) fields[field] = value;
}
return Object.keys(fields).length ? fields : null;
}
2 changes: 2 additions & 0 deletions src/ledger/shared.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { GRID, INTENSITY_BG, YEARS, COLS, type GridCell } from './data';
import { captureEvent } from '../analytics/posthog';
import { captureGoogleEvent } from '../analytics/ga4';
import { markLoginPending } from '../analytics/loginFunnel';

/* ---------- scroll reveal hook ---------- */
Expand Down Expand Up @@ -42,6 +43,7 @@ export function GithubButton({ large = false, onLogin, remember = false }: { lar
// pending flag lets the post-callback boot emit login_succeeded once.
markLoginPending();
captureEvent('github_login_started', {});
captureGoogleEvent('login_start', { method: 'github' });
window.location.assign(remember ? '/api/auth/login?remember=1' : '/api/auth/login');
};
return (
Expand Down
8 changes: 5 additions & 3 deletions src/legal/PrivacyPolicyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ export function PrivacyPolicyPage() {
</p>
<p>
This policy applies to Dev Ledger’s website, GitHub App, account and sync services, dashboard,
feedback tools, and product analytics. It does not govern GitHub, Vercel, Supabase, PostHog, or any
other third-party service under their own terms and privacy notices.
feedback tools, and product analytics. It does not govern GitHub, Vercel, Supabase, PostHog, Google,
or any other third-party service under their own terms and privacy notices.
</p>
</LegalSection>

Expand All @@ -48,7 +48,8 @@ export function PrivacyPolicyPage() {
<li><span className="text-[#d6ff3e]">▸</span> <span className="text-neutral-300">Pull-request metadata:</span> pull-request id and number, author identifiers, state, and created/closed/merged timestamps.</li>
<li><span className="text-[#d6ff3e]">▸</span> <span className="text-neutral-300">Account operations:</span> server-side session records, sync state, coverage windows, and security-event records.</li>
<li><span className="text-[#d6ff3e]">▸</span> <span className="text-neutral-300">Feedback:</span> the feedback type, title, optional description, optional verified screenshot, and automatic page/range/build context that you choose to submit.</li>
<li><span className="text-[#d6ff3e]">▸</span> <span className="text-neutral-300">Limited product analytics:</span> selected dashboard range, sync lifecycle category, share-card opens/downloads, feedback type, and delete-data completion.</li>
<li><span className="text-[#d6ff3e]">▸</span> <span className="text-neutral-300">Limited product analytics:</span> selected dashboard range, sync lifecycle category, share-card opens/downloads, feedback type, delete-data completion, and sanitized page views.</li>
<li><span className="text-[#d6ff3e]">▸</span> <span className="text-neutral-300">Acquisition analytics:</span> sanitized page views, coarse campaign-attribution fields when present in the URL (utm_source, utm_medium, utm_campaign, utm_term, utm_content), and login funnel steps measured by Google Analytics 4 — no query strings, URL fragments, account identifiers, or repository data are sent.</li>
</ul>
</LegalSection>

Expand Down Expand Up @@ -109,6 +110,7 @@ export function PrivacyPolicyPage() {
<li><span className="text-[#d6ff3e]">▸</span> <span className="text-neutral-300">Vercel</span> — application hosting, delivery, operational logs, and cookieless Web Analytics;</li>
<li><span className="text-[#d6ff3e]">▸</span> <span className="text-neutral-300">Supabase</span> — application database infrastructure;</li>
<li><span className="text-[#d6ff3e]">▸</span> <span className="text-neutral-300">PostHog</span> — limited product analytics in the EU region, with autocapture, session replay, heatmaps, surveys, console capture, and performance capture disabled in the current configuration.</li>
<li><span className="text-[#d6ff3e]">▸</span> <span className="text-neutral-300">Google Analytics 4</span> — acquisition and traffic-source measurement: sanitized page views, campaign attribution fields, and login funnel events. GA4’s own client/session identifier cookies (_ga, _ga_*) are used for this measurement; Google Signals, advertising personalization, remarketing, and User-ID are not enabled. Google processes this data under its own privacy terms.</li>
</ul>
<p>
We may also disclose information when reasonably necessary to comply with law, enforce legal rights, or
Expand Down
3 changes: 2 additions & 1 deletion src/legal/TermsOfServicePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ export function TermsOfServicePage() {

<LegalSection id="terms-thirdparty" num="08" title="THIRD-PARTY SERVICES">
<p>
Dev Ledger depends on third-party services including GitHub, Vercel, Supabase, and PostHog. Their services
Dev Ledger depends on third-party services including GitHub, Vercel, Supabase, PostHog, and Google
Analytics. Their services
may be unavailable, changed, rate-limited, or discontinued independently of Dev Ledger, and your use of
those services is also subject to their own terms and policies.
</p>
Expand Down
Loading
Loading