Skip to content
5 changes: 5 additions & 0 deletions .changeset/rotten-days-whisper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

Introduce client-side caching to TelemetryCollector
122 changes: 110 additions & 12 deletions packages/shared/src/__tests__/telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import 'cross-fetch/polyfill';

import { TelemetryCollector } from '../telemetry';
import { TelemetryClientCache } from '../telemetry/clientCache';

jest.useFakeTimers();

const TEST_PK = 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk';

describe('TelemetryCollector', () => {
beforeEach(() => {
localStorage.clear();
});

test('does nothing when disabled', async () => {
const fetchSpy = jest.spyOn(global, 'fetch');

Expand Down Expand Up @@ -145,29 +150,122 @@ describe('TelemetryCollector', () => {
publishableKey: TEST_PK,
});

collector.record({ event: 'TEST_EVENT', payload: {} });
collector.record({ event: 'TEST_EVENT', payload: {} });
collector.record({ event: 'TEST_EVENT', payload: { method: 'useFoo' } });
collector.record({ event: 'TEST_EVENT', payload: { method: 'useBar' } });

expect(fetchSpy).toHaveBeenCalled();

fetchSpy.mockRestore();
});

test('does not send events if the random seed does not exceed the event-specific sampling rate', async () => {
const fetchSpy = jest.spyOn(global, 'fetch');
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.1);
describe('with server-side sampling', () => {
let windowSpy;

const collector = new TelemetryCollector({
publishableKey: TEST_PK,
beforeEach(() => {
windowSpy = jest.spyOn(window, 'window', 'get');
});

collector.record({ event: 'TEST_EVENT', eventSamplingRate: 0.01, payload: {} });
afterEach(() => {
windowSpy.mockRestore();
});

jest.runAllTimers();
test('does not send events if the random seed does not exceed the event-specific sampling rate', async () => {
windowSpy.mockImplementation(() => undefined);

expect(fetchSpy).not.toHaveBeenCalled();
const fetchSpy = jest.spyOn(global, 'fetch');
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.1);

fetchSpy.mockRestore();
randomSpy.mockRestore;
const collector = new TelemetryCollector({
publishableKey: TEST_PK,
});

collector.record({ event: 'TEST_EVENT', eventSamplingRate: 0.01, payload: {} });

jest.runAllTimers();

expect(fetchSpy).not.toHaveBeenCalled();

fetchSpy.mockRestore();
randomSpy.mockRestore;
});
});

describe('with client-side caching', () => {
test('sends event when it is not in the cache', () => {
const fetchSpy = jest.spyOn(global, 'fetch');

const collector = new TelemetryCollector({
publishableKey: TEST_PK,
});

collector.record({
event: 'TEST_EVENT',
payload: {
foo: true,
},
});

jest.runAllTimers();

expect(fetchSpy).toHaveBeenCalled();

fetchSpy.mockRestore();
});

test('does not send event when it is in the cache', () => {
const fetchSpy = jest.spyOn(global, 'fetch');

const collector = new TelemetryCollector({
publishableKey: TEST_PK,
});

const event = 'TEST_EVENT';

collector.record({
event,
payload: {
foo: true,
},
});

collector.record({
event,
payload: {
foo: true,
},
});

jest.runAllTimers();

expect(fetchSpy).toHaveBeenCalledTimes(1);

fetchSpy.mockRestore();
});

test('fallbacks to event-specific sampling rate when storage is not supported', () => {
jest.spyOn(TelemetryClientCache.prototype, 'isStorageSupported', 'get').mockReturnValue(false);

const fetchSpy = jest.spyOn(global, 'fetch');
const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.1);

const collector = new TelemetryCollector({
publishableKey: TEST_PK,
});

collector.record({
event: 'TEST_EVENT',
eventSamplingRate: 0.01,
payload: {
foo: true,
},
});

jest.runAllTimers();

expect(fetchSpy).not.toHaveBeenCalled();

fetchSpy.mockRestore();
randomSpy.mockRestore;
});
});
});
94 changes: 94 additions & 0 deletions packages/shared/src/telemetry/clientCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { TelemetryEventRaw } from './types';

type TtlInMilliseconds = number;

const DEFAULT_CACHE_TTL_MS = 86400000; // 24 hours

/**
* Manages caching for telemetry events using the browser's localStorage to
* mitigate event flooding in frequently executed code paths.
*/
export class TelemetryClientCache {
#storageKey = 'clerk_telemetry';
#cacheTtl = DEFAULT_CACHE_TTL_MS;

cacheAndRetrieve(event: TelemetryEventRaw): boolean {
const now = Date.now();
const key = this.#generateKey(event);
const entry = this.#cache?.[key];

if (!entry) {
const updatedCache = {
...this.#cache,
[key]: now,
};

localStorage.setItem(this.#storageKey, JSON.stringify(updatedCache));
}

const hasExpired = entry && now - entry > this.#cacheTtl;
if (hasExpired) {
localStorage.removeItem(key);
}

return !!entry;
}

#generateKey({ event, payload }: TelemetryEventRaw): string {
const payloadUniqueKey = JSON.stringify(
Object.keys(payload)
.sort()
.map(key => payload[key]),
);

return `${event}:${payloadUniqueKey}`;
}

get #cache(): Record<string, TtlInMilliseconds> | undefined {
const cacheString = localStorage.getItem(this.#storageKey);

if (!cacheString) {
return {};
}

return JSON.parse(cacheString);
}

/**
* Checks if the browser's localStorage is supported and writable.
*
* If any of these operations fail, it indicates that localStorage is either
* not supported or not writable (e.g., in cases where the storage is full or
* the browser is in a privacy mode that restricts localStorage usage).
*/
get isStorageSupported(): boolean {
if (typeof window === 'undefined') {
return false;
}

const storage = window['localStorage'];

if (!storage) {
return false;
}

try {
const testKey = 'test';
storage.setItem(testKey, testKey);
storage.removeItem(testKey);

return true;
} catch (err: unknown) {
const isQuotaExceededError =
err instanceof DOMException &&
// Check error names for different browsers
(err.name === 'QuotaExceededError' || err.name === 'NS_ERROR_DOM_QUOTA_REACHED');

if (isQuotaExceededError && storage.length > 0) {
storage.removeItem(this.#storageKey);
}

return false;
}
}
}
26 changes: 20 additions & 6 deletions packages/shared/src/telemetry/collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { InstanceType } from '@clerk/types';

import { parsePublishableKey } from '../keys';
import { isTruthy } from '../underscore';
import { TelemetryClientCache } from './clientCache';
import type { TelemetryCollectorOptions, TelemetryEvent, TelemetryEventRaw } from './types';

type TelemetryCollectorConfig = Pick<
Expand Down Expand Up @@ -43,6 +44,7 @@ const DEFAULT_CONFIG: Partial<TelemetryCollectorConfig> = {

export class TelemetryCollector {
#config: Required<TelemetryCollectorConfig>;
#clientCache: TelemetryClientCache;
#metadata: TelemetryMetadata = {} as TelemetryMetadata;
#buffer: TelemetryEvent[] = [];
#pendingFlush: any;
Expand Down Expand Up @@ -78,6 +80,8 @@ export class TelemetryCollector {
// Only send the first 16 characters of the secret key to to avoid sending the full key. We can still query against the partial key.
this.#metadata.secretKey = options.secretKey.substring(0, 16);
}

this.#clientCache = new TelemetryClientCache();
}

get isEnabled(): boolean {
Expand Down Expand Up @@ -110,7 +114,7 @@ export class TelemetryCollector {

this.#logEvent(preparedPayload.event, preparedPayload);

if (!this.#shouldRecord(event.eventSamplingRate)) {
if (!this.#shouldRecord(event)) {
return;
}

Expand All @@ -119,13 +123,23 @@ export class TelemetryCollector {
this.#scheduleFlush();
}

#shouldRecord(eventSamplingRate?: number): boolean {
#shouldRecord(event: TelemetryEventRaw) {
return this.isEnabled && !this.isDebug && this.#shouldBeSampled(event);
}

#shouldBeSampled(event: TelemetryEventRaw) {
const randomSeed = Math.random();
const shouldBeSampled =
randomSeed <= this.#config.samplingRate &&
(typeof eventSamplingRate === 'undefined' || randomSeed <= eventSamplingRate);
const clientCache = this.#clientCache;

return this.isEnabled && !this.isDebug && shouldBeSampled;
if (clientCache.isStorageSupported) {
const isCached = clientCache.cacheAndRetrieve(event);
return !isCached;
}

return (
randomSeed <= this.#config.samplingRate &&
(typeof event.eventSamplingRate === 'undefined' || randomSeed <= event.eventSamplingRate)
);
}

#scheduleFlush(): void {
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/telemetry/events/method-called.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { TelemetryEventRaw } from '../types';

const EVENT_METHOD_CALLED = 'METHOD_CALLED' as const;
const EVENT_SAMPLING_RATE = 0.1;
Copy link
Copy Markdown
Member Author

@LauraBeatris LauraBeatris Apr 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also introducing a sampling rate lower than the default for this event, to handle the server-side scenarios.

CC @brkalow let me know if that makes sense


type EventMethodCalled = {
method: string;
Expand All @@ -15,6 +16,7 @@ export function eventMethodCalled(
): TelemetryEventRaw<EventMethodCalled> {
return {
event: EVENT_METHOD_CALLED,
eventSamplingRate: EVENT_SAMPLING_RATE,
payload: {
method,
...payload,
Expand Down