diff --git a/examples/next-rwa/package.json b/examples/next-rwa/package.json index 260f25a6b..b3b6dc123 100644 --- a/examples/next-rwa/package.json +++ b/examples/next-rwa/package.json @@ -10,7 +10,7 @@ "lint": "next lint" }, "dependencies": { - "@auth0/nextjs-auth0": "^4.20.0", + "@auth0/nextjs-auth0": "^4.21.0", "@auth0/universal-components-react": "workspace:*", "@radix-ui/react-slot": "^1.2.3", "@tailwindcss/postcss": "^4.1.17", diff --git a/packages/core/src/api/__tests__/api-utils.test.ts b/packages/core/src/api/__tests__/api-utils.test.ts index 5d27276c9..eb09d047c 100644 --- a/packages/core/src/api/__tests__/api-utils.test.ts +++ b/packages/core/src/api/__tests__/api-utils.test.ts @@ -5,11 +5,26 @@ import { createMockContextInterface, TEST_DOMAIN, } from '../../internals/__mocks__/shared/api-service.mocks'; -import { AUTH0_SCOPE_HEADER, createProxyFetcher, createSpaFetcher } from '../api-utils'; +import { + AUTH0_CLIENT_HEADER, + AUTH0_SCOPE_HEADER, + createProxyFetcher, + createSpaFetcher, +} from '../api-utils'; import { ContentType, HeaderName } from '../http-constants'; +import type { TelemetryConfig } from '../telemetry'; import { stubFetch } from './__mocks__/api-utils.mocks'; +const defaultTelemetry: TelemetryConfig = { + css: 'unknown', + distribution: 'npm', + framework: 'react', + enabled: true, +}; + +const mockGetComponent = () => 'test-component'; + describe('api-utils', () => { describe('createProxyFetcher', () => { afterEach(() => { @@ -18,7 +33,10 @@ describe('api-utils', () => { it('sets content-type header to application/json', async () => { const mockFetch = stubFetch(); - const fetcher = createProxyFetcher(); + const fetcher = createProxyFetcher({ + telemetry: defaultTelemetry, + getComponent: mockGetComponent, + }); await fetcher('https://example.com/api', { method: 'POST' }, undefined); @@ -28,7 +46,10 @@ describe('api-utils', () => { it('sets auth0-scope header when scope array is provided', async () => { const mockFetch = stubFetch(); - const fetcher = createProxyFetcher(); + const fetcher = createProxyFetcher({ + telemetry: defaultTelemetry, + getComponent: mockGetComponent, + }); await fetcher( 'https://example.com/api', @@ -45,7 +66,10 @@ describe('api-utils', () => { it('does not set auth0-scope header when scope array is empty', async () => { const mockFetch = stubFetch(); - const fetcher = createProxyFetcher(); + const fetcher = createProxyFetcher({ + telemetry: defaultTelemetry, + getComponent: mockGetComponent, + }); await fetcher('https://example.com/api', { method: 'GET' }, { scope: [] }); @@ -55,7 +79,10 @@ describe('api-utils', () => { it('does not set auth0-scope header when authParams is undefined', async () => { const mockFetch = stubFetch(); - const fetcher = createProxyFetcher(); + const fetcher = createProxyFetcher({ + telemetry: defaultTelemetry, + getComponent: mockGetComponent, + }); await fetcher('https://example.com/api', { method: 'GET' }, undefined); @@ -65,7 +92,10 @@ describe('api-utils', () => { it('preserves existing headers from init', async () => { const mockFetch = stubFetch(); - const fetcher = createProxyFetcher(); + const fetcher = createProxyFetcher({ + telemetry: defaultTelemetry, + getComponent: mockGetComponent, + }); const customHeaders = new Headers({ 'X-Custom': 'value' }); await fetcher( @@ -82,7 +112,10 @@ describe('api-utils', () => { it('preserves other init options', async () => { const mockFetch = stubFetch(); - const fetcher = createProxyFetcher(); + const fetcher = createProxyFetcher({ + telemetry: defaultTelemetry, + getComponent: mockGetComponent, + }); const body = JSON.stringify({ data: 'test' }); await fetcher( @@ -96,6 +129,75 @@ describe('api-utils', () => { expect(requestInit?.body).toBe(body); expect(requestInit?.credentials).toBe('include'); }); + + it('sets Auth0-Client telemetry header with proxy mode', async () => { + const mockFetch = stubFetch(); + const fetcher = createProxyFetcher({ + telemetry: { css: 'tailwind', distribution: 'npm', framework: 'react', enabled: true }, + getComponent: () => 'user-mfa-management', + }); + + await fetcher('https://example.com/me/authentication-methods', { method: 'GET' }, undefined); + + const [, requestInit] = mockFetch.mock.calls[0]!; + const header = (requestInit?.headers as Headers).get(AUTH0_CLIENT_HEADER); + expect(header).toBeTruthy(); + + const decoded = JSON.parse(atob(header!)); + expect(decoded.is_proxy_mode).toBe(true); + expect(decoded.component).toBe('user-mfa-management'); + expect(decoded.name).toBe('universal-components'); + expect(decoded.css).toBe('tailwind'); + expect(decoded.distribution).toBe('npm'); + expect(decoded.framework).toBe('react'); + }); + + it('uses component from getComponent callback', async () => { + const mockFetch = stubFetch(); + const fetcher = createProxyFetcher({ + telemetry: { css: 'scoped', distribution: 'shadcn', framework: 'react', enabled: true }, + getComponent: () => 'organization-sso-configuration', + }); + + await fetcher('https://example.com/my-org/identity-providers', { method: 'GET' }, undefined); + + const [, requestInit] = mockFetch.mock.calls[0]!; + const header = (requestInit?.headers as Headers).get(AUTH0_CLIENT_HEADER); + const decoded = JSON.parse(atob(header!)); + expect(decoded.component).toBe('organization-sso-configuration'); + expect(decoded.css).toBe('scoped'); + expect(decoded.distribution).toBe('shadcn'); + }); + + it('uses custom fetcher when provided', async () => { + const customFetcher = vi.fn().mockResolvedValue(new Response()); + const fetcher = createProxyFetcher({ + customFetcher, + telemetry: defaultTelemetry, + getComponent: mockGetComponent, + }); + + await fetcher('https://example.com/api', { method: 'GET' }, undefined); + + expect(customFetcher).toHaveBeenCalledWith( + 'https://example.com/api', + expect.objectContaining({ method: 'GET' }), + undefined, + ); + }); + + it('does not set Auth0-Client header when telemetry is disabled', async () => { + const mockFetch = stubFetch(); + const fetcher = createProxyFetcher({ + telemetry: { ...defaultTelemetry, enabled: false }, + getComponent: mockGetComponent, + }); + + await fetcher('https://example.com/api', { method: 'GET' }, undefined); + + const [, requestInit] = mockFetch.mock.calls[0]!; + expect((requestInit?.headers as Headers).get(AUTH0_CLIENT_HEADER)).toBeNull(); + }); }); describe('createSpaFetcher', () => { @@ -124,14 +226,19 @@ describe('api-utils', () => { const config = createSpaConfig(); const dpopNonceId = '__test_dpop_nonce__'; - createSpaFetcher(config, dpopNonceId); + createSpaFetcher(config, dpopNonceId, defaultTelemetry, mockGetComponent); expect(mockCreateFetcher).toHaveBeenCalledWith({ dpopNonceId }); }); it('sets Content-Type header to application/json', async () => { const config = createSpaConfig(); - const fetcher = createSpaFetcher(config, '__test_nonce__'); + const fetcher = createSpaFetcher( + config, + '__test_nonce__', + defaultTelemetry, + mockGetComponent, + ); await fetcher('https://example.com/api', { method: 'POST' }, undefined); @@ -141,7 +248,12 @@ describe('api-utils', () => { it('preserves existing headers from init when adding Content-Type', async () => { const config = createSpaConfig(); - const fetcher = createSpaFetcher(config, '__test_nonce__'); + const fetcher = createSpaFetcher( + config, + '__test_nonce__', + defaultTelemetry, + mockGetComponent, + ); const customHeaders = new Headers({ 'X-Custom': 'value' }); await fetcher( @@ -158,7 +270,12 @@ describe('api-utils', () => { it('preserves other init options when adding Content-Type header', async () => { const config = createSpaConfig(); - const fetcher = createSpaFetcher(config, '__test_nonce__'); + const fetcher = createSpaFetcher( + config, + '__test_nonce__', + defaultTelemetry, + mockGetComponent, + ); const body = JSON.stringify({ data: 'test' }); await fetcher( @@ -176,7 +293,12 @@ describe('api-utils', () => { it('delegates to SDK fetchWithAuth with scope and audience', async () => { const config = createSpaConfig(); - const fetcher = createSpaFetcher(config, '__test_nonce__'); + const fetcher = createSpaFetcher( + config, + '__test_nonce__', + defaultTelemetry, + mockGetComponent, + ); await fetcher( 'https://example.com/api', @@ -193,7 +315,12 @@ describe('api-utils', () => { it('handles undefined authParams', async () => { const config = createSpaConfig(); - const fetcher = createSpaFetcher(config, '__test_nonce__'); + const fetcher = createSpaFetcher( + config, + '__test_nonce__', + defaultTelemetry, + mockGetComponent, + ); await fetcher('https://example.com/api', { method: 'GET' }, undefined); @@ -206,7 +333,12 @@ describe('api-utils', () => { it('handles empty scope array', async () => { const config = createSpaConfig(); - const fetcher = createSpaFetcher(config, '__test_nonce__'); + const fetcher = createSpaFetcher( + config, + '__test_nonce__', + defaultTelemetry, + mockGetComponent, + ); await fetcher('https://example.com/api', { method: 'GET' }, { scope: [] }); @@ -219,7 +351,12 @@ describe('api-utils', () => { it('handles undefined init parameter', async () => { const config = createSpaConfig(); - const fetcher = createSpaFetcher(config, '__test_nonce__'); + const fetcher = createSpaFetcher( + config, + '__test_nonce__', + defaultTelemetry, + mockGetComponent, + ); await fetcher('https://example.com/api', undefined, { scope: ['read:users'] }); @@ -232,5 +369,63 @@ describe('api-utils', () => { }, ); }); + + it('sets Auth0-Client telemetry header with SPA mode', async () => { + const config = createSpaConfig(); + const fetcher = createSpaFetcher( + config, + '__test_nonce__', + { css: 'tailwind', distribution: 'npm', framework: 'react', enabled: true }, + () => 'user-mfa-management', + ); + + await fetcher('https://example.com/me/authentication-methods', { method: 'GET' }, undefined); + + const [, requestInit] = mockFetchWithAuth.mock.calls[0]!; + const header = (requestInit?.headers as Headers).get(AUTH0_CLIENT_HEADER); + expect(header).toBeTruthy(); + + const decoded = JSON.parse(atob(header!)); + expect(decoded.is_proxy_mode).toBe(false); + expect(decoded.component).toBe('user-mfa-management'); + expect(decoded.name).toBe('universal-components'); + expect(decoded.css).toBe('tailwind'); + expect(decoded.distribution).toBe('npm'); + expect(decoded.framework).toBe('react'); + }); + + it('uses component from getComponent callback', async () => { + const config = createSpaConfig(); + const fetcher = createSpaFetcher( + config, + '__test_nonce__', + { css: 'scoped', distribution: 'shadcn', framework: 'react', enabled: true }, + () => 'organization-domain-management', + ); + + await fetcher('https://example.com/my-org/domains', { method: 'GET' }, undefined); + + const [, requestInit] = mockFetchWithAuth.mock.calls[0]!; + const header = (requestInit?.headers as Headers).get(AUTH0_CLIENT_HEADER); + const decoded = JSON.parse(atob(header!)); + expect(decoded.component).toBe('organization-domain-management'); + expect(decoded.css).toBe('scoped'); + expect(decoded.distribution).toBe('shadcn'); + }); + + it('does not set Auth0-Client header when telemetry is disabled', async () => { + const config = createSpaConfig(); + const fetcher = createSpaFetcher( + config, + '__test_nonce__', + { ...defaultTelemetry, enabled: false }, + mockGetComponent, + ); + + await fetcher('https://example.com/api', { method: 'GET' }, undefined); + + const [, requestInit] = mockFetchWithAuth.mock.calls[0]!; + expect((requestInit?.headers as Headers).get(AUTH0_CLIENT_HEADER)).toBeNull(); + }); }); }); diff --git a/packages/core/src/api/__tests__/telemetry.test.ts b/packages/core/src/api/__tests__/telemetry.test.ts new file mode 100644 index 000000000..9f58d8446 --- /dev/null +++ b/packages/core/src/api/__tests__/telemetry.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTelemetryHeader, PACKAGE_VERSION, TELEMETRY_NAME } from '../telemetry'; + +describe('telemetry', () => { + describe('constants', () => { + it('should have correct telemetry name', () => { + expect(TELEMETRY_NAME).toBe('universal-components'); + }); + + it('should have a valid version format', () => { + expect(PACKAGE_VERSION).toMatch(/^\d+\.\d+\.\d+/); + }); + }); + + describe('buildTelemetryHeader', () => { + it('should return base64-encoded JSON for proxy mode with full telemetry config', () => { + const header = buildTelemetryHeader({ + isProxyMode: true, + component: 'user-mfa-management', + css: 'tailwind', + distribution: 'npm', + framework: 'react', + }); + + const decoded = JSON.parse(atob(header)); + expect(decoded).toEqual({ + name: 'universal-components', + version: PACKAGE_VERSION, + is_proxy_mode: true, + framework: 'react', + component: 'user-mfa-management', + distribution: 'npm', + css: 'tailwind', + }); + }); + + it('should return base64-encoded JSON for SPA mode with shadcn distribution', () => { + const header = buildTelemetryHeader({ + isProxyMode: false, + component: 'organization-sso-configuration', + css: 'scoped', + distribution: 'shadcn', + framework: 'react', + }); + + const decoded = JSON.parse(atob(header)); + expect(decoded).toEqual({ + name: 'universal-components', + version: PACKAGE_VERSION, + is_proxy_mode: false, + framework: 'react', + component: 'organization-sso-configuration', + distribution: 'shadcn', + css: 'scoped', + }); + }); + + it('should use provided component value', () => { + const header = buildTelemetryHeader({ + isProxyMode: false, + component: 'unknown', + css: 'unknown', + distribution: 'npm', + framework: 'react', + }); + + const decoded = JSON.parse(atob(header)); + expect(decoded.component).toBe('unknown'); + expect(decoded.css).toBe('unknown'); + }); + + it('should include all required telemetry fields', () => { + const header = buildTelemetryHeader({ + isProxyMode: true, + component: 'organization-domain-management', + css: 'tailwind', + distribution: 'npm', + framework: 'react', + }); + + const decoded = JSON.parse(atob(header)); + expect(decoded).toHaveProperty('name'); + expect(decoded).toHaveProperty('version'); + expect(decoded).toHaveProperty('is_proxy_mode'); + expect(decoded).toHaveProperty('framework'); + expect(decoded).toHaveProperty('component'); + expect(decoded).toHaveProperty('distribution'); + expect(decoded).toHaveProperty('css'); + }); + + it('should use provided framework value', () => { + const header = buildTelemetryHeader({ + isProxyMode: false, + component: 'user-mfa-management', + css: 'tailwind', + distribution: 'npm', + framework: 'vue', + }); + + const decoded = JSON.parse(atob(header)); + expect(decoded.framework).toBe('vue'); + }); + }); +}); diff --git a/packages/core/src/api/api-utils.ts b/packages/core/src/api/api-utils.ts index 92c355d6b..545d4ca28 100644 --- a/packages/core/src/api/api-utils.ts +++ b/packages/core/src/api/api-utils.ts @@ -7,31 +7,56 @@ import type { FetcherAuthParams, FetcherSupplier, SpaAuthConfig } from '../auth/auth-types'; import { ContentType, HeaderName } from './http-constants'; +import { + buildTelemetryHeader, + type TelemetryComponentGetter, + type TelemetryConfig, +} from './telemetry'; export const AUTH0_SCOPE_HEADER = HeaderName.Auth0Scope; +export const AUTH0_CLIENT_HEADER = 'Auth0-Client'; /** - * Creates a fetcher function for proxy mode that injects scopes via auth0-scope header. - * The proxy will extract scopes from the header and request the appropriate token. - * @param customFetcher - Optional custom fetch implementation to use instead of global fetch - * @returns Fetcher function that sets auth0-scope and content-type headers - * @internal + * Configuration for proxy mode fetcher with telemetry. */ -export function createProxyFetcher( +export interface ProxyFetcherConfig { customFetcher?: ( url: string, init?: RequestInit, authParams?: FetcherAuthParams, - ) => Promise, -): FetcherSupplier { + ) => Promise; + telemetry: TelemetryConfig; + getComponent: TelemetryComponentGetter; +} + +/** + * Creates a fetcher function for proxy mode that injects scopes via auth0-scope header. + * The proxy will extract scopes from the header and request the appropriate token. + * Also adds telemetry header with component info from getComponent callback. + * @param config - Fetcher configuration with optional custom fetcher, telemetry config, and getComponent callback + * @returns Fetcher function that sets auth0-scope, content-type, and telemetry headers + * @internal + */ +export function createProxyFetcher(config: ProxyFetcherConfig): FetcherSupplier { + const fetchFn = config.customFetcher; return async (url, init, authParams) => { const headers = new Headers(init?.headers); headers.set(HeaderName.ContentType, ContentType.JSON); if (authParams?.scope?.length) { headers.set(HeaderName.Auth0Scope, authParams.scope.join(' ')); } - if (customFetcher) { - return customFetcher(url, { ...init, headers }, authParams); + if (config.telemetry.enabled) { + headers.set( + AUTH0_CLIENT_HEADER, + buildTelemetryHeader({ + isProxyMode: true, + component: config.getComponent(), + ...config.telemetry, + }), + ); + } + if (fetchFn) { + return fetchFn(url, { ...init, headers }, authParams); } return fetch(url, { ...init, headers }); }; @@ -39,16 +64,34 @@ export function createProxyFetcher( /** * Creates a fetcher function for SPA mode using Auth0 SDK's createFetcher. + * Also adds telemetry header with component info from getComponent callback. * @param config - SPA auth configuration with context interface * @param dpopNonceId - Unique identifier for DPoP nonce management - * @returns Fetcher function that delegates to SDK's fetchWithAuth with JSON content-type + * @param telemetry - Telemetry configuration + * @param getComponent - Callback to get current component name + * @returns Fetcher function that delegates to SDK's fetchWithAuth with JSON content-type and telemetry * @internal */ -export function createSpaFetcher(config: SpaAuthConfig, dpopNonceId: string): FetcherSupplier { +export function createSpaFetcher( + config: SpaAuthConfig, + dpopNonceId: string, + telemetry: TelemetryConfig, + getComponent: TelemetryComponentGetter, +): FetcherSupplier { const sdkFetcher = config.contextInterface.createFetcher({ dpopNonceId }); return (url, init, authParams) => { const headers = new Headers(init?.headers); headers.set(HeaderName.ContentType, ContentType.JSON); + if (telemetry.enabled) { + headers.set( + AUTH0_CLIENT_HEADER, + buildTelemetryHeader({ + isProxyMode: false, + component: getComponent(), + ...telemetry, + }), + ); + } return sdkFetcher.fetchWithAuth( url, { ...init, headers }, diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 26410fbc6..b5455f001 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -8,3 +8,4 @@ export * from './api-error'; export * from './business-error'; export * from './error-utils'; export * from './http-constants'; +export * from './telemetry'; diff --git a/packages/core/src/api/telemetry.ts b/packages/core/src/api/telemetry.ts new file mode 100644 index 000000000..dbb4ceeaf --- /dev/null +++ b/packages/core/src/api/telemetry.ts @@ -0,0 +1,91 @@ +/** + * Telemetry utilities for Auth0 UI Components. + * @module telemetry + * @internal + */ + +import pkg from '../../package.json'; + +/** + * The package name used in telemetry. + */ +export const TELEMETRY_NAME = 'universal-components'; + +/** + * The current package version. Read from package.json at build time. + */ +export const PACKAGE_VERSION: string = pkg.version; + +/** + * CSS implementation type for telemetry. + */ +export type CssImplementation = 'scoped' | 'tailwind' | 'unknown'; + +/** + * Distribution channel type. + */ +export type DistributionChannel = 'npm' | 'shadcn'; + +/** + * Framework type for telemetry. + */ +export type Framework = 'react' | 'vue' | 'angular'; + +/** + * Callback to get current component name from React context. + * Called by fetchers on each request to get the component that initiated the call. + */ +export type TelemetryComponentGetter = () => string; + +/** + * Telemetry configuration passed from framework packages. + * Groups all telemetry-related settings in one object. + */ +export interface TelemetryConfig { + css: CssImplementation; + distribution: DistributionChannel; + framework: Framework; + enabled?: boolean; +} + +/** + * Telemetry payload structure sent in the Auth0-Client header. + */ +export interface TelemetryPayload { + name: string; + version: string; + is_proxy_mode: boolean; + framework: string; + component: string; + distribution: DistributionChannel; + css: CssImplementation; +} + +/** + * Configuration options for building telemetry header. + * Extends TelemetryConfig with request-specific options. + */ +export interface TelemetryOptions extends TelemetryConfig { + isProxyMode: boolean; + component: string; +} + +/** + * Builds the base64-encoded telemetry header value. + * @param options - Telemetry configuration options including component name + * @returns Base64-encoded JSON telemetry payload + * @internal + */ +export function buildTelemetryHeader(options: TelemetryOptions): string { + const payload: TelemetryPayload = { + name: TELEMETRY_NAME, + version: PACKAGE_VERSION, + is_proxy_mode: options.isProxyMode, + framework: options.framework, + component: options.component, + distribution: options.distribution, + css: options.css, + }; + + return btoa(JSON.stringify(payload)); +} diff --git a/packages/core/src/auth/__tests__/core-client.test.ts b/packages/core/src/auth/__tests__/core-client.test.ts index 9f82b287b..77373be75 100644 --- a/packages/core/src/auth/__tests__/core-client.test.ts +++ b/packages/core/src/auth/__tests__/core-client.test.ts @@ -63,10 +63,18 @@ describe('createCoreClient', () => { initializeMfaStepUpClientMock.mockReturnValue(mockMfaApiClient); }); + const defaultTelemetry = { + css: 'unknown' as const, + distribution: 'npm' as const, + framework: 'react' as const, + }; + + const mockGetComponent = () => 'test-component'; + describe('i18n initialization', () => { it('initializes i18n with default options when none are provided', async () => { const authDetails = createAuthDetails(); - await createCoreClient(authDetails); + await createCoreClient(authDetails, undefined, defaultTelemetry, mockGetComponent); expect(createI18nServiceMock).toHaveBeenCalledWith({ currentLanguage: 'en-US', @@ -77,14 +85,19 @@ describe('createCoreClient', () => { it('initializes i18n with provided language options', async () => { const i18nOptions = { currentLanguage: 'es', fallbackLanguage: 'en' }; const authDetails = createAuthDetails(); - await createCoreClient(authDetails, i18nOptions); + await createCoreClient(authDetails, i18nOptions, defaultTelemetry, mockGetComponent); expect(createI18nServiceMock).toHaveBeenCalledWith(i18nOptions); }); it('exposes i18nService on the client', async () => { const authDetails = createAuthDetails(); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.i18nService).toBe(mockI18nService); }); @@ -93,42 +106,71 @@ describe('createCoreClient', () => { describe('isProxyMode', () => { it('returns false when authProxyUrl is undefined', async () => { const authDetails = createAuthDetails(); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.isProxyMode()).toBe(false); }); it('returns true when authProxyUrl is set', async () => { const authDetails = createAuthDetails({ authProxyUrl: 'https://proxy.auth0.com' }); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.isProxyMode()).toBe(true); }); it('returns false when authProxyUrl is empty string', async () => { const authDetails = createAuthDetails({ authProxyUrl: '' }); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.isProxyMode()).toBe(false); }); }); describe('API client initialization', () => { - it('initializes MyOrg client with auth details', async () => { + it('initializes MyOrg client with auth details, telemetry config, and getComponent', async () => { const authDetails = createAuthDetails(); - await createCoreClient(authDetails); + await createCoreClient( + authDetails, + undefined, + { css: 'tailwind', distribution: 'npm', framework: 'react' }, + mockGetComponent, + ); expect(createMyOrganizationClientMock).toHaveBeenCalledWith( expect.objectContaining({ mode: 'spa', domain: TEST_DOMAIN }), + { css: 'tailwind', distribution: 'npm', framework: 'react' }, + mockGetComponent, ); }); - it('initializes MyAccount client with auth details', async () => { + it('initializes MyAccount client with auth details, telemetry config, and getComponent', async () => { const authDetails = createAuthDetails(); - await createCoreClient(authDetails); + await createCoreClient( + authDetails, + undefined, + { css: 'scoped', distribution: 'shadcn', framework: 'react' }, + mockGetComponent, + ); expect(createMyAccountClientMock).toHaveBeenCalledWith( expect.objectContaining({ mode: 'spa', domain: TEST_DOMAIN }), + { css: 'scoped', distribution: 'shadcn', framework: 'react' }, + mockGetComponent, ); }); }); @@ -136,28 +178,48 @@ describe('createCoreClient', () => { describe('API client access', () => { it('exposes myAccountApiClient directly on the client', async () => { const authDetails = createAuthDetails(); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.myAccountApiClient).toBe(mockMyAccountClient); }); it('exposes myOrganizationApiClient directly on the client', async () => { const authDetails = createAuthDetails(); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.myOrganizationApiClient).toBe(mockMyOrganizationClient); }); it('returns myAccountApiClient when available via getter', async () => { const authDetails = createAuthDetails(); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.getMyAccountApiClient()).toBe(mockMyAccountClient); }); it('returns myOrganizationApiClient when available via getter', async () => { const authDetails = createAuthDetails(); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.getMyOrganizationApiClient()).toBe(mockMyOrganizationClient); }); @@ -166,7 +228,12 @@ describe('createCoreClient', () => { createMyAccountClientMock.mockReturnValueOnce(null as unknown as MyAccountClient); const authDetails = createAuthDetails(); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(() => client.getMyAccountApiClient()).toThrow( 'myAccountApiClient is not enabled. Please use it within Auth0ComponentProvider.', @@ -176,7 +243,12 @@ describe('createCoreClient', () => { it('throws when myOrganizationApiClient is not available', async () => { createMyOrganizationClientMock.mockReturnValueOnce(null as unknown as MyOrganizationClient); const authDetails = createAuthDetails(); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(() => client.getMyOrganizationApiClient()).toThrow( 'myOrganizationApiClient is not enabled. Please ensure you are in an Auth0 Organization context.', @@ -185,7 +257,12 @@ describe('createCoreClient', () => { it('returns mfaApiClient via getter', async () => { const authDetails = createAuthDetails(); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.getMFAStepUpApiClient()).toBe(mockMfaApiClient); }); @@ -194,14 +271,24 @@ describe('createCoreClient', () => { describe('client properties', () => { it('exposes auth details on the client', async () => { const authDetails = createAuthDetails(); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.auth).toEqual(authDetails); }); it('preserves authProxyUrl in auth details', async () => { const authDetails = createAuthDetails({ authProxyUrl: 'https://custom-proxy.com' }); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.auth.authProxyUrl).toBe('https://custom-proxy.com'); }); @@ -209,7 +296,12 @@ describe('createCoreClient', () => { it('preserves contextInterface in auth details', async () => { const customContext = createMockContextInterface(); const authDetails = createAuthDetails({ contextInterface: customContext }); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.auth.contextInterface).toBe(customContext); }); @@ -218,7 +310,12 @@ describe('createCoreClient', () => { describe('getDomain', () => { it('returns domain in SPA mode', async () => { const authDetails = createAuthDetails({ domain: TEST_DOMAIN }); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.getDomain()).toBe(TEST_DOMAIN); }); @@ -228,7 +325,12 @@ describe('createCoreClient', () => { authProxyUrl: 'https://proxy.auth0.com', domain: TEST_DOMAIN, }); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.getDomain()).toBe(TEST_DOMAIN); }); @@ -238,7 +340,12 @@ describe('createCoreClient', () => { authProxyUrl: 'https://proxy.auth0.com', domain: undefined, }); - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.getDomain()).toBeUndefined(); }); @@ -247,7 +354,12 @@ describe('createCoreClient', () => { describe('previewMode', () => { it('returns a core client with previewMode and disables API clients', async () => { const authDetails = { ...createAuthDetails(), previewMode: true }; - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.auth).toEqual({}); expect(client.myAccountApiClient).toBeUndefined(); @@ -257,35 +369,60 @@ describe('createCoreClient', () => { it('isProxyMode returns false in previewMode', async () => { const authDetails = { ...createAuthDetails(), previewMode: true }; - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.isProxyMode()).toBe(false); }); it('getMyAccountApiClient throws in previewMode', async () => { const authDetails = { ...createAuthDetails(), previewMode: true }; - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(() => client.getMyAccountApiClient()).toThrow('Function not implemented.'); }); it('getMyOrganizationApiClient throws in previewMode', async () => { const authDetails = { ...createAuthDetails(), previewMode: true }; - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(() => client.getMyOrganizationApiClient()).toThrow('Function not implemented.'); }); it('getMFAStepUpApiClient throws in previewMode', async () => { const authDetails = { ...createAuthDetails(), previewMode: true }; - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(() => client.getMFAStepUpApiClient()).toThrow('Function not implemented.'); }); it('getDomain returns undefined in previewMode', async () => { const authDetails = { ...createAuthDetails(), previewMode: true }; - const client = await createCoreClient(authDetails); + const client = await createCoreClient( + authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); expect(client.getDomain()).toBeUndefined(); }); diff --git a/packages/core/src/auth/core-client.ts b/packages/core/src/auth/core-client.ts index f97fae60c..cedd07281 100644 --- a/packages/core/src/auth/core-client.ts +++ b/packages/core/src/auth/core-client.ts @@ -6,6 +6,7 @@ import { initializeMfaStepUpClient } from '@core/services/mfa-step-up/mfa-step-up-api-service'; +import type { TelemetryComponentGetter, TelemetryConfig } from '../api/telemetry'; import type { I18nInitOptions } from '../i18n'; import { createI18nService } from '../i18n'; import { createMyAccountClient } from '../services/my-account/my-account-client'; @@ -20,11 +21,15 @@ import { AuthUtils } from './auth-utils'; * * @param authDetails - Authentication configuration details * @param i18nOptions - Internationalization options + * @param telemetry - Telemetry configuration (css, distribution, framework) + * @param getComponent - Callback to get current component name from React context * @returns Promise resolving to the initialized CoreClient */ export async function createCoreClient( authDetails: AuthDetails, - i18nOptions?: I18nInitOptions, + i18nOptions: I18nInitOptions | undefined, + telemetry: TelemetryConfig, + getComponent: TelemetryComponentGetter, ): Promise { const i18nService = await createI18nService( i18nOptions || { currentLanguage: 'en-US', fallbackLanguage: 'en-US' }, @@ -57,8 +62,8 @@ export async function createCoreClient( const authConfig = AuthUtils.resolveAuthConfig(authDetails); - const myOrganizationApiClient = createMyOrganizationClient(authConfig); - const myAccountApiClient = createMyAccountClient(authConfig); + const myOrganizationApiClient = createMyOrganizationClient(authConfig, telemetry, getComponent); + const myAccountApiClient = createMyAccountClient(authConfig, telemetry, getComponent); const mfaApiClient = initializeMfaStepUpClient(authConfig); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4883372e6..fde15fe15 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,6 +25,14 @@ export { FetcherAuthParams, } from './auth/auth-types'; +export { + type CssImplementation, + type DistributionChannel, + type Framework, + type TelemetryComponentGetter, + type TelemetryConfig, +} from './api/telemetry'; + export * from './schemas'; export * from './theme'; diff --git a/packages/core/src/services/my-account/__tests__/my-account-client.test.ts b/packages/core/src/services/my-account/__tests__/my-account-client.test.ts index 609aedfc2..6a330ed8b 100644 --- a/packages/core/src/services/my-account/__tests__/my-account-client.test.ts +++ b/packages/core/src/services/my-account/__tests__/my-account-client.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { stubFetch } from '../../../api/__tests__/__mocks__/api-utils.mocks'; import { AUTH0_SCOPE_HEADER } from '../../../api/api-utils'; +import type { TelemetryConfig } from '../../../api/telemetry'; import type { FetcherSupplier, SpaAuthConfig } from '../../../auth/auth-types'; import { createMockContextInterface, @@ -17,6 +18,14 @@ import { vi.mock('@auth0/myaccount-js', () => ({ MyAccountClient: vi.fn() })); +const defaultTelemetry: TelemetryConfig = { + css: 'unknown', + distribution: 'npm', + framework: 'react', +}; + +const mockGetComponent = () => 'test-component'; + describe('createMyAccountClient', () => { const mockFetchWithAuth = vi.fn().mockResolvedValue(new Response()); const mockCreateFetcher = vi.fn().mockReturnValue({ @@ -40,7 +49,15 @@ describe('createMyAccountClient', () => { }); it('creates client with baseUrl in proxy mode', () => { - createMyAccountClient(mockProxyConfig); + createMyAccountClient( + mockProxyConfig, + { + css: 'tailwind', + distribution: 'npm', + framework: 'react', + }, + mockGetComponent, + ); expect(MyAccountClient).toHaveBeenCalledWith( expect.objectContaining({ @@ -51,7 +68,15 @@ describe('createMyAccountClient', () => { }); it('creates client with domain in SPA mode', () => { - createMyAccountClient(createSpaConfig()); + createMyAccountClient( + createSpaConfig(), + { + css: 'scoped', + distribution: 'shadcn', + framework: 'react', + }, + mockGetComponent, + ); expect(MyAccountClient).toHaveBeenCalledWith( expect.objectContaining({ @@ -62,7 +87,7 @@ describe('createMyAccountClient', () => { }); it('calls SDK createFetcher with correct dpopNonceId in SPA mode', () => { - createMyAccountClient(createSpaConfig()); + createMyAccountClient(createSpaConfig(), defaultTelemetry, mockGetComponent); expect(mockCreateFetcher).toHaveBeenCalledWith({ dpopNonceId: MY_ACCOUNT_DPOP_NONCE_ID, @@ -76,7 +101,15 @@ describe('createMyAccountClient', () => { it('sets auth0-scope header when authParams has scope array', async () => { const mockFetch = stubFetch(); - createMyAccountClient(mockProxyConfig); + createMyAccountClient( + mockProxyConfig, + { + css: 'tailwind', + distribution: 'npm', + framework: 'react', + }, + mockGetComponent, + ); const constructorOptions = vi.mocked(MyAccountClient).mock.calls[0]![0]; const fetcher = constructorOptions.fetcher as FetcherSupplier; @@ -87,11 +120,6 @@ describe('createMyAccountClient', () => { { scope: ['read:users', 'write:users'], audience: 'test-audience' }, ); - expect(mockFetch).toHaveBeenCalledWith( - 'https://example.com', - expect.objectContaining({ method: 'GET' }), - ); - const [, requestInit] = mockFetch.mock.calls[0]!; expect((requestInit?.headers as Headers).get(AUTH0_SCOPE_HEADER)).toBe( 'read:users write:users', @@ -100,7 +128,15 @@ describe('createMyAccountClient', () => { it('does not set auth0-scope header when authParams has empty scope array', async () => { const mockFetch = stubFetch(); - createMyAccountClient(mockProxyConfig); + createMyAccountClient( + mockProxyConfig, + { + css: 'scoped', + distribution: 'shadcn', + framework: 'react', + }, + mockGetComponent, + ); const constructorOptions = vi.mocked(MyAccountClient).mock.calls[0]![0]; const fetcher = constructorOptions.fetcher as FetcherSupplier; @@ -114,7 +150,7 @@ describe('createMyAccountClient', () => { describe('SPA mode fetcher', () => { it('calls SDK fetchWithAuth with scope and audience', async () => { - createMyAccountClient(createSpaConfig()); + createMyAccountClient(createSpaConfig(), defaultTelemetry, mockGetComponent); const constructorOptions = vi.mocked(MyAccountClient).mock.calls[0]![0]; const fetcher = constructorOptions.fetcher as FetcherSupplier; @@ -133,7 +169,15 @@ describe('createMyAccountClient', () => { }); it('handles undefined authParams', async () => { - createMyAccountClient(createSpaConfig()); + createMyAccountClient( + createSpaConfig(), + { + css: 'tailwind', + distribution: 'npm', + framework: 'react', + }, + mockGetComponent, + ); const constructorOptions = vi.mocked(MyAccountClient).mock.calls[0]![0]; const fetcher = constructorOptions.fetcher as FetcherSupplier; diff --git a/packages/core/src/services/my-account/my-account-client.ts b/packages/core/src/services/my-account/my-account-client.ts index 82bbe0c3a..f45592f58 100644 --- a/packages/core/src/services/my-account/my-account-client.ts +++ b/packages/core/src/services/my-account/my-account-client.ts @@ -7,6 +7,7 @@ import { MyAccountClient } from '@auth0/myaccount-js'; import { createProxyFetcher, createSpaFetcher } from '../../api/api-utils'; +import type { TelemetryComponentGetter, TelemetryConfig } from '../../api/telemetry'; import type { ClientAuthConfig } from '../../auth/auth-types'; export const MY_ACCOUNT_PROXY_PATH = 'me'; @@ -15,22 +16,34 @@ export const MY_ACCOUNT_DPOP_NONCE_ID = '__auth0_my_account_api__'; /** * Creates a MyAccountClient configured for the given auth mode. * @param config - Auth configuration (proxy or SPA mode) + * @param telemetry - Telemetry configuration (css, distribution, framework) + * @param getComponent - Callback to get current component name * @returns Configured MyAccountClient instance * @internal */ -export function createMyAccountClient(config: ClientAuthConfig) { - if (config.mode === 'proxy') { +export function createMyAccountClient( + config: ClientAuthConfig, + telemetry: TelemetryConfig, + getComponent: TelemetryComponentGetter, +) { + const isProxyMode = config.mode === 'proxy'; + + if (isProxyMode) { return new MyAccountClient({ domain: config.domain ?? '', baseUrl: new URL(MY_ACCOUNT_PROXY_PATH, config.proxyUrl).href, - telemetry: false, - fetcher: createProxyFetcher(config.fetcher), + telemetry: false, // We handle telemetry in our custom fetcher + fetcher: createProxyFetcher({ + customFetcher: config.fetcher, + telemetry, + getComponent, + }), }); } return new MyAccountClient({ domain: config.domain, - telemetry: false, - fetcher: createSpaFetcher(config, MY_ACCOUNT_DPOP_NONCE_ID), + telemetry: false, // We handle telemetry in our custom fetcher + fetcher: createSpaFetcher(config, MY_ACCOUNT_DPOP_NONCE_ID, telemetry, getComponent), }); } diff --git a/packages/core/src/services/my-organization/__tests__/my-organization-client.test.ts b/packages/core/src/services/my-organization/__tests__/my-organization-client.test.ts index 6c3445d5e..c708947c4 100644 --- a/packages/core/src/services/my-organization/__tests__/my-organization-client.test.ts +++ b/packages/core/src/services/my-organization/__tests__/my-organization-client.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { stubFetch } from '../../../api/__tests__/__mocks__/api-utils.mocks'; import { AUTH0_SCOPE_HEADER } from '../../../api/api-utils'; +import type { TelemetryConfig } from '../../../api/telemetry'; import type { FetcherSupplier, SpaAuthConfig } from '../../../auth/auth-types'; import { createMockContextInterface, @@ -17,6 +18,14 @@ import { vi.mock('@auth0/myorganization-js', () => ({ MyOrganizationClient: vi.fn() })); +const defaultTelemetry: TelemetryConfig = { + css: 'unknown', + distribution: 'npm', + framework: 'react', +}; + +const mockGetComponent = () => 'test-component'; + describe('createMyOrganizationClient', () => { const mockFetchWithAuth = vi.fn().mockResolvedValue(new Response()); const mockCreateFetcher = vi.fn().mockReturnValue({ @@ -40,7 +49,15 @@ describe('createMyOrganizationClient', () => { }); it('creates client with baseUrl in proxy mode', () => { - createMyOrganizationClient(mockProxyConfig); + createMyOrganizationClient( + mockProxyConfig, + { + css: 'tailwind', + distribution: 'npm', + framework: 'react', + }, + mockGetComponent, + ); expect(MyOrganizationClient).toHaveBeenCalledWith( expect.objectContaining({ @@ -51,7 +68,15 @@ describe('createMyOrganizationClient', () => { }); it('creates client with domain in SPA mode', () => { - createMyOrganizationClient(createSpaConfig()); + createMyOrganizationClient( + createSpaConfig(), + { + css: 'scoped', + distribution: 'shadcn', + framework: 'react', + }, + mockGetComponent, + ); expect(MyOrganizationClient).toHaveBeenCalledWith( expect.objectContaining({ @@ -62,7 +87,7 @@ describe('createMyOrganizationClient', () => { }); it('calls SDK createFetcher with correct dpopNonceId in SPA mode', () => { - createMyOrganizationClient(createSpaConfig()); + createMyOrganizationClient(createSpaConfig(), defaultTelemetry, mockGetComponent); expect(mockCreateFetcher).toHaveBeenCalledWith({ dpopNonceId: MY_ORGANIZATION_DPOP_NONCE_ID, @@ -76,7 +101,15 @@ describe('createMyOrganizationClient', () => { it('sets auth0-scope header when authParams has scope array', async () => { const mockFetch = stubFetch(); - createMyOrganizationClient(mockProxyConfig); + createMyOrganizationClient( + mockProxyConfig, + { + css: 'tailwind', + distribution: 'npm', + framework: 'react', + }, + mockGetComponent, + ); const constructorOptions = vi.mocked(MyOrganizationClient).mock.calls[0]![0]; const fetcher = constructorOptions.fetcher as FetcherSupplier; @@ -94,7 +127,15 @@ describe('createMyOrganizationClient', () => { describe('SPA mode fetcher', () => { it('calls SDK fetchWithAuth with scope and audience', async () => { - createMyOrganizationClient(createSpaConfig()); + createMyOrganizationClient( + createSpaConfig(), + { + css: 'scoped', + distribution: 'shadcn', + framework: 'react', + }, + mockGetComponent, + ); const constructorOptions = vi.mocked(MyOrganizationClient).mock.calls[0]![0]; const fetcher = constructorOptions.fetcher as FetcherSupplier; diff --git a/packages/core/src/services/my-organization/my-organization-client.ts b/packages/core/src/services/my-organization/my-organization-client.ts index f2efb5834..9340ffa59 100644 --- a/packages/core/src/services/my-organization/my-organization-client.ts +++ b/packages/core/src/services/my-organization/my-organization-client.ts @@ -7,6 +7,7 @@ import { MyOrganizationClient } from '@auth0/myorganization-js'; import { createProxyFetcher, createSpaFetcher } from '../../api/api-utils'; +import type { TelemetryComponentGetter, TelemetryConfig } from '../../api/telemetry'; import type { ClientAuthConfig } from '../../auth/auth-types'; export const MY_ORGANIZATION_PROXY_PATH = 'my-org'; @@ -15,22 +16,34 @@ export const MY_ORGANIZATION_DPOP_NONCE_ID = '__auth0_my_organization_api__'; /** * Creates a MyOrganizationClient configured for the given auth mode. * @param config - Auth configuration (proxy or SPA mode) + * @param telemetry - Telemetry configuration (css, distribution, framework) + * @param getComponent - Callback to get current component name * @returns Configured MyOrganizationClient instance * @internal */ -export function createMyOrganizationClient(config: ClientAuthConfig) { - if (config.mode === 'proxy') { +export function createMyOrganizationClient( + config: ClientAuthConfig, + telemetry: TelemetryConfig, + getComponent: TelemetryComponentGetter, +) { + const isProxyMode = config.mode === 'proxy'; + + if (isProxyMode) { return new MyOrganizationClient({ domain: config.domain ?? '', baseUrl: new URL(MY_ORGANIZATION_PROXY_PATH, config.proxyUrl).href, - telemetry: false, - fetcher: createProxyFetcher(config.fetcher), + telemetry: false, // We handle telemetry in our custom fetcher + fetcher: createProxyFetcher({ + customFetcher: config.fetcher, + telemetry, + getComponent, + }), }); } return new MyOrganizationClient({ domain: config.domain, - telemetry: false, - fetcher: createSpaFetcher(config, MY_ORGANIZATION_DPOP_NONCE_ID), + telemetry: false, // We handle telemetry in our custom fetcher + fetcher: createSpaFetcher(config, MY_ORGANIZATION_DPOP_NONCE_ID, telemetry, getComponent), }); } diff --git a/packages/react/src/components/auth0/my-account/user-mfa-management.tsx b/packages/react/src/components/auth0/my-account/user-mfa-management.tsx index 5d54041d8..e13260aa0 100644 --- a/packages/react/src/components/auth0/my-account/user-mfa-management.tsx +++ b/packages/react/src/components/auth0/my-account/user-mfa-management.tsx @@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardDescription, CardTitle } from '@/components/ui/card'; import { List, ListItem } from '@/components/ui/list'; import { useUserMFA } from '@/hooks/my-account/use-user-mfa'; +import { useTelemetry } from '@/hooks/shared/use-telemetry'; import { useTheme } from '@/hooks/shared/use-theme'; import { useTranslator } from '@/hooks/shared/use-translator'; import { cn } from '@/lib/utils'; @@ -74,6 +75,8 @@ function UserMFAMgmt({ onBeforeAction, schema, }: UserMFAMgmtProps) { + useTelemetry('user-mfa-management'); + const { factorsByType, isLoadingFactors, diff --git a/packages/react/src/components/auth0/my-organization/domain-table.tsx b/packages/react/src/components/auth0/my-organization/domain-table.tsx index 10d01c95b..8f92706cf 100644 --- a/packages/react/src/components/auth0/my-organization/domain-table.tsx +++ b/packages/react/src/components/auth0/my-organization/domain-table.tsx @@ -16,6 +16,7 @@ import { StyledScope } from '@/components/auth0/shared/styled-scope'; import { Badge } from '@/components/ui/badge'; import { useDomainTable } from '@/hooks/my-organization/use-domain-table'; import { useDomainTableLogic } from '@/hooks/my-organization/use-domain-table-logic'; +import { useTelemetry } from '@/hooks/shared/use-telemetry'; import { useTheme } from '@/hooks/shared/use-theme'; import { useTranslator } from '@/hooks/shared/use-translator'; import { getStatusBadgeVariant } from '@/lib/utils/my-organization/domain-management/domain-management-utils'; @@ -31,6 +32,8 @@ import type { * @internal */ function DomainTable(props: DomainTableProps) { + useTelemetry('domain-management'); + const { schema, hideHeader = false, diff --git a/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx b/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx index 98ee1df85..cc1d96f3f 100644 --- a/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx +++ b/packages/react/src/components/auth0/my-organization/organization-details-edit.tsx @@ -8,6 +8,7 @@ import { GateKeeper } from '@/components/auth0/shared/gate-keeper/gate-keeper'; import { Header } from '@/components/auth0/shared/header'; import { StyledScope } from '@/components/auth0/shared/styled-scope'; import { useOrganizationDetailsEdit } from '@/hooks/my-organization/use-organization-details-edit'; +import { useTelemetry } from '@/hooks/shared/use-telemetry'; import { useTheme } from '@/hooks/shared/use-theme'; import { useTranslator } from '@/hooks/shared/use-translator'; import type { @@ -30,6 +31,8 @@ import type { * @internal */ function OrganizationDetailsEdit(props: OrganizationDetailsEditProps): React.JSX.Element { + useTelemetry('organization-details'); + const { schema, customMessages = {}, diff --git a/packages/react/src/components/auth0/my-organization/sso-provider-create.tsx b/packages/react/src/components/auth0/my-organization/sso-provider-create.tsx index 3ede9ed0e..054faa81e 100644 --- a/packages/react/src/components/auth0/my-organization/sso-provider-create.tsx +++ b/packages/react/src/components/auth0/my-organization/sso-provider-create.tsx @@ -13,6 +13,7 @@ import { Wizard } from '@/components/auth0/shared/wizard'; import type { StepProps } from '@/components/auth0/shared/wizard'; import { useSsoProviderCreate } from '@/hooks/my-organization/use-sso-provider-create'; import { useSsoProviderCreateLogic } from '@/hooks/my-organization/use-sso-provider-create-logic'; +import { useTelemetry } from '@/hooks/shared/use-telemetry'; import { useTheme } from '@/hooks/shared/use-theme'; import { useTranslator } from '@/hooks/shared/use-translator'; import type { @@ -36,6 +37,8 @@ import type { * @returns JSX element */ function SsoProviderCreate(props: SsoProviderCreateProps) { + useTelemetry('sso-create-configuration'); + const { createAction, backButton, diff --git a/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx b/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx index fc908ab08..3682959c7 100644 --- a/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx +++ b/packages/react/src/components/auth0/my-organization/sso-provider-edit.tsx @@ -14,6 +14,7 @@ import { StyledScope } from '@/components/auth0/shared/styled-scope'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useSsoProviderEdit } from '@/hooks/my-organization/use-sso-provider-edit'; import { useSsoProviderEditLogic } from '@/hooks/my-organization/use-sso-provider-edit-logic'; +import { useTelemetry } from '@/hooks/shared/use-telemetry'; import { useTheme } from '@/hooks/shared/use-theme'; import { useTranslator } from '@/hooks/shared/use-translator'; import { cn } from '@/lib/utils'; @@ -41,6 +42,8 @@ import type { * @returns JSX element */ function SsoProviderEdit(props: SsoProviderEditProps) { + useTelemetry('sso-edit-configuration'); + const { providerId, backButton, diff --git a/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx b/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx index 9bfd07a6a..d7c4255b2 100644 --- a/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx +++ b/packages/react/src/components/auth0/my-organization/sso-provider-table.tsx @@ -16,6 +16,7 @@ import { GateKeeper } from '@/components/auth0/shared/gate-keeper/gate-keeper'; import { Header } from '@/components/auth0/shared/header'; import { StyledScope } from '@/components/auth0/shared/styled-scope'; import { useSsoProviderTable } from '@/hooks/my-organization/use-sso-provider-table'; +import { useTelemetry } from '@/hooks/shared/use-telemetry'; import { useTheme } from '@/hooks/shared/use-theme'; import { useTranslator } from '@/hooks/shared/use-translator'; import type { @@ -38,6 +39,8 @@ import type { * @internal */ function SsoProviderTable(props: SsoProviderTableProps) { + useTelemetry('sso-table-configuration'); + const { customMessages = {}, styling = { variables: { common: {}, light: {}, dark: {} }, classes: {} }, diff --git a/packages/react/src/hooks/__tests__/use-core-client-initialization.test.tsx b/packages/react/src/hooks/__tests__/use-core-client-initialization.test.tsx index 0a854cf9e..bec005114 100644 --- a/packages/react/src/hooks/__tests__/use-core-client-initialization.test.tsx +++ b/packages/react/src/hooks/__tests__/use-core-client-initialization.test.tsx @@ -1,4 +1,4 @@ -import type { CoreClientInterface } from '@auth0/universal-components-core'; +import type { CoreClientInterface, TelemetryConfig } from '@auth0/universal-components-core'; import { renderHook, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -12,10 +12,20 @@ describe('useCoreClientInitialization', () => { initialize: vi.fn(), } as unknown as CoreClientInterface; + const defaultTelemetry: TelemetryConfig = { + css: 'tailwind', + distribution: 'npm', + framework: 'react', + }; + + const mockGetComponent = vi.fn(() => 'test-component'); + const defaultProps = { authDetails: { authProxyUrl: '/api/auth', }, + telemetry: defaultTelemetry, + getComponent: mockGetComponent, }; beforeEach(() => { @@ -39,7 +49,12 @@ describe('useCoreClientInitialization', () => { expect(result.current).toBe(mockCoreClient); }); - expect(createCoreClient).toHaveBeenCalledWith(defaultProps.authDetails, undefined); + expect(createCoreClient).toHaveBeenCalledWith( + defaultProps.authDetails, + undefined, + defaultTelemetry, + mockGetComponent, + ); }); it('should pass i18nOptions to createCoreClient', async () => { @@ -48,6 +63,12 @@ describe('useCoreClientInitialization', () => { const propsWithI18n = { authDetails: { authProxyUrl: '/api/auth' }, i18nOptions: { currentLanguage: 'es', fallbackLanguage: 'en' }, + telemetry: { + css: 'scoped' as const, + distribution: 'shadcn' as const, + framework: 'react' as const, + }, + getComponent: mockGetComponent, }; const { result } = renderHook(() => useCoreClientInitialization(propsWithI18n)); @@ -59,6 +80,8 @@ describe('useCoreClientInitialization', () => { expect(createCoreClient).toHaveBeenCalledWith( propsWithI18n.authDetails, propsWithI18n.i18nOptions, + propsWithI18n.telemetry, + mockGetComponent, ); }); @@ -90,6 +113,8 @@ describe('useCoreClientInitialization', () => { rerender({ authDetails: { authProxyUrl: '/api/auth-v2' }, + telemetry: defaultTelemetry, + getComponent: mockGetComponent, }); await waitFor(() => { @@ -102,6 +127,8 @@ describe('useCoreClientInitialization', () => { const propsWithDomain = { authDetails: { authProxyUrl: '/api/auth', domain: 'test.auth0.com' }, + telemetry: defaultTelemetry, + getComponent: mockGetComponent, }; const { result, rerender } = renderHook((props) => useCoreClientInitialization(props), { @@ -114,6 +141,8 @@ describe('useCoreClientInitialization', () => { rerender({ authDetails: { authProxyUrl: '/api/auth', domain: 'new.auth0.com' }, + telemetry: defaultTelemetry, + getComponent: mockGetComponent, }); expect(createCoreClient).toHaveBeenCalledTimes(1); diff --git a/packages/react/src/hooks/__tests__/use-telemetry.test.tsx b/packages/react/src/hooks/__tests__/use-telemetry.test.tsx new file mode 100644 index 000000000..2fa11df2f --- /dev/null +++ b/packages/react/src/hooks/__tests__/use-telemetry.test.tsx @@ -0,0 +1,74 @@ +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { describe, it, expect, beforeEach } from 'vitest'; + +import { useTelemetry } from '@/hooks/shared/use-telemetry'; +import { TelemetryProvider } from '@/providers/telemetry-provider'; + +describe('useTelemetry', () => { + let componentRef: { current: string }; + + beforeEach(() => { + componentRef = { current: 'unknown' }; + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it('should set componentRef to the provided component name', () => { + renderHook(() => useTelemetry('test-component'), { wrapper }); + + expect(componentRef.current).toBe('test-component'); + }); + + it('should restore previous value on unmount', () => { + componentRef.current = 'previous-component'; + + const { unmount } = renderHook(() => useTelemetry('test-component'), { wrapper }); + + expect(componentRef.current).toBe('test-component'); + + unmount(); + + expect(componentRef.current).toBe('previous-component'); + }); + + it('should update componentRef when component name changes', () => { + const { rerender } = renderHook(({ name }) => useTelemetry(name), { + wrapper, + initialProps: { name: 'first-component' }, + }); + + expect(componentRef.current).toBe('first-component'); + + rerender({ name: 'second-component' }); + + expect(componentRef.current).toBe('second-component'); + }); + + it('should handle nested components correctly', () => { + const outerRef = { current: 'unknown' }; + const OuterWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { unmount: unmountOuter } = renderHook(() => useTelemetry('outer-component'), { + wrapper: OuterWrapper, + }); + + expect(outerRef.current).toBe('outer-component'); + + const { unmount: unmountInner } = renderHook(() => useTelemetry('inner-component'), { + wrapper: OuterWrapper, + }); + + expect(outerRef.current).toBe('inner-component'); + + unmountInner(); + expect(outerRef.current).toBe('outer-component'); + + unmountOuter(); + expect(outerRef.current).toBe('unknown'); + }); +}); diff --git a/packages/react/src/hooks/shared/use-core-client-initialization.ts b/packages/react/src/hooks/shared/use-core-client-initialization.ts index 9c9ad0ae8..df0de3b5a 100644 --- a/packages/react/src/hooks/shared/use-core-client-initialization.ts +++ b/packages/react/src/hooks/shared/use-core-client-initialization.ts @@ -8,6 +8,8 @@ import type { CoreClientInterface, AuthDetails, I18nInitOptions, + TelemetryConfig, + TelemetryComponentGetter, } from '@auth0/universal-components-core'; import { createCoreClient } from '@auth0/universal-components-core'; import * as React from 'react'; @@ -15,6 +17,8 @@ import * as React from 'react'; interface UseCoreClientInitializationProps { authDetails: AuthDetails; i18nOptions?: I18nInitOptions; + telemetry: TelemetryConfig; + getComponent: TelemetryComponentGetter; } /** @@ -25,21 +29,33 @@ interface UseCoreClientInitializationProps { export const useCoreClientInitialization = ({ authDetails, i18nOptions, + telemetry, + getComponent, }: UseCoreClientInitializationProps): CoreClientInterface | null => { const { authProxyUrl } = authDetails; const [coreClient, setCoreClient] = React.useState(null); React.useEffect(() => { + // Wait for CSS detection to complete before initializing (skip if telemetry disabled) + if (telemetry.enabled && telemetry.css === 'unknown') { + return; + } + const initializeCoreClient = async () => { try { - const initializedCoreClient = await createCoreClient(authDetails, i18nOptions); + const initializedCoreClient = await createCoreClient( + authDetails, + i18nOptions, + telemetry, + getComponent, + ); setCoreClient(initializedCoreClient); } catch (error) { console.error(error); } }; initializeCoreClient(); - }, [authProxyUrl, i18nOptions]); + }, [authProxyUrl, i18nOptions, telemetry, getComponent]); return coreClient; }; diff --git a/packages/react/src/hooks/shared/use-telemetry.ts b/packages/react/src/hooks/shared/use-telemetry.ts new file mode 100644 index 000000000..4dca0b143 --- /dev/null +++ b/packages/react/src/hooks/shared/use-telemetry.ts @@ -0,0 +1,28 @@ +/** + * Hook for block components to declare their telemetry name. + * @module use-telemetry + * @internal + */ + +import * as React from 'react'; + +import { useTelemetryContext } from '@/providers/telemetry-provider'; + +/** + * Sets the telemetry component name for all API calls within this component tree. + * Call this at the top of block components. + * + * @param componentName - The telemetry name (e.g., 'organization-sso-configuration') + * @internal + */ +export function useTelemetry(componentName: string): void { + const { componentRef } = useTelemetryContext(); + + React.useLayoutEffect(() => { + const previous = componentRef.current; + componentRef.current = componentName; + return () => { + componentRef.current = previous; + }; + }, [componentRef, componentName]); +} diff --git a/packages/react/src/lib/utils/shared/__tests__/css-detection.test.ts b/packages/react/src/lib/utils/shared/__tests__/css-detection.test.ts new file mode 100644 index 000000000..bf63da7dc --- /dev/null +++ b/packages/react/src/lib/utils/shared/__tests__/css-detection.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { detectCssImplementation } from '@/lib/utils/shared/css-detection'; + +describe('detectCssImplementation', () => { + const originalDocument = global.document; + + afterEach(() => { + vi.restoreAllMocks(); + global.document = originalDocument; + }); + + describe('when document is undefined (SSR)', () => { + beforeEach(() => { + // @ts-expect-error - simulating SSR environment + global.document = undefined; + }); + + it('should return "unknown"', () => { + expect(detectCssImplementation()).toBe('unknown'); + }); + }); + + describe('when document is available (client)', () => { + let mockProbe: HTMLDivElement; + + beforeEach(() => { + mockProbe = document.createElement('div'); + vi.spyOn(document, 'createElement').mockReturnValue(mockProbe); + vi.spyOn(document.body, 'appendChild').mockImplementation(() => mockProbe); + vi.spyOn(document.body, 'removeChild').mockImplementation(() => mockProbe); + }); + + it('should return "tailwind" when .sr-only has position: absolute globally', () => { + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + position: 'absolute', + } as CSSStyleDeclaration); + + expect(detectCssImplementation()).toBe('tailwind'); + }); + + it('should return "scoped" when .sr-only does not have position: absolute globally', () => { + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + position: 'static', + } as CSSStyleDeclaration); + + expect(detectCssImplementation()).toBe('scoped'); + }); + + it('should create a probe element with correct attributes', () => { + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + position: 'static', + } as CSSStyleDeclaration); + + detectCssImplementation(); + + expect(mockProbe.className).toBe('sr-only'); + expect(mockProbe.style.visibility).toBe('hidden'); + expect(mockProbe.getAttribute('aria-hidden')).toBe('true'); + }); + + it('should clean up the probe element after detection', () => { + const appendSpy = vi.spyOn(document.body, 'appendChild'); + const removeSpy = vi.spyOn(document.body, 'removeChild'); + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + position: 'static', + } as CSSStyleDeclaration); + + detectCssImplementation(); + + expect(appendSpy).toHaveBeenCalledWith(mockProbe); + expect(removeSpy).toHaveBeenCalledWith(mockProbe); + }); + }); +}); diff --git a/packages/react/src/lib/utils/shared/css-detection.ts b/packages/react/src/lib/utils/shared/css-detection.ts new file mode 100644 index 000000000..7080e219e --- /dev/null +++ b/packages/react/src/lib/utils/shared/css-detection.ts @@ -0,0 +1,45 @@ +/** + * CSS implementation detection for telemetry. + * @module css-detection + * @internal + */ + +import type { CssImplementation } from '@auth0/universal-components-core'; + +/** + * Detects the CSS implementation being used by the application. + * + * Scoped CSS: imports `@auth0/universal-components-react/styles` which has + * all styles prefixed with `.auth0-universal` selector (e.g., `.auth0-universal .sr-only`). + * + * Tailwind CSS: imports `@auth0/universal-components-react/tailwind` or uses + * Tailwind directly where utilities like `.sr-only` are global. + * + * Detection: Check if `.sr-only` styles only apply inside `.auth0-universal` wrapper. + * - Scoped: `.sr-only` without wrapper has no effect, with wrapper it works + * - Tailwind: `.sr-only` works globally without needing wrapper + * + * @returns The detected CSS implementation ('scoped' or 'tailwind') + * @internal + */ +export function detectCssImplementation(): CssImplementation { + if (typeof document === 'undefined') { + return 'unknown'; + } + const element = document.body; + // Create probe without .auth0-universal wrapper + const probeWithoutWrapper = document.createElement('div'); + probeWithoutWrapper.className = 'sr-only'; + probeWithoutWrapper.style.visibility = 'hidden'; + probeWithoutWrapper.setAttribute('aria-hidden', 'true'); + element.appendChild(probeWithoutWrapper); + + const computedWithout = getComputedStyle(probeWithoutWrapper); + // In Tailwind mode, .sr-only works globally (position: absolute) + // In Scoped mode, .sr-only only works inside .auth0-universal wrapper + const srOnlyWorksGlobally = computedWithout.position === 'absolute'; + + element.removeChild(probeWithoutWrapper); + + return srOnlyWorksGlobally ? 'tailwind' : 'scoped'; +} diff --git a/packages/react/src/providers/__tests__/proxy-provider.test.tsx b/packages/react/src/providers/__tests__/proxy-provider.test.tsx index 2fdd3db47..476cda2e4 100644 --- a/packages/react/src/providers/__tests__/proxy-provider.test.tsx +++ b/packages/react/src/providers/__tests__/proxy-provider.test.tsx @@ -135,7 +135,7 @@ describe('Auth0ComponentProvider', () => { }); it('should render fallback when coreClient is not initialized', () => { - mockUseCoreClientInitialization.mockReturnValueOnce(null as never); + mockUseCoreClientInitialization.mockReturnValue(null as never); render( { }); it('should render fallback when coreClient is not initialized', () => { - mockUseCoreClientInitialization.mockReturnValueOnce(null as never); + mockUseCoreClientInitialization.mockReturnValue(null as never); render( diff --git a/packages/react/src/providers/__tests__/telemetry-provider.test.tsx b/packages/react/src/providers/__tests__/telemetry-provider.test.tsx new file mode 100644 index 000000000..69c578a11 --- /dev/null +++ b/packages/react/src/providers/__tests__/telemetry-provider.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, it, expect } from 'vitest'; + +import { + TelemetryContext, + TelemetryProvider, + useTelemetryContext, +} from '@/providers/telemetry-provider'; + +describe('TelemetryProvider', () => { + it('should provide componentRef to children', () => { + const componentRef = { current: 'test-component' }; + + const TestChild = () => { + const context = React.useContext(TelemetryContext); + return
{context?.componentRef.current}
; + }; + + render( + + + , + ); + + expect(screen.getByTestId('result')).toHaveTextContent('test-component'); + }); + + it('should update when componentRef changes', () => { + const componentRef = { current: 'initial' }; + + const TestChild = () => { + const context = React.useContext(TelemetryContext); + return
{context?.componentRef.current}
; + }; + + render( + + + , + ); + + expect(screen.getByTestId('result')).toHaveTextContent('initial'); + + componentRef.current = 'updated'; + + expect(componentRef.current).toBe('updated'); + }); +}); + +describe('useTelemetryContext', () => { + it('should return context value when used within provider', () => { + const componentRef = { current: 'context-test' }; + + const TestChild = () => { + const context = useTelemetryContext(); + return
{context.componentRef.current}
; + }; + + render( + + + , + ); + + expect(screen.getByTestId('result')).toHaveTextContent('context-test'); + }); + + it('should throw error when used outside provider', () => { + const TestChild = () => { + useTelemetryContext(); + return
Should not render
; + }; + + expect(() => render()).toThrow( + 'useTelemetry must be used within Auth0ComponentProvider', + ); + }); +}); diff --git a/packages/react/src/providers/proxy-provider.tsx b/packages/react/src/providers/proxy-provider.tsx index 1d576567b..cb98a5089 100644 --- a/packages/react/src/providers/proxy-provider.tsx +++ b/packages/react/src/providers/proxy-provider.tsx @@ -5,7 +5,13 @@ 'use client'; -import type { AuthDetails } from '@auth0/universal-components-core'; +import type { + AuthDetails, + CssImplementation, + DistributionChannel, + TelemetryComponentGetter, + TelemetryConfig, +} from '@auth0/universal-components-core'; import * as React from 'react'; import { Toaster } from '@/components/auth0/shared/sonner'; @@ -14,10 +20,22 @@ import { Spinner } from '@/components/ui/spinner'; import { CoreClientContext } from '@/hooks/shared/use-core-client'; import { useCoreClientInitialization } from '@/hooks/shared/use-core-client-initialization'; import { useToastProvider } from '@/hooks/shared/use-toast-provider'; +import { detectCssImplementation } from '@/lib/utils/shared/css-detection'; import { QueryProvider } from '@/providers/query-provider'; +import { TelemetryProvider } from '@/providers/telemetry-provider'; import { ThemeProvider } from '@/providers/theme-provider'; import type { Auth0ComponentProviderProps } from '@/types/auth-types'; +/** + * Build-time constant for distribution channel. + * - npm build: tsup replaces __DISTRIBUTION__ with 'npm' + * - shadcn copy: __DISTRIBUTION__ undefined, falls back to 'shadcn' + */ +declare const __DISTRIBUTION__: DistributionChannel; +const DISTRIBUTION: DistributionChannel = + typeof __DISTRIBUTION__ !== 'undefined' ? __DISTRIBUTION__ : 'shadcn'; +const FRAMEWORK = 'react' as const; + /** * Auth0 provider for RWAs using backend proxy auth. * @param props - Provider configuration including domain, proxyConfig, i18n, themeSettings, toastSettings, cacheConfig, loader, and children. @@ -41,10 +59,28 @@ export const Auth0ComponentProvider = ({ cacheConfig, loader, children, + telemetry: telemetryEnabled = true, }: Extract & { children: React.ReactNode }) => { const mergedToastSettings = useToastProvider(toastSettings); const { baseUrl, fetcher } = proxyConfig; + // CSS detection for telemetry + const [css, setCss] = React.useState('unknown'); + + // Component name ref - updated by useTelemetry in block components + const componentRef = React.useRef('unknown'); + + // Stable callback for core package to call + const getComponent = React.useCallback(() => componentRef.current, []); + + // useLayoutEffect ensures CSS is detected before paint, avoiding incorrect telemetry on early API calls + // Skip detection if telemetry is disabled since the value won't be used + React.useLayoutEffect(() => { + if (telemetryEnabled) { + setCss(detectCssImplementation()); + } + }, [telemetryEnabled]); + const memoizedAuthDetails = React.useMemo( () => ({ domain, @@ -55,9 +91,21 @@ export const Auth0ComponentProvider = ({ [domain, baseUrl, fetcher, previewMode], ); + const telemetry = React.useMemo( + () => ({ + css, + distribution: DISTRIBUTION, + framework: FRAMEWORK, + enabled: telemetryEnabled, + }), + [css, telemetryEnabled], + ); + const coreClient = useCoreClientInitialization({ authDetails: memoizedAuthDetails, i18nOptions: i18n, + telemetry, + getComponent, }); const coreClientValue = React.useMemo( @@ -76,29 +124,31 @@ export const Auth0ComponentProvider = ({ ); return ( - - {mergedToastSettings.provider === 'sonner' && ( - - )} - {coreClient ? ( - - {children} - - ) : ( - fallback - )} - + + + {mergedToastSettings.provider === 'sonner' && ( + + )} + {coreClient ? ( + + {children} + + ) : ( + fallback + )} + + ); }; diff --git a/packages/react/src/providers/spa-provider.tsx b/packages/react/src/providers/spa-provider.tsx index 4ecc5b0eb..eeaa6fee7 100644 --- a/packages/react/src/providers/spa-provider.tsx +++ b/packages/react/src/providers/spa-provider.tsx @@ -6,7 +6,14 @@ 'use client'; import { useAuth0 } from '@auth0/auth0-react'; -import type { AuthDetails, BasicAuth0ContextInterface } from '@auth0/universal-components-core'; +import type { + AuthDetails, + BasicAuth0ContextInterface, + CssImplementation, + DistributionChannel, + TelemetryComponentGetter, + TelemetryConfig, +} from '@auth0/universal-components-core'; import * as React from 'react'; import { Toaster } from '@/components/auth0/shared/sonner'; @@ -15,10 +22,22 @@ import { Spinner } from '@/components/ui/spinner'; import { CoreClientContext } from '@/hooks/shared/use-core-client'; import { useCoreClientInitialization } from '@/hooks/shared/use-core-client-initialization'; import { useToastProvider } from '@/hooks/shared/use-toast-provider'; +import { detectCssImplementation } from '@/lib/utils/shared/css-detection'; import { QueryProvider } from '@/providers/query-provider'; +import { TelemetryProvider } from '@/providers/telemetry-provider'; import { ThemeProvider } from '@/providers/theme-provider'; import type { Auth0ComponentProviderProps } from '@/types/auth-types'; +/** + * Build-time constant for distribution channel. + * - npm build: tsup replaces __DISTRIBUTION__ with 'npm' + * - shadcn copy: __DISTRIBUTION__ undefined, falls back to 'shadcn' + */ +declare const __DISTRIBUTION__: DistributionChannel; +const DISTRIBUTION: DistributionChannel = + typeof __DISTRIBUTION__ !== 'undefined' ? __DISTRIBUTION__ : 'shadcn'; +const FRAMEWORK = 'react' as const; + /** * Auth0 provider for SPAs. Wraps components with required contexts. * @param props - Provider configuration including domain, mode, authContext, i18n, themeSettings, toastSettings, cacheConfig, loader, and children. @@ -44,9 +63,27 @@ export const Auth0ComponentProvider = ( loader, children, authContext, + telemetry: telemetryEnabled = true, } = props; const mergedToastSettings = useToastProvider(toastSettings); + // CSS detection for telemetry + const [css, setCss] = React.useState('unknown'); + + // Component name ref - updated by useTelemetry in block components + const componentRef = React.useRef('unknown'); + + // Stable callback for core package to call + const getComponent = React.useCallback(() => componentRef.current, []); + + // useLayoutEffect ensures CSS is detected before paint, avoiding incorrect telemetry on early API calls + // Skip detection if telemetry is disabled since the value won't be used + React.useLayoutEffect(() => { + if (telemetryEnabled) { + setCss(detectCssImplementation()); + } + }, [telemetryEnabled]); + const auth0ReactContext = useAuth0(); const resolvedAuthContext = React.useMemo(() => { @@ -70,9 +107,21 @@ export const Auth0ComponentProvider = ( [resolvedAuthContext, previewMode], ); + const telemetry = React.useMemo( + () => ({ + css, + distribution: DISTRIBUTION, + framework: FRAMEWORK, + enabled: telemetryEnabled, + }), + [css, telemetryEnabled], + ); + const coreClient = useCoreClientInitialization({ authDetails: memoizedAuthDetails, i18nOptions: i18n, + telemetry, + getComponent, }); const coreClientValue = React.useMemo( @@ -91,29 +140,31 @@ export const Auth0ComponentProvider = ( ); return ( - - {mergedToastSettings.provider === 'sonner' && ( - - )} - {coreClient ? ( - - {children} - - ) : ( - fallback - )} - + + + {mergedToastSettings.provider === 'sonner' && ( + + )} + {coreClient ? ( + + {children} + + ) : ( + fallback + )} + + ); }; diff --git a/packages/react/src/providers/telemetry-provider.tsx b/packages/react/src/providers/telemetry-provider.tsx new file mode 100644 index 000000000..da3072768 --- /dev/null +++ b/packages/react/src/providers/telemetry-provider.tsx @@ -0,0 +1,45 @@ +/** + * Telemetry provider for tracking which block component initiated API calls. + * @module telemetry-provider + * @internal + */ + +import * as React from 'react'; + +interface TelemetryContextValue { + componentRef: { current: string }; +} + +export const TelemetryContext = React.createContext(null); + +interface TelemetryProviderProps { + children: React.ReactNode; + componentRef: { current: string }; +} + +/** + * Provider that wraps the component tree to enable telemetry tracking. + * @param props - Provider props + * @param props.children - Child components + * @param props.componentRef - Ref to track current component name + * @returns Provider component with telemetry context + * @internal + */ +export function TelemetryProvider({ children, componentRef }: TelemetryProviderProps) { + const value = React.useMemo(() => ({ componentRef }), [componentRef]); + return {children}; +} + +/** + * Hook to access the telemetry context. + * @returns The telemetry context value + * @throws Error if used outside of Auth0ComponentProvider + * @internal + */ +export function useTelemetryContext() { + const context = React.useContext(TelemetryContext); + if (!context) { + throw new Error('useTelemetry must be used within Auth0ComponentProvider'); + } + return context; +} diff --git a/packages/react/src/tests/utils/test-provider.tsx b/packages/react/src/tests/utils/test-provider.tsx index dc937f4c0..e837b673c 100644 --- a/packages/react/src/tests/utils/test-provider.tsx +++ b/packages/react/src/tests/utils/test-provider.tsx @@ -7,6 +7,7 @@ import type { FieldValues, UseFormReturn } from 'react-hook-form'; import { Form } from '@/components/ui/form'; import { CoreClientContext } from '@/hooks/shared/use-core-client'; import { GateKeeperContext } from '@/providers/gate-keeper-context'; +import { TelemetryProvider } from '@/providers/telemetry-provider'; import { createMockCoreClient } from '@/tests/utils/__mocks__/core/core-client.mocks'; // Create a new QueryClient for each test to avoid shared state @@ -68,11 +69,16 @@ export const TestProvider: React.FC = ({ [mockCoreClient], ); + // Create a ref for telemetry tracker + const componentRef = React.useRef('test-component'); + return ( - - {children} - + + + {children} + + ); }; diff --git a/packages/react/src/types/auth-types.ts b/packages/react/src/types/auth-types.ts index a7beb2021..8de742407 100644 --- a/packages/react/src/types/auth-types.ts +++ b/packages/react/src/types/auth-types.ts @@ -38,4 +38,5 @@ export type Auth0ComponentProviderProps = ( /** TanStack Query cache config. Use `{ enabled: false }` to disable. */ cacheConfig?: QueryCacheConfig; previewMode?: boolean; + telemetry?: boolean; }; diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts index 1dfd8f643..006196d63 100644 --- a/packages/react/tsup.config.ts +++ b/packages/react/tsup.config.ts @@ -15,4 +15,7 @@ export default defineConfig({ banner: { js: '"use client";', }, + define: { + __DISTRIBUTION__: JSON.stringify('npm'), + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b62e46c8a..84af11b55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,8 +188,8 @@ importers: examples/next-rwa: dependencies: '@auth0/nextjs-auth0': - specifier: ^4.20.0 - version: 4.20.0(next@16.1.5(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: ^4.21.0 + version: 4.21.0(next@16.1.5(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@auth0/universal-components-react': specifier: workspace:* version: link:../../packages/react @@ -737,8 +737,8 @@ packages: resolution: {integrity: sha512-mYGa95tFj3xgUKKVSi4B95Yt4FPppFfbtmWM9fvXUEgwSgmLHre6vHLwcnsXTPB/rF7ATpAtMMIsWq1N5h9Y4w==} engines: {node: '>=20.0.0'} - '@auth0/nextjs-auth0@4.20.0': - resolution: {integrity: sha512-F6kpRKcLUqtxE622+oazsXTbay+19G+jhIN8IamiGHGLjNdFgaDwgHCaWnZNuHQrhvlqoGLpJ+5bjfGh4v38cw==} + '@auth0/nextjs-auth0@4.21.0': + resolution: {integrity: sha512-J8EM8nixcCWx7hlGVlV4HzsYI67wrDwWhaW1HYr+7AY2e+XMBJCcKmj/JAh8l0akUf/XwDyCPnqBQIMRZjvVyw==} peerDependencies: next: ^14.2.35 || ~15.0.7 || ~15.1.11 || ~15.2.8 || ~15.3.8 || ~15.4.10 || ~15.5.9 || ^16.0.10 react: 19.2.1 @@ -5336,12 +5336,6 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jose@6.1.2: - resolution: {integrity: sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==} - - jose@6.1.3: - resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} - jose@6.2.1: resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} @@ -5850,9 +5844,6 @@ packages: nwsapi@2.2.22: resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} - oauth4webapi@3.8.3: - resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==} - oauth4webapi@3.8.5: resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} @@ -5914,9 +5905,6 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} - openid-client@6.8.1: - resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==} - openid-client@6.8.2: resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} @@ -7538,14 +7526,14 @@ snapshots: dependencies: '@auth0/auth0-auth-js': 1.5.0 - '@auth0/nextjs-auth0@4.20.0(next@16.1.5(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@auth0/nextjs-auth0@4.21.0(next@16.1.5(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@edge-runtime/cookies': 5.0.2 '@panva/hkdf': 1.2.1 - jose: 6.1.3 + jose: 6.2.1 next: 16.1.5(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - oauth4webapi: 3.8.3 - openid-client: 6.8.1 + oauth4webapi: 3.8.5 + openid-client: 6.8.2 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) swr: 2.3.7(react@19.2.1) @@ -12291,10 +12279,6 @@ snapshots: jiti@2.6.1: {} - jose@6.1.2: {} - - jose@6.1.3: {} - jose@6.2.1: {} joycon@3.1.1: {} @@ -12780,8 +12764,6 @@ snapshots: nwsapi@2.2.22: {} - oauth4webapi@3.8.3: {} - oauth4webapi@3.8.5: {} object-assign@4.1.1: {} @@ -12850,11 +12832,6 @@ snapshots: dependencies: mimic-function: 5.0.1 - openid-client@6.8.1: - dependencies: - jose: 6.1.2 - oauth4webapi: 3.8.3 - openid-client@6.8.2: dependencies: jose: 6.2.1