@@ -5,69 +5,44 @@ type TtlInMilliseconds = number;
5
5
const DEFAULT_CACHE_TTL_MS = 86400000 ; // 24 hours
6
6
7
7
/**
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.
11
20
*/
12
21
export class TelemetryEventThrottler {
13
- #storageKey = 'clerk_telemetry_throttler' ;
22
+ #cache: ThrottlerCache ;
14
23
#cacheTtl = DEFAULT_CACHE_TTL_MS ;
15
- #memoryCache: Map < string , TtlInMilliseconds > | null = null ;
16
24
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
+ }
22
28
29
+ isEventThrottled ( payload : TelemetryEvent ) : boolean {
23
30
const now = Date . now ( ) ;
24
31
const key = this . #generateKey( payload ) ;
25
- const entry = this . #cache?. [ key ] ;
32
+ const entry = this . #cache. getItem ( key ) ;
26
33
27
34
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 ;
34
37
}
35
38
36
- const shouldInvalidate = entry && now - entry > this . #cacheTtl;
39
+ const shouldInvalidate = now - entry > this . #cacheTtl;
37
40
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 ;
68
43
}
69
44
70
- return true ; // Event is throttled
45
+ return true ;
71
46
}
72
47
73
48
/**
@@ -91,51 +66,85 @@ export class TelemetryEventThrottler {
91
66
. map ( key => sanitizedEvent [ key ] ) ,
92
67
) ;
93
68
}
69
+ }
94
70
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' ;
101
76
102
- return JSON . parse ( cacheString ) ;
77
+ getItem ( key : string ) : TtlInMilliseconds | undefined {
78
+ return this . #getCache( ) [ key ] ;
103
79
}
104
80
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 {
122
82
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 ) ) ;
128
86
} catch ( err : unknown ) {
129
87
const isQuotaExceededError =
130
88
err instanceof DOMException &&
131
89
// Check error names for different browsers
132
90
( err . name === 'QuotaExceededError' || err . name === 'NS_ERROR_DOM_QUOTA_REACHED' ) ;
133
91
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) ;
136
95
}
96
+ }
97
+ }
137
98
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 ;
139
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 ) ;
140
149
}
141
150
}
0 commit comments