diff --git a/.changeset/mosaic-component-mounted-telemetry.md b/.changeset/mosaic-component-mounted-telemetry.md
new file mode 100644
index 00000000000..0b13cdaeeb4
--- /dev/null
+++ b/.changeset/mosaic-component-mounted-telemetry.md
@@ -0,0 +1,5 @@
+---
+'@clerk/mosaic': patch
+---
+
+`` and `` now report a `COMPONENT_MOUNTED` telemetry event on development instances, with a summary of how they are configured (modes, counts and flags, never user data). Opt out the same way as other Clerk telemetry: pass `telemetry={false}` to your Clerk provider or set your framework's telemetry-disabled environment variable, such as `NEXT_PUBLIC_CLERK_TELEMETRY_DISABLED=1`.
diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md
index 127bb615991..24327899ce4 100644
--- a/packages/mosaic/AGENTS.md
+++ b/packages/mosaic/AGENTS.md
@@ -52,6 +52,7 @@ Most problems a feature hits already have a shared answer. Find the row for your
| Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) |
| An inline message row that animates open and closed | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `styles/feedback.styles.ts` |
| A table whose sorting, paging and search are done by the server | `useServerDataTable` (`src/hooks/`) |
+| Usage telemetry when a public component mounts | `useComponentMountedTelemetry` (`src/hooks/`), called from the public wrapper |
| Data read from a Clerk resource that should be cached across mounts | A query hook in `src/queries/`, called from the model (`use-user-sessions-query.ts`) |
| An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` |
| Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) |
diff --git a/packages/mosaic/eslint/config.mjs b/packages/mosaic/eslint/config.mjs
index fa55f068a51..5737df1ab5e 100644
--- a/packages/mosaic/eslint/config.mjs
+++ b/packages/mosaic/eslint/config.mjs
@@ -67,6 +67,12 @@ export default [
message: 'Read the support email in the model and pass plain data to the controller or view.',
allowTypeImports: true,
},
+ {
+ regex: '(^|/)use-component-mounted-telemetry(?:[.]ts)?$',
+ importNames: ['useComponentMountedTelemetry'],
+ message: 'Record component telemetry from the public component wrapper, not from a controller or view.',
+ allowTypeImports: true,
+ },
],
},
],
diff --git a/packages/mosaic/src/__tests__/feature/telemetry.ts b/packages/mosaic/src/__tests__/feature/telemetry.ts
new file mode 100644
index 00000000000..1d675fd9722
--- /dev/null
+++ b/packages/mosaic/src/__tests__/feature/telemetry.ts
@@ -0,0 +1,35 @@
+import { vi } from 'vitest';
+
+export const debugTelemetry = { telemetry: { debug: true } };
+
+export function captureTelemetry() {
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
+ vi.spyOn(console, 'groupCollapsed').mockImplementation(() => {});
+ vi.spyOn(console, 'groupEnd').mockImplementation(() => {});
+
+ return {
+ mounts: (component: string) =>
+ log.mock.calls
+ .map(([entry]: unknown[]) => entry)
+ .filter(isComponentMounted)
+ .map(entry => entry.payload)
+ .filter(payload => payload.component === component),
+ };
+}
+
+interface ComponentMountedEntry {
+ event: 'COMPONENT_MOUNTED';
+ payload: Record;
+}
+
+function isComponentMounted(entry: unknown): entry is ComponentMountedEntry {
+ return (
+ typeof entry === 'object' &&
+ entry !== null &&
+ 'event' in entry &&
+ entry.event === 'COMPONENT_MOUNTED' &&
+ 'payload' in entry &&
+ typeof entry.payload === 'object' &&
+ entry.payload !== null
+ );
+}
diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
index 1a752b5ba21..7d3d7bd3880 100644
--- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
+++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
@@ -15,6 +15,7 @@ import {
fapiUser,
} from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
+import { captureTelemetry, debugTelemetry } from '../../../__tests__/feature/telemetry';
import type { OrganizationSwitcherProps } from '../organization-switcher';
import { OrganizationSwitcher } from '../organization-switcher';
@@ -327,3 +328,28 @@ describe('OrganizationSwitcher custom trigger badges', () => {
expect(trigger()).toHaveAccessibleName('Open organization menu for Acme');
});
});
+
+describe('OrganizationSwitcher telemetry', () => {
+ it('records one mount with its configuration once Clerk loads', async () => {
+ const telemetry = captureTelemetry();
+ serveFapi(signedIn());
+ await renderWithClerk(tree({ hidePersonal: true, createOrganizationUrl: '/new' }), debugTelemetry);
+ await open();
+
+ expect(telemetry.mounts('OrganizationSwitcher')).toEqual([
+ {
+ component: 'OrganizationSwitcher',
+ source: 'mosaic',
+ organizationProfileMode: 'modal',
+ createOrganizationMode: 'navigation',
+ hidePersonal: true,
+ skipInvitationScreen: false,
+ customMenuItems: 0,
+ organizationProfileCustomPages: 0,
+ customTriggerLabel: false,
+ customTriggerBadge: false,
+ fallback: false,
+ },
+ ]);
+ });
+});
diff --git a/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx b/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx
index ace9a4a3f94..ee889cd3314 100644
--- a/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx
+++ b/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx
@@ -2,9 +2,11 @@
import type { ReactElement } from 'react';
+import { useComponentMountedTelemetry } from '../../hooks/use-component-mounted-telemetry';
import type { SwitcherProps } from '../switcher/switcher';
import { Switcher } from '../switcher/switcher';
import type { OrganizationSwitcherModelOptions } from '../switcher/switcher.model';
+import { switcherTelemetry } from '../switcher/switcher.telemetry';
import type { SwitcherMenuProps } from '../switcher/switcher.types';
import type { SwitcherTriggerProps } from '../switcher/switcher.view';
@@ -60,6 +62,8 @@ export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions &
* ```
*/
export function OrganizationSwitcher(props: OrganizationSwitcherProps = {}): ReactElement {
+ useComponentMountedTelemetry('OrganizationSwitcher', switcherTelemetry(props));
+
return (
{
expect(renderTriggerBadge).not.toHaveBeenCalled();
});
});
+
+describe('UserButton telemetry', () => {
+ it('records one mount with its configuration once Clerk loads', async () => {
+ const telemetry = captureTelemetry();
+ serveFapi(signedIn());
+ await renderWithClerk(
+ tree({
+ mode: 'user',
+ userProfileUrl: '/account',
+ customMenuItems: [{ id: 'support', label: 'Support', href: '/support' }],
+ userProfileProps: { customPages: [{ url: 'https://example.com', label: 'Docs' }] },
+ }),
+ debugTelemetry,
+ );
+ await open();
+
+ expect(telemetry.mounts('UserButton')).toEqual([
+ {
+ component: 'UserButton',
+ source: 'mosaic',
+ mode: 'user',
+ triggerVariant: 'condensed',
+ userProfileMode: 'navigation',
+ organizationProfileMode: 'modal',
+ createOrganizationMode: 'modal',
+ hidePersonal: false,
+ skipInvitationScreen: false,
+ customMenuItems: 1,
+ userProfileCustomPages: 1,
+ organizationProfileCustomPages: 0,
+ customTriggerLabel: false,
+ customTriggerBadge: false,
+ fallback: false,
+ },
+ ]);
+ });
+});
diff --git a/packages/mosaic/src/features/user-button/user-button.tsx b/packages/mosaic/src/features/user-button/user-button.tsx
index 5f6ab970961..1b81c8f8de8 100644
--- a/packages/mosaic/src/features/user-button/user-button.tsx
+++ b/packages/mosaic/src/features/user-button/user-button.tsx
@@ -2,8 +2,10 @@
import type { ReactElement } from 'react';
+import { useComponentMountedTelemetry } from '../../hooks/use-component-mounted-telemetry';
import type { SwitcherProps } from '../switcher/switcher';
import { Switcher } from '../switcher/switcher';
+import { switcherTelemetry } from '../switcher/switcher.telemetry';
import type { SwitcherMode } from '../switcher/switcher.types';
import type { SwitcherTriggerVariant } from '../switcher/switcher.view';
@@ -104,11 +106,21 @@ export type UserButtonProps = SwitcherProps & UserButtonModeProps;
*/
export function UserButton({ mode, triggerVariant = 'full', ...props }: UserButtonProps = {}): ReactElement {
const combined = mode !== 'user';
+ const switcherMode = combined ? 'combined' : 'user';
+ const resolvedTriggerVariant = combined ? triggerVariant : 'condensed';
+ useComponentMountedTelemetry('UserButton', {
+ ...switcherTelemetry(props),
+ mode: switcherMode,
+ triggerVariant: resolvedTriggerVariant,
+ userProfileMode: props.userProfileUrl ? 'navigation' : 'modal',
+ userProfileCustomPages: props.userProfileProps?.customPages?.length ?? 0,
+ });
+
return (
);
}
diff --git a/packages/mosaic/src/hooks/use-component-mounted-telemetry.ts b/packages/mosaic/src/hooks/use-component-mounted-telemetry.ts
new file mode 100644
index 00000000000..1ebfd56a222
--- /dev/null
+++ b/packages/mosaic/src/hooks/use-component-mounted-telemetry.ts
@@ -0,0 +1,17 @@
+import { useClerk } from '@clerk/shared/react';
+import { eventComponentMounted } from '@clerk/shared/telemetry';
+import { useEffect } from 'react';
+
+export type ComponentMountedTelemetry = Record;
+
+export function useComponentMountedTelemetry(component: string, configuration: ComponentMountedTelemetry): void {
+ const { telemetry } = useClerk();
+
+ useEffect(() => {
+ telemetry?.record({
+ ...eventComponentMounted(component, { source: 'mosaic', ...configuration }),
+ eventSamplingRate: 1,
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- A mount is recorded once per collector; a later prop change is not a new mount.
+ }, [telemetry]);
+}
diff --git a/packages/swingset/src/app/(clerk)/layout.tsx b/packages/swingset/src/app/(clerk)/layout.tsx
index 45f75b113bd..00260110930 100644
--- a/packages/swingset/src/app/(clerk)/layout.tsx
+++ b/packages/swingset/src/app/(clerk)/layout.tsx
@@ -69,6 +69,7 @@ export default function ClerkLayout({ children }: { children: ReactNode }) {
signInFallbackRedirectUrl='/live'
signUpFallbackRedirectUrl='/live'
afterSignOutUrl='/live'
+ telemetry={false}
>
}>{children}