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}