Skip to content

Commit 6f585b6

Browse files
committed
feat(shared): Introduce telemetry LocalStorageThrottlerCache & InMemoryThrottlerCache implementations
1 parent 4e809d2 commit 6f585b6

File tree

2 files changed

+96
-83
lines changed

2 files changed

+96
-83
lines changed

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 & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -5,69 +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. Falls back to
10-
* in-memory storage in environments without localStorage (e.g., React Native).
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.
1120
*/
1221
export class TelemetryEventThrottler {
13-
#storageKey = 'clerk_telemetry_throttler';
22+
#cache: ThrottlerCache;
1423
#cacheTtl = DEFAULT_CACHE_TTL_MS;
15-
#memoryCache: Map<string, TtlInMilliseconds> | null = null;
1624

17-
isEventThrottled(payload: TelemetryEvent): boolean {
18-
if (!this.#isValidBrowser) {
19-
// React Native or other non-browser environment - use in-memory cache
20-
return this.#isEventThrottledInMemory(payload);
21-
}
25+
constructor(cache: ThrottlerCache) {
26+
this.#cache = cache;
27+
}
2228

29+
isEventThrottled(payload: TelemetryEvent): boolean {
2330
const now = Date.now();
2431
const key = this.#generateKey(payload);
25-
const entry = this.#cache?.[key];
32+
const entry = this.#cache.getItem(key);
2633

2734
if (!entry) {
28-
const updatedCache = {
29-
...this.#cache,
30-
[key]: now,
31-
};
32-
33-
localStorage.setItem(this.#storageKey, JSON.stringify(updatedCache));
35+
this.#cache.setItem(key, now);
36+
return false;
3437
}
3538

36-
const shouldInvalidate = entry && now - entry > this.#cacheTtl;
39+
const shouldInvalidate = now - entry > this.#cacheTtl;
3740
if (shouldInvalidate) {
38-
const updatedCache = this.#cache;
39-
delete updatedCache[key];
40-
41-
localStorage.setItem(this.#storageKey, JSON.stringify(updatedCache));
42-
}
43-
44-
return !!entry;
45-
}
46-
47-
/**
48-
* Handles throttling in non-browser environments using in-memory storage.
49-
* This is used in React Native and other environments without localStorage.
50-
*/
51-
#isEventThrottledInMemory(payload: TelemetryEvent): boolean {
52-
if (!this.#memoryCache) {
53-
this.#memoryCache = new Map();
54-
}
55-
56-
const now = Date.now();
57-
const key = this.#generateKey(payload);
58-
59-
// Defensive: clear cache if it gets too large to prevent memory issues
60-
if (this.#memoryCache.size > 10000) {
61-
this.#memoryCache.clear();
62-
}
63-
64-
const lastSeen = this.#memoryCache.get(key);
65-
if (!lastSeen || now - lastSeen > this.#cacheTtl) {
66-
this.#memoryCache.set(key, now);
67-
return false; // Not throttled - allow the event
41+
this.#cache.setItem(key, now);
42+
return false;
6843
}
6944

70-
return true; // Event is throttled
45+
return true;
7146
}
7247

7348
/**
@@ -91,51 +66,85 @@ export class TelemetryEventThrottler {
9166
.map(key => sanitizedEvent[key]),
9267
);
9368
}
69+
}
9470

95-
get #cache(): Record<string, TtlInMilliseconds> | undefined {
96-
const cacheString = localStorage.getItem(this.#storageKey);
97-
98-
if (!cacheString) {
99-
return {};
100-
}
71+
/**
72+
* LocalStorage-based cache implementation for browser environments.
73+
*/
74+
export class LocalStorageThrottlerCache implements ThrottlerCache {
75+
#storageKey = 'clerk_telemetry_throttler';
10176

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

105-
/**
106-
* Checks if the browser's localStorage is supported and writable.
107-
*
108-
* If any of these operations fail, it indicates that localStorage is either
109-
* not supported or not writable (e.g., in cases where the storage is full or
110-
* the browser is in a privacy mode that restricts localStorage usage).
111-
*/
112-
get #isValidBrowser(): boolean {
113-
if (typeof window === 'undefined') {
114-
return false;
115-
}
116-
117-
const storage = window.localStorage;
118-
if (!storage) {
119-
return false;
120-
}
121-
81+
setItem(key: string, value: TtlInMilliseconds): void {
12282
try {
123-
const testKey = 'test';
124-
storage.setItem(testKey, testKey);
125-
storage.removeItem(testKey);
126-
127-
return true;
83+
const cache = this.#getCache();
84+
cache[key] = value;
85+
localStorage.setItem(this.#storageKey, JSON.stringify(cache));
12886
} catch (err: unknown) {
12987
const isQuotaExceededError =
13088
err instanceof DOMException &&
13189
// Check error names for different browsers
13290
(err.name === 'QuotaExceededError' || err.name === 'NS_ERROR_DOM_QUOTA_REACHED');
13391

134-
if (isQuotaExceededError && storage.length > 0) {
135-
storage.removeItem(this.#storageKey);
92+
if (isQuotaExceededError && localStorage.length > 0) {
93+
// Clear our cache if quota exceeded
94+
localStorage.removeItem(this.#storageKey);
13695
}
96+
}
97+
}
13798

138-
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 {};
118+
}
119+
}
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;
139138
}
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);
140149
}
141150
}

0 commit comments

Comments
 (0)