Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/mosaic-component-mounted-telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/mosaic': patch
---

`<UserButton />` and `<OrganizationSwitcher />` 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`.
1 change: 1 addition & 0 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`) |
Expand Down
6 changes: 6 additions & 0 deletions packages/mosaic/eslint/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
],
},
],
Expand Down
35 changes: 35 additions & 0 deletions packages/mosaic/src/__tests__/feature/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
},
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -60,6 +62,8 @@ export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions &
* ```
*/
export function OrganizationSwitcher(props: OrganizationSwitcherProps = {}): ReactElement {
useComponentMountedTelemetry('OrganizationSwitcher', switcherTelemetry(props));

return (
<Switcher
{...props}
Expand Down
15 changes: 15 additions & 0 deletions packages/mosaic/src/features/switcher/switcher.telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { OrganizationSwitcherProps } from '../organization-switcher/organization-switcher';

export function switcherTelemetry(props: OrganizationSwitcherProps) {
return {
organizationProfileMode: props.organizationProfileUrl ? 'navigation' : 'modal',
createOrganizationMode: props.createOrganizationUrl ? 'navigation' : 'modal',
hidePersonal: Boolean(props.hidePersonal),
skipInvitationScreen: Boolean(props.skipInvitationScreen),
customMenuItems: props.customMenuItems?.length ?? 0,
organizationProfileCustomPages: props.organizationProfileProps?.customPages?.length ?? 0,
customTriggerLabel: props.renderTriggerLabel !== undefined,
customTriggerBadge: props.renderTriggerBadge !== undefined,
fallback: props.fallback !== undefined,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from '../../../__tests__/feature/fapi';
import { openMenu } from '../../../__tests__/feature/menu';
import { renderWithClerk } from '../../../__tests__/feature/render';
import { captureTelemetry, debugTelemetry } from '../../../__tests__/feature/telemetry';
import { PortalProvider } from '../../../primitives/portal';
import type { UserButtonProps } from '../user-button';
import { UserButton } from '../user-button';
Expand Down Expand Up @@ -1355,3 +1356,40 @@ describe('UserButton custom trigger badges', () => {
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,
},
]);
});
});
16 changes: 14 additions & 2 deletions packages/mosaic/src/features/user-button/user-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 (
<Switcher
{...props}
mode={combined ? 'combined' : 'user'}
triggerVariant={combined ? triggerVariant : 'condensed'}
mode={switcherMode}
triggerVariant={resolvedTriggerVariant}
/>
);
}
17 changes: 17 additions & 0 deletions packages/mosaic/src/hooks/use-component-mounted-telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useClerk } from '@clerk/shared/react';
import { eventComponentMounted } from '@clerk/shared/telemetry';
import { useEffect } from 'react';

export type ComponentMountedTelemetry = Record<string, boolean | number | string>;

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]);
}
1 change: 1 addition & 0 deletions packages/swingset/src/app/(clerk)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default function ClerkLayout({ children }: { children: ReactNode }) {
signInFallbackRedirectUrl='/live'
signUpFallbackRedirectUrl='/live'
afterSignOutUrl='/live'
telemetry={false}
>
<LiveChrome userButton={<LiveUserButton />}>{children}</LiveChrome>
</ClerkProvider>
Expand Down
Loading