Skip to content

Commit 2c0128b

Browse files
feat(shared): Add in-memory telemetry throttling for non-browsers (#6842)
Co-authored-by: Nikos Douvlis <[email protected]>
1 parent 8777f35 commit 2c0128b

File tree

4 files changed

+182
-54
lines changed

4 files changed

+182
-54
lines changed

.changeset/wet-mails-yawn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/shared': patch
3+
---
4+
5+
Update telemetry throttling to work in native environments

packages/shared/src/__tests__/telemetry.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,87 @@ describe('TelemetryCollector', () => {
393393
});
394394
});
395395

396+
describe('with in-memory throttling (React Native)', () => {
397+
beforeEach(() => {
398+
// Mock React Native environment - no window
399+
windowSpy.mockImplementation(() => undefined);
400+
});
401+
402+
test('throttles events using in-memory cache when localStorage is not available', () => {
403+
const collector = new TelemetryCollector({
404+
publishableKey: TEST_PK,
405+
});
406+
407+
const event = 'TEST_EVENT';
408+
const payload = { foo: true };
409+
410+
// First event should go through
411+
collector.record({ event, payload });
412+
jest.runAllTimers();
413+
expect(fetchSpy).toHaveBeenCalledTimes(1);
414+
415+
// Same event should be throttled
416+
collector.record({ event, payload });
417+
jest.runAllTimers();
418+
expect(fetchSpy).toHaveBeenCalledTimes(1);
419+
420+
// Different event should go through
421+
collector.record({ event: 'DIFFERENT_EVENT', payload });
422+
jest.runAllTimers();
423+
expect(fetchSpy).toHaveBeenCalledTimes(2);
424+
});
425+
426+
test('allows event after TTL expires in memory cache', () => {
427+
const originalDateNow = Date.now;
428+
const cacheTtl = 86400000; // 24 hours
429+
430+
let now = originalDateNow();
431+
const dateNowSpy = jest.spyOn(Date, 'now').mockImplementation(() => now);
432+
433+
const collector = new TelemetryCollector({
434+
publishableKey: TEST_PK,
435+
maxBufferSize: 1,
436+
});
437+
438+
const event = 'TEST_EVENT';
439+
const payload = { foo: true };
440+
441+
// First event
442+
collector.record({ event, payload });
443+
444+
// Move time forward beyond the cache TTL
445+
now += cacheTtl + 1;
446+
447+
// Same event should now be allowed
448+
collector.record({ event, payload });
449+
450+
jest.runAllTimers();
451+
452+
expect(fetchSpy).toHaveBeenCalledTimes(2);
453+
454+
dateNowSpy.mockRestore();
455+
});
456+
457+
test('clears memory cache when it exceeds size limit', () => {
458+
const collector = new TelemetryCollector({
459+
publishableKey: TEST_PK,
460+
});
461+
462+
// Generate many different events to exceed the cache size limit
463+
for (let i = 0; i < 10001; i++) {
464+
collector.record({
465+
event: 'TEST_EVENT',
466+
payload: { id: i },
467+
});
468+
}
469+
470+
jest.runAllTimers();
471+
472+
// Should have been called for all events since cache was cleared
473+
expect(fetchSpy).toHaveBeenCalled();
474+
});
475+
});
476+
396477
describe('error handling', () => {
397478
test('record() method does not bubble up errors from internal operations', () => {
398479
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

packages/shared/src/telemetry/collector.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type {
2121

2222
import { parsePublishableKey } from '../keys';
2323
import { isTruthy } from '../underscore';
24-
import { TelemetryEventThrottler } from './throttler';
24+
import { InMemoryThrottlerCache, LocalStorageThrottlerCache, TelemetryEventThrottler } from './throttler';
2525
import type { TelemetryCollectorOptions } from './types';
2626

2727
/**
@@ -141,7 +141,11 @@ export class TelemetryCollector implements TelemetryCollectorInterface {
141141
this.#metadata.secretKey = options.secretKey.substring(0, 16);
142142
}
143143

144-
this.#eventThrottler = new TelemetryEventThrottler();
144+
// Use LocalStorage cache in browsers where it's supported, otherwise fall back to in-memory cache
145+
const cache = LocalStorageThrottlerCache.isSupported()
146+
? new LocalStorageThrottlerCache()
147+
: new InMemoryThrottlerCache();
148+
this.#eventThrottler = new TelemetryEventThrottler(cache);
145149
}
146150

147151
get isEnabled(): boolean {

packages/shared/src/telemetry/throttler.ts

Lines changed: 90 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,44 @@ type TtlInMilliseconds = number;
55
const DEFAULT_CACHE_TTL_MS = 86400000; // 24 hours
66

77
/**
8-
* Manages throttling for telemetry events using the browser's localStorage to
9-
* mitigate event flooding in frequently executed code paths.
8+
* Interface for cache storage used by the telemetry throttler.
9+
* Implementations can use localStorage, in-memory storage, or any other storage mechanism.
10+
*/
11+
export interface ThrottlerCache {
12+
getItem(key: string): TtlInMilliseconds | undefined;
13+
setItem(key: string, value: TtlInMilliseconds): void;
14+
removeItem(key: string): void;
15+
}
16+
17+
/**
18+
* Manages throttling for telemetry events using a configurable cache implementation
19+
* to mitigate event flooding in frequently executed code paths.
1020
*/
1121
export class TelemetryEventThrottler {
12-
#storageKey = 'clerk_telemetry_throttler';
22+
#cache: ThrottlerCache;
1323
#cacheTtl = DEFAULT_CACHE_TTL_MS;
1424

15-
isEventThrottled(payload: TelemetryEvent): boolean {
16-
if (!this.#isValidBrowser) {
17-
return false;
18-
}
25+
constructor(cache: ThrottlerCache) {
26+
this.#cache = cache;
27+
}
1928

29+
isEventThrottled(payload: TelemetryEvent): boolean {
2030
const now = Date.now();
2131
const key = this.#generateKey(payload);
22-
const entry = this.#cache?.[key];
32+
const entry = this.#cache.getItem(key);
2333

2434
if (!entry) {
25-
const updatedCache = {
26-
...this.#cache,
27-
[key]: now,
28-
};
29-
30-
localStorage.setItem(this.#storageKey, JSON.stringify(updatedCache));
35+
this.#cache.setItem(key, now);
36+
return false;
3137
}
3238

33-
const shouldInvalidate = entry && now - entry > this.#cacheTtl;
39+
const shouldInvalidate = now - entry > this.#cacheTtl;
3440
if (shouldInvalidate) {
35-
const updatedCache = this.#cache;
36-
delete updatedCache[key];
37-
38-
localStorage.setItem(this.#storageKey, JSON.stringify(updatedCache));
41+
this.#cache.setItem(key, now);
42+
return false;
3943
}
4044

41-
return !!entry;
45+
return true;
4246
}
4347

4448
/**
@@ -62,51 +66,85 @@ export class TelemetryEventThrottler {
6266
.map(key => sanitizedEvent[key]),
6367
);
6468
}
69+
}
6570

66-
get #cache(): Record<string, TtlInMilliseconds> | undefined {
67-
const cacheString = localStorage.getItem(this.#storageKey);
68-
69-
if (!cacheString) {
70-
return {};
71-
}
71+
/**
72+
* LocalStorage-based cache implementation for browser environments.
73+
*/
74+
export class LocalStorageThrottlerCache implements ThrottlerCache {
75+
#storageKey = 'clerk_telemetry_throttler';
7276

73-
return JSON.parse(cacheString);
77+
getItem(key: string): TtlInMilliseconds | undefined {
78+
return this.#getCache()[key];
7479
}
7580

76-
/**
77-
* Checks if the browser's localStorage is supported and writable.
78-
*
79-
* If any of these operations fail, it indicates that localStorage is either
80-
* not supported or not writable (e.g., in cases where the storage is full or
81-
* the browser is in a privacy mode that restricts localStorage usage).
82-
*/
83-
get #isValidBrowser(): boolean {
84-
if (typeof window === 'undefined') {
85-
return false;
86-
}
87-
88-
const storage = window.localStorage;
89-
if (!storage) {
90-
return false;
91-
}
92-
81+
setItem(key: string, value: TtlInMilliseconds): void {
9382
try {
94-
const testKey = 'test';
95-
storage.setItem(testKey, testKey);
96-
storage.removeItem(testKey);
97-
98-
return true;
83+
const cache = this.#getCache();
84+
cache[key] = value;
85+
localStorage.setItem(this.#storageKey, JSON.stringify(cache));
9986
} catch (err: unknown) {
10087
const isQuotaExceededError =
10188
err instanceof DOMException &&
10289
// Check error names for different browsers
10390
(err.name === 'QuotaExceededError' || err.name === 'NS_ERROR_DOM_QUOTA_REACHED');
10491

105-
if (isQuotaExceededError && storage.length > 0) {
106-
storage.removeItem(this.#storageKey);
92+
if (isQuotaExceededError && localStorage.length > 0) {
93+
// Clear our cache if quota exceeded
94+
localStorage.removeItem(this.#storageKey);
10795
}
96+
}
97+
}
10898

109-
return false;
99+
removeItem(key: string): void {
100+
try {
101+
const cache = this.#getCache();
102+
delete cache[key];
103+
localStorage.setItem(this.#storageKey, JSON.stringify(cache));
104+
} catch {
105+
// Silently fail if we can't remove
106+
}
107+
}
108+
109+
#getCache(): Record<string, TtlInMilliseconds> {
110+
try {
111+
const cacheString = localStorage.getItem(this.#storageKey);
112+
if (!cacheString) {
113+
return {};
114+
}
115+
return JSON.parse(cacheString);
116+
} catch {
117+
return {};
110118
}
111119
}
120+
121+
static isSupported(): boolean {
122+
return typeof window !== 'undefined' && !!window.localStorage;
123+
}
124+
}
125+
126+
/**
127+
* In-memory cache implementation for non-browser environments (e.g., React Native).
128+
*/
129+
export class InMemoryThrottlerCache implements ThrottlerCache {
130+
#cache: Map<string, TtlInMilliseconds> = new Map();
131+
#maxSize = 10000; // Defensive limit to prevent memory issues
132+
133+
getItem(key: string): TtlInMilliseconds | undefined {
134+
// Defensive: clear cache if it gets too large
135+
if (this.#cache.size > this.#maxSize) {
136+
this.#cache.clear();
137+
return undefined;
138+
}
139+
140+
return this.#cache.get(key);
141+
}
142+
143+
setItem(key: string, value: TtlInMilliseconds): void {
144+
this.#cache.set(key, value);
145+
}
146+
147+
removeItem(key: string): void {
148+
this.#cache.delete(key);
149+
}
112150
}

0 commit comments

Comments
 (0)