返回 CodeWhale
product-usage.ts
根目录 / web / lib / telemetry / product-usage.ts
1 /**
2 * product-usage.ts — aggregate, default-on, user-disableable usage counting
3 * for the website.
4 *
5 * This is the browser side of the first-party telemetry contract
6 * (telemetry-ingest/src/schema.ts, docs/TELEMETRY.md): closed schema version
7 * 3 carrying policy notice version 5, one `product_usage` event with thirteen
8 * unsigned counters, a random v4 install id unrelated to any person and
9 * rotated every 90 days, and nothing else — no page, URL, referrer, error
10 * text, account, or content ever enters the envelope. There is no analytics
11 * SDK and no processor token in the browser; the same-origin route
12 * (app/api/product-telemetry) forwards a validated batch to the canonical
13 * ingest only when the operator has configured that exact endpoint.
14 *
15 * Counting is on by default and every recorded opt-out stays off. The only
16 * stored state is the person's own choice: an explicit "off" from any policy
17 * version disables counting, an explicit "on" keeps it, and the absence of a
18 * record is the default. Unreadable stored state fails closed. The notice
19 * version is policy metadata, never a record that anyone accepted anything.
20 * Turning counting off clears the queued counts and the install id, cancels
21 * any pending delivery, and — through the `storage` event — does the same in
22 * every other open tab.
23 *
24 * Framework-free and injectable so the contract is testable in Node: the
25 * storage, clock, id source, and transport are parameters with browser
26 * defaults.
27 */
28
29 export const SCHEMA_VERSION = 3;
30 export const NOTICE_VERSION = 5;
31 export const INSTALL_ID_ROTATION_MS = 90 * 24 * 60 * 60 * 1000;
32 export const MAX_ENVELOPE_BYTES = 4 * 1024;
33 /** Counts wait this long after the last interaction before one delivery. */
34 export const FLUSH_DELAY_MS = 20_000;
35
36 /** Kept under its historical key so an opt-out recorded under the old policy still counts. */
37 export const PREFERENCE_STORAGE_KEY = "cw-usage-consent";
38 export const INSTALL_STORAGE_KEY = "cw-usage-install";
39 export const COUNTERS_STORAGE_KEY = "cw-usage-counters";
40
41 export const PRODUCT_COUNTER_FIELDS = [
42 "page_view",
43 "docs_view",
44 "install_copy",
45 "download",
46 "signup",
47 "login",
48 "session_create",
49 "session_resume",
50 "turn_submit",
51 "turn_complete",
52 "settings_open",
53 "integration_connect",
54 "error_shown",
55 ] as const;
56
57 export type ProductCounter = (typeof PRODUCT_COUNTER_FIELDS)[number];
58 export type ProductCounters = Record<ProductCounter, number>;
59
60 export const SURFACES = ["website", "web-app", "desktop"] as const;
61 export type Surface = (typeof SURFACES)[number];
62
63 export const ENVELOPE_FIELDS = [
64 "schema_version",
65 "notice_version",
66 "sent_at",
67 "install_id",
68 "app_version",
69 "git_sha",
70 "surface",
71 "os",
72 "arch",
73 "libc",
74 "tty",
75 "events",
76 ] as const;
77
78 export interface ProductUsageEnvelope {
79 schema_version: 3;
80 notice_version: 5;
81 sent_at: string;
82 install_id: string;
83 app_version: string;
84 git_sha: null;
85 surface: Surface;
86 os: "other";
87 arch: "other";
88 libc: "none";
89 tty: false;
90 events: [{ event: "product_usage"; counters: ProductCounters }];
91 }
92
93 const U32_MAX = 4294967295;
94 const SENT_AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
95 const INSTALL_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
96 const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/;
97
98 export function emptyCounters(): ProductCounters {
99 return Object.fromEntries(PRODUCT_COUNTER_FIELDS.map((field) => [field, 0])) as ProductCounters;
100 }
101
102 function isPlainObject(value: unknown): value is Record<string, unknown> {
103 return typeof value === "object" && value !== null && !Array.isArray(value);
104 }
105
106 function keysExactly(value: Record<string, unknown>, expected: readonly string[]): string | null {
107 const actual = Object.keys(value);
108 for (const key of actual) if (!expected.includes(key)) return `unexpected key ${key}`;
109 for (const key of expected) if (!(key in value)) return `missing key ${key}`;
110 return null;
111 }
112
113 /**
114 * The closed-set validator, mirroring the ingest's rules for a browser
115 * batch: exact key sets everywhere, constant envelope values, one
116 * `product_usage` event, every counter a u32. Unknown keys reject the whole
117 * envelope — there is no sanitising path.
118 */
119 export function validateEnvelope(
120 value: unknown,
121 options: { surfaces?: readonly Surface[] } = {},
122 ): { ok: true; envelope: ProductUsageEnvelope } | { ok: false; reason: string } {
123 const surfaces = options.surfaces ?? SURFACES;
124 if (!isPlainObject(value)) return { ok: false, reason: "not an object" };
125 const keyError = keysExactly(value, ENVELOPE_FIELDS);
126 if (keyError) return { ok: false, reason: `envelope: ${keyError}` };
127 if (value.schema_version !== SCHEMA_VERSION) return { ok: false, reason: "schema_version" };
128 if (value.notice_version !== NOTICE_VERSION) return { ok: false, reason: "notice_version" };
129 if (typeof value.sent_at !== "string" || !SENT_AT_RE.test(value.sent_at)) return { ok: false, reason: "sent_at" };
130 if (typeof value.install_id !== "string" || !INSTALL_ID_RE.test(value.install_id)) return { ok: false, reason: "install_id" };
131 if (typeof value.app_version !== "string" || value.app_version.length > 64 || !VERSION_RE.test(value.app_version)) {
132 return { ok: false, reason: "app_version" };
133 }
134 if (value.git_sha !== null) return { ok: false, reason: "git_sha" };
135 if (typeof value.surface !== "string" || !surfaces.includes(value.surface as Surface)) return { ok: false, reason: "surface" };
136 if (value.os !== "other") return { ok: false, reason: "os" };
137 if (value.arch !== "other") return { ok: false, reason: "arch" };
138 if (value.libc !== "none") return { ok: false, reason: "libc" };
139 if (value.tty !== false) return { ok: false, reason: "tty" };
140 if (!Array.isArray(value.events) || value.events.length !== 1) return { ok: false, reason: "events" };
141 const event = value.events[0];
142 if (!isPlainObject(event)) return { ok: false, reason: "event: not an object" };
143 const eventKeyError = keysExactly(event, ["event", "counters"]);
144 if (eventKeyError) return { ok: false, reason: `event: ${eventKeyError}` };
145 if (event.event !== "product_usage") return { ok: false, reason: "event: name" };
146 if (!isPlainObject(event.counters)) return { ok: false, reason: "counters: not an object" };
147 const counterKeyError = keysExactly(event.counters, PRODUCT_COUNTER_FIELDS);
148 if (counterKeyError) return { ok: false, reason: `counters: ${counterKeyError}` };
149 for (const field of PRODUCT_COUNTER_FIELDS) {
150 const item = event.counters[field];
151 if (typeof item !== "number" || !Number.isInteger(item) || item < 0 || item > U32_MAX) {
152 return { ok: false, reason: `counters: ${field}` };
153 }
154 }
155 return { ok: true, envelope: value as unknown as ProductUsageEnvelope };
156 }
157
158 /** RFC3339 UTC at second precision, exactly `to_rfc3339_opts(Secs, true)`. */
159 export function sentAt(now: number): string {
160 return new Date(Math.floor(now / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
161 }
162
163 export function buildEnvelope(input: {
164 counters: ProductCounters;
165 installId: string;
166 appVersion: string;
167 surface: Surface;
168 now: number;
169 }): ProductUsageEnvelope {
170 return {
171 schema_version: SCHEMA_VERSION,
172 notice_version: NOTICE_VERSION,
173 sent_at: sentAt(input.now),
174 install_id: input.installId,
175 app_version: input.appVersion,
176 git_sha: null,
177 surface: input.surface,
178 os: "other",
179 arch: "other",
180 libc: "none",
181 tty: false,
182 events: [{ event: "product_usage", counters: { ...input.counters } }],
183 };
184 }
185
186 // --------------------------------------------------------------- preference
187
188 export interface UsagePreferenceRecord {
189 /** Policy notice version in force when the choice was made. Metadata only. */
190 version: number;
191 granted: boolean;
192 decidedAt: string;
193 }
194
195 /** `default` is the absence of a record: counting is on. `on` / `off` are explicit choices. */
196 export type UsagePreference = "on" | "off" | "default";
197
198 /**
199 * Reads the stored choice. Counting is on by default, so no record means
200 * `default`. Any explicit refusal — from this policy version or an older one
201 * — stays `off`; an old decline is still a decline. A record that exists but
202 * cannot be read fails closed as `off` rather than being replaced by the
203 * default.
204 */
205 export function readUsagePreference(raw: string | null | undefined): UsagePreference {
206 if (raw === null || raw === undefined || raw === "") return "default";
207 try {
208 const parsed = JSON.parse(raw) as Partial<UsagePreferenceRecord>;
209 if (!isPlainObject(parsed) || typeof parsed.granted !== "boolean") return "off";
210 return parsed.granted ? "on" : "off";
211 } catch {
212 return "off";
213 }
214 }
215
216 export function usageCountingEnabled(preference: UsagePreference): boolean {
217 return preference !== "off";
218 }
219
220 export function usagePreferenceRecord(granted: boolean, now: number): string {
221 const record: UsagePreferenceRecord = { version: NOTICE_VERSION, granted, decidedAt: sentAt(now) };
222 return JSON.stringify(record);
223 }
224
225 // --------------------------------------------------------------- install id
226
227 interface InstallRecord {
228 id: string;
229 createdAt: number;
230 }
231
232 /** The current install id, or a fresh one when missing, malformed, or older than 90 days. */
233 export function resolveInstallId(
234 raw: string | null | undefined,
235 now: number,
236 randomUuid: () => string,
237 ): { id: string; raw: string; rotated: boolean } {
238 try {
239 if (raw) {
240 const parsed = JSON.parse(raw) as Partial<InstallRecord>;
241 if (
242 isPlainObject(parsed) &&
243 typeof parsed.id === "string" &&
244 INSTALL_ID_RE.test(parsed.id) &&
245 typeof parsed.createdAt === "number" &&
246 now - parsed.createdAt >= 0 &&
247 now - parsed.createdAt < INSTALL_ID_ROTATION_MS
248 ) {
249 return { id: parsed.id, raw, rotated: false };
250 }
251 }
252 } catch {
253 /* unreadable: rotate */
254 }
255 const id = randomUuid();
256 const record: InstallRecord = { id, createdAt: now };
257 return { id, raw: JSON.stringify(record), rotated: true };
258 }
259
260 // ----------------------------------------------------------------- counters
261
262 export function readCounters(raw: string | null | undefined): ProductCounters {
263 const counters = emptyCounters();
264 if (!raw) return counters;
265 try {
266 const parsed = JSON.parse(raw) as Record<string, unknown>;
267 if (!isPlainObject(parsed)) return counters;
268 for (const field of PRODUCT_COUNTER_FIELDS) {
269 const value = parsed[field];
270 if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= U32_MAX) {
271 counters[field] = value;
272 }
273 }
274 } catch {
275 /* unreadable: start from zero */
276 }
277 return counters;
278 }
279
280 export function hasCounts(counters: ProductCounters): boolean {
281 return PRODUCT_COUNTER_FIELDS.some((field) => counters[field] > 0);
282 }
283
284 // ----------------------------------------------------------------- recorder
285
286 export interface StorageLike {
287 getItem(key: string): string | null;
288 setItem(key: string, value: string): void;
289 removeItem(key: string): void;
290 }
291
292 export interface RecorderOptions {
293 surface: Surface;
294 appVersion: string;
295 /** Same-origin route that forwards to the canonical ingest. */
296 endpoint: string;
297 storage: StorageLike;
298 now?: () => number;
299 randomUuid?: () => string;
300 /** Transport; returns whether the batch was accepted. Never retried. */
301 send?: (endpoint: string, body: string) => Promise<boolean>;
302 setTimer?: (callback: () => void, delayMs: number) => unknown;
303 clearTimer?: (handle: unknown) => void;
304 flushDelayMs?: number;
305 }
306
307 export interface UsageRecorder {
308 preference(): UsagePreference;
309 /** Deliberately turn counting back on after an opt-out. */
310 enable(): void;
311 /** Record a durable opt-out and clear everything queued in this browser. */
312 disable(): void;
313 /** Re-read the preference from storage (another tab may have changed it). */
314 sync(): void;
315 record(counter: ProductCounter): void;
316 /** Deliver whatever is queued now (also used on pagehide). */
317 flush(): Promise<void>;
318 pending(): ProductCounters;
319 }
320
321 function browserSend(endpoint: string, body: string): Promise<boolean> {
322 if (typeof fetch !== "function") return Promise.resolve(false);
323 const controller = typeof AbortController === "function" ? new AbortController() : null;
324 const timer = controller ? setTimeout(() => controller.abort(), 1500) : null;
325 return fetch(endpoint, {
326 method: "POST",
327 headers: { "content-type": "application/json" },
328 body,
329 keepalive: true,
330 credentials: "omit",
331 referrerPolicy: "no-referrer",
332 signal: controller?.signal,
333 })
334 .then((response) => response.ok)
335 .catch(() => false)
336 .finally(() => {
337 if (timer !== null) clearTimeout(timer);
338 });
339 }
340
341 export function createUsageRecorder(options: RecorderOptions): UsageRecorder {
342 const now = options.now ?? (() => Date.now());
343 const randomUuid = options.randomUuid ?? (() => crypto.randomUUID());
344 const send = options.send ?? browserSend;
345 const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay));
346 const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>));
347 const flushDelayMs = options.flushDelayMs ?? FLUSH_DELAY_MS;
348 const { storage } = options;
349
350 let counters = emptyCounters();
351 let timer: unknown = null;
352 let inFlight = false;
353
354 const read = (key: string) => {
355 try {
356 return storage.getItem(key);
357 } catch {
358 return null;
359 }
360 };
361 const write = (key: string, value: string) => {
362 try {
363 storage.setItem(key, value);
364 } catch {
365 /* storage unavailable: counting stays in memory for this page only */
366 }
367 };
368 const remove = (key: string) => {
369 try {
370 storage.removeItem(key);
371 } catch {
372 /* nothing to clear */
373 }
374 };
375
376 const cancelTimer = () => {
377 if (timer !== null) clearTimer(timer);
378 timer = null;
379 };
380
381 /** Everything queued and every identity goes; nothing pending survives. */
382 const clearAll = () => {
383 cancelTimer();
384 counters = emptyCounters();
385 remove(COUNTERS_STORAGE_KEY);
386 remove(INSTALL_STORAGE_KEY);
387 };
388
389 const preference = () => readUsagePreference(read(PREFERENCE_STORAGE_KEY));
390 const enabled = () => usageCountingEnabled(preference());
391
392 const schedule = () => {
393 if (timer !== null) return;
394 timer = setTimer(() => {
395 timer = null;
396 void flush();
397 }, flushDelayMs);
398 };
399
400 const flush = async () => {
401 if (inFlight) return;
402 cancelTimer();
403 if (!enabled()) {
404 clearAll();
405 return;
406 }
407 const batch = counters;
408 if (!hasCounts(batch)) return;
409 const install = resolveInstallId(read(INSTALL_STORAGE_KEY), now(), randomUuid);
410 if (install.rotated) write(INSTALL_STORAGE_KEY, install.raw);
411 const envelope = buildEnvelope({
412 counters: batch,
413 installId: install.id,
414 appVersion: options.appVersion,
415 surface: options.surface,
416 now: now(),
417 });
418 const body = JSON.stringify(envelope);
419 if (!validateEnvelope(envelope).ok || body.length > MAX_ENVELOPE_BYTES) return;
420 // Discard after one attempt, accepted or not: there is no retry queue,
421 // and a count that did not land is not worth remembering.
422 counters = emptyCounters();
423 remove(COUNTERS_STORAGE_KEY);
424 inFlight = true;
425 try {
426 await send(options.endpoint, body);
427 } finally {
428 inFlight = false;
429 }
430 };
431
432 // Hydrate any counts a previous page on this origin left behind, but only
433 // while counting is allowed; otherwise clear them as an opt-out's debris.
434 if (enabled()) {
435 counters = readCounters(read(COUNTERS_STORAGE_KEY));
436 } else {
437 clearAll();
438 }
439
440 return {
441 preference,
442 enable() {
443 write(PREFERENCE_STORAGE_KEY, usagePreferenceRecord(true, now()));
444 },
445 disable() {
446 write(PREFERENCE_STORAGE_KEY, usagePreferenceRecord(false, now()));
447 clearAll();
448 },
449 sync() {
450 if (!enabled()) clearAll();
451 },
452 record(counter) {
453 if (!enabled()) {
454 clearAll();
455 return;
456 }
457 if (counters[counter] < U32_MAX) counters[counter] += 1;
458 write(COUNTERS_STORAGE_KEY, JSON.stringify(counters));
459 schedule();
460 },
461 flush,
462 pending: () => ({ ...counters }),
463 };
464 }
465
465 lines TYPESCRIPT