diff --git a/.changeset/wet-mails-yawn.md b/.changeset/wet-mails-yawn.md new file mode 100644 index 00000000000..600c55922f9 --- /dev/null +++ b/.changeset/wet-mails-yawn.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Update telemetry throttling to work in native environments diff --git a/packages/shared/src/__tests__/telemetry.test.ts b/packages/shared/src/__tests__/telemetry.test.ts index 41bc3a77d8c..66bdae35c68 100644 --- a/packages/shared/src/__tests__/telemetry.test.ts +++ b/packages/shared/src/__tests__/telemetry.test.ts @@ -393,6 +393,87 @@ describe('TelemetryCollector', () => { }); }); + describe('with in-memory throttling (React Native)', () => { + beforeEach(() => { + // Mock React Native environment - no window + windowSpy.mockImplementation(() => undefined); + }); + + test('throttles events using in-memory cache when localStorage is not available', () => { + const collector = new TelemetryCollector({ + publishableKey: TEST_PK, + }); + + const event = 'TEST_EVENT'; + const payload = { foo: true }; + + // First event should go through + collector.record({ event, payload }); + jest.runAllTimers(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Same event should be throttled + collector.record({ event, payload }); + jest.runAllTimers(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Different event should go through + collector.record({ event: 'DIFFERENT_EVENT', payload }); + jest.runAllTimers(); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + test('allows event after TTL expires in memory cache', () => { + const originalDateNow = Date.now; + const cacheTtl = 86400000; // 24 hours + + let now = originalDateNow(); + const dateNowSpy = jest.spyOn(Date, 'now').mockImplementation(() => now); + + const collector = new TelemetryCollector({ + publishableKey: TEST_PK, + maxBufferSize: 1, + }); + + const event = 'TEST_EVENT'; + const payload = { foo: true }; + + // First event + collector.record({ event, payload }); + + // Move time forward beyond the cache TTL + now += cacheTtl + 1; + + // Same event should now be allowed + collector.record({ event, payload }); + + jest.runAllTimers(); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + + dateNowSpy.mockRestore(); + }); + + test('clears memory cache when it exceeds size limit', () => { + const collector = new TelemetryCollector({ + publishableKey: TEST_PK, + }); + + // Generate many different events to exceed the cache size limit + for (let i = 0; i < 10001; i++) { + collector.record({ + event: 'TEST_EVENT', + payload: { id: i }, + }); + } + + jest.runAllTimers(); + + // Should have been called for all events since cache was cleared + expect(fetchSpy).toHaveBeenCalled(); + }); + }); + describe('error handling', () => { test('record() method does not bubble up errors from internal operations', () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/packages/shared/src/telemetry/collector.ts b/packages/shared/src/telemetry/collector.ts index dad20550105..79b092e8ade 100644 --- a/packages/shared/src/telemetry/collector.ts +++ b/packages/shared/src/telemetry/collector.ts @@ -21,7 +21,7 @@ import type { import { parsePublishableKey } from '../keys'; import { isTruthy } from '../underscore'; -import { TelemetryEventThrottler } from './throttler'; +import { InMemoryThrottlerCache, LocalStorageThrottlerCache, TelemetryEventThrottler } from './throttler'; import type { TelemetryCollectorOptions } from './types'; /** @@ -141,7 +141,11 @@ export class TelemetryCollector implements TelemetryCollectorInterface { this.#metadata.secretKey = options.secretKey.substring(0, 16); } - this.#eventThrottler = new TelemetryEventThrottler(); + // Use LocalStorage cache in browsers where it's supported, otherwise fall back to in-memory cache + const cache = LocalStorageThrottlerCache.isSupported() + ? new LocalStorageThrottlerCache() + : new InMemoryThrottlerCache(); + this.#eventThrottler = new TelemetryEventThrottler(cache); } get isEnabled(): boolean { diff --git a/packages/shared/src/telemetry/throttler.ts b/packages/shared/src/telemetry/throttler.ts index d5898f88662..9ed196783f6 100644 --- a/packages/shared/src/telemetry/throttler.ts +++ b/packages/shared/src/telemetry/throttler.ts @@ -5,40 +5,44 @@ type TtlInMilliseconds = number; const DEFAULT_CACHE_TTL_MS = 86400000; // 24 hours /** - * Manages throttling for telemetry events using the browser's localStorage to - * mitigate event flooding in frequently executed code paths. + * Interface for cache storage used by the telemetry throttler. + * Implementations can use localStorage, in-memory storage, or any other storage mechanism. + */ +export interface ThrottlerCache { + getItem(key: string): TtlInMilliseconds | undefined; + setItem(key: string, value: TtlInMilliseconds): void; + removeItem(key: string): void; +} + +/** + * Manages throttling for telemetry events using a configurable cache implementation + * to mitigate event flooding in frequently executed code paths. */ export class TelemetryEventThrottler { - #storageKey = 'clerk_telemetry_throttler'; + #cache: ThrottlerCache; #cacheTtl = DEFAULT_CACHE_TTL_MS; - isEventThrottled(payload: TelemetryEvent): boolean { - if (!this.#isValidBrowser) { - return false; - } + constructor(cache: ThrottlerCache) { + this.#cache = cache; + } + isEventThrottled(payload: TelemetryEvent): boolean { const now = Date.now(); const key = this.#generateKey(payload); - const entry = this.#cache?.[key]; + const entry = this.#cache.getItem(key); if (!entry) { - const updatedCache = { - ...this.#cache, - [key]: now, - }; - - localStorage.setItem(this.#storageKey, JSON.stringify(updatedCache)); + this.#cache.setItem(key, now); + return false; } - const shouldInvalidate = entry && now - entry > this.#cacheTtl; + const shouldInvalidate = now - entry > this.#cacheTtl; if (shouldInvalidate) { - const updatedCache = this.#cache; - delete updatedCache[key]; - - localStorage.setItem(this.#storageKey, JSON.stringify(updatedCache)); + this.#cache.setItem(key, now); + return false; } - return !!entry; + return true; } /** @@ -62,51 +66,85 @@ export class TelemetryEventThrottler { .map(key => sanitizedEvent[key]), ); } +} - get #cache(): Record | undefined { - const cacheString = localStorage.getItem(this.#storageKey); - - if (!cacheString) { - return {}; - } +/** + * LocalStorage-based cache implementation for browser environments. + */ +export class LocalStorageThrottlerCache implements ThrottlerCache { + #storageKey = 'clerk_telemetry_throttler'; - return JSON.parse(cacheString); + getItem(key: string): TtlInMilliseconds | undefined { + return this.#getCache()[key]; } - /** - * 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 #isValidBrowser(): boolean { - if (typeof window === 'undefined') { - return false; - } - - const storage = window.localStorage; - if (!storage) { - return false; - } - + setItem(key: string, value: TtlInMilliseconds): void { try { - const testKey = 'test'; - storage.setItem(testKey, testKey); - storage.removeItem(testKey); - - return true; + const cache = this.#getCache(); + cache[key] = value; + localStorage.setItem(this.#storageKey, JSON.stringify(cache)); } 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); + if (isQuotaExceededError && localStorage.length > 0) { + // Clear our cache if quota exceeded + localStorage.removeItem(this.#storageKey); } + } + } - return false; + removeItem(key: string): void { + try { + const cache = this.#getCache(); + delete cache[key]; + localStorage.setItem(this.#storageKey, JSON.stringify(cache)); + } catch { + // Silently fail if we can't remove + } + } + + #getCache(): Record { + try { + const cacheString = localStorage.getItem(this.#storageKey); + if (!cacheString) { + return {}; + } + return JSON.parse(cacheString); + } catch { + return {}; } } + + static isSupported(): boolean { + return typeof window !== 'undefined' && !!window.localStorage; + } +} + +/** + * In-memory cache implementation for non-browser environments (e.g., React Native). + */ +export class InMemoryThrottlerCache implements ThrottlerCache { + #cache: Map = new Map(); + #maxSize = 10000; // Defensive limit to prevent memory issues + + getItem(key: string): TtlInMilliseconds | undefined { + // Defensive: clear cache if it gets too large + if (this.#cache.size > this.#maxSize) { + this.#cache.clear(); + return undefined; + } + + return this.#cache.get(key); + } + + setItem(key: string, value: TtlInMilliseconds): void { + this.#cache.set(key, value); + } + + removeItem(key: string): void { + this.#cache.delete(key); + } }