diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 00856735535..160a1e44387 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -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" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 539b50e961e..d3e09dfc168 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -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'; @@ -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)); + } } try { diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index f38f9f785f5..87540bebfab 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -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'; @@ -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]); + React.useEffect(() => { void isomorphicClerkRef.current.__unstable__updateProps({ options }); }, [options.localization]); diff --git a/packages/shared/src/telemetry/events/__tests__/theme-usage.test.ts b/packages/shared/src/telemetry/events/__tests__/theme-usage.test.ts new file mode 100644 index 00000000000..d5557a89ce6 --- /dev/null +++ b/packages/shared/src/telemetry/events/__tests__/theme-usage.test.ts @@ -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: {}, + }); + }); +}); diff --git a/packages/shared/src/telemetry/events/index.ts b/packages/shared/src/telemetry/events/index.ts index e9ad6ca4ceb..84b7c4eb5de 100644 --- a/packages/shared/src/telemetry/events/index.ts +++ b/packages/shared/src/telemetry/events/index.ts @@ -1,3 +1,4 @@ export * from './component-mounted'; export * from './method-called'; export * from './framework-metadata'; +export * from './theme-usage'; diff --git a/packages/shared/src/telemetry/events/theme-usage.ts b/packages/shared/src/telemetry/events/theme-usage.ts new file mode 100644 index 00000000000..5ee5e0e56f5 --- /dev/null +++ b/packages/shared/src/telemetry/events/theme-usage.ts @@ -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 { + 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; +} diff --git a/packages/themes/src/createTheme.ts b/packages/themes/src/createTheme.ts index 2c5e86f844e..ea43ce03a89 100644 --- a/packages/themes/src/createTheme.ts +++ b/packages/themes/src/createTheme.ts @@ -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 { + /** + * Optional name for the theme, used for telemetry and debugging. + * @example 'shadcn', 'neobrutalism', 'custom-dark' + */ + name?: string; + /** * {@link Theme.elements} */ diff --git a/packages/themes/src/themes/dark.ts b/packages/themes/src/themes/dark.ts index 3e560d7f0dc..3a9e4530695 100644 --- a/packages/themes/src/themes/dark.ts +++ b/packages/themes/src/themes/dark.ts @@ -1,6 +1,7 @@ import { experimental_createTheme } from '../createTheme'; export const dark = experimental_createTheme({ + name: 'dark', variables: { colorBackground: '#212126', colorNeutral: 'white', diff --git a/packages/themes/src/themes/neobrutalism.ts b/packages/themes/src/themes/neobrutalism.ts index d992eeb68d7..3d49f81aa5b 100644 --- a/packages/themes/src/themes/neobrutalism.ts +++ b/packages/themes/src/themes/neobrutalism.ts @@ -20,6 +20,7 @@ const shadowStyle = { }; export const neobrutalism = experimental_createTheme({ + name: 'neobrutalism', //@ts-expect-error not public api simpleStyles: true, variables: { diff --git a/packages/themes/src/themes/shadcn.ts b/packages/themes/src/themes/shadcn.ts index 0df03c8ca31..82cb9435513 100644 --- a/packages/themes/src/themes/shadcn.ts +++ b/packages/themes/src/themes/shadcn.ts @@ -1,6 +1,7 @@ import { experimental_createTheme } from '../createTheme'; export const shadcn = experimental_createTheme({ + name: 'shadcn', cssLayerName: 'components', variables: { colorBackground: 'var(--card)', diff --git a/packages/themes/src/themes/shadesOfPurple.ts b/packages/themes/src/themes/shadesOfPurple.ts index f99f111d306..a80392ed149 100644 --- a/packages/themes/src/themes/shadesOfPurple.ts +++ b/packages/themes/src/themes/shadesOfPurple.ts @@ -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', diff --git a/packages/themes/src/themes/simple.ts b/packages/themes/src/themes/simple.ts index a985a3ae484..228dfb752e3 100644 --- a/packages/themes/src/themes/simple.ts +++ b/packages/themes/src/themes/simple.ts @@ -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, });