diff --git a/.changeset/bitter-waves-burn.md b/.changeset/bitter-waves-burn.md new file mode 100644 index 00000000000..37920d90403 --- /dev/null +++ b/.changeset/bitter-waves-burn.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Use throttling instead of sampling for telemetry events of UI components on keyless apps. diff --git a/.changeset/slow-shoes-give.md b/.changeset/slow-shoes-give.md new file mode 100644 index 00000000000..0f9131be5af --- /dev/null +++ b/.changeset/slow-shoes-give.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': minor +--- + +Update TelemetryCollector to accept a `perEventSampling` property for controling per-event sampling rates. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 55893738a3c..6278f974848 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -3,7 +3,7 @@ { "path": "./dist/clerk.js", "maxSize": "621KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "75KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" }, - { "path": "./dist/clerk.headless*.js", "maxSize": "57KB" }, + { "path": "./dist/clerk.headless*.js", "maxSize": "57.1KB" }, { "path": "./dist/ui-common*.js", "maxSize": "113KB" }, { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "118KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 1e549bcb3b6..539b50e961e 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -439,6 +439,7 @@ export class Clerk implements ClerkInterface { this.telemetry = new TelemetryCollector({ clerkVersion: Clerk.version, samplingRate: 1, + perEventSampling: this.#options.__internal_keyless_claimKeylessApplicationUrl ? false : undefined, publishableKey: this.publishableKey, ...this.#options.telemetry, }); @@ -537,12 +538,13 @@ export class Clerk implements ClerkInterface { }; public openGoogleOneTap = (props?: GoogleOneTapProps): void => { + const component = 'GoogleOneTap'; this.assertComponentsReady(this.#componentControls); void this.#componentControls - .ensureMounted({ preloadHint: 'GoogleOneTap' }) + .ensureMounted({ preloadHint: component }) .then(controls => controls.openModal('googleOneTap', props || {})); - this.telemetry?.record(eventPrebuiltComponentOpened(`GoogleOneTap`, props)); + this.telemetry?.record(eventPrebuiltComponentOpened(component, props)); }; public closeGoogleOneTap = (): void => { @@ -560,12 +562,13 @@ export class Clerk implements ClerkInterface { } return; } + const component = 'SignIn'; void this.#componentControls - .ensureMounted({ preloadHint: 'SignIn' }) + .ensureMounted({ preloadHint: component }) .then(controls => controls.openModal('signIn', props || {})); const additionalData = { withSignUp: props?.withSignUp ?? this.#isCombinedSignInOrUpFlow() }; - this.telemetry?.record(eventPrebuiltComponentOpened(`SignIn`, props, additionalData)); + this.telemetry?.record(eventPrebuiltComponentOpened(component, props, additionalData)); }; public closeSignIn = (): void => { @@ -612,11 +615,12 @@ export class Clerk implements ClerkInterface { } return; } + const component = 'PlanDetails'; void this.#componentControls - .ensureMounted({ preloadHint: 'PlanDetails' }) + .ensureMounted({ preloadHint: component }) .then(controls => controls.openDrawer('planDetails', props || {})); - this.telemetry?.record(eventPrebuiltComponentOpened(`PlanDetails`, props)); + this.telemetry?.record(eventPrebuiltComponentOpened(component, props)); }; public __internal_closePlanDetails = (): void => { @@ -718,7 +722,7 @@ export class Clerk implements ClerkInterface { .ensureMounted({ preloadHint: 'UserProfile' }) .then(controls => controls.openModal('userProfile', props || {})); - const additionalData = props?.customPages?.length || 0 > 0 ? { customPages: true } : undefined; + const additionalData = (props?.customPages?.length || 0) > 0 ? { customPages: true } : undefined; this.telemetry?.record(eventPrebuiltComponentOpened('UserProfile', props, additionalData)); }; @@ -795,9 +799,10 @@ export class Clerk implements ClerkInterface { public mountSignIn = (node: HTMLDivElement, props?: SignInProps): void => { this.assertComponentsReady(this.#componentControls); - void this.#componentControls.ensureMounted({ preloadHint: 'SignIn' }).then(controls => + const component = 'SignIn'; + void this.#componentControls.ensureMounted({ preloadHint: component }).then(controls => controls.mountComponent({ - name: 'SignIn', + name: component, appearanceKey: 'signIn', node, props, @@ -805,7 +810,7 @@ export class Clerk implements ClerkInterface { ); const additionalData = { withSignUp: props?.withSignUp ?? this.#isCombinedSignInOrUpFlow() }; - this.telemetry?.record(eventPrebuiltComponentMounted(`SignIn`, props, additionalData)); + this.telemetry?.record(eventPrebuiltComponentMounted(component, props, additionalData)); }; public unmountSignIn = (node: HTMLDivElement): void => { @@ -819,16 +824,17 @@ export class Clerk implements ClerkInterface { public mountSignUp = (node: HTMLDivElement, props?: SignUpProps): void => { this.assertComponentsReady(this.#componentControls); - void this.#componentControls.ensureMounted({ preloadHint: 'SignUp' }).then(controls => + const component = 'SignUp'; + void this.#componentControls.ensureMounted({ preloadHint: component }).then(controls => controls.mountComponent({ - name: 'SignUp', + name: component, appearanceKey: 'signUp', node, props, }), ); - this.telemetry?.record(eventPrebuiltComponentMounted(`SignUp`, props)); + this.telemetry?.record(eventPrebuiltComponentMounted(component, props)); }; public unmountSignUp = (node: HTMLDivElement): void => { @@ -850,17 +856,18 @@ export class Clerk implements ClerkInterface { } return; } - void this.#componentControls.ensureMounted({ preloadHint: 'UserProfile' }).then(controls => + const component = 'UserProfile'; + void this.#componentControls.ensureMounted({ preloadHint: component }).then(controls => controls.mountComponent({ - name: 'UserProfile', + name: component, appearanceKey: 'userProfile', node, props, }), ); - const additionalData = props?.customPages?.length || 0 > 0 ? { customPages: true } : undefined; - this.telemetry?.record(eventPrebuiltComponentMounted('UserProfile', props, additionalData)); + const additionalData = (props?.customPages?.length || 0) > 0 ? { customPages: true } : undefined; + this.telemetry?.record(eventPrebuiltComponentMounted(component, props, additionalData)); }; public unmountUserProfile = (node: HTMLDivElement): void => { diff --git a/packages/shared/src/__tests__/telemetry.test.ts b/packages/shared/src/__tests__/telemetry.test.ts index 6a73e8d8594..bbd8b5b4be1 100644 --- a/packages/shared/src/__tests__/telemetry.test.ts +++ b/packages/shared/src/__tests__/telemetry.test.ts @@ -200,6 +200,49 @@ describe('TelemetryCollector', () => { randomSpy.mockRestore(); }); + + test('ignores event-specific sampling rate when eventSampling is false', async () => { + windowSpy.mockImplementation(() => undefined); + + const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.5); + + const collector = new TelemetryCollector({ + publishableKey: TEST_PK, + samplingRate: 1.0, // Global sampling rate allows all events + perEventSampling: false, // Disable event-specific sampling + }); + + // This event would normally be rejected due to low eventSamplingRate (0.1 < 0.5) + // but should be sent because eventSampling is disabled + collector.record({ event: 'TEST_EVENT', eventSamplingRate: 0.1, payload: {} }); + + jest.runAllTimers(); + + expect(fetchSpy).toHaveBeenCalled(); + + randomSpy.mockRestore(); + }); + + test('respects event-specific sampling rate when eventSampling is true (default)', async () => { + windowSpy.mockImplementation(() => undefined); + + const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.5); + + const collector = new TelemetryCollector({ + publishableKey: TEST_PK, + samplingRate: 1.0, // Global sampling rate allows all events + perEventSampling: true, // Enable event-specific sampling (default) + }); + + // This event should be rejected due to low eventSamplingRate (0.1 < 0.5) + collector.record({ event: 'TEST_EVENT', eventSamplingRate: 0.1, payload: {} }); + + jest.runAllTimers(); + + expect(fetchSpy).not.toHaveBeenCalled(); + + randomSpy.mockRestore(); + }); }); describe('with client-side throttling', () => { diff --git a/packages/shared/src/telemetry/collector.ts b/packages/shared/src/telemetry/collector.ts index 638073968c2..ae9944e36bc 100644 --- a/packages/shared/src/telemetry/collector.ts +++ b/packages/shared/src/telemetry/collector.ts @@ -46,7 +46,7 @@ function isWindowClerkWithMetadata(clerk: unknown): clerk is { constructor: { sd type TelemetryCollectorConfig = Pick< TelemetryCollectorOptions, - 'samplingRate' | 'disabled' | 'debug' | 'maxBufferSize' + 'samplingRate' | 'disabled' | 'debug' | 'maxBufferSize' | 'perEventSampling' > & { endpoint: string; }; @@ -80,6 +80,7 @@ export class TelemetryCollector implements TelemetryCollectorInterface { this.#config = { maxBufferSize: options.maxBufferSize ?? DEFAULT_CONFIG.maxBufferSize, samplingRate: options.samplingRate ?? DEFAULT_CONFIG.samplingRate, + perEventSampling: options.perEventSampling ?? true, disabled: options.disabled ?? false, debug: options.debug ?? false, endpoint: DEFAULT_CONFIG.endpoint, @@ -167,7 +168,9 @@ export class TelemetryCollector implements TelemetryCollectorInterface { const toBeSampled = randomSeed <= this.#config.samplingRate && - (typeof eventSamplingRate === 'undefined' || randomSeed <= eventSamplingRate); + (this.#config.perEventSampling === false || + typeof eventSamplingRate === 'undefined' || + randomSeed <= eventSamplingRate); if (!toBeSampled) { return false; diff --git a/packages/shared/src/telemetry/events/component-mounted.ts b/packages/shared/src/telemetry/events/component-mounted.ts index 7f3e7bbc5f3..3b63d75e8b8 100644 --- a/packages/shared/src/telemetry/events/component-mounted.ts +++ b/packages/shared/src/telemetry/events/component-mounted.ts @@ -17,6 +17,9 @@ type EventPrebuiltComponent = ComponentMountedBase & { type EventComponentMounted = ComponentMountedBase & TelemetryEventRaw['payload']; +/** + * @internal + */ function createPrebuiltComponentEvent(event: typeof EVENT_COMPONENT_MOUNTED | typeof EVENT_COMPONENT_OPENED) { return function ( component: string, @@ -44,7 +47,6 @@ function createPrebuiltComponentEvent(event: typeof EVENT_COMPONENT_MOUNTED | ty * @param component - The name of the component. * @param props - The props passed to the component. Will be filtered to a known list of props. * @param additionalPayload - Additional data to send with the event. - * * @example * telemetry.record(eventPrebuiltComponentMounted('SignUp', props)); */ @@ -62,7 +64,6 @@ export function eventPrebuiltComponentMounted( * @param component - The name of the component. * @param props - The props passed to the component. Will be filtered to a known list of props. * @param additionalPayload - Additional data to send with the event. - * * @example * telemetry.record(eventPrebuiltComponentOpened('GoogleOneTap', props)); */ @@ -81,7 +82,6 @@ export function eventPrebuiltComponentOpened( * * @param component - The name of the component. * @param props - The props passed to the component. Ideally you only pass a handful of props here. - * * @example * telemetry.record(eventComponentMounted('SignUp', props)); */ diff --git a/packages/shared/src/telemetry/types.ts b/packages/shared/src/telemetry/types.ts index 997d0071455..1fb6ade01da 100644 --- a/packages/shared/src/telemetry/types.ts +++ b/packages/shared/src/telemetry/types.ts @@ -8,11 +8,17 @@ export type TelemetryCollectorOptions = { */ debug?: boolean; /** - * Sampling rate, 0-1 + * Sampling rate, 0-1. */ samplingRate?: number; /** - * Set a custom buffer size to control how often events are sent + * If false, the sampling rates provided per event will be ignored and the global sampling rate will be used. + * + * @default true + */ + perEventSampling?: boolean; + /** + * Set a custom buffer size to control how often events are sent. */ maxBufferSize?: number; /**