diff --git a/src/hooks/useTelemetry.ts b/src/hooks/useTelemetry.ts new file mode 100644 index 0000000..b7ec72e --- /dev/null +++ b/src/hooks/useTelemetry.ts @@ -0,0 +1,26 @@ +import { useCallback, useEffect } from 'react'; +import { telemetryClient } from '@/telemetry/TelemetryClient'; +import type { TelemetryEvent } from '@/telemetry/types'; + +export interface UseTelemetryResult { + track: (event: TelemetryEvent) => void; +} + +/** + * Returns a stable `track` function that posts a telemetry event to the + * Web Worker pipeline with zero synchronous work on the main thread. + * + * The client is initialised lazily on the first mount so the worker is not + * spawned until the app actually needs telemetry. + */ +export function useTelemetry(): UseTelemetryResult { + useEffect(() => { + telemetryClient.init(); + }, []); + + const track = useCallback((event: TelemetryEvent): void => { + telemetryClient.track(event); + }, []); + + return { track }; +} diff --git a/src/telemetry/TelemetryClient.ts b/src/telemetry/TelemetryClient.ts new file mode 100644 index 0000000..279db90 --- /dev/null +++ b/src/telemetry/TelemetryClient.ts @@ -0,0 +1,77 @@ +import type { TelemetryEvent, WorkerInMessage } from './types'; + +let sequence = 0; + +function nextSeq(): number { + return ++sequence; +} + +function makeSessionId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`; +} + +// --------------------------------------------------------------------------- +// Singleton client +// --------------------------------------------------------------------------- + +export class TelemetryClient { + private worker: Worker | null = null; + private sessionId: string = makeSessionId(); + private walletAddress: string | null = null; + private buildId: string = import.meta.env?.VITE_BUILD_ID ?? 'dev'; + private visibilityHandler: (() => void) | null = null; + + init(): void { + if (this.worker) return; + try { + this.worker = new Worker( + new URL('../workers/telemetry.worker.ts', import.meta.url), + { type: 'module' }, + ); + } catch { + // Workers are unavailable (e.g. unit-test environment) — fail silently. + return; + } + + this.visibilityHandler = () => { + if (document.visibilityState === 'hidden') { + this.postMessage({ kind: 'FLUSH_AND_BEACON' }); + } + }; + document.addEventListener('visibilitychange', this.visibilityHandler); + } + + destroy(): void { + if (this.visibilityHandler) { + document.removeEventListener('visibilitychange', this.visibilityHandler); + this.visibilityHandler = null; + } + this.worker?.terminate(); + this.worker = null; + } + + setWalletAddress(address: string | null): void { + this.walletAddress = address; + } + + track(event: TelemetryEvent): void { + this.postMessage({ + kind: 'TRACK', + event: { + ...event, + sessionId: this.sessionId, + walletAddress: this.walletAddress, + buildId: this.buildId, + timestamp: Date.now(), + sequence: nextSeq(), + }, + }); + } + + private postMessage(msg: WorkerInMessage): void { + this.worker?.postMessage(msg); + } +} + +// Singleton instance shared across the app. +export const telemetryClient = new TelemetryClient(); diff --git a/src/telemetry/__tests__/telemetryBatcher.test.ts b/src/telemetry/__tests__/telemetryBatcher.test.ts new file mode 100644 index 0000000..b3cd4f3 --- /dev/null +++ b/src/telemetry/__tests__/telemetryBatcher.test.ts @@ -0,0 +1,341 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + BATCH_SIZE_LIMIT, + FLUSH_INTERVAL_MS, + TelemetryBatcher, +} from '../telemetryBatcher'; +import type { EnvelopedEvent, TelemetryBatch } from '../types'; + +function makeEvent(sequence: number, sessionId = 'sess-1'): EnvelopedEvent { + return { + type: 'UserAction', + action: 'click', + target: 'button', + metadata: {}, + sessionId, + walletAddress: null, + buildId: 'test', + timestamp: Date.now(), + sequence, + }; +} + +function makeSuccessFlush() { + return vi.fn<[TelemetryBatch], Promise<{ success: boolean }>>().mockResolvedValue({ success: true }); +} + +function makeFailFlush() { + return vi.fn<[TelemetryBatch], Promise<{ success: boolean }>>().mockResolvedValue({ success: false }); +} + +describe('TelemetryBatcher batching', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('does not flush before BATCH_SIZE_LIMIT is reached', async () => { + const onFlush = makeSuccessFlush(); + const batcher = new TelemetryBatcher(onFlush, vi.fn()); + batcher.start(); + + for (let i = 0; i < BATCH_SIZE_LIMIT - 1; i++) { + batcher.add(makeEvent(i + 1)); + } + await Promise.resolve(); + + expect(onFlush).not.toHaveBeenCalled(); + batcher.stop(); + }); + + it('flushes immediately when BATCH_SIZE_LIMIT events are added', async () => { + const onFlush = makeSuccessFlush(); + const batcher = new TelemetryBatcher(onFlush, vi.fn()); + batcher.start(); + + for (let i = 0; i < BATCH_SIZE_LIMIT; i++) { + batcher.add(makeEvent(i + 1)); + } + // Settle the async flush without running the interval timer forever + await Promise.resolve(); + await Promise.resolve(); + + expect(onFlush).toHaveBeenCalledTimes(1); + const batch = onFlush.mock.calls[0][0]; + expect(batch.events).toHaveLength(BATCH_SIZE_LIMIT); + batcher.stop(); + }); + + it('flushes after FLUSH_INTERVAL_MS elapses with fewer than BATCH_SIZE_LIMIT events', async () => { + const onFlush = makeSuccessFlush(); + const batcher = new TelemetryBatcher(onFlush, vi.fn()); + batcher.start(); + + batcher.add(makeEvent(1)); + batcher.add(makeEvent(2)); + + expect(onFlush).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + expect(onFlush).toHaveBeenCalledTimes(1); + expect(onFlush.mock.calls[0][0].events).toHaveLength(2); + batcher.stop(); + }); + + it('does not flush when the pending queue is empty', async () => { + const onFlush = makeSuccessFlush(); + const batcher = new TelemetryBatcher(onFlush, vi.fn()); + batcher.start(); + + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + expect(onFlush).not.toHaveBeenCalled(); + batcher.stop(); + }); + + it('flushes multiple times across intervals', async () => { + const onFlush = makeSuccessFlush(); + const batcher = new TelemetryBatcher(onFlush, vi.fn()); + batcher.start(); + + batcher.add(makeEvent(1)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + batcher.add(makeEvent(2)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + expect(onFlush).toHaveBeenCalledTimes(2); + batcher.stop(); + }); +}); + +describe('TelemetryBatcher batch header', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('sets currentBatchStartSequence to the first event sequence', async () => { + const onFlush = makeSuccessFlush(); + const batcher = new TelemetryBatcher(onFlush, vi.fn()); + batcher.start(); + + batcher.add(makeEvent(7)); + batcher.add(makeEvent(8)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + expect(onFlush.mock.calls[0][0].header.currentBatchStartSequence).toBe(7); + batcher.stop(); + }); + + it('sets lastDeliveredSequence to 0 before any successful flush', async () => { + const onFlush = makeSuccessFlush(); + const batcher = new TelemetryBatcher(onFlush, vi.fn()); + batcher.start(); + + batcher.add(makeEvent(1)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + expect(onFlush.mock.calls[0][0].header.lastDeliveredSequence).toBe(0); + batcher.stop(); + }); + + it('reports the correct lastDeliveredSequence after a successful flush', async () => { + const onFlush = makeSuccessFlush(); + const batcher = new TelemetryBatcher(onFlush, vi.fn()); + batcher.start(); + + batcher.add(makeEvent(1)); + batcher.add(makeEvent(2)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + batcher.add(makeEvent(3)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + const secondBatch = onFlush.mock.calls[1][0]; + expect(secondBatch.header.lastDeliveredSequence).toBe(2); + batcher.stop(); + }); + + it('reports a sequence gap in the batch header after a failed flush', async () => { + const onFlush = vi.fn() + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false }) + .mockResolvedValue({ success: true }); + + const batcher = new TelemetryBatcher(onFlush, vi.fn()); + batcher.start(); + + // First batch: seq 1-2, succeeds + batcher.add(makeEvent(1)); + batcher.add(makeEvent(2)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + // Second batch: seq 3-4, fails + batcher.add(makeEvent(3)); + batcher.add(makeEvent(4)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + // Third batch: seq 5 + retained 3-4, the header must reflect the gap + batcher.add(makeEvent(5)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + const thirdBatch = onFlush.mock.calls[2][0]; + // lastDeliveredSequence is still 2 (the last successful one) + expect(thirdBatch.header.lastDeliveredSequence).toBe(2); + // retained events are prepended so currentBatchStartSequence is 3 + expect(thirdBatch.header.currentBatchStartSequence).toBe(3); + // batch contains retained (3,4) + new (5) + expect(thirdBatch.events).toHaveLength(3); + batcher.stop(); + }); +}); + +describe('TelemetryBatcher failed flush retention', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('retains events from a failed flush and prepends them to the next batch', async () => { + const onFlush = vi.fn() + .mockResolvedValueOnce({ success: false }) + .mockResolvedValue({ success: true }); + + const batcher = new TelemetryBatcher(onFlush, vi.fn()); + batcher.start(); + + batcher.add(makeEvent(1)); + batcher.add(makeEvent(2)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + batcher.add(makeEvent(3)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + const secondBatch = onFlush.mock.calls[1][0]; + const seqs = secondBatch.events.map((e: EnvelopedEvent) => e.sequence); + // Retained events come first + expect(seqs).toEqual([1, 2, 3]); + batcher.stop(); + }); + + it('does not update lastDeliveredSequence after a failed flush', async () => { + const onFlush = vi.fn() + .mockResolvedValueOnce({ success: false }) + .mockResolvedValue({ success: true }); + + const batcher = new TelemetryBatcher(onFlush, vi.fn()); + batcher.start(); + + batcher.add(makeEvent(1)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + batcher.add(makeEvent(2)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + const secondBatch = onFlush.mock.calls[1][0]; + expect(secondBatch.header.lastDeliveredSequence).toBe(0); + batcher.stop(); + }); + + it('drops oldest events and inserts a DroppedEvents sentinel when cap is exceeded', async () => { + const onFlush = makeFailFlush(); + const batcher = new TelemetryBatcher(onFlush, vi.fn()); + // No start() — use manual flush() calls to avoid timer complexity + + let seq = 0; + // Add 49 events per cycle (below BATCH_SIZE_LIMIT so no auto-flush), + // then flush manually. After enough cycles retained > MAX_RETAINED_EVENTS + // and the sentinel appears in the next batch sent to onFlush. + for (let b = 0; b <= 12; b++) { + for (let i = 0; i < 49; i++) batcher.add(makeEvent(++seq)); + await batcher.flush(); + } + + const batchWithSentinel = onFlush.mock.calls.find(([b]) => + b.events.some((e: EnvelopedEvent) => e.type === 'DroppedEvents'), + ); + expect(batchWithSentinel).toBeDefined(); + const sentinel = batchWithSentinel![0].events.find( + (e: EnvelopedEvent) => e.type === 'DroppedEvents', + ); + expect((sentinel as { count: number }).count).toBeGreaterThan(0); + }); +}); + +describe('TelemetryBatcher flushAndBeacon', () => { + it('calls onBeacon with all pending events', () => { + const onBeacon = vi.fn(); + const batcher = new TelemetryBatcher(makeSuccessFlush(), onBeacon); + + batcher.add(makeEvent(1)); + batcher.add(makeEvent(2)); + batcher.add(makeEvent(3)); + batcher.flushAndBeacon(); + + expect(onBeacon).toHaveBeenCalledTimes(1); + const batch: TelemetryBatch = onBeacon.mock.calls[0][0]; + expect(batch.events).toHaveLength(3); + }); + + it('does not call onBeacon when there are no events', () => { + const onBeacon = vi.fn(); + const batcher = new TelemetryBatcher(makeSuccessFlush(), onBeacon); + + batcher.flushAndBeacon(); + + expect(onBeacon).not.toHaveBeenCalled(); + }); + + it('includes retained events from a previous failed flush in the beacon batch', async () => { + vi.useFakeTimers(); + + const onFlush = makeFailFlush(); + const onBeacon = vi.fn(); + const batcher = new TelemetryBatcher(onFlush, onBeacon); + batcher.start(); + + batcher.add(makeEvent(1)); + batcher.add(makeEvent(2)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + batcher.add(makeEvent(3)); + batcher.flushAndBeacon(); + + const batch: TelemetryBatch = onBeacon.mock.calls[0][0]; + const seqs = batch.events.map((e: EnvelopedEvent) => e.sequence); + expect(seqs).toEqual([1, 2, 3]); + + batcher.stop(); + vi.useRealTimers(); + }); + + it('sets currentBatchStartSequence from the first retained event', async () => { + vi.useFakeTimers(); + + const onFlush = makeFailFlush(); + const onBeacon = vi.fn(); + const batcher = new TelemetryBatcher(onFlush, onBeacon); + batcher.start(); + + batcher.add(makeEvent(5)); + await vi.advanceTimersByTimeAsync(FLUSH_INTERVAL_MS); + + batcher.add(makeEvent(6)); + batcher.flushAndBeacon(); + + const batch: TelemetryBatch = onBeacon.mock.calls[0][0]; + expect(batch.header.currentBatchStartSequence).toBe(5); + + batcher.stop(); + vi.useRealTimers(); + }); +}); + +describe('TelemetryBatcher track() performance', () => { + it('add() completes in under 0.1ms (zero synchronous work)', () => { + const batcher = new TelemetryBatcher(makeSuccessFlush(), vi.fn()); + const event = makeEvent(1); + + const start = performance.now(); + batcher.add(event); + const elapsed = performance.now() - start; + + expect(elapsed).toBeLessThan(0.1); + }); +}); diff --git a/src/telemetry/telemetryBatcher.ts b/src/telemetry/telemetryBatcher.ts new file mode 100644 index 0000000..c1a107f --- /dev/null +++ b/src/telemetry/telemetryBatcher.ts @@ -0,0 +1,127 @@ +import type { EnvelopedEvent, TelemetryBatch } from './types'; + +export const BATCH_SIZE_LIMIT = 50; +export const FLUSH_INTERVAL_MS = 5_000; +export const MAX_RETAINED_EVENTS = 500; + +export interface FlushResult { + success: boolean; +} + +export type OnFlush = (batch: TelemetryBatch) => Promise; +export type OnBeacon = (batch: TelemetryBatch) => void; + +/** + * Core batcher extracted from the worker so it can be unit-tested + * without instantiating a real Web Worker. + * + * Responsibilities: + * - Buffer incoming events and flush every FLUSH_INTERVAL_MS or at BATCH_SIZE_LIMIT. + * - Retain failed batches and prepend them to the next flush (at-least-once delivery). + * - Cap retained events at MAX_RETAINED_EVENTS; drop oldest, inserting a DroppedEvents sentinel. + * - Track the last successfully delivered sequence number for gap detection. + * - Provide a synchronous flushAndBeacon() path for page-unload delivery. + */ +export class TelemetryBatcher { + private readonly onFlush: OnFlush; + private readonly onBeacon: OnBeacon; + private pending: EnvelopedEvent[] = []; + private retained: EnvelopedEvent[] = []; + private lastDeliveredSequence = 0; + private flushTimer: ReturnType | null = null; + private flushing = false; + + constructor(onFlush: OnFlush, onBeacon: OnBeacon) { + this.onFlush = onFlush; + this.onBeacon = onBeacon; + } + + start(): void { + if (this.flushTimer != null) return; + this.flushTimer = setInterval(() => { + void this.flush(); + }, FLUSH_INTERVAL_MS); + } + + stop(): void { + if (this.flushTimer != null) { + clearInterval(this.flushTimer); + this.flushTimer = null; + } + } + + add(event: EnvelopedEvent): void { + this.pending.push(event); + if (this.pending.length >= BATCH_SIZE_LIMIT) { + void this.flush(); + } + } + + async flush(): Promise { + if (this.flushing) return; + this.flushing = true; + try { + const events: EnvelopedEvent[] = [...this.retained, ...this.pending]; + this.pending = []; + this.retained = []; + + if (events.length === 0) return; + + const batch = this.makeBatch(events); + const result = await this.onFlush(batch); + + if (result.success) { + const last = events[events.length - 1]; + if (last) this.lastDeliveredSequence = last.sequence; + } else { + this.retainEvents(events); + } + } finally { + this.flushing = false; + } + } + + flushAndBeacon(): void { + const events: EnvelopedEvent[] = [...this.retained, ...this.pending]; + this.pending = []; + this.retained = []; + + if (events.length === 0) return; + + this.onBeacon(this.makeBatch(events)); + } + + // ------------------------------------------------------------------------- + + private makeBatch(events: EnvelopedEvent[]): TelemetryBatch { + return { + header: { + lastDeliveredSequence: this.lastDeliveredSequence, + currentBatchStartSequence: events[0]?.sequence ?? 0, + }, + events, + }; + } + + private retainEvents(failed: EnvelopedEvent[]): void { + const combined = [...failed, ...this.pending]; + this.pending = []; + + if (combined.length <= MAX_RETAINED_EVENTS) { + this.retained = combined; + return; + } + + const dropCount = combined.length - MAX_RETAINED_EVENTS + 1; + const sentinel: EnvelopedEvent = { + type: 'DroppedEvents', + count: dropCount, + sessionId: combined[0]?.sessionId ?? '', + walletAddress: combined[0]?.walletAddress ?? null, + buildId: combined[0]?.buildId ?? '', + timestamp: Date.now(), + sequence: 0, + }; + this.retained = [sentinel, ...combined.slice(dropCount)]; + } +} diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts new file mode 100644 index 0000000..2db709b --- /dev/null +++ b/src/telemetry/types.ts @@ -0,0 +1,94 @@ +// --------------------------------------------------------------------------- +// Per-event payloads +// --------------------------------------------------------------------------- + +export interface PageLoadPayload { + type: 'PageLoad'; + page: string; + ttfb: number; + dcl: number; + lcp: number; + fid: number; + cls: number; + loadComplete: number; +} + +export interface ApiCallPayload { + type: 'ApiCall'; + endpoint: string; + method: string; + durationMs: number; + statusCode: number; + cacheHit: boolean; +} + +export interface SigningStepPayload { + type: 'SigningStep'; + step: string; + durationMs: number; + signerType: string; +} + +export interface ComponentErrorPayload { + type: 'ComponentError'; + componentName: string; + errorMessage: string; + errorStack: string; + buildId: string; +} + +export interface UserActionPayload { + type: 'UserAction'; + action: string; + target: string; + metadata: Record; +} + +export interface DroppedEventsPayload { + type: 'DroppedEvents'; + count: number; +} + +export type TelemetryEvent = + | PageLoadPayload + | ApiCallPayload + | SigningStepPayload + | ComponentErrorPayload + | UserActionPayload; + +// --------------------------------------------------------------------------- +// Envelope — fields present on every enveloped event +// --------------------------------------------------------------------------- + +export interface TelemetryEnvelope { + sessionId: string; + walletAddress: string | null; + buildId: string; + timestamp: number; + sequence: number; +} + +export type EnvelopedEvent = TelemetryEnvelope & (TelemetryEvent | DroppedEventsPayload); + +// --------------------------------------------------------------------------- +// Batch +// --------------------------------------------------------------------------- + +export interface BatchHeader { + lastDeliveredSequence: number; + currentBatchStartSequence: number; +} + +export interface TelemetryBatch { + header: BatchHeader; + events: EnvelopedEvent[]; +} + +// --------------------------------------------------------------------------- +// Worker messages +// --------------------------------------------------------------------------- + +export type WorkerInMessage = + | { kind: 'TRACK'; event: EnvelopedEvent } + | { kind: 'FLUSH_AND_BEACON' } + | { kind: 'SET_CONTEXT'; sessionId: string; walletAddress: string | null; buildId: string }; diff --git a/src/workers/telemetry.worker.ts b/src/workers/telemetry.worker.ts new file mode 100644 index 0000000..75c7e50 --- /dev/null +++ b/src/workers/telemetry.worker.ts @@ -0,0 +1,68 @@ +import { TelemetryBatcher } from '../telemetry/telemetryBatcher'; +import type { TelemetryBatch, WorkerInMessage } from '../telemetry/types'; + +// --------------------------------------------------------------------------- +// Compression helpers +// --------------------------------------------------------------------------- + +async function gzipBlob(payload: string): Promise { + if (typeof CompressionStream === 'undefined') { + return new Blob([payload], { type: 'application/octet-stream' }); + } + const stream = new CompressionStream('gzip'); + const writer = stream.writable.getWriter(); + await writer.write(new TextEncoder().encode(payload)); + await writer.close(); + const chunks: Uint8Array[] = []; + const reader = stream.readable.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(value); + } + return new Blob(chunks, { type: 'application/octet-stream' }); +} + +// --------------------------------------------------------------------------- +// Flush via fetch (normal path) +// --------------------------------------------------------------------------- + +async function sendVisFetch(batch: TelemetryBatch): Promise<{ success: boolean }> { + try { + const blob = await gzipBlob(JSON.stringify(batch)); + const res = await fetch('/telemetry', { + method: 'POST', + headers: { 'Content-Encoding': 'gzip', 'Content-Type': 'application/octet-stream' }, + body: blob, + }); + return { success: res.ok }; + } catch { + return { success: false }; + } +} + +// --------------------------------------------------------------------------- +// Flush via sendBeacon (page-unload path) +// --------------------------------------------------------------------------- + +function sendViaBeacon(batch: TelemetryBatch): void { + const payload = JSON.stringify(batch); + const blob = new Blob([payload], { type: 'application/octet-stream' }); + navigator.sendBeacon('/telemetry', blob); +} + +// --------------------------------------------------------------------------- +// Worker entrypoint +// --------------------------------------------------------------------------- + +const batcher = new TelemetryBatcher(sendVisFetch, sendViaBeacon); +batcher.start(); + +self.addEventListener('message', (e: MessageEvent) => { + const msg = e.data; + if (msg.kind === 'TRACK') { + batcher.add(msg.event); + } else if (msg.kind === 'FLUSH_AND_BEACON') { + batcher.flushAndBeacon(); + } +});