Skip to content

feat(clerk-js,themes,shared): Add theme-usage telemetry #6529

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"files": [
{ "path": "./dist/clerk.js", "maxSize": "622.25KB" },
{ "path": "./dist/clerk.js", "maxSize": "622.37KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "76KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "58KB" },
Expand Down
6 changes: 6 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { isHttpOrHttps, isValidProxyUrl, proxyUrlToAbsoluteURL } from '@clerk/sh
import {
eventPrebuiltComponentMounted,
eventPrebuiltComponentOpened,
eventThemeUsage,
TelemetryCollector,
} from '@clerk/shared/telemetry';
import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url';
Expand Down Expand Up @@ -443,6 +444,11 @@ export class Clerk implements ClerkInterface {
publishableKey: this.publishableKey,
...this.#options.telemetry,
});

// Record theme usage telemetry when appearance is provided
if (this.#options.appearance) {
this.telemetry.record(eventThemeUsage(this.#options.appearance));
}
Comment on lines +447 to +451
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Potential duplicate THEME_USAGE events (core load vs React provider); add simple de-duplication

Clerk.load records theme usage when appearance is provided. The React Provider also records on appearance changes. On initial mount, both can fire for the same theme, producing duplicates. Add a simple guard to skip recording the same theme name twice in a row.

Proposed change:

-      // Record theme usage telemetry when appearance is provided
-      if (this.#options.appearance) {
-        this.telemetry.record(eventThemeUsage(this.#options.appearance));
-      }
+      // Record theme usage telemetry when appearance is provided (dedupe consecutive identical names)
+      if (this.#options.appearance) {
+        const evt = eventThemeUsage(this.#options.appearance);
+        const currentThemeName = evt.payload?.themeName;
+        if (currentThemeName !== this.#lastThemeUsageName) {
+          this.telemetry.record(evt);
+          this.#lastThemeUsageName = currentThemeName;
+        }
+      }

Add this private field to the class (near other private fields):

// Tracks last emitted theme usage to avoid duplicates
#lastThemeUsageName?: string;

Verification suggestion:

  • In a React app, initialize Clerk with appearance and mount ClerkProvider with the same appearance. Confirm only one THEME_USAGE event is emitted on boot, and another only when theme changes.

🏁 Script executed:

#!/bin/bash
set -e

# 1. Locate all occurrences of eventThemeUsage across the repo
rg "eventThemeUsage" -n

# 2. Identify where telemetry.record is called with eventThemeUsage (including in React provider)
rg "record.*eventThemeUsage" -n

Length of output: 2277


Add de-duplication for consecutive THEME_USAGE events

In packages/clerk-js/src/core/clerk.ts (around lines 447–451), wrap the existing call to telemetry.record(eventThemeUsage…) with a guard that skips recording if the theme name hasn’t changed since the last emission:

--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -447,7 +447,14 @@ export class Clerk {
       // Record theme usage telemetry when appearance is provided
       if (this.#options.appearance) {
-        this.telemetry.record(eventThemeUsage(this.#options.appearance));
+        const evt = eventThemeUsage(this.#options.appearance);
+        const currentTheme = evt.payload.themeName;
+        if (currentTheme !== this.#lastThemeUsageName) {
+          this.telemetry.record(evt);
+          this.#lastThemeUsageName = currentTheme;
+        }
       }

Add this private field alongside the other #-prefixed members of the class:

// Tracks the last emitted theme name to prevent back-to-back duplicates
#lastThemeUsageName?: string;

Why?

  • Both Clerk.load and the React <ClerkProvider> will fire a THEME_USAGE event on mount with the same appearance.
  • Without this guard you’ll see two identical events at boot; with it, you get exactly one per theme and still record on real theme changes.

How to verify:

  1. In a test app call Clerk.load({ appearance: { /*…*/ } }).
  2. Mount <ClerkProvider appearance={/* same */}>.
  3. Confirm only one THEME_USAGE is emitted on startup, and a second only after you change appearance.
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/clerk.ts around lines 447–451, add a private field
on the class (next to the other # members) to track the last emitted theme name
(e.g. #lastThemeUsageName?: string) and wrap the
telemetry.record(eventThemeUsage(...)) call with a guard that compares the
current appearance name to #lastThemeUsageName; if they are equal, skip
recording, otherwise call telemetry.record(...) and update #lastThemeUsageName
to the current theme name. This prevents back-to-back duplicate THEME_USAGE
events while still recording on real theme changes.

}

try {
Expand Down
8 changes: 8 additions & 0 deletions packages/react/src/contexts/ClerkContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
SessionContext,
UserContext,
} from '@clerk/shared/react';
import { eventThemeUsage } from '@clerk/shared/telemetry';
import type { ClientResource, InitialState, Resources } from '@clerk/types';
import React from 'react';

Expand Down Expand Up @@ -119,6 +120,13 @@ const useLoadedIsomorphicClerk = (options: IsomorphicClerkOptions) => {
void isomorphicClerkRef.current.__unstable__updateProps({ appearance: options.appearance });
}, [options.appearance]);

// Record theme usage telemetry when appearance changes
React.useEffect(() => {
if (options.appearance && isomorphicClerkRef.current.telemetry) {
isomorphicClerkRef.current.telemetry.record(eventThemeUsage(options.appearance));
}
}, [options.appearance]);

Comment on lines +123 to +129
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Guard against repeated emissions when appearance object identity changes without an actual theme change

The effect emits an event whenever options.appearance reference changes. In practice, this can emit duplicates on re-renders. Track the last emitted theme name and only record when it changes.

Note: This does not fully avoid a possible duplicate with the core Clerk.load emission; combine with the core-side dedupe proposed in clerk.ts for complete coverage.

   // Record theme usage telemetry when appearance changes
-  React.useEffect(() => {
-    if (options.appearance && isomorphicClerkRef.current.telemetry) {
-      isomorphicClerkRef.current.telemetry.record(eventThemeUsage(options.appearance));
-    }
-  }, [options.appearance]);
+  const lastThemeNameRef = React.useRef<string | undefined>(undefined);
+  React.useEffect(() => {
+    const telemetry = isomorphicClerkRef.current.telemetry;
+    if (!telemetry) {
+      return;
+    }
+    const evt = eventThemeUsage(options.appearance);
+    const nextName = evt.payload?.themeName;
+    if (nextName === lastThemeNameRef.current) {
+      return;
+    }
+    telemetry.record(evt);
+    lastThemeNameRef.current = nextName;
+  }, [options.appearance]);
🤖 Prompt for AI Agents
In packages/react/src/contexts/ClerkContextProvider.tsx around lines 123 to 129,
the effect records telemetry on every options.appearance reference change which
can emit duplicates; add a local ref (e.g., lastEmittedThemeRef) to store the
last emitted theme name, derive the current theme name from options.appearance
(handle strings and appearance objects), compare it to
lastEmittedThemeRef.current and only call
isomorphicClerkRef.current.telemetry.record(...) when they differ, then update
lastEmittedThemeRef.current to the new name; keep the same dependency on
options.appearance so it runs on changes but prevents repeated emissions for
identical theme names.

React.useEffect(() => {
void isomorphicClerkRef.current.__unstable__updateProps({ options });
}, [options.localization]);
Expand Down
127 changes: 127 additions & 0 deletions packages/shared/src/telemetry/events/__tests__/theme-usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { eventThemeUsage } from '../theme-usage';

describe('eventThemeUsage', () => {
it('should create telemetry event with shadcn theme name', () => {
const appearance = {
theme: {
__type: 'prebuilt_appearance' as const,
name: 'shadcn',
variables: { colorPrimary: 'var(--primary)' },
},
};

const result = eventThemeUsage(appearance);

expect(result).toEqual({
event: 'THEME_USAGE',
eventSamplingRate: 0.1,
payload: { themeName: 'shadcn' },
});
});

it('should handle string themes', () => {
const appearance = {
theme: 'clerk' as any, // String themes are valid at runtime
};

const result = eventThemeUsage(appearance);

expect(result).toEqual({
event: 'THEME_USAGE',
eventSamplingRate: 0.1,
payload: { themeName: 'clerk' },
});
});

it('should handle array of themes', () => {
const appearance = {
theme: [
'clerk' as any, // String themes are valid at runtime
{
__type: 'prebuilt_appearance' as const,
name: 'shadcn',
},
] as any,
};

const result = eventThemeUsage(appearance);

expect(result).toEqual({
event: 'THEME_USAGE',
eventSamplingRate: 0.1,
payload: { themeName: 'clerk' },
});
});

it('should handle themes without explicit names', () => {
const appearance = {
theme: {
__type: 'prebuilt_appearance' as const,
variables: { colorPrimary: 'blue' },
},
};

const result = eventThemeUsage(appearance);

expect(result).toEqual({
event: 'THEME_USAGE',
eventSamplingRate: 0.1,
payload: { themeName: undefined },
});
});

it('should prioritize theme over deprecated baseTheme', () => {
const appearance = {
theme: 'clerk' as any, // String themes are valid at runtime
baseTheme: {
__type: 'prebuilt_appearance' as const,
name: 'shadcn',
},
};

const result = eventThemeUsage(appearance);

expect(result).toEqual({
event: 'THEME_USAGE',
eventSamplingRate: 0.1,
payload: { themeName: 'clerk' },
});
});

it('should use baseTheme when theme is not provided', () => {
const appearance = {
baseTheme: {
__type: 'prebuilt_appearance' as const,
name: 'shadcn',
},
};

const result = eventThemeUsage(appearance);

expect(result).toEqual({
event: 'THEME_USAGE',
eventSamplingRate: 0.1,
payload: { themeName: 'shadcn' },
});
});

it('should handle undefined appearance', () => {
const result = eventThemeUsage();

expect(result).toEqual({
event: 'THEME_USAGE',
eventSamplingRate: 0.1,
payload: {},
});
});

it('should handle null appearance', () => {
const result = eventThemeUsage(null as any);

expect(result).toEqual({
event: 'THEME_USAGE',
eventSamplingRate: 0.1,
payload: {},
});
});
});
1 change: 1 addition & 0 deletions packages/shared/src/telemetry/events/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './component-mounted';
export * from './method-called';
export * from './framework-metadata';
export * from './theme-usage';
83 changes: 83 additions & 0 deletions packages/shared/src/telemetry/events/theme-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { Appearance, BaseTheme, TelemetryEventRaw } from '@clerk/types';

const EVENT_THEME_USAGE = 'THEME_USAGE';
const EVENT_SAMPLING_RATE = 0.1;

type EventThemeUsage = {
/**
* The name of the theme being used (e.g., "shadcn", "neobrutalism", etc.).
*/
themeName?: string;
};

/**
* Helper function for `telemetry.record()`. Create a consistent event object for tracking theme usage in ClerkProvider.
*
* @param appearance - The appearance prop from ClerkProvider.
* @example
* telemetry.record(eventThemeUsage(appearance));
*/
export function eventThemeUsage(appearance?: Appearance): TelemetryEventRaw<EventThemeUsage> {
const payload = analyzeThemeUsage(appearance);

return {
event: EVENT_THEME_USAGE,
eventSamplingRate: EVENT_SAMPLING_RATE,
payload,
};
}

/**
* Analyzes the appearance prop to extract theme usage information for telemetry.
*
* @internal
*/
function analyzeThemeUsage(appearance?: Appearance): EventThemeUsage {
if (!appearance || typeof appearance !== 'object') {
return {};
}

// Prioritize the new theme property over deprecated baseTheme
const themeProperty = appearance.theme || appearance.baseTheme;

if (!themeProperty) {
return {};
}

let themeName: string | undefined;

if (Array.isArray(themeProperty)) {
// Look for the first identifiable theme name in the array
for (const theme of themeProperty) {
const name = extractThemeName(theme);
if (name) {
themeName = name;
break;
}
}
} else {
themeName = extractThemeName(themeProperty);
}

return { themeName };
}

/**
* Extracts the theme name from a theme object.
*
* @internal
*/
function extractThemeName(theme: BaseTheme): string | undefined {
if (typeof theme === 'string') {
return theme;
}

if (typeof theme === 'object' && theme !== null) {
// Check for explicit theme name
if ('name' in theme && typeof theme.name === 'string') {
return theme.name;
}
}

return undefined;
}
6 changes: 6 additions & 0 deletions packages/themes/src/createTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import type { Appearance, BaseTheme, DeepPartial, Elements, Theme } from '@clerk
import type { InternalTheme } from '../../clerk-js/src/ui/foundations';

interface CreateClerkThemeParams extends DeepPartial<Theme> {
/**
* Optional name for the theme, used for telemetry and debugging.
* @example 'shadcn', 'neobrutalism', 'custom-dark'
*/
name?: string;

/**
* {@link Theme.elements}
*/
Expand Down
1 change: 1 addition & 0 deletions packages/themes/src/themes/dark.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { experimental_createTheme } from '../createTheme';

export const dark = experimental_createTheme({
name: 'dark',
variables: {
colorBackground: '#212126',
colorNeutral: 'white',
Expand Down
1 change: 1 addition & 0 deletions packages/themes/src/themes/neobrutalism.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const shadowStyle = {
};

export const neobrutalism = experimental_createTheme({
name: 'neobrutalism',
//@ts-expect-error not public api
simpleStyles: true,
variables: {
Expand Down
1 change: 1 addition & 0 deletions packages/themes/src/themes/shadcn.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { experimental_createTheme } from '../createTheme';

export const shadcn = experimental_createTheme({
name: 'shadcn',
cssLayerName: 'components',
variables: {
colorBackground: 'var(--card)',
Expand Down
1 change: 1 addition & 0 deletions packages/themes/src/themes/shadesOfPurple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { experimental_createTheme } from '../createTheme';
import { dark } from './dark';

export const shadesOfPurple = experimental_createTheme({
name: 'shadesOfPurple',
baseTheme: dark,
variables: {
colorBackground: '#3f3c77',
Expand Down
1 change: 1 addition & 0 deletions packages/themes/src/themes/simple.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { experimental_createTheme } from '../createTheme';

export const experimental__simple = experimental_createTheme({
name: 'simple',
//@ts-expect-error not public api
simpleStyles: true,
});
Loading