Spaces:
Running
Running
Download src/lib/analytics.ts from Sarvesh213/getcert-backend: direct link, hf CLI and curl.
- Browser
- Download file 6.8 kB
-
https://huggingface.co/spaces/Sarvesh213/getcert-backend/resolve/main/src/lib/analytics.ts
- Command line
-
hf download hf://spaces/Sarvesh213/getcert-backend/src/lib/analytics.ts
-
curl -L -o analytics.ts https://huggingface.co/spaces/Sarvesh213/getcert-backend/resolve/main/src/lib/analytics.ts
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) } : {}), | |
| }); | |
| } | |