Skip to content
5 changes: 5 additions & 0 deletions .changeset/bitter-waves-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Use throttling instead of sampling for telemetry events of UI components on keyless apps.
5 changes: 5 additions & 0 deletions .changeset/slow-shoes-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': minor
---

Update TelemetryCollector to accept a `perEventSampling` property for controling per-event sampling rates.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
41 changes: 24 additions & 17 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -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 => {
Expand All @@ -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 => {
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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));
};

Expand Down Expand Up @@ -795,17 +799,18 @@ 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,
}),
);

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 => {
Expand All @@ -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 => {
Expand All @@ -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 => {
Expand Down
43 changes: 43 additions & 0 deletions packages/shared/src/__tests__/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
7 changes: 5 additions & 2 deletions packages/shared/src/telemetry/collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions packages/shared/src/telemetry/events/component-mounted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
*/
Expand All @@ -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));
*/
Expand All @@ -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));
*/
Expand Down
10 changes: 8 additions & 2 deletions packages/shared/src/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
Loading