getcert-backend / src /lib /analytics.ts
ggop213's picture
feat: deploy Gemma 4 optimizations and backend updates to HF Spaces
b69dd51
Raw History Blame Contribute Delete
6.8 kB
/**
* analytics.ts β€” GA4 + first-party event collection.
*
* Two independent sinks:
* 1. GA4 (gtag) β€” only when VITE_GA_MEASUREMENT_ID is set.
* 2. First-party β€” POST {API}/api/v2/events -> Supabase `events` table.
*
* Both sinks are gated on consent so a single switch controls tracking.
* Events fired before gtag.js finishes loading are queued on window.dataLayer
* by Google's own snippet below, so early page_views are never dropped.
*/
export type ConsentState = 'granted' | 'denied';
const CONSENT_KEY = 'getcert_consent_v1';
const GA_MEASUREMENT_ID = String(import.meta.env.VITE_GA_MEASUREMENT_ID || '').trim();
const API_BASE_URL = String(import.meta.env.VITE_API_BASE_URL || '')
.trim()
.replace(/\/$/, '');
/**
* Consent applied when the visitor has not made an explicit choice yet.
*
* 'granted' -> opt-out banner: tracking runs immediately, the banner offers a
* Decline button. Best data coverage; fine when the primary
* audience is outside the EEA.
* 'denied' -> opt-in banner: nothing is sent until the visitor accepts.
* Strictest GDPR posture, at the cost of most page views.
*/
const DEFAULT_CONSENT: ConsentState = 'granted';
const SESSION_KEY = 'getcert_sid_v1';
declare global {
interface Window {
dataLayer?: unknown[];
gtag?: (...args: unknown[]) => void;
}
}
let gaLoaded = false;
let explicitChoice: boolean | null = null;
function readStoredConsent(): ConsentState | null {
try {
const raw = localStorage.getItem(CONSENT_KEY);
return raw === 'granted' || raw === 'denied' ? raw : null;
} catch {
return null;
}
}
export function getConsent(): ConsentState {
return readStoredConsent() ?? DEFAULT_CONSENT;
}
export function hasExplicitConsentChoice(): boolean {
if (explicitChoice !== null) return explicitChoice;
const stored = readStoredConsent();
explicitChoice = stored !== null;
return explicitChoice;
}
export function hasAnalyticsConsent(): boolean {
return getConsent() === 'granted';
}
function ensureDataLayer(): unknown[] {
if (!Array.isArray(window.dataLayer)) window.dataLayer = [];
return window.dataLayer;
}
function pushGtag(...args: unknown[]): void {
if (!GA_MEASUREMENT_ID) return;
const gtag = window.gtag;
if (!gtag) {
ensureDataLayer().push(args);
return;
}
gtag(...args);
}
/** Stable anonymous session id for stitching first-party rows together. */
export function getSessionId(): string {
try {
let sid = sessionStorage.getItem(SESSION_KEY);
if (!sid) {
sid =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
sessionStorage.setItem(SESSION_KEY, sid);
}
return sid;
} catch {
return 'anonymous';
}
}
function injectGaScript(): void {
if (!GA_MEASUREMENT_ID || gaLoaded) return;
if (document.querySelector(`script[src*="${GA_MEASUREMENT_ID}"]`)) {
gaLoaded = true;
return;
}
// Google's standard loader: defines gtag + dataLayer queue before the script.
window.dataLayer = window.dataLayer || [];
const gtagImpl = (...args: unknown[]) => {
(window.dataLayer as unknown[][]).push(args);
};
window.gtag = gtagImpl as typeof window.gtag;
gtagImpl('js', new Date());
gtagImpl('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: getConsent() === 'granted' ? 'granted' : 'denied',
wait_for_update: 500,
});
gtagImpl('config', GA_MEASUREMENT_ID, {
send_page_view: false,
anonymize_ip: true,
});
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(GA_MEASUREMENT_ID)}`;
script.onload = () => {
gaLoaded = true;
};
script.onerror = () => {
gaLoaded = false;
};
document.head.appendChild(script);
}
/** Load GA4 if measurement id is configured and consent allows it. */
export function initAnalytics(): void {
if (!GA_MEASUREMENT_ID) return;
if (hasAnalyticsConsent()) injectGaScript();
}
/** Persist a consent choice, then load or revoke tracking accordingly. */
export function setConsent(state: ConsentState): void {
try {
localStorage.setItem(CONSENT_KEY, state);
} catch {
/* storage unavailable β€” in-memory choice below still applies */
}
explicitChoice = true;
pushGtag('consent', 'update', {
analytics_storage: state,
ad_storage: state,
ad_user_data: state,
ad_personalization: state,
});
if (state === 'granted') {
injectGaScript();
}
}
function sendFirstParty(name: string, payload: Record<string, unknown>): void {
if (!API_BASE_URL || !hasAnalyticsConsent()) return;
const body = JSON.stringify({ name, ...payload, sessionId: getSessionId() });
try {
// keepalive lets the request survive an in-flight navigation.
void fetch(`${API_BASE_URL}/api/v2/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
credentials: 'omit',
}).catch(() => {});
} catch {
/* analytics must never surface as a user-facing error */
}
}
const EVENT_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
/**
* Fire one event at both sinks.
* `name` must be snake_case (GA4 event-name rules).
*/
export function track(name: string, params: Record<string, unknown> = {}): void {
if (!EVENT_NAME_RE.test(name)) return;
if (!hasAnalyticsConsent()) return;
if (GA_MEASUREMENT_ID) {
pushGtag('event', name, params);
}
sendFirstParty(name, params);
}
/** SPA page_view β€” fired by AnalyticsBridge on every location change. */
export function trackPageView(path: string, title: string, referrer: string): void {
if (!hasAnalyticsConsent()) return;
const params = {
page_location: `${window.location.origin}${path}`,
page_path: path,
page_title: title,
referrer: referrer || undefined,
};
if (GA_MEASUREMENT_ID) {
pushGtag('event', 'page_view', params);
}
sendFirstParty('page_view', { path, title, referrer: referrer || null });
}
/** Captures a caught or uncaught JS error at both sinks. */
export function trackError(
source: 'global' | 'boundary' | 'promise',
error: unknown,
extra: { componentStack?: string } = {},
): void {
const err = error instanceof Error ? error : new Error(String(error));
track('js_error', {
error_source: source,
error_name: err.name || 'Error',
error_message: (err.message || 'unknown').slice(0, 300),
page_path: window.location.pathname,
...(extra.componentStack ? { component_stack: extra.componentStack.slice(0, 400) } : {}),
});
}