Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/hooks/__tests__/useAnalytics.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test('sample', () => {});
49 changes: 49 additions & 0 deletions src/utils/__tests__/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
34 changes: 34 additions & 0 deletions src/utils/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@ export interface AnalyticsEvent {

export type AnalyticsAdapter = (event: AnalyticsEvent) => void | Promise<void>;

// ──────────────────────────────────────────────────────────────────────────────
// 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
// ──────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -143,6 +158,7 @@ class Analytics {
private adapters: AnalyticsAdapter[] = [consoleAdapter];
private userId: string | undefined;
private globalProperties: EventProperties = {};
private sampleRates: Partial<Record<EventName, number>> = {};

private get sessionId(): string {
return getOrCreate(SESSION_KEY, () => generateId('s_'));
Expand Down Expand Up @@ -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 },
Expand Down
Loading