diff --git a/src/hooks/__tests__/useAnalytics.test.tsx b/src/hooks/__tests__/useAnalytics.test.tsx new file mode 100644 index 00000000..b283e670 --- /dev/null +++ b/src/hooks/__tests__/useAnalytics.test.tsx @@ -0,0 +1 @@ +test('sample', () => {}); diff --git a/src/utils/__tests__/analytics.test.ts b/src/utils/__tests__/analytics.test.ts new file mode 100644 index 00000000..f0e1ba52 --- /dev/null +++ b/src/utils/__tests__/analytics.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import analytics from '../analytics'; +import type { AnalyticsAdapter } from '../analytics'; + +describe('analytics high-volume sampling', () => { + const adapter: AnalyticsAdapter = vi.fn(); + + beforeEach(() => { + analytics.clearAdapters(); + analytics.addAdapter(adapter); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + }); + + afterEach(() => { + analytics.clearAdapters(); + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it('sends events that are not marked as high-volume', () => { + analytics.track('course_view'); + expect(adapter).toHaveBeenCalledTimes(1); + }); + + it('always sends high-volume events when sample rate is 1', () => { + analytics.setSampleRate('page_view', 1); + analytics.track('page_view'); + expect(adapter).toHaveBeenCalledTimes(1); + }); + + it('never sends high-volume events when sample rate is 0', () => { + analytics.setSampleRate('page_view', 0); + analytics.track('page_view'); + expect(adapter).not.toHaveBeenCalled(); + }); + + it('sends when the random draw is below the configured sample rate', () => { + analytics.setSampleRate('page_view', 0.5); + vi.mocked(Math.random).mockReturnValue(0.25); + analytics.track('page_view'); + expect(adapter).toHaveBeenCalledTimes(1); + }); + + it('skips when the random draw is at or above the configured sample rate', () => { + analytics.setSampleRate('page_view', 0.5); + analytics.track('page_view'); // Math.random mocked to 0.5 + expect(adapter).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index d11f1c9e..7ce66ad2 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -82,6 +82,21 @@ export interface AnalyticsEvent { export type AnalyticsAdapter = (event: AnalyticsEvent) => void | Promise; +// ────────────────────────────────────────────────────────────────────────────── +// Sampling configuration for high-volume events +// ────────────────────────────────────────────────────────────────────────────── + +const HIGH_VOLUME_EVENT_NAMES: EventName[] = [ + 'page_view', + 'button_clicked', + 'link_clicked', + 'search_performed', + 'filter_applied', + 'sort_changed', +]; + +const DEFAULT_SAMPLE_RATE = 0.1; // Only send 10% of high-volume events + // ────────────────────────────────────────────────────────────────────────────── // Built-in adapters // ────────────────────────────────────────────────────────────────────────────── @@ -143,6 +158,7 @@ class Analytics { private adapters: AnalyticsAdapter[] = [consoleAdapter]; private userId: string | undefined; private globalProperties: EventProperties = {}; + private sampleRates: Partial> = {}; private get sessionId(): string { return getOrCreate(SESSION_KEY, () => generateId('s_')); @@ -188,7 +204,25 @@ class Analytics { this.globalProperties = { ...this.globalProperties, ...properties }; } + /** Set sampling rate for a specific event type. Rate is 0-1. */ + setSampleRate(eventName: EventName, rate: number): this { + this.sampleRates[eventName] = Math.min(1, Math.max(0, rate)); + return this; + } + + /** Whether an event should be sent given its configured/default sampling rate. */ + private shouldSend(name: EventName): boolean { + const sampleRate = + this.sampleRates[name] ?? (HIGH_VOLUME_EVENT_NAMES.includes(name) ? DEFAULT_SAMPLE_RATE : 1); + if (sampleRate >= 1) return true; + if (sampleRate <= 0) return false; + return Math.random() < sampleRate; + } + track(name: EventName, properties: EventProperties = {}): void { + // Sample high-volume events unless explicitly configured otherwise + if (!this.shouldSend(name)) return; + const event: AnalyticsEvent = { name, properties: { ...this.globalProperties, ...properties },