-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathanalytics.ts
More file actions
198 lines (180 loc) · 7.12 KB
/
Copy pathanalytics.ts
File metadata and controls
198 lines (180 loc) · 7.12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
// GA4 (gtag.js) transport. The script is injected lazily on the first tracked
// event, so a visitor who bounces off the landing page never pays for it.
//
// Analytics is OFF unless every one of these holds:
// • import.meta.env.PROD — dev and Vitest are dead code paths
// • a well-formed G-XXXXXXXXXX id — see .env.production
// • a hostname in PROD_HOSTS — keeps forks, localhost and `vite preview` out
// • no Global Privacy Control — honours the browser-level opt-out
//
// On the measurement id: it is NOT a secret. It ships inlined in the bundle and
// is readable from the deployed page by anyone, which is why the hostname gate
// below — not secrecy — is what protects the property from stray reporting.
//
// Nothing in this module may ever throw into a caller. An editor that drops a
// knob turn because a metric failed is strictly worse than a missing metric, so
// every entry point is wrapped and every failure degrades to a silent no-op.
import type { AnalyticsEvent, AnalyticsParams } from './analyticsEvents';
const ID_SHAPE = /^G-[A-Z0-9]{4,}$/;
const PROD_HOSTS = new Set(['kabirtamari.com', 'gp200.afterhour.uk']);
/** Mirrors src/core/debugFlags.ts. Set to '1' in DevTools and reload to route
* events into GA4 DebugView:
* localStorage.setItem('gp200:debug:analytics', '1') */
export const ANALYTICS_DEBUG_FLAG = 'gp200:debug:analytics';
// Hard ceiling on events per page session. The taxonomy emits well under 20, so
// this never binds in practice; it exists so that a future instrumentation bug
// (an effect firing in a render loop) costs a bounded amount of memory and
// bandwidth instead of growing without limit. Deliberately not a "detect the ad
// blocker" heuristic — gtag.js does not drain dataLayer, and a blocker serving
// an empty 200 still fires onload, so queue length proves nothing either way.
const MAX_EVENTS_PER_SESSION = 200;
declare global {
interface Window {
dataLayer?: unknown[];
}
}
let enabled: boolean | null = null;
let bootstrapped = false;
let sent = 0;
const onceKeys = new Set<string>();
/** Read lazily, not into a module-level const: Vite still replaces the
* expression statically at build time, and keeping it inside a function is
* what lets tests swap the value with vi.stubEnv after importing. */
function measurementId(): string {
return import.meta.env.VITE_GA_MEASUREMENT_ID ?? '';
}
/** Indirection rather than a bare `location`: jsdom's window.location is not
* assignable, so this is the only clean seam for vi.stubGlobal in tests. */
function hostname(): string {
return globalThis.location?.hostname ?? '';
}
function debugMode(): boolean {
try {
return localStorage.getItem(ANALYTICS_DEBUG_FLAG) === '1';
} catch {
return false;
}
}
/** Global Privacy Control — a browser/extension-level "do not sell or share"
* signal. Not in the DOM lib yet, hence the cast. */
function privacyControlOn(): boolean {
const nav = globalThis.navigator as (Navigator & { globalPrivacyControl?: boolean }) | undefined;
return nav?.globalPrivacyControl === true;
}
export function isAnalyticsEnabled(): boolean {
enabled ??=
import.meta.env.PROD &&
ID_SHAPE.test(measurementId()) &&
typeof document !== 'undefined' &&
PROD_HOSTS.has(hostname()) &&
!privacyControlOn();
return enabled;
}
// gtag.js reads the pushed `arguments` OBJECT off dataLayer — a plain array is
// not equivalent for all downstream tag behaviour, so keep the canonical form.
function gtag() {
// oxlint-disable-next-line prefer-rest-params
window.dataLayer!.push(arguments);
}
const call = gtag as unknown as (...args: unknown[]) => void;
/** Injects gtag.js and sends the initial config. Returns false when analytics
* is off or the injection failed, in which case callers must do nothing. */
function bootstrap(): boolean {
if (!isAnalyticsEnabled()) return false;
if (bootstrapped) return true;
bootstrapped = true;
const id = measurementId();
try {
window.dataLayer ??= [];
call('js', new Date());
call('config', id, {
// No Google Signals and no ad personalisation: this is product analytics,
// not an advertising integration. Both must be off for the "disclose, no
// consent banner" position in the README to be honest.
allow_google_signals: false,
allow_ad_personalization_signals: false,
anonymize_ip: true,
...(debugMode() ? { debug_mode: true } : {}),
});
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`;
// Most content blockers surface a network error here; stop queueing once we
// know nothing will ever consume the queue.
script.onerror = () => {
enabled = false;
};
document.head.appendChild(script);
} catch {
// A CSP refusal or a sandboxed document lands here. Disable rather than throw.
enabled = false;
return false;
}
return true;
}
/** Clamp to GA4's per-param caps and drop empties. */
function sanitize(params?: Record<string, unknown>): Record<string, unknown> | undefined {
if (!params) return undefined;
const out: Record<string, unknown> = {};
let n = 0;
for (const [rawKey, value] of Object.entries(params)) {
if (value === undefined || value === null || n >= 24) continue;
out[rawKey.slice(0, 40)] = typeof value === 'string' ? value.slice(0, 100) : value;
n++;
}
return out;
}
export function track<E extends AnalyticsEvent>(event: E, params?: AnalyticsParams[E]): void {
if (!bootstrap()) return;
if (sent >= MAX_EVENTS_PER_SESSION) return;
sent++;
try {
call('event', event, sanitize(params as Record<string, unknown> | undefined));
} catch {
// Never break the app for a metric.
}
}
/** Fire at most once per page session, keyed by `key` (e.g. `panel:looper`).
* Used wherever the interesting signal is reach rather than frequency. */
export function trackOnce<E extends AnalyticsEvent>(
key: string,
event: E,
params?: AnalyticsParams[E],
): void {
if (onceKeys.has(key)) return;
onceKeys.add(key);
track(event, params);
}
/** Session-scoped dimensions attached to every subsequent event. */
export function setAnalyticsContext(props: Record<string, string | number | boolean>): void {
if (!bootstrap()) return;
try {
call('set', 'user_properties', props);
} catch {
// no-op
}
}
/** Manual page_view. The app has no router, so GA4's history-based enhanced
* measurement never fires and every virtual view has to be sent by hand. */
export function trackVirtualPageView(path: string, title: string): void {
if (!bootstrap()) return;
if (sent >= MAX_EVENTS_PER_SESSION) return;
sent++;
try {
const origin = globalThis.location?.origin ?? '';
call('event', 'page_view', {
page_title: title,
page_location: `${origin}${import.meta.env.BASE_URL}${path}`,
});
} catch {
// no-op
}
}
/** Test seam: clears every piece of memoised module state. */
export function __resetAnalyticsForTests(): void {
enabled = null;
bootstrapped = false;
sent = 0;
onceKeys.clear();
if (typeof window !== 'undefined') delete window.dataLayer;
}