@@ -5,40 +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.
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.
10
20
*/
11
21
export class TelemetryEventThrottler {
12
- #storageKey = 'clerk_telemetry_throttler' ;
22
+ #cache: ThrottlerCache ;
13
23
#cacheTtl = DEFAULT_CACHE_TTL_MS ;
14
24
15
- isEventThrottled ( payload : TelemetryEvent ) : boolean {
16
- if ( ! this . #isValidBrowser) {
17
- return false ;
18
- }
25
+ constructor ( cache : ThrottlerCache ) {
26
+ this . #cache = cache ;
27
+ }
19
28
29
+ isEventThrottled ( payload : TelemetryEvent ) : boolean {
20
30
const now = Date . now ( ) ;
21
31
const key = this . #generateKey( payload ) ;
22
- const entry = this . #cache?. [ key ] ;
32
+ const entry = this . #cache. getItem ( key ) ;
23
33
24
34
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 ;
31
37
}
32
38
33
- const shouldInvalidate = entry && now - entry > this . #cacheTtl;
39
+ const shouldInvalidate = now - entry > this . #cacheTtl;
34
40
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 ;
39
43
}
40
44
41
- return ! ! entry ;
45
+ return true ;
42
46
}
43
47
44
48
/**
@@ -62,51 +66,85 @@ export class TelemetryEventThrottler {
62
66
. map ( key => sanitizedEvent [ key ] ) ,
63
67
) ;
64
68
}
69
+ }
65
70
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' ;
72
76
73
- return JSON . parse ( cacheString ) ;
77
+ getItem ( key : string ) : TtlInMilliseconds | undefined {
78
+ return this . #getCache( ) [ key ] ;
74
79
}
75
80
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 {
93
82
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 ) ) ;
99
86
} catch ( err : unknown ) {
100
87
const isQuotaExceededError =
101
88
err instanceof DOMException &&
102
89
// Check error names for different browsers
103
90
( err . name === 'QuotaExceededError' || err . name === 'NS_ERROR_DOM_QUOTA_REACHED' ) ;
104
91
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) ;
107
95
}
96
+ }
97
+ }
108
98
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 { } ;
110
118
}
111
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 ;
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
+ }
112
150
}
0 commit comments