From b69a7be8ce0d8f693423f18e08903e9f4de067ad Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 13 Aug 2025 21:25:40 -0500 Subject: [PATCH 1/7] feat(clerk-js): Introduce debugLogger --- .changeset/all-cougars-hide.md | 6 + .../__snapshots__/file-structure.test.ts.snap | 2 + packages/clerk-js/bundlewatch.config.json | 8 +- packages/clerk-js/src/core/clerk.ts | 52 ++- packages/clerk-js/src/core/fapiClient.ts | 6 + .../modules/debug/__tests__/logger.test.ts | 373 ++++++++++++++++++ .../clerk-js/src/core/modules/debug/index.ts | 283 +++++++++++++ .../clerk-js/src/core/modules/debug/logger.ts | 185 +++++++++ .../transports/__tests__/telemetry.test.ts | 61 +++ .../modules/debug/transports/composite.ts | 50 +++ .../core/modules/debug/transports/console.ts | 83 ++++ .../modules/debug/transports/telemetry.ts | 61 +++ .../clerk-js/src/core/modules/debug/types.ts | 155 ++++++++ .../src/core/resources/Environment.ts | 3 + packages/clerk-js/src/utils/debug.ts | 236 +++++++++++ .../src/__tests__/telemetry.logs.test.ts | 164 ++++++++ packages/shared/src/telemetry/collector.ts | 174 +++++++- packages/shared/src/utils/index.ts | 1 + packages/shared/src/utils/uuid.ts | 60 +++ packages/types/src/environment.ts | 1 + packages/types/src/json.ts | 7 +- packages/types/src/telemetry.ts | 16 + pnpm-lock.yaml | 16 - 23 files changed, 1957 insertions(+), 46 deletions(-) create mode 100644 .changeset/all-cougars-hide.md create mode 100644 packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts create mode 100644 packages/clerk-js/src/core/modules/debug/index.ts create mode 100644 packages/clerk-js/src/core/modules/debug/logger.ts create mode 100644 packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.test.ts create mode 100644 packages/clerk-js/src/core/modules/debug/transports/composite.ts create mode 100644 packages/clerk-js/src/core/modules/debug/transports/console.ts create mode 100644 packages/clerk-js/src/core/modules/debug/transports/telemetry.ts create mode 100644 packages/clerk-js/src/core/modules/debug/types.ts create mode 100644 packages/clerk-js/src/utils/debug.ts create mode 100644 packages/shared/src/__tests__/telemetry.logs.test.ts create mode 100644 packages/shared/src/utils/uuid.ts diff --git a/.changeset/all-cougars-hide.md b/.changeset/all-cougars-hide.md new file mode 100644 index 00000000000..c8e55280026 --- /dev/null +++ b/.changeset/all-cougars-hide.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Introduce debugLogger for internal debugging support diff --git a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap index c079f5947e2..23c6aa543a3 100644 --- a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap +++ b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap @@ -125,6 +125,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "types/sign-up-resource.mdx", "types/signed-in-session-resource.mdx", "types/state-selectors.mdx", + "types/telemetry-log-entry.mdx", "types/use-auth-return.mdx", "types/use-session-list-return.mdx", "types/use-session-return.mdx", @@ -148,6 +149,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "shared/derive-state.mdx", "shared/extract-dev-browser-jwt-from-url.mdx", "shared/fast-deep-merge-and-replace.mdx", + "shared/generate-uuid.mdx", "shared/get-clerk-js-major-version-or-tag.mdx", "shared/get-cookie-suffix.mdx", "shared/get-env-variable.mdx", diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 4fd266e054a..9ced5d9e720 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,12 +1,12 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "625KB" }, + { "path": "./dist/clerk.js", "maxSize": "626KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "78KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "119KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "61KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "113KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "114KB" }, { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "118KB" }, - { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, + { "path": "./dist/vendors*.js", "maxSize": "41KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, { "path": "./dist/stripe-vendors*.js", "maxSize": "1KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 5679e7ef1cc..c4b9a1b045e 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -84,6 +84,9 @@ import type { Web3Provider, } from '@clerk/types'; +import type { DebugLoggerInterface } from '@/utils/debug'; +import { debugLogger, initDebugLogger } from '@/utils/debug'; + import type { MountComponentRenderer } from '../ui/Components'; import { ALLOWED_PROTOCOLS, @@ -159,6 +162,11 @@ type SetActiveHook = (intent?: 'sign-out') => void | Promise; export type ClerkCoreBroadcastChannelEvent = { type: 'signout' }; +/** + * Interface for the debug logger with all available logging methods + */ +// DebugLoggerInterface imported from '@/utils/debug' + declare global { interface Window { Clerk?: Clerk; @@ -197,8 +205,8 @@ export class Clerk implements ClerkInterface { public static sdkMetadata: SDKMetadata = { name: __PKG_NAME__, version: __PKG_VERSION__, - environment: process.env.NODE_ENV || 'production', }; + private static _billing: CommerceBillingNamespace; private static _apiKeys: APIKeysNamespace; private _checkout: ClerkInterface['__experimental_checkout'] | undefined; @@ -210,6 +218,8 @@ export class Clerk implements ClerkInterface { public __internal_country?: string | null; public telemetry: TelemetryCollector | undefined; public readonly __internal_state: State = new State(); + // Deprecated: use global singleton from `@/utils/debug` + public debugLogger?: DebugLoggerInterface; protected internal_last_error: ClerkAPIError | null = null; // converted to protected environment to support `updateEnvironment` type assertion @@ -404,6 +414,7 @@ export class Clerk implements ClerkInterface { public getFapiClient = (): FapiClient => this.#fapiClient; public load = async (options?: ClerkOptions): Promise => { + debugLogger.info('load() start', {}, 'clerk'); if (this.loaded) { return; } @@ -448,10 +459,18 @@ export class Clerk implements ClerkInterface { } else { await this.#loadInNonStandardBrowser(); } - } catch (e) { + if (this.environment?.clientDebugMode) { + initDebugLogger({ + enabled: true, + telemetryCollector: this.telemetry, + }); + } + debugLogger.info('load() complete', {}, 'clerk'); + } catch (error) { this.#publicEventBus.emit(clerkEvents.Status, 'error'); + debugLogger.error('load() failed', { error }, 'clerk'); // bubble up the error - throw e; + throw error; } }; @@ -477,6 +496,16 @@ export class Clerk implements ClerkInterface { const opts = callbackOrOptions && typeof callbackOrOptions === 'object' ? callbackOrOptions : options || {}; const redirectUrl = opts?.redirectUrl || this.buildAfterSignOutUrl(); + debugLogger.debug( + 'signOut() start', + { + hasClient: Boolean(this.client), + multiSessionCount: this.client?.signedInSessions.length ?? 0, + redirectUrl, + sessionTarget: opts?.sessionId ?? null, + }, + 'clerk', + ); const signOutCallback = typeof callbackOrOptions === 'function' ? callbackOrOptions : undefined; const executeSignOut = async () => { @@ -520,6 +549,7 @@ export class Clerk implements ClerkInterface { await executeSignOut(); + debugLogger.info('signOut() complete', { redirectUrl }, 'clerk'); return; } @@ -531,6 +561,7 @@ export class Clerk implements ClerkInterface { if (shouldSignOutCurrent) { await executeSignOut(); + debugLogger.info('signOut() complete', { redirectUrl }, 'clerk'); } }; @@ -1202,13 +1233,25 @@ export class Clerk implements ClerkInterface { const { organization, beforeEmit, redirectUrl, navigate: setActiveNavigate } = params; let { session } = params; this.__internal_setActiveInProgress = true; - + debugLogger.debug( + 'setActive() start', + { + hasClient: Boolean(this.client), + sessionTarget: typeof session === 'string' ? session : (session?.id ?? session ?? null), + organizationTarget: + typeof organization === 'string' ? organization : (organization?.id ?? organization ?? null), + redirectUrl: redirectUrl ?? null, + }, + 'clerk', + ); try { if (!this.client) { + debugLogger.warn('Clerk setActive called before client is loaded', {}, 'clerk'); throw new Error('setActive is being called before the client is loaded. Wait for init.'); } if (session === undefined && !this.session) { + debugLogger.warn('Clerk setActive precondition not met: no target session and no active session', {}, 'clerk'); throw new Error( 'setActive should either be called with a session param or there should be already an active session.', ); @@ -1414,6 +1457,7 @@ export class Clerk implements ClerkInterface { const customNavigate = options?.replace && this.#options.routerReplace ? this.#options.routerReplace : this.#options.routerPush; + debugLogger.info(`Clerk is navigating to: ${toURL}`); if (this.#options.routerDebug) { console.log(`Clerk is navigating to: ${toURL}`); } diff --git a/packages/clerk-js/src/core/fapiClient.ts b/packages/clerk-js/src/core/fapiClient.ts index f4373fc7584..d12cce9a181 100644 --- a/packages/clerk-js/src/core/fapiClient.ts +++ b/packages/clerk-js/src/core/fapiClient.ts @@ -3,6 +3,8 @@ import { retry } from '@clerk/shared/retry'; import { camelToSnake } from '@clerk/shared/underscore'; import type { ClerkAPIErrorJSON, ClientJSON, InstanceType } from '@clerk/types'; +import { debugLogger } from '@/utils/debug'; + import { buildEmailAddress as buildEmailAddressUtil, buildURL as buildUrlUtil, stringifyQueryParams } from '../utils'; import { SUPPORTED_FAPI_VERSION } from './constants'; import { clerkNetworkError } from './errors'; @@ -250,12 +252,16 @@ export function createFapiClient(options: FapiClientOptions): FapiClient { response = new Response('{}', requestInit); // Mock an empty json response } } catch (e) { + debugLogger.error('network error', { error: e, url: urlStr, method }, 'fapiClient'); clerkNetworkError(urlStr, e); } // 204 No Content responses do not have a body so we should not try to parse it const json: FapiResponseJSON | null = response.status !== 204 ? await response.json() : null; const fapiResponse: FapiResponse = Object.assign(response, { payload: json }); + if (!response.ok) { + debugLogger.error('request failed', { method, path: requestInit.path, status: response.status }, 'fapiClient'); + } await runAfterResponseCallbacks(requestInit, fapiResponse); return fapiResponse; } diff --git a/packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts b/packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts new file mode 100644 index 00000000000..1fc77109d64 --- /dev/null +++ b/packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts @@ -0,0 +1,373 @@ +import { DebugLogger } from '../logger'; +import type { DebugLogFilter } from '../types'; + +// Mock transport for testing +class MockTransport { + public sentEntries: any[] = []; + + async send(entry: any): Promise { + this.sentEntries.push(entry); + } + + reset(): void { + this.sentEntries = []; + } +} + +describe('DebugLogger', () => { + let logger: DebugLogger; + let mockTransport: MockTransport; + + beforeEach(() => { + mockTransport = new MockTransport(); + logger = new DebugLogger(mockTransport, 'trace'); + }); + + afterEach(() => { + mockTransport.reset(); + }); + + describe('basic logging functionality', () => { + it('should log messages at appropriate levels', () => { + logger.error('error message'); + logger.warn('warn message'); + logger.info('info message'); + logger.debug('debug message'); + logger.trace('trace message'); + + expect(mockTransport.sentEntries).toHaveLength(5); + expect(mockTransport.sentEntries[0].level).toBe('error'); + expect(mockTransport.sentEntries[1].level).toBe('warn'); + expect(mockTransport.sentEntries[2].level).toBe('info'); + expect(mockTransport.sentEntries[3].level).toBe('debug'); + expect(mockTransport.sentEntries[4].level).toBe('trace'); + }); + + it('should include context and source in log entries', () => { + const context = { userId: '123', action: 'test' }; + const source = 'test-module'; + + logger.info('test message', context, source); + + expect(mockTransport.sentEntries).toHaveLength(1); + expect(mockTransport.sentEntries[0].context).toEqual(context); + expect(mockTransport.sentEntries[0].source).toBe(source); + }); + + it('should respect log level filtering', () => { + const infoLogger = new DebugLogger(mockTransport, 'info'); + + infoLogger.trace('trace message'); + infoLogger.debug('debug message'); + infoLogger.info('info message'); + infoLogger.warn('warn message'); + infoLogger.error('error message'); + + expect(mockTransport.sentEntries).toHaveLength(3); + expect(mockTransport.sentEntries.map(e => e.level)).toEqual(['info', 'warn', 'error']); + }); + }); + + describe('filter functionality', () => { + describe('level filtering', () => { + it('should filter by specific log level', () => { + const filters: DebugLogFilter[] = [{ level: 'error' }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('info message'); + filteredLogger.warn('warn message'); + filteredLogger.error('error message'); + + expect(mockTransport.sentEntries).toHaveLength(1); + expect(mockTransport.sentEntries[0].level).toBe('error'); + }); + + it('should allow all levels when no level filter is specified', () => { + const filters: DebugLogFilter[] = [{}]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('info message'); + filteredLogger.warn('warn message'); + filteredLogger.error('error message'); + + expect(mockTransport.sentEntries).toHaveLength(3); + }); + }); + + describe('source filtering', () => { + it('should filter by exact string source', () => { + const filters: DebugLogFilter[] = [{ source: 'auth-module' }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('message 1', undefined, 'auth-module'); + filteredLogger.info('message 2', undefined, 'other-module'); + filteredLogger.info('message 3', undefined, 'auth-module'); + + expect(mockTransport.sentEntries).toHaveLength(2); + expect(mockTransport.sentEntries[0].source).toBe('auth-module'); + expect(mockTransport.sentEntries[1].source).toBe('auth-module'); + }); + + it('should filter by RegExp source pattern', () => { + const filters: DebugLogFilter[] = [{ source: /auth-.*/ }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('message 1', undefined, 'auth-module'); + filteredLogger.info('message 2', undefined, 'auth-service'); + filteredLogger.info('message 3', undefined, 'other-module'); + + expect(mockTransport.sentEntries).toHaveLength(2); + expect(mockTransport.sentEntries[0].source).toBe('auth-module'); + expect(mockTransport.sentEntries[1].source).toBe('auth-service'); + }); + + it('should not log when source is undefined and filter expects a source', () => { + const filters: DebugLogFilter[] = [{ source: 'auth-module' }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('message without source'); + + expect(mockTransport.sentEntries).toHaveLength(0); + }); + }); + + describe('include pattern filtering', () => { + it('should include messages matching string patterns', () => { + const filters: DebugLogFilter[] = [{ includePatterns: ['error', 'failed'] }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('User login failed'); + filteredLogger.info('Operation completed successfully'); + filteredLogger.error('Database connection error'); + + expect(mockTransport.sentEntries).toHaveLength(2); + expect(mockTransport.sentEntries[0].message).toBe('User login failed'); + expect(mockTransport.sentEntries[1].message).toBe('Database connection error'); + }); + + it('should include messages matching RegExp patterns', () => { + const filters: DebugLogFilter[] = [{ includePatterns: [/error/i, /failed/i] }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('User login FAILED'); + filteredLogger.info('Operation completed successfully'); + filteredLogger.error('Database connection ERROR'); + + expect(mockTransport.sentEntries).toHaveLength(2); + expect(mockTransport.sentEntries[0].message).toBe('User login FAILED'); + expect(mockTransport.sentEntries[1].message).toBe('Database connection ERROR'); + }); + + it('should include messages matching mixed string and RegExp patterns', () => { + const filters: DebugLogFilter[] = [{ includePatterns: ['error', /failed/i] }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('User login FAILED'); + filteredLogger.info('Operation completed successfully'); + filteredLogger.error('Database connection error'); + + expect(mockTransport.sentEntries).toHaveLength(2); + expect(mockTransport.sentEntries[0].message).toBe('User login FAILED'); + expect(mockTransport.sentEntries[1].message).toBe('Database connection error'); + }); + + it('should not log when no include patterns match', () => { + const filters: DebugLogFilter[] = [{ includePatterns: ['error', 'failed'] }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('Operation completed successfully'); + filteredLogger.info('User logged in'); + + expect(mockTransport.sentEntries).toHaveLength(0); + }); + }); + + describe('exclude pattern filtering', () => { + it('should exclude messages matching string patterns', () => { + const filters: DebugLogFilter[] = [{ excludePatterns: ['debug', 'trace'] }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('User login debug info'); + filteredLogger.info('Operation completed successfully'); + filteredLogger.error('Database connection error'); + + expect(mockTransport.sentEntries).toHaveLength(2); + expect(mockTransport.sentEntries[0].message).toBe('Operation completed successfully'); + expect(mockTransport.sentEntries[1].message).toBe('Database connection error'); + }); + + it('should exclude messages matching RegExp patterns', () => { + const filters: DebugLogFilter[] = [{ excludePatterns: [/debug/i, /trace/i] }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('User login DEBUG info'); + filteredLogger.info('Operation completed successfully'); + filteredLogger.error('Database connection error'); + + expect(mockTransport.sentEntries).toHaveLength(2); + expect(mockTransport.sentEntries[0].message).toBe('Operation completed successfully'); + expect(mockTransport.sentEntries[1].message).toBe('Database connection error'); + }); + + it('should exclude messages matching mixed string and RegExp patterns', () => { + const filters: DebugLogFilter[] = [{ excludePatterns: ['debug', /trace/i] }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('User login debug info'); + filteredLogger.info('Operation completed successfully'); + filteredLogger.error('Database connection error'); + + expect(mockTransport.sentEntries).toHaveLength(2); + expect(mockTransport.sentEntries[0].message).toBe('Operation completed successfully'); + expect(mockTransport.sentEntries[1].message).toBe('Database connection error'); + }); + + it('should exclude messages containing error in the message', () => { + const filters: DebugLogFilter[] = [{ excludePatterns: ['error'] }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('User login successful'); + filteredLogger.info('Operation completed successfully'); + filteredLogger.error('Database connection error'); + + expect(mockTransport.sentEntries).toHaveLength(2); + expect(mockTransport.sentEntries[0].message).toBe('User login successful'); + expect(mockTransport.sentEntries[1].message).toBe('Operation completed successfully'); + }); + }); + + describe('complex filter combinations', () => { + it('should apply multiple filters with AND logic', () => { + const filters: DebugLogFilter[] = [{ level: 'error', source: 'auth-module' }, { includePatterns: ['failed'] }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.error('Login failed', undefined, 'auth-module'); + filteredLogger.error('Database error', undefined, 'auth-module'); + filteredLogger.info('Login failed', undefined, 'auth-module'); + filteredLogger.error('Login failed', undefined, 'other-module'); + + expect(mockTransport.sentEntries).toHaveLength(1); + expect(mockTransport.sentEntries[0].message).toBe('Login failed'); + expect(mockTransport.sentEntries[0].level).toBe('error'); + expect(mockTransport.sentEntries[0].source).toBe('auth-module'); + }); + + it('should handle empty filter arrays', () => { + const filteredLogger = new DebugLogger(mockTransport, 'debug', []); + + filteredLogger.info('test message'); + filteredLogger.warn('test warning'); + + expect(mockTransport.sentEntries).toHaveLength(2); + }); + + it('should handle undefined filters', () => { + const filteredLogger = new DebugLogger(mockTransport, 'debug', undefined); + + filteredLogger.info('test message'); + filteredLogger.warn('test warning'); + + expect(mockTransport.sentEntries).toHaveLength(2); + }); + }); + + describe('edge cases', () => { + it('should handle empty string patterns', () => { + const filters: DebugLogFilter[] = [{ includePatterns: [''] }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('any message'); + + expect(mockTransport.sentEntries).toHaveLength(1); + }); + + it('should handle empty RegExp patterns', () => { + const filters: DebugLogFilter[] = [{ includePatterns: [/.*/] }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('any message'); + + expect(mockTransport.sentEntries).toHaveLength(1); + }); + + it('should handle special RegExp characters in string patterns', () => { + const filters: DebugLogFilter[] = [{ includePatterns: ['user.*login'] }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('user.*login attempt'); + filteredLogger.info('user login attempt'); + + expect(mockTransport.sentEntries).toHaveLength(1); + expect(mockTransport.sentEntries[0].message).toBe('user.*login attempt'); + }); + + it('should handle case-sensitive RegExp patterns', () => { + const filters: DebugLogFilter[] = [{ includePatterns: [/ERROR/] }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('Database ERROR'); + filteredLogger.info('Database error'); + + expect(mockTransport.sentEntries).toHaveLength(1); + expect(mockTransport.sentEntries[0].message).toBe('Database ERROR'); + }); + + it('should handle multiple include and exclude patterns', () => { + const filters: DebugLogFilter[] = [{ includePatterns: ['error', 'failed'], excludePatterns: ['debug'] }]; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); + + filteredLogger.info('Login failed'); + filteredLogger.info('Database error debug info'); + filteredLogger.info('Operation completed successfully'); + + expect(mockTransport.sentEntries).toHaveLength(1); + expect(mockTransport.sentEntries[0].message).toBe('Login failed'); + }); + }); + }); + + describe('log entry structure', () => { + it('should generate proper log entry structure', () => { + const context = { userId: '123' }; + const source = 'test-module'; + + logger.info('test message', context, source); + + expect(mockTransport.sentEntries).toHaveLength(1); + const entry = mockTransport.sentEntries[0]; + + expect(entry).toHaveProperty('id'); + expect(entry).toHaveProperty('timestamp'); + expect(entry).toHaveProperty('level'); + expect(entry).toHaveProperty('message'); + expect(entry).toHaveProperty('context'); + expect(entry).toHaveProperty('source'); + + expect(typeof entry.id).toBe('string'); + expect(typeof entry.timestamp).toBe('number'); + expect(entry.level).toBe('info'); + expect(entry.message).toBe('test message'); + expect(entry.context).toEqual(context); + expect(entry.source).toBe(source); + }); + + it('should generate unique IDs for each log entry', () => { + logger.info('message 1'); + logger.info('message 2'); + + expect(mockTransport.sentEntries).toHaveLength(2); + expect(mockTransport.sentEntries[0].id).not.toBe(mockTransport.sentEntries[1].id); + }); + + it('should use current timestamp for log entries', () => { + const before = Date.now(); + logger.info('test message'); + const after = Date.now(); + + expect(mockTransport.sentEntries).toHaveLength(1); + const timestamp = mockTransport.sentEntries[0].timestamp; + expect(timestamp).toBeGreaterThanOrEqual(before); + expect(timestamp).toBeLessThanOrEqual(after); + }); + }); +}); diff --git a/packages/clerk-js/src/core/modules/debug/index.ts b/packages/clerk-js/src/core/modules/debug/index.ts new file mode 100644 index 00000000000..14a29f53846 --- /dev/null +++ b/packages/clerk-js/src/core/modules/debug/index.ts @@ -0,0 +1,283 @@ +import type { TelemetryCollector } from '@clerk/shared/telemetry'; + +import { DebugLogger } from './logger'; +import { CompositeTransport } from './transports/composite'; +import { ConsoleTransport } from './transports/console'; +import { TelemetryTransport } from './transports/telemetry'; +import type { DebugLogFilter, DebugLogLevel } from './types'; + +const DEFAULT_LOG_LEVEL: DebugLogLevel = 'info'; + +/** + * Validates logger options + */ +function validateLoggerOptions(options: T): void { + if (options.logLevel && typeof options.logLevel !== 'string') { + throw new Error('logLevel must be a string'); + } +} + +/** + * Options for configuring the debug logger. + */ +export interface LoggerOptions { + /** Optional URL to which telemetry logs will be sent. */ + endpoint?: string; + /** Optional array of filters to control which logs are emitted. */ + filters?: DebugLogFilter[]; + /** Minimum log level to capture. */ + logLevel?: DebugLogLevel; + /** Optional collector instance for custom telemetry handling. */ + telemetryCollector?: TelemetryCollector; +} + +/** + * Options for console-only logger configuration. + */ +export interface ConsoleLoggerOptions { + /** Optional array of filters to control which logs are emitted. */ + filters?: DebugLogFilter[]; + /** Minimum log level to capture. */ + logLevel?: DebugLogLevel; +} + +/** + * Configuration options for a telemetry-based debug logger. + */ +export interface TelemetryLoggerOptions { + /** Optional URL to which telemetry logs will be sent. */ + endpoint?: string; + /** Optional array of filters to control which logs are emitted. */ + filters?: DebugLogFilter[]; + /** Minimum log level to capture. */ + logLevel?: DebugLogLevel; + /** Optional collector instance for custom telemetry handling. */ + telemetryCollector?: TelemetryCollector; +} + +/** + * Options for composite logger configuration. + */ +export interface CompositeLoggerOptions { + /** Optional array of filters to control which logs are emitted. */ + filters?: DebugLogFilter[]; + /** Minimum log level to capture. */ + logLevel?: DebugLogLevel; + /** Collection of transports used by the composite logger. */ + transports: Array<{ transport: ConsoleTransport | TelemetryTransport }>; +} + +/** + * Manages a singleton instance of the debug logger. + * + * Ensures only one logger is initialized and provides access to it. + * Handles asynchronous initialization and configuration. + */ +class DebugLoggerManager { + private static instance: DebugLoggerManager; + private initialized = false; + private logger: DebugLogger | null = null; + private initializationPromise: Promise | null = null; + + private constructor() {} + + /** + * Get the singleton instance + */ + static getInstance(): DebugLoggerManager { + if (!DebugLoggerManager.instance) { + DebugLoggerManager.instance = new DebugLoggerManager(); + } + return DebugLoggerManager.instance; + } + + /** + * Initialize the debug logger with the given options + * + * @param options - Configuration options for the logger + * @returns Promise resolving to the debug logger instance + */ + async initialize(options: LoggerOptions = {}): Promise { + if (this.initialized) { + return this.logger; + } + + if (this.initializationPromise) { + return this.initializationPromise; + } + + this.initializationPromise = this.performInitialization(options); + return this.initializationPromise; + } + + /** + * Performs the actual initialization logic + * + * @param options - Configuration options for the logger + * @returns Promise resolving to the debug logger instance + */ + private async performInitialization(options: LoggerOptions): Promise { + try { + validateLoggerOptions(options); + const { logLevel, filters, telemetryCollector } = options; + const finalLogLevel = logLevel ?? DEFAULT_LOG_LEVEL; + + const transports = [ + { transport: new ConsoleTransport() }, + { transport: new TelemetryTransport(telemetryCollector) }, + ]; + + const transportInstances = transports.map(t => t.transport); + const compositeTransport = new CompositeTransport(transportInstances); + const logger = new DebugLogger(compositeTransport, finalLogLevel, filters); + + this.logger = logger; + this.initialized = true; + return this.logger; + } catch (error) { + console.error('Failed to initialize debug module:', error); + this.initializationPromise = null; + return null; + } + } + + /** + * Gets the current logger instance + */ + getLogger(): DebugLogger | null { + return this.logger; + } + + /** + * Checks if the debug logger is initialized + */ + isInitialized(): boolean { + return this.initialized; + } + + /** + * Resets the initialization state + */ + reset(): void { + this.initialized = false; + this.logger = null; + this.initializationPromise = null; + } +} + +/** + * Gets or initializes the debug logger + * + * @param options - Configuration options for the logger + * @returns Promise resolving to the debug logger instance + */ +export async function getDebugLogger(options: LoggerOptions = {}): Promise { + const manager = DebugLoggerManager.getInstance(); + return manager.initialize(options); +} + +/** + * Creates a composite logger with both console and telemetry transports + * @param options - Configuration options for the logger + * @returns Object containing the logger and composite transport + */ +export function createLogger(options: { + endpoint?: string; + logLevel?: DebugLogLevel; + filters?: DebugLogFilter[]; + telemetryCollector?: TelemetryCollector; +}): { logger: DebugLogger; transport: CompositeTransport } | null { + try { + validateLoggerOptions(options); + const { logLevel, filters, telemetryCollector } = options; + const finalLogLevel = logLevel ?? DEFAULT_LOG_LEVEL; + + return createCompositeLogger({ + transports: [{ transport: new ConsoleTransport() }, { transport: new TelemetryTransport(telemetryCollector) }], + logLevel: finalLogLevel, + filters, + }); + } catch (error) { + console.error('Failed to create logger:', error); + return null; + } +} + +/** + * Creates a console-only logger + * + * @param options - Configuration options for the console logger + * @returns Object containing the logger and console transport + */ +export function createConsoleLogger( + options: ConsoleLoggerOptions, +): { logger: DebugLogger; transport: ConsoleTransport } | null { + try { + validateLoggerOptions(options); + const { logLevel, filters } = options; + const finalLogLevel = logLevel ?? DEFAULT_LOG_LEVEL; + const transport = new ConsoleTransport(); + const logger = new DebugLogger(transport, finalLogLevel, filters); + return { logger, transport }; + } catch (error) { + console.error('Failed to create console logger:', error); + return null; + } +} + +/** + * Creates a telemetry-only logger + * + * @param options - Configuration options for the telemetry logger + * @returns Object containing the logger and telemetry transport + */ +export function createTelemetryLogger( + options: TelemetryLoggerOptions, +): { logger: DebugLogger; transport: TelemetryTransport } | null { + try { + validateLoggerOptions(options); + const { logLevel, filters, telemetryCollector } = options; + const finalLogLevel = logLevel ?? DEFAULT_LOG_LEVEL; + const transport = new TelemetryTransport(telemetryCollector); + const logger = new DebugLogger(transport, finalLogLevel, filters); + return { logger, transport }; + } catch (error) { + console.error('Failed to create telemetry logger:', error); + return null; + } +} + +/** + * Creates a composite logger with multiple transports + * + * @param options - Configuration options for the composite logger + * @returns Object containing the logger and composite transport + */ +export function createCompositeLogger( + options: CompositeLoggerOptions, +): { logger: DebugLogger; transport: CompositeTransport } | null { + try { + validateLoggerOptions(options); + const { transports, logLevel, filters } = options; + const finalLogLevel = logLevel ?? DEFAULT_LOG_LEVEL; + + const transportInstances = transports.map(t => t.transport); + const compositeTransport = new CompositeTransport(transportInstances); + const logger = new DebugLogger(compositeTransport, finalLogLevel, filters); + + return { logger, transport: compositeTransport }; + } catch (error) { + console.error('Failed to create composite logger:', error); + return null; + } +} + +/** + * Reset the debug logger + * + * @internal + */ +export function __internal_resetDebugLogger(): void { + const manager = DebugLoggerManager.getInstance(); + manager.reset(); +} diff --git a/packages/clerk-js/src/core/modules/debug/logger.ts b/packages/clerk-js/src/core/modules/debug/logger.ts new file mode 100644 index 00000000000..0308be5268c --- /dev/null +++ b/packages/clerk-js/src/core/modules/debug/logger.ts @@ -0,0 +1,185 @@ +import { generateUuid } from '@clerk/shared/utils'; + +import type { DebugLogEntry, DebugLogFilter, DebugLogLevel, DebugTransport } from './types'; + +/** + * Default log level for debug logging + */ +const DEFAULT_LOG_LEVEL: DebugLogLevel = 'debug'; + +/** + * Minimal debug logger interface for engineers. + * + * @public + */ +export class DebugLogger { + private readonly filters?: DebugLogFilter[]; + private readonly logLevel: DebugLogLevel; + private readonly transport: DebugTransport; + + /** + * Creates a new debug logger. + * + * @param transport - Transport used to send log entries + * @param logLevel - Minimum log level to record. Defaults to `'debug'` + * @param filters - Optional list of filters to include or exclude messages + */ + constructor(transport: DebugTransport, logLevel?: DebugLogLevel, filters?: DebugLogFilter[]) { + this.transport = transport; + this.logLevel = logLevel ?? DEFAULT_LOG_LEVEL; + this.filters = filters; + } + + /** + * Log a debug message. + * + * @param message - Text description of the event + * @param context - Optional structured context to attach + * @param source - Optional logical source identifier + */ + debug(message: string, context?: Record, source?: string): void { + this.log('debug', message, context, source); + } + + /** + * Log an error message. + * + * @param message - Text description of the event + * @param context - Optional structured context to attach + * @param source - Optional logical source identifier + */ + error(message: string, context?: Record, source?: string): void { + this.log('error', message, context, source); + } + + /** + * Log an informational message. + * + * @param message - Text description of the event + * @param context - Optional structured context to attach + * @param source - Optional logical source identifier + */ + info(message: string, context?: Record, source?: string): void { + this.log('info', message, context, source); + } + + /** + * Log a trace message. + * + * @param message - Text description of the event + * @param context - Optional structured context to attach + * @param source - Optional logical source identifier + */ + trace(message: string, context?: Record, source?: string): void { + this.log('trace', message, context, source); + } + + /** + * Log a warning message. + * + * @param message - Text description of the event + * @param context - Optional structured context to attach + * @param source - Optional logical source identifier + */ + warn(message: string, context?: Record, source?: string): void { + this.log('warn', message, context, source); + } + + private log(level: DebugLogLevel, message: string, context?: Record, source?: string): void { + if (!this.shouldLogLevel(level)) { + return; + } + + if (!this.shouldLogFilters(level, message, source)) { + return; + } + + const entry: DebugLogEntry = { + id: generateUuid(), + timestamp: Date.now(), + level, + message, + context, + source, + }; + + this.transport.send(entry).catch(err => { + console.error('Failed to send log entry:', err); + }); + } + + private shouldLogLevel(level: DebugLogLevel): boolean { + const levels: DebugLogLevel[] = ['error', 'warn', 'info', 'debug', 'trace']; + const currentLevelIndex = levels.indexOf(this.logLevel); + const messageLevelIndex = levels.indexOf(level); + return messageLevelIndex <= currentLevelIndex; + } + + private shouldLogFilters(level: DebugLogLevel, message: string, source?: string): boolean { + if (!this.filters || this.filters.length === 0) { + return true; + } + + return this.filters.every(filter => { + if (filter.level && filter.level !== level) { + return false; + } + + if (filter.source && !this.matchesSource(filter.source, source)) { + return false; + } + + if ( + filter.includePatterns && + filter.includePatterns.length > 0 && + !this.shouldInclude(message, filter.includePatterns) + ) { + return false; + } + + if ( + filter.excludePatterns && + filter.excludePatterns.length > 0 && + this.shouldExclude(message, filter.excludePatterns) + ) { + return false; + } + + return true; + }); + } + + /** + * Checks if a source matches the given pattern (string or RegExp) + */ + private matchesSource(pattern: string | RegExp, source?: string): boolean { + if (typeof pattern === 'string') { + return source === pattern; + } + return source !== undefined && pattern.test(source); + } + + /** + * Checks if a message should be included based on the given patterns + */ + private shouldInclude(message: string, patterns: (string | RegExp)[]): boolean { + return patterns.some(pattern => this.matchesPattern(message, pattern)); + } + + /** + * Checks if a message should be excluded based on the given patterns + */ + private shouldExclude(message: string, patterns: (string | RegExp)[]): boolean { + return patterns.some(pattern => this.matchesPattern(message, pattern)); + } + + /** + * Checks if a message matches a given pattern (string or RegExp) + */ + private matchesPattern(message: string, pattern: string | RegExp): boolean { + if (typeof pattern === 'string') { + return message.includes(pattern); + } + return pattern.test(message); + } +} diff --git a/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.test.ts b/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.test.ts new file mode 100644 index 00000000000..89e6ed07f91 --- /dev/null +++ b/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.test.ts @@ -0,0 +1,61 @@ +import type { TelemetryCollector } from '@clerk/shared/telemetry'; + +import type { DebugLogEntry } from '../../types'; +import { TelemetryTransport } from '../telemetry'; + +describe('TelemetryTransport', () => { + let mockCollector: jest.Mocked; + let transport: TelemetryTransport; + + beforeEach(() => { + mockCollector = { + recordLog: jest.fn(), + record: jest.fn(), + isEnabled: true, + isDebug: false, + } as jest.Mocked; + + transport = new TelemetryTransport(mockCollector); + }); + + it('should send debug log entries to the telemetry collector', async () => { + const logEntry: DebugLogEntry = { + id: 'test-id', + level: 'info', + message: 'Test message', + timestamp: Date.now(), + context: { test: 'value' }, + source: 'test', + userId: 'user-123', + sessionId: 'session-456', + organizationId: 'org-789', + }; + + await transport.send(logEntry); + + expect(mockCollector.recordLog).toHaveBeenCalledWith({ + id: 'test-id', + level: 'info', + message: 'Test message', + timestamp: logEntry.timestamp, + context: { test: 'value' }, + source: 'test', + userId: 'user-123', + sessionId: 'session-456', + organizationId: 'org-789', + }); + }); + + it('should handle missing telemetry collector gracefully', async () => { + const transportWithoutCollector = new TelemetryTransport(); + const logEntry: DebugLogEntry = { + id: 'test-id', + level: 'info', + message: 'Test message', + timestamp: Date.now(), + }; + + // Should not throw when no collector is provided + await expect(transportWithoutCollector.send(logEntry)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/clerk-js/src/core/modules/debug/transports/composite.ts b/packages/clerk-js/src/core/modules/debug/transports/composite.ts new file mode 100644 index 00000000000..6d155603246 --- /dev/null +++ b/packages/clerk-js/src/core/modules/debug/transports/composite.ts @@ -0,0 +1,50 @@ +import type { DebugLogEntry, DebugLogFilter, DebugTransport } from '../types'; + +/** + * Options for configuring a composite debug transport that fans out logs + * to multiple underlying transports. + * + * @public + */ +export interface CompositeLoggerOptions { + filters?: DebugLogFilter[]; + logLevel?: 'error' | 'warn' | 'info' | 'debug' | 'trace'; + transports: Array<{ + options?: Record; + transport: DebugTransport; + }>; +} + +/** + * A transport that forwards each log entry to a list of child transports. + * Failures in one transport do not block others. + * + * @public + */ +export class CompositeTransport implements DebugTransport { + private readonly transports: DebugTransport[]; + + /** + * Create a composite transport. + * + * @param transports - Transports that will receive each log entry + */ + constructor(transports: DebugTransport[]) { + this.transports = transports; + } + + /** + * Send a log entry to all configured transports. + * Errors from individual transports are caught and logged to the console. + * + * @param entry - The debug log entry to send + */ + async send(entry: DebugLogEntry): Promise { + const promises = this.transports.map(transport => + transport.send(entry).catch(err => { + console.error('Failed to send to transport:', err); + }), + ); + await Promise.allSettled(promises); + } +} diff --git a/packages/clerk-js/src/core/modules/debug/transports/console.ts b/packages/clerk-js/src/core/modules/debug/transports/console.ts new file mode 100644 index 00000000000..beca7099149 --- /dev/null +++ b/packages/clerk-js/src/core/modules/debug/transports/console.ts @@ -0,0 +1,83 @@ +import type { DebugLogEntry, DebugTransport } from '../types'; + +/** + * ANSI color codes for console output + */ +const COLORS = { + blue: '\x1b[34m', + bright: '\x1b[1m', + cyan: '\x1b[36m', + dim: '\x1b[2m', + gray: '\x1b[90m', + green: '\x1b[32m', + magenta: '\x1b[35m', + red: '\x1b[31m', + reset: '\x1b[0m', + white: '\x1b[37m', + yellow: '\x1b[33m', +} as const; + +/** + * Color mapping for different log levels + */ +const LEVEL_COLORS = { + debug: COLORS.green, + error: COLORS.red, + info: COLORS.blue, + trace: COLORS.magenta, + warn: COLORS.yellow, +} as const; + +/** + * A transport that writes debug logs to the host environment's console + * (e.g. browser devtools or Node.js stdout) with ANSI color accents. + * + * @public + */ +export class ConsoleTransport implements DebugTransport { + /** + * Write a single log entry to the console, choosing the appropriate + * console method based on the entry level. + * + * @param entry - The debug log entry to print + */ + send(entry: DebugLogEntry): Promise { + const timestamp = new Date(entry.timestamp).toISOString(); + const level = entry.level.toUpperCase(); + const source = entry.source ? `[${entry.source}]` : ''; + const context = entry.context ? ` ${JSON.stringify(entry.context)}` : ''; + + const levelColor = LEVEL_COLORS[entry.level] || COLORS.white; + + const prefix = `${COLORS.bright}${COLORS.cyan}[Clerk Debug]${COLORS.reset}`; + const timestampColored = `${COLORS.dim}${timestamp}${COLORS.reset}`; + const levelColored = `${levelColor}${level}${COLORS.reset}`; + const sourceColored = source ? `${COLORS.gray}${source}${COLORS.reset}` : ''; + const messageColored = `${COLORS.white}${entry.message}${COLORS.reset}`; + const contextColored = context ? `${COLORS.dim}${context}${COLORS.reset}` : ''; + + const message = `${prefix} ${timestampColored} ${levelColored}${sourceColored}: ${messageColored}${contextColored}`; + + switch (entry.level) { + case 'error': + console.error(message); + break; + case 'warn': + console.warn(message); + break; + case 'info': + console.info(message); + break; + case 'debug': + console.debug(message); + break; + case 'trace': + console.trace(message); + break; + default: + console.log(message); + } + + return Promise.resolve(); + } +} diff --git a/packages/clerk-js/src/core/modules/debug/transports/telemetry.ts b/packages/clerk-js/src/core/modules/debug/transports/telemetry.ts new file mode 100644 index 00000000000..af211b72e52 --- /dev/null +++ b/packages/clerk-js/src/core/modules/debug/transports/telemetry.ts @@ -0,0 +1,61 @@ +import type { TelemetryCollector } from '@clerk/shared/telemetry'; + +import type { DebugLogEntry, DebugLogFilter, DebugLogLevel, DebugTransport } from '../types'; + +/** + * Options for configuring a telemetry-backed transport. + * + * @public + */ +export interface TelemetryLoggerOptions { + endpoint?: string; + logLevel?: DebugLogLevel; + filters?: DebugLogFilter[]; +} + +/** + * A transport that forwards debug logs to the shared telemetry collector + * for aggregation and remote analysis. + * + * If no collector is provided, calls are no-ops. + * + * @public + */ +export class TelemetryTransport implements DebugTransport { + private readonly collector?: TelemetryCollector; + + /** + * Create a telemetry transport. + * + * @param collector - Optional telemetry collector instance + */ + constructor(collector?: TelemetryCollector) { + this.collector = collector; + } + + /** + * Record a log entry with the telemetry collector. + * If the collector is absent, the call is ignored. + * + * @param entry - The debug log entry to record + */ + async send(entry: DebugLogEntry): Promise { + if (!this.collector) { + return; + } + + await Promise.resolve( + this.collector.recordLog({ + context: entry.context, + id: entry.id, + level: entry.level, + message: entry.message, + organizationId: entry.organizationId, + sessionId: entry.sessionId, + source: entry.source, + timestamp: entry.timestamp, + userId: entry.userId, + }), + ); + } +} diff --git a/packages/clerk-js/src/core/modules/debug/types.ts b/packages/clerk-js/src/core/modules/debug/types.ts new file mode 100644 index 00000000000..3260b625a74 --- /dev/null +++ b/packages/clerk-js/src/core/modules/debug/types.ts @@ -0,0 +1,155 @@ +/** + * Debug logging levels for different types of information + */ +export type DebugLogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'; + +/** + * Valid debug log levels + */ +export const VALID_LOG_LEVELS: readonly DebugLogLevel[] = ['error', 'warn', 'info', 'debug', 'trace'] as const; + +/** + * Debug event types that can be tracked + */ +export type DebugEventType = 'navigation' | 'custom_event'; + +/** + * Base interface for all debug log entries + */ +export interface DebugLogEntry { + readonly context?: Record; + readonly id: string; + readonly level: DebugLogLevel; + readonly message: string; + readonly organizationId?: string; + readonly sessionId?: string; + readonly source?: string; + readonly timestamp: number; + readonly userId?: string; +} + +/** + * Debug data structure for sending debug information to endpoints + */ +export interface DebugData { + readonly error?: ErrorDetails; + readonly eventId: string; + readonly eventType: DebugEventType; + readonly metadata?: Record; + readonly organizationId?: string; + readonly sessionId?: string; + readonly timestamp: number; + readonly userId?: string; +} + +/** + * Transport interface for sending debug log entries to different destinations + */ +export interface DebugTransport { + /** + * Send a single debug log entry + */ + send(entry: DebugLogEntry): Promise; +} + +/** + * Error details for debugging purposes + */ +export interface ErrorDetails { + readonly cause?: unknown; + readonly code?: string | number; + readonly columnNumber?: number; + readonly lineNumber?: number; + readonly message: string; + readonly name: string; + readonly stack?: string; + readonly url?: string; +} + +/** + * Configuration options for the debug logger + */ +export interface DebugLoggerConfig { + readonly bufferSize: number; + readonly filters?: DebugLogFilter[]; + readonly flushInterval: number; + readonly logLevel: DebugLogLevel; + readonly maxLogEntries: number; + readonly transport?: DebugTransport; +} + +/** + * Filter configuration for debug logs + */ +export interface DebugLogFilter { + readonly excludePatterns?: (string | RegExp)[]; + readonly includePatterns?: (string | RegExp)[]; + readonly level?: DebugLogLevel; + readonly sessionId?: string; + readonly source?: string | RegExp; + readonly userId?: string; +} + +/** + * Validates if a value is a valid debug log level + */ +export function isValidLogLevel(level: unknown): level is DebugLogLevel { + return typeof level === 'string' && VALID_LOG_LEVELS.includes(level as DebugLogLevel); +} + +/** + * Type guard for checking if an object is a DebugLogEntry + */ +export function isDebugLogEntry(obj: unknown): obj is DebugLogEntry { + return ( + typeof obj === 'object' && + obj !== null && + 'id' in obj && + 'timestamp' in obj && + 'level' in obj && + 'message' in obj && + typeof (obj as DebugLogEntry).id === 'string' && + typeof (obj as DebugLogEntry).timestamp === 'number' && + typeof (obj as DebugLogEntry).level === 'string' && + isValidLogLevel((obj as DebugLogEntry).level) && + typeof (obj as DebugLogEntry).message === 'string' + ); +} + +/** + * Type guard for checking if an object is DebugData + */ +export function isDebugData(obj: unknown): obj is DebugData { + const validEventTypes: DebugEventType[] = ['navigation', 'custom_event']; + + return ( + typeof obj === 'object' && + obj !== null && + 'eventType' in obj && + 'eventId' in obj && + 'timestamp' in obj && + typeof (obj as DebugData).eventType === 'string' && + validEventTypes.includes((obj as DebugData).eventType) && + typeof (obj as DebugData).eventId === 'string' && + typeof (obj as DebugData).timestamp === 'number' + ); +} + +/** + * Utility type for creating partial debug logger configurations + */ +export type PartialDebugLoggerConfig = Partial; + +/** + * Utility type for creating debug log entries without readonly constraint + */ +export type MutableDebugLogEntry = { + -readonly [K in keyof DebugLogEntry]: DebugLogEntry[K]; +}; + +/** + * Utility type for creating debug data without readonly constraint + */ +export type MutableDebugData = { + -readonly [K in keyof DebugData]: DebugData[K]; +}; diff --git a/packages/clerk-js/src/core/resources/Environment.ts b/packages/clerk-js/src/core/resources/Environment.ts index eadb9a9f698..8ea58a5f0b5 100644 --- a/packages/clerk-js/src/core/resources/Environment.ts +++ b/packages/clerk-js/src/core/resources/Environment.ts @@ -20,6 +20,7 @@ export class Environment extends BaseResource implements EnvironmentResource { authConfig: AuthConfigResource = new AuthConfig(); displayConfig: DisplayConfigResource = new DisplayConfig(); maintenanceMode: boolean = false; + clientDebugMode: boolean = false; pathRoot = '/environment'; userSettings: UserSettingsResource = new UserSettings(); organizationSettings: OrganizationSettingsResource = new OrganizationSettings(); @@ -48,6 +49,7 @@ export class Environment extends BaseResource implements EnvironmentResource { this.authConfig = new AuthConfig(data.auth_config); this.displayConfig = new DisplayConfig(data.display_config); this.maintenanceMode = this.withDefault(data.maintenance_mode, this.maintenanceMode); + this.clientDebugMode = this.withDefault(data.client_debug_mode, this.clientDebugMode); this.organizationSettings = new OrganizationSettings(data.organization_settings); this.userSettings = new UserSettings(data.user_settings); this.commerceSettings = new CommerceSettings(data.commerce_settings); @@ -88,6 +90,7 @@ export class Environment extends BaseResource implements EnvironmentResource { display_config: this.displayConfig.__internal_toSnapshot(), id: this.id ?? '', maintenance_mode: this.maintenanceMode, + client_debug_mode: this.clientDebugMode, organization_settings: this.organizationSettings.__internal_toSnapshot(), user_settings: this.userSettings.__internal_toSnapshot(), commerce_settings: this.commerceSettings.__internal_toSnapshot(), diff --git a/packages/clerk-js/src/utils/debug.ts b/packages/clerk-js/src/utils/debug.ts new file mode 100644 index 00000000000..6b32525c7b1 --- /dev/null +++ b/packages/clerk-js/src/utils/debug.ts @@ -0,0 +1,236 @@ +import type { TelemetryCollector } from '@clerk/shared/telemetry'; + +import type { DebugLogFilter, DebugLogLevel } from '@/core/modules/debug/types'; + +/** + * Lightweight logger surface that callers can import as a singleton. + * Methods are no-ops until initialized via `initDebugLogger`. + */ +export interface DebugLoggerInterface { + debug(message: string, context?: Record, source?: string): void; + error(message: string, context?: Record, source?: string): void; + info(message: string, context?: Record, source?: string): void; + trace(message: string, context?: Record, source?: string): void; + warn(message: string, context?: Record, source?: string): void; +} + +type InitOptions = { + enabled?: boolean; + filters?: DebugLogFilter[]; + logLevel?: DebugLogLevel; + telemetryCollector?: TelemetryCollector; +}; + +let isEnabled = false; +let realLogger: DebugLoggerInterface | null = null; +let lastOptions: Omit | null = null; + +type BufferedLogEntry = { + level: DebugLogLevel; + message: string; + context?: Record; + source?: string; + ts: number; +}; + +const MAX_BUFFERED_LOGS = 200; +const preInitBuffer: BufferedLogEntry[] = []; + +function pushBuffered(level: DebugLogLevel, message: string, context?: Record, source?: string): void { + preInitBuffer.push({ level, message, context, source, ts: Date.now() }); + if (preInitBuffer.length > MAX_BUFFERED_LOGS) { + preInitBuffer.shift(); + } +} + +function flushBuffered(): void { + if (!realLogger || preInitBuffer.length === 0) { + return; + } + for (const entry of preInitBuffer) { + const mergedContext = { + ...(entry.context || {}), + __preInit: true, + __preInitTs: entry.ts, + } as Record; + switch (entry.level) { + case 'error': + realLogger.error(entry.message, mergedContext, entry.source); + break; + case 'warn': + realLogger.warn(entry.message, mergedContext, entry.source); + break; + case 'info': + realLogger.info(entry.message, mergedContext, entry.source); + break; + case 'debug': + realLogger.debug(entry.message, mergedContext, entry.source); + break; + case 'trace': + realLogger.trace(entry.message, mergedContext, entry.source); + break; + default: + break; + } + } + preInitBuffer.length = 0; +} + +async function ensureInitialized(): Promise { + try { + if (!isEnabled || realLogger) { + return; + } + + const { getDebugLogger } = await import('@/core/modules/debug'); + const logger = await getDebugLogger({ + filters: lastOptions?.filters, + logLevel: lastOptions?.logLevel ?? 'trace', + telemetryCollector: lastOptions?.telemetryCollector, + }); + + if (logger) { + realLogger = logger; + flushBuffered(); + } + } catch (error) { + const message = 'Debug logger initialization failed'; + if (isEnabled && realLogger) { + try { + realLogger.trace(message, { error }); + } catch { + // ignore secondary logging errors + } + } else { + try { + // Use a safe, minimal fallback to avoid noisy errors + console.debug?.(message, error); + } catch { + // ignore secondary logging errors + } + } + // Silently return to avoid unhandled rejections and preserve behavior + return; + } +} + +/** + * @public + * Initialize or update the global debug logger configuration. + * + * Behavior: + * - Safe to call multiple times; subsequent calls update options and re-initialize if needed + * - When disabled, the logger becomes a no-op and any existing real logger is cleared + * - Initialization happens asynchronously; errors are handled internally without throwing + * + * Options and defaults: + * - options.enabled: defaults to true + * - options.logLevel: defaults to 'trace' + * - options.filters: optional include/exclude filters and matching rules + * - options.telemetryCollector: optional telemetry sink to forward logs + * + * @param options - Configuration options + * @param options.enabled - Enables the logger; when false, logger is a no-op (default: true) + * @param options.filters - Filters applied to log entries (level, source, include/exclude patterns) + * @param options.logLevel - Minimal level to log; lower-priority logs are ignored (default: 'trace') + * @param options.telemetryCollector - Collector used by the debug transport for emitting telemetry + * + * @example + * ```ts + * import { initDebugLogger, debugLogger } from '@/utils/debug'; + * + * initDebugLogger({ enabled: true, logLevel: 'info' }); + * debugLogger.info('Widget rendered', { widgetId: 'w1' }, 'ui'); + * ``` + */ +export function initDebugLogger(options: InitOptions = {}): void { + const { enabled = true, ...rest } = options; + lastOptions = rest; + isEnabled = Boolean(enabled); + + if (!isEnabled) { + realLogger = null; + return; + } + + if (realLogger) { + void (async () => { + try { + const { __internal_resetDebugLogger, getDebugLogger } = await import('@/core/modules/debug'); + __internal_resetDebugLogger(); + const logger = await getDebugLogger({ + filters: lastOptions?.filters, + logLevel: lastOptions?.logLevel ?? 'trace', + telemetryCollector: lastOptions?.telemetryCollector, + }); + if (logger) { + realLogger = logger; + flushBuffered(); + } + } catch (error) { + try { + console.debug?.('Debug logger reconfiguration failed', error); + } catch { + // ignore secondary logging errors + } + } + })(); + return; + } + + void ensureInitialized(); +} + +/** + * Singleton debug logger surface. + * + * - No-op until `initDebugLogger` initializes the real logger + * - Safe to import anywhere; all methods are guarded + * + * @example + * ```ts + * import { initDebugLogger, debugLogger } from '@/utils/debug'; + * + * initDebugLogger({ enabled: true, logLevel: 'info' }); + * debugLogger.info('Loaded dashboard', { page: 'home' }, 'ui'); + * ``` + */ +const baseDebugLogger: DebugLoggerInterface = { + debug(message: string, context?: Record, source?: string): void { + if (!realLogger) { + pushBuffered('debug', message, context, source); + return; + } + realLogger.debug(message, context, source); + }, + error(message: string, context?: Record, source?: string): void { + if (!realLogger) { + pushBuffered('error', message, context, source); + return; + } + realLogger.error(message, context, source); + }, + info(message: string, context?: Record, source?: string): void { + if (!realLogger) { + pushBuffered('info', message, context, source); + return; + } + realLogger.info(message, context, source); + }, + trace(message: string, context?: Record, source?: string): void { + if (!realLogger) { + pushBuffered('trace', message, context, source); + return; + } + realLogger.trace(message, context, source); + }, + warn(message: string, context?: Record, source?: string): void { + if (!realLogger) { + pushBuffered('warn', message, context, source); + return; + } + realLogger.warn(message, context, source); + }, +}; + +export const debugLogger: Readonly = Object.freeze(baseDebugLogger); diff --git a/packages/shared/src/__tests__/telemetry.logs.test.ts b/packages/shared/src/__tests__/telemetry.logs.test.ts new file mode 100644 index 00000000000..0ac912eb90b --- /dev/null +++ b/packages/shared/src/__tests__/telemetry.logs.test.ts @@ -0,0 +1,164 @@ +import 'cross-fetch/polyfill'; + +import { TelemetryCollector } from '../telemetry'; + +jest.useFakeTimers(); + +const TEST_PK = 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk'; + +describe('TelemetryCollector.recordLog', () => { + let fetchSpy: jest.SpyInstance; + let windowSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest.spyOn(global, 'fetch'); + windowSpy = jest.spyOn(window, 'window', 'get'); + }); + + afterEach(() => { + windowSpy.mockRestore(); + fetchSpy.mockRestore(); + }); + + test('sends a valid log with normalized timestamp and sanitized context', () => { + const collector = new TelemetryCollector({ publishableKey: TEST_PK }); + + const ts = Date.now(); + collector.recordLog({ + id: 'abc123', + level: 'info', + message: 'Hello world', + timestamp: ts, + context: { a: 1, b: undefined, c: () => {} }, + } as any); + + jest.runAllTimers(); + + expect(fetchSpy).toHaveBeenCalled(); + const [url, init] = fetchSpy.mock.calls[0]; + expect(String(url)).toMatch('/v1/logs'); + + const initOptions = init as RequestInit; + expect(typeof initOptions.body).toBe('string'); + const body = JSON.parse(initOptions.body as string); + expect(Array.isArray(body.logs)).toBe(true); + expect(body.logs).toHaveLength(1); + + const log = body.logs[0]; + expect(log.lvl).toBe('info'); + expect(log.msg).toBe('Hello world'); + expect(log.iid).toBe('abc123'); + expect(log.ts).toBe(new Date(ts).toISOString()); + expect(log.pk).toBe(TEST_PK); + // Function and undefined stripped out + expect(log.payload).toEqual({ a: 1 }); + }); + + test('nullifies context when missing, non-object, array, or circular', () => { + const collector = new TelemetryCollector({ publishableKey: TEST_PK }); + + const base = { + id: 'id1', + level: 'error' as const, + message: 'msg', + timestamp: Date.now(), + }; + + // undefined context + fetchSpy.mockClear(); + collector.recordLog({ ...base, context: undefined } as any); + jest.runAllTimers(); + const initOptions1 = fetchSpy.mock.calls[0][1] as RequestInit; + expect(typeof initOptions1.body).toBe('string'); + let body = JSON.parse(initOptions1.body as string); + expect(body.logs[0].payload).toBeNull(); + + // array context + fetchSpy.mockClear(); + collector.recordLog({ ...base, context: [1, 2, 3] } as any); + jest.runAllTimers(); + const initOptions2 = fetchSpy.mock.calls[0][1] as RequestInit; + expect(typeof initOptions2.body).toBe('string'); + body = JSON.parse(initOptions2.body as string); + expect(body.logs[0].payload).toBeNull(); + + // circular context + fetchSpy.mockClear(); + const circular: any = { foo: 'bar' }; + circular.self = circular; + collector.recordLog({ ...base, context: circular } as any); + jest.runAllTimers(); + const initOptions3 = fetchSpy.mock.calls[0][1] as RequestInit; + expect(typeof initOptions3.body).toBe('string'); + body = JSON.parse(initOptions3.body as string); + expect(body.logs[0].payload).toBeNull(); + }); + + test('drops invalid entries: missing id, invalid level, empty message, invalid timestamp', () => { + const collector = new TelemetryCollector({ publishableKey: TEST_PK }); + + // missing id + fetchSpy.mockClear(); + collector.recordLog({ + id: '' as unknown as string, // force invalid at runtime + level: 'info', + message: 'ok', + timestamp: Date.now(), + } as any); + jest.runAllTimers(); + expect(fetchSpy).not.toHaveBeenCalled(); + + // invalid level + fetchSpy.mockClear(); + collector.recordLog({ + id: 'id', + level: 'fatal' as unknown as any, + message: 'ok', + timestamp: Date.now(), + } as any); + jest.runAllTimers(); + expect(fetchSpy).not.toHaveBeenCalled(); + + // empty message + fetchSpy.mockClear(); + collector.recordLog({ + id: 'id', + level: 'debug', + message: '', + timestamp: Date.now(), + }); + jest.runAllTimers(); + expect(fetchSpy).not.toHaveBeenCalled(); + + // invalid timestamp (NaN) + fetchSpy.mockClear(); + collector.recordLog({ + id: 'id', + level: 'warn', + message: 'ok', + timestamp: Number.NaN, + }); + jest.runAllTimers(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test('accepts parsable timestamp strings', () => { + const collector = new TelemetryCollector({ publishableKey: TEST_PK }); + const tsString = new Date().toISOString(); + + collector.recordLog({ + id: 'abc', + level: 'trace', + message: 'ts string', + // @ts-expect-error testing runtime acceptance of string timestamps + timestamp: tsString, + }); + + jest.runAllTimers(); + expect(fetchSpy).toHaveBeenCalled(); + const initOptions4 = fetchSpy.mock.calls[0][1] as RequestInit; + expect(typeof initOptions4.body).toBe('string'); + const body = JSON.parse(initOptions4.body as string); + expect(body.logs[0].ts).toBe(new Date(tsString).toISOString()); + }); +}); diff --git a/packages/shared/src/telemetry/collector.ts b/packages/shared/src/telemetry/collector.ts index ae9944e36bc..f3da4bbbaaf 100644 --- a/packages/shared/src/telemetry/collector.ts +++ b/packages/shared/src/telemetry/collector.ts @@ -16,6 +16,7 @@ import type { TelemetryCollector as TelemetryCollectorInterface, TelemetryEvent, TelemetryEventRaw, + TelemetryLogEntry, } from '@clerk/types'; import { parsePublishableKey } from '../keys'; @@ -60,6 +61,35 @@ type TelemetryMetadata = Required< instanceType: InstanceType; }; +/** + * Structure of log data sent to the telemetry endpoint. + */ +type TelemetryLogData = { + /** Service that generated the log. */ + sdk: string; + /** The version of the SDK where the event originated from. */ + sdkv: string; + /** The version of Clerk where the event originated from. */ + cv: string; + /** Log level (info, warn, error, debug, etc.). */ + lvl: string; + /** Log message. */ + msg: string; + /** Instance ID. */ + iid: string; + /** Timestamp when log was generated. */ + ts: string; + /** Primary key. */ + pk: string | null; + /** Additional payload for the log. */ + payload: Record | null; +}; + +type TelemetryBufferItem = { kind: 'event'; value: TelemetryEvent } | { kind: 'log'; value: TelemetryLogData }; + +// Accepted log levels for runtime validation +const VALID_LOG_LEVELS = new Set(['error', 'warn', 'info', 'debug', 'trace']); + const DEFAULT_CONFIG: Partial = { samplingRate: 1, maxBufferSize: 5, @@ -73,8 +103,8 @@ export class TelemetryCollector implements TelemetryCollectorInterface { #config: Required; #eventThrottler: TelemetryEventThrottler; #metadata: TelemetryMetadata = {} as TelemetryMetadata; - #buffer: TelemetryEvent[] = []; - #pendingFlush: any; + #buffer: TelemetryBufferItem[] = []; + #pendingFlush: number | ReturnType | null = null; constructor(options: TelemetryCollectorOptions) { this.#config = { @@ -154,7 +184,61 @@ export class TelemetryCollector implements TelemetryCollectorInterface { return; } - this.#buffer.push(preparedPayload); + this.#buffer.push({ kind: 'event', value: preparedPayload }); + + this.#scheduleFlush(); + } + + /** + * Records a telemetry log entry if logging is enabled and not in debug mode. + * + * @param entry - The telemetry log entry to record. + */ + recordLog(entry: TelemetryLogEntry): void { + if (!this.#shouldRecordLog(entry)) { + return; + } + + const idIsValid = typeof entry?.id === 'string' && entry.id.trim().length > 0; + const levelIsValid = typeof entry?.level === 'string' && VALID_LOG_LEVELS.has(entry.level); + const messageIsValid = typeof entry?.message === 'string' && entry.message.trim().length > 0; + + let normalizedTimestamp: Date | null = null; + const timestampInput: unknown = (entry as unknown as { timestamp?: unknown })?.timestamp; + if (typeof timestampInput === 'number' || typeof timestampInput === 'string') { + const candidate = new Date(timestampInput); + if (!Number.isNaN(candidate.getTime())) { + normalizedTimestamp = candidate; + } + } + + if (!idIsValid || !levelIsValid || !messageIsValid || normalizedTimestamp === null) { + if (this.isDebug && typeof console !== 'undefined') { + console.warn('[clerk/telemetry] Dropping invalid telemetry log entry', { + idIsValid, + levelIsValid, + messageIsValid, + timestampIsValid: normalizedTimestamp !== null, + }); + } + return; + } + + const sdkMetadata = this.#getSDKMetadata(); + + const logData: TelemetryLogData = { + sdk: sdkMetadata.name, + sdkv: sdkMetadata.version, + cv: this.#metadata.clerkVersion ?? '', + lvl: entry.level, + msg: entry.message, + iid: entry.id, + ts: normalizedTimestamp.toISOString(), + pk: this.#metadata.publishableKey || null, + payload: this.#sanitizeContext(entry.context), + }; + + this.#buffer.push({ kind: 'log', value: logData }); this.#scheduleFlush(); } @@ -163,6 +247,11 @@ export class TelemetryCollector implements TelemetryCollectorInterface { return this.isEnabled && !this.isDebug && this.#shouldBeSampled(preparedPayload, eventSamplingRate); } + #shouldRecordLog(_entry: TelemetryLogEntry): boolean { + // Always allow logs from debug logger to be sent. Debug logger itself is already gated elsewhere. + return true; + } + #shouldBeSampled(preparedPayload: TelemetryEvent, eventSamplingRate?: number) { const randomSeed = Math.random(); @@ -191,8 +280,11 @@ export class TelemetryCollector implements TelemetryCollectorInterface { // If the buffer is full, flush immediately to make sure we minimize the chance of event loss. // Cancel any pending flushes as we're going to flush immediately if (this.#pendingFlush) { - const cancel = typeof cancelIdleCallback !== 'undefined' ? cancelIdleCallback : clearTimeout; - cancel(this.#pendingFlush); + if (typeof cancelIdleCallback !== 'undefined') { + cancelIdleCallback(Number(this.#pendingFlush)); + } else { + clearTimeout(Number(this.#pendingFlush)); + } } this.#flush(); return; @@ -206,37 +298,60 @@ export class TelemetryCollector implements TelemetryCollectorInterface { if ('requestIdleCallback' in window) { this.#pendingFlush = requestIdleCallback(() => { this.#flush(); + this.#pendingFlush = null; }); } else { // This is not an ideal solution, but it at least waits until the next tick this.#pendingFlush = setTimeout(() => { this.#flush(); + this.#pendingFlush = null; }, 0); } } #flush(): void { // Capture the current buffer and clear it immediately to avoid closure references - const eventsToSend = [...this.#buffer]; + const itemsToSend = [...this.#buffer]; this.#buffer = []; this.#pendingFlush = null; - if (eventsToSend.length === 0) { + if (itemsToSend.length === 0) { return; } - fetch(new URL('/v1/event', this.#config.endpoint), { - method: 'POST', - // TODO: We send an array here with that idea that we can eventually send multiple events. - body: JSON.stringify({ - events: eventsToSend, - }), - keepalive: true, - headers: { - 'Content-Type': 'application/json', - }, - }).catch(() => void 0); + const eventsToSend = itemsToSend + .filter(item => item.kind === 'event') + .map(item => (item as { kind: 'event'; value: TelemetryEvent }).value); + + const logsToSend = itemsToSend + .filter(item => item.kind === 'log') + .map(item => (item as { kind: 'log'; value: TelemetryLogData }).value); + + if (eventsToSend.length > 0) { + const eventsUrl = new URL('/v1/event', this.#config.endpoint); + fetch(eventsUrl, { + headers: { + 'Content-Type': 'application/json', + }, + keepalive: true, + method: 'POST', + // TODO: We send an array here with that idea that we can eventually send multiple events. + body: JSON.stringify({ events: eventsToSend }), + }).catch(() => void 0); + } + + if (logsToSend.length > 0) { + const logsUrl = new URL('/v1/logs', this.#config.endpoint); + fetch(logsUrl, { + headers: { + 'Content-Type': 'application/json', + }, + keepalive: true, + method: 'POST', + body: JSON.stringify({ logs: logsToSend }), + }).catch(() => void 0); + } } /** @@ -276,7 +391,6 @@ export class TelemetryCollector implements TelemetryCollectorInterface { if (isWindowClerkWithMetadata(windowClerk) && windowClerk.constructor.sdkMetadata) { const { name, version } = windowClerk.constructor.sdkMetadata; - // Only update properties if they exist to avoid overwriting with undefined if (name !== undefined) { sdkMetadata.name = name; } @@ -307,4 +421,26 @@ export class TelemetryCollector implements TelemetryCollectorInterface { payload, }; } + + /** + * Best-effort sanitization of the context payload. Returns a plain object with JSON-serializable + * values or null when the input is missing or not serializable. Arrays are not accepted. + */ + #sanitizeContext(context: unknown): Record | null { + if (context === null || typeof context === 'undefined') { + return null; + } + if (typeof context !== 'object') { + return null; + } + try { + const cleaned = JSON.parse(JSON.stringify(context)); + if (cleaned && typeof cleaned === 'object' && !Array.isArray(cleaned)) { + return cleaned as Record; + } + return null; + } catch { + return null; + } + } } diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 0d2d3bdbf12..49067016333 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -6,3 +6,4 @@ export { noop } from './noop'; export * from './runtimeEnvironment'; export { handleValueOrFn } from './handleValueOrFn'; export { fastDeepMergeAndReplace, fastDeepMergeAndKeep } from './fastDeepMerge'; +export { generateUuid } from './uuid'; diff --git a/packages/shared/src/utils/uuid.ts b/packages/shared/src/utils/uuid.ts new file mode 100644 index 00000000000..ecc2b177c2a --- /dev/null +++ b/packages/shared/src/utils/uuid.ts @@ -0,0 +1,60 @@ +/** + * Generates a RFC 4122 v4 UUID using the best available source of randomness. + * + * Order of preference: + * - crypto.randomUUID (when available) + * - crypto.getRandomValues with manual v4 formatting + * - Math.random-based fallback (not cryptographically secure; last resort) + */ +export function generateUuid(): string { + const cryptoApi = (globalThis as unknown as { crypto?: Crypto }).crypto; + + if (cryptoApi && typeof (cryptoApi as any).randomUUID === 'function') { + return (cryptoApi as any).randomUUID(); + } + + if (cryptoApi && typeof cryptoApi.getRandomValues === 'function') { + const bytes = new Uint8Array(16); + cryptoApi.getRandomValues(bytes); + + // Per RFC 4122 ยง4.4 + bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10 + + const hex: string[] = []; + for (let i = 0; i < bytes.length; i++) { + hex.push((bytes[i] + 0x100).toString(16).substring(1)); + } + + return ( + hex[0] + + hex[1] + + hex[2] + + hex[3] + + '-' + + hex[4] + + hex[5] + + '-' + + hex[6] + + hex[7] + + '-' + + hex[8] + + hex[9] + + '-' + + hex[10] + + hex[11] + + hex[12] + + hex[13] + + hex[14] + + hex[15] + ); + } + + // Last-resort fallback for very old environments (not cryptographically secure) + // Format: 8-4-4-4-12, with version=4 and variant=8|9|a|b + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.floor(Math.random() * 16); + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} diff --git a/packages/types/src/environment.ts b/packages/types/src/environment.ts index b8e2d23cbc2..fbe1725b412 100644 --- a/packages/types/src/environment.ts +++ b/packages/types/src/environment.ts @@ -19,5 +19,6 @@ export interface EnvironmentResource extends ClerkResource { isDevelopmentOrStaging: () => boolean; onWindowLocationHost: () => boolean; maintenanceMode: boolean; + clientDebugMode: boolean; __internal_toSnapshot: () => EnvironmentJSONSnapshot; } diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index a666690907e..d4cfab0cb91 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -71,13 +71,14 @@ export interface ImageJSON { } export interface EnvironmentJSON extends ClerkResourceJSON { - auth_config: AuthConfigJSON; api_keys_settings: APIKeysSettingsJSON; + auth_config: AuthConfigJSON; + client_debug_mode?: boolean; commerce_settings: CommerceSettingsJSON; display_config: DisplayConfigJSON; - user_settings: UserSettingsJSON; - organization_settings: OrganizationSettingsJSON; maintenance_mode: boolean; + organization_settings: OrganizationSettingsJSON; + user_settings: UserSettingsJSON; } export interface ClientJSON extends ClerkResourceJSON { diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 7cd0118e056..60725596271 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -43,8 +43,24 @@ export type TelemetryEventRaw = { payload: Payload; }; +/** + * Debug log entry interface for telemetry collector + */ +export interface TelemetryLogEntry { + readonly context?: Record; + readonly id: string; + readonly level: 'error' | 'warn' | 'info' | 'debug' | 'trace'; + readonly message: string; + readonly organizationId?: string; + readonly sessionId?: string; + readonly source?: string; + readonly timestamp: number; + readonly userId?: string; +} + export interface TelemetryCollector { isEnabled: boolean; isDebug: boolean; record(event: TelemetryEventRaw): void; + recordLog(entry: TelemetryLogEntry): void; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 644ea557c51..7bae365780d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3412,82 +3412,66 @@ packages: '@miniflare/cache@2.14.4': resolution: {integrity: sha512-ayzdjhcj+4mjydbNK7ZGDpIXNliDbQY4GPcY2KrYw0v1OSUdj5kZUkygD09fqoGRfAks0d91VelkyRsAXX8FQA==} engines: {node: '>=16.13'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/core@2.14.4': resolution: {integrity: sha512-FMmZcC1f54YpF4pDWPtdQPIO8NXfgUxCoR9uyrhxKJdZu7M6n8QKopPVNuaxR40jcsdxb7yKoQoFWnHfzJD9GQ==} engines: {node: '>=16.13'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/d1@2.14.4': resolution: {integrity: sha512-pMBVq9XWxTDdm+RRCkfXZP+bREjPg1JC8s8C0JTovA9OGmLQXqGTnFxIaS9vf1d8k3uSUGhDzPTzHr0/AUW1gA==} engines: {node: '>=16.7'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/durable-objects@2.14.4': resolution: {integrity: sha512-+JrmHP6gHHrjxV8S3axVw5lGHLgqmAGdcO/1HJUPswAyJEd3Ah2YnKhpo+bNmV4RKJCtEq9A2hbtVjBTD2YzwA==} engines: {node: '>=16.13'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/html-rewriter@2.14.4': resolution: {integrity: sha512-GB/vZn7oLbnhw+815SGF+HU5EZqSxbhIa3mu2L5MzZ2q5VOD5NHC833qG8c2GzDPhIaZ99ITY+ZJmbR4d+4aNQ==} engines: {node: '>=16.13'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/kv@2.14.4': resolution: {integrity: sha512-QlERH0Z+klwLg0xw+/gm2yC34Nnr/I0GcQ+ASYqXeIXBwjqOtMBa3YVQnocaD+BPy/6TUtSpOAShHsEj76R2uw==} engines: {node: '>=16.13'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/queues@2.14.4': resolution: {integrity: sha512-aXQ5Ik8Iq1KGMBzGenmd6Js/jJgqyYvjom95/N9GptCGpiVWE5F0XqC1SL5rCwURbHN+aWY191o8XOFyY2nCUA==} engines: {node: '>=16.7'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/r2@2.14.4': resolution: {integrity: sha512-4ctiZWh7Ty7LB3brUjmbRiGMqwyDZgABYaczDtUidblo2DxX4JZPnJ/ZAyxMPNJif32kOJhcg6arC2hEthR9Sw==} engines: {node: '>=16.13'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/runner-vm@2.14.4': resolution: {integrity: sha512-Nog0bB9SVhPbZAkTWfO4lpLAUsBXKEjlb4y+y66FJw77mPlmPlVdpjElCvmf8T3VN/pqh83kvELGM+/fucMf4g==} engines: {node: '>=16.13'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/shared-test-environment@2.14.4': resolution: {integrity: sha512-FdU2/8wEd00vIu+MfofLiHcfZWz+uCbE2VTL85KpyYfBsNGAbgRtzFMpOXdoXLqQfRu6MBiRwWpb2FbMrBzi7g==} engines: {node: '>=16.13'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/shared@2.14.4': resolution: {integrity: sha512-upl4RSB3hyCnITOFmRZjJj4A72GmkVrtfZTilkdq5Qe5TTlzsjVeDJp7AuNUM9bM8vswRo+N5jOiot6O4PVwwQ==} engines: {node: '>=16.13'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/sites@2.14.4': resolution: {integrity: sha512-O5npWopi+fw9W9Ki0gy99nuBbgDva/iXy8PDC4dAXDB/pz45nISDqldabk0rL2t4W2+lY6LXKzdOw+qJO1GQTA==} engines: {node: '>=16.13'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/storage-file@2.14.4': resolution: {integrity: sha512-JxcmX0hXf4cB0cC9+s6ZsgYCq+rpyUKRPCGzaFwymWWplrO3EjPVxKCcMxG44jsdgsII6EZihYUN2J14wwCT7A==} engines: {node: '>=16.13'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/storage-memory@2.14.4': resolution: {integrity: sha512-9jB5BqNkMZ3SFjbPFeiVkLi1BuSahMhc/W1Y9H0W89qFDrrD+z7EgRgDtHTG1ZRyi9gIlNtt9qhkO1B6W2qb2A==} engines: {node: '>=16.13'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/watcher@2.14.4': resolution: {integrity: sha512-PYn05ET2USfBAeXF6NZfWl0O32KVyE8ncQ/ngysrh3hoIV7l3qGGH7ubeFx+D8VWQ682qYhwGygUzQv2j1tGGg==} engines: {node: '>=16.13'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/web-sockets@2.14.4': resolution: {integrity: sha512-stTxvLdJ2IcGOs76AnvGYAzGvx8JvQPRxC5DW0P5zdAAnhL33noqb5LKdPt3P37BKp9FzBKZHuihQI9oVqwm0g==} engines: {node: '>=16.13'} - deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@modelcontextprotocol/sdk@1.7.0': resolution: {integrity: sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==} From 1b1f96f8e788c1219c6e22729a046e87f31d0ff7 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 13 Aug 2025 22:07:24 -0500 Subject: [PATCH 2/7] remove setting ID --- packages/clerk-js/src/core/clerk.ts | 4 ++-- packages/clerk-js/src/core/modules/debug/logger.ts | 3 --- packages/clerk-js/src/core/modules/debug/types.ts | 3 --- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index c4b9a1b045e..190328ec6bf 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -549,7 +549,7 @@ export class Clerk implements ClerkInterface { await executeSignOut(); - debugLogger.info('signOut() complete', { redirectUrl }, 'clerk'); + debugLogger.info('signOut() complete', { redirectUrl: stripOrigin(redirectUrl) }, 'clerk'); return; } @@ -561,7 +561,7 @@ export class Clerk implements ClerkInterface { if (shouldSignOutCurrent) { await executeSignOut(); - debugLogger.info('signOut() complete', { redirectUrl }, 'clerk'); + debugLogger.info('signOut() complete', { redirectUrl: stripOrigin(redirectUrl) }, 'clerk'); } }; diff --git a/packages/clerk-js/src/core/modules/debug/logger.ts b/packages/clerk-js/src/core/modules/debug/logger.ts index 0308be5268c..0e5834a1fb3 100644 --- a/packages/clerk-js/src/core/modules/debug/logger.ts +++ b/packages/clerk-js/src/core/modules/debug/logger.ts @@ -1,5 +1,3 @@ -import { generateUuid } from '@clerk/shared/utils'; - import type { DebugLogEntry, DebugLogFilter, DebugLogLevel, DebugTransport } from './types'; /** @@ -95,7 +93,6 @@ export class DebugLogger { } const entry: DebugLogEntry = { - id: generateUuid(), timestamp: Date.now(), level, message, diff --git a/packages/clerk-js/src/core/modules/debug/types.ts b/packages/clerk-js/src/core/modules/debug/types.ts index 3260b625a74..cda98d0d38c 100644 --- a/packages/clerk-js/src/core/modules/debug/types.ts +++ b/packages/clerk-js/src/core/modules/debug/types.ts @@ -18,7 +18,6 @@ export type DebugEventType = 'navigation' | 'custom_event'; */ export interface DebugLogEntry { readonly context?: Record; - readonly id: string; readonly level: DebugLogLevel; readonly message: string; readonly organizationId?: string; @@ -104,11 +103,9 @@ export function isDebugLogEntry(obj: unknown): obj is DebugLogEntry { return ( typeof obj === 'object' && obj !== null && - 'id' in obj && 'timestamp' in obj && 'level' in obj && 'message' in obj && - typeof (obj as DebugLogEntry).id === 'string' && typeof (obj as DebugLogEntry).timestamp === 'number' && typeof (obj as DebugLogEntry).level === 'string' && isValidLogLevel((obj as DebugLogEntry).level) && From 1c07e4c3d5ea5627beea67897c34e606f569edd9 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 13 Aug 2025 22:41:28 -0500 Subject: [PATCH 3/7] remove generating and sending id --- .../src/core/modules/debug/transports/telemetry.ts | 1 - packages/shared/src/telemetry/collector.ts | 9 +++------ packages/types/src/telemetry.ts | 1 - 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/clerk-js/src/core/modules/debug/transports/telemetry.ts b/packages/clerk-js/src/core/modules/debug/transports/telemetry.ts index af211b72e52..4878397a1b7 100644 --- a/packages/clerk-js/src/core/modules/debug/transports/telemetry.ts +++ b/packages/clerk-js/src/core/modules/debug/transports/telemetry.ts @@ -47,7 +47,6 @@ export class TelemetryTransport implements DebugTransport { await Promise.resolve( this.collector.recordLog({ context: entry.context, - id: entry.id, level: entry.level, message: entry.message, organizationId: entry.organizationId, diff --git a/packages/shared/src/telemetry/collector.ts b/packages/shared/src/telemetry/collector.ts index f3da4bbbaaf..c1167e07242 100644 --- a/packages/shared/src/telemetry/collector.ts +++ b/packages/shared/src/telemetry/collector.ts @@ -75,8 +75,8 @@ type TelemetryLogData = { lvl: string; /** Log message. */ msg: string; - /** Instance ID. */ - iid: string; + /** Instance ID (deprecated; omitted). */ + iid?: string; /** Timestamp when log was generated. */ ts: string; /** Primary key. */ @@ -199,7 +199,6 @@ export class TelemetryCollector implements TelemetryCollectorInterface { return; } - const idIsValid = typeof entry?.id === 'string' && entry.id.trim().length > 0; const levelIsValid = typeof entry?.level === 'string' && VALID_LOG_LEVELS.has(entry.level); const messageIsValid = typeof entry?.message === 'string' && entry.message.trim().length > 0; @@ -212,10 +211,9 @@ export class TelemetryCollector implements TelemetryCollectorInterface { } } - if (!idIsValid || !levelIsValid || !messageIsValid || normalizedTimestamp === null) { + if (!levelIsValid || !messageIsValid || normalizedTimestamp === null) { if (this.isDebug && typeof console !== 'undefined') { console.warn('[clerk/telemetry] Dropping invalid telemetry log entry', { - idIsValid, levelIsValid, messageIsValid, timestampIsValid: normalizedTimestamp !== null, @@ -232,7 +230,6 @@ export class TelemetryCollector implements TelemetryCollectorInterface { cv: this.#metadata.clerkVersion ?? '', lvl: entry.level, msg: entry.message, - iid: entry.id, ts: normalizedTimestamp.toISOString(), pk: this.#metadata.publishableKey || null, payload: this.#sanitizeContext(entry.context), diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 60725596271..0c8c735ebf8 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -48,7 +48,6 @@ export type TelemetryEventRaw = { */ export interface TelemetryLogEntry { readonly context?: Record; - readonly id: string; readonly level: 'error' | 'warn' | 'info' | 'debug' | 'trace'; readonly message: string; readonly organizationId?: string; From 1c993485133539a6890994761d7868d54a1a2b2b Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 13 Aug 2025 22:59:55 -0500 Subject: [PATCH 4/7] fix tests --- .../modules/debug/__tests__/logger.test.ts | 10 ---------- .../transports/__tests__/telemetry.test.ts | 3 --- .../src/__tests__/telemetry.logs.test.ts | 19 +------------------ 3 files changed, 1 insertion(+), 31 deletions(-) diff --git a/packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts b/packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts index 1fc77109d64..2c88de7cdf6 100644 --- a/packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts +++ b/packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts @@ -336,14 +336,12 @@ describe('DebugLogger', () => { expect(mockTransport.sentEntries).toHaveLength(1); const entry = mockTransport.sentEntries[0]; - expect(entry).toHaveProperty('id'); expect(entry).toHaveProperty('timestamp'); expect(entry).toHaveProperty('level'); expect(entry).toHaveProperty('message'); expect(entry).toHaveProperty('context'); expect(entry).toHaveProperty('source'); - expect(typeof entry.id).toBe('string'); expect(typeof entry.timestamp).toBe('number'); expect(entry.level).toBe('info'); expect(entry.message).toBe('test message'); @@ -351,14 +349,6 @@ describe('DebugLogger', () => { expect(entry.source).toBe(source); }); - it('should generate unique IDs for each log entry', () => { - logger.info('message 1'); - logger.info('message 2'); - - expect(mockTransport.sentEntries).toHaveLength(2); - expect(mockTransport.sentEntries[0].id).not.toBe(mockTransport.sentEntries[1].id); - }); - it('should use current timestamp for log entries', () => { const before = Date.now(); logger.info('test message'); diff --git a/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.test.ts b/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.test.ts index 89e6ed07f91..78ad98f6c61 100644 --- a/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.test.ts +++ b/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.test.ts @@ -20,7 +20,6 @@ describe('TelemetryTransport', () => { it('should send debug log entries to the telemetry collector', async () => { const logEntry: DebugLogEntry = { - id: 'test-id', level: 'info', message: 'Test message', timestamp: Date.now(), @@ -34,7 +33,6 @@ describe('TelemetryTransport', () => { await transport.send(logEntry); expect(mockCollector.recordLog).toHaveBeenCalledWith({ - id: 'test-id', level: 'info', message: 'Test message', timestamp: logEntry.timestamp, @@ -49,7 +47,6 @@ describe('TelemetryTransport', () => { it('should handle missing telemetry collector gracefully', async () => { const transportWithoutCollector = new TelemetryTransport(); const logEntry: DebugLogEntry = { - id: 'test-id', level: 'info', message: 'Test message', timestamp: Date.now(), diff --git a/packages/shared/src/__tests__/telemetry.logs.test.ts b/packages/shared/src/__tests__/telemetry.logs.test.ts index 0ac912eb90b..695ddbaa60c 100644 --- a/packages/shared/src/__tests__/telemetry.logs.test.ts +++ b/packages/shared/src/__tests__/telemetry.logs.test.ts @@ -25,7 +25,6 @@ describe('TelemetryCollector.recordLog', () => { const ts = Date.now(); collector.recordLog({ - id: 'abc123', level: 'info', message: 'Hello world', timestamp: ts, @@ -47,7 +46,7 @@ describe('TelemetryCollector.recordLog', () => { const log = body.logs[0]; expect(log.lvl).toBe('info'); expect(log.msg).toBe('Hello world'); - expect(log.iid).toBe('abc123'); + expect(log.iid).toBeUndefined(); expect(log.ts).toBe(new Date(ts).toISOString()); expect(log.pk).toBe(TEST_PK); // Function and undefined stripped out @@ -58,7 +57,6 @@ describe('TelemetryCollector.recordLog', () => { const collector = new TelemetryCollector({ publishableKey: TEST_PK }); const base = { - id: 'id1', level: 'error' as const, message: 'msg', timestamp: Date.now(), @@ -97,21 +95,9 @@ describe('TelemetryCollector.recordLog', () => { test('drops invalid entries: missing id, invalid level, empty message, invalid timestamp', () => { const collector = new TelemetryCollector({ publishableKey: TEST_PK }); - // missing id - fetchSpy.mockClear(); - collector.recordLog({ - id: '' as unknown as string, // force invalid at runtime - level: 'info', - message: 'ok', - timestamp: Date.now(), - } as any); - jest.runAllTimers(); - expect(fetchSpy).not.toHaveBeenCalled(); - // invalid level fetchSpy.mockClear(); collector.recordLog({ - id: 'id', level: 'fatal' as unknown as any, message: 'ok', timestamp: Date.now(), @@ -122,7 +108,6 @@ describe('TelemetryCollector.recordLog', () => { // empty message fetchSpy.mockClear(); collector.recordLog({ - id: 'id', level: 'debug', message: '', timestamp: Date.now(), @@ -133,7 +118,6 @@ describe('TelemetryCollector.recordLog', () => { // invalid timestamp (NaN) fetchSpy.mockClear(); collector.recordLog({ - id: 'id', level: 'warn', message: 'ok', timestamp: Number.NaN, @@ -147,7 +131,6 @@ describe('TelemetryCollector.recordLog', () => { const tsString = new Date().toISOString(); collector.recordLog({ - id: 'abc', level: 'trace', message: 'ts string', // @ts-expect-error testing runtime acceptance of string timestamps From 1e1241f6c0a531297ed3137dff59fd52d6d7ef47 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 14 Aug 2025 07:14:01 -0500 Subject: [PATCH 5/7] wip --- .../core/modules/debug/__tests__/logger.test.ts | 8 ++++---- .../clerk-js/src/core/modules/debug/types.ts | 16 +++++++++++++--- packages/shared/src/telemetry/collector.ts | 4 ++-- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts b/packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts index 2c88de7cdf6..26a6d2b91c9 100644 --- a/packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts +++ b/packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts @@ -1,16 +1,16 @@ import { DebugLogger } from '../logger'; -import type { DebugLogFilter } from '../types'; +import type { DebugLogEntry, DebugLogFilter } from '../types'; // Mock transport for testing class MockTransport { - public sentEntries: any[] = []; + public sentEntries: DebugLogEntry[] = []; - async send(entry: any): Promise { + async send(entry: DebugLogEntry): Promise { this.sentEntries.push(entry); } reset(): void { - this.sentEntries = []; + this.sentEntries.length = 0; } } diff --git a/packages/clerk-js/src/core/modules/debug/types.ts b/packages/clerk-js/src/core/modules/debug/types.ts index cda98d0d38c..7bd226b911e 100644 --- a/packages/clerk-js/src/core/modules/debug/types.ts +++ b/packages/clerk-js/src/core/modules/debug/types.ts @@ -8,6 +8,11 @@ export type DebugLogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'; */ export const VALID_LOG_LEVELS: readonly DebugLogLevel[] = ['error', 'warn', 'info', 'debug', 'trace'] as const; +/** + * Valid debug event types + */ +export const VALID_EVENT_TYPES: readonly DebugEventType[] = ['navigation', 'custom_event'] as const; + /** * Debug event types that can be tracked */ @@ -96,6 +101,13 @@ export function isValidLogLevel(level: unknown): level is DebugLogLevel { return typeof level === 'string' && VALID_LOG_LEVELS.includes(level as DebugLogLevel); } +/** + * Validates if a value is a valid debug event type + */ +export function isValidEventType(eventType: unknown): eventType is DebugEventType { + return typeof eventType === 'string' && VALID_EVENT_TYPES.includes(eventType as DebugEventType); +} + /** * Type guard for checking if an object is a DebugLogEntry */ @@ -117,8 +129,6 @@ export function isDebugLogEntry(obj: unknown): obj is DebugLogEntry { * Type guard for checking if an object is DebugData */ export function isDebugData(obj: unknown): obj is DebugData { - const validEventTypes: DebugEventType[] = ['navigation', 'custom_event']; - return ( typeof obj === 'object' && obj !== null && @@ -126,7 +136,7 @@ export function isDebugData(obj: unknown): obj is DebugData { 'eventId' in obj && 'timestamp' in obj && typeof (obj as DebugData).eventType === 'string' && - validEventTypes.includes((obj as DebugData).eventType) && + isValidEventType((obj as DebugData).eventType) && typeof (obj as DebugData).eventId === 'string' && typeof (obj as DebugData).timestamp === 'number' ); diff --git a/packages/shared/src/telemetry/collector.ts b/packages/shared/src/telemetry/collector.ts index c1167e07242..cc2f61656be 100644 --- a/packages/shared/src/telemetry/collector.ts +++ b/packages/shared/src/telemetry/collector.ts @@ -72,10 +72,10 @@ type TelemetryLogData = { /** The version of Clerk where the event originated from. */ cv: string; /** Log level (info, warn, error, debug, etc.). */ - lvl: string; + lvl: TelemetryLogEntry['level']; /** Log message. */ msg: string; - /** Instance ID (deprecated; omitted). */ + /** Instance ID - optional. */ iid?: string; /** Timestamp when log was generated. */ ts: string; From 98133bba9052f8a384360ffdd7d09e360c0a649d Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 14 Aug 2025 07:22:26 -0500 Subject: [PATCH 6/7] convert tests to vitest --- .../{logger.test.ts => logger.spec.ts} | 255 ++++++++---------- .../{telemetry.test.ts => telemetry.spec.ts} | 15 +- 2 files changed, 125 insertions(+), 145 deletions(-) rename packages/clerk-js/src/core/modules/debug/__tests__/{logger.test.ts => logger.spec.ts} (50%) rename packages/clerk-js/src/core/modules/debug/transports/__tests__/{telemetry.test.ts => telemetry.spec.ts} (80%) diff --git a/packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts b/packages/clerk-js/src/core/modules/debug/__tests__/logger.spec.ts similarity index 50% rename from packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts rename to packages/clerk-js/src/core/modules/debug/__tests__/logger.spec.ts index 26a6d2b91c9..76d1c315099 100644 --- a/packages/clerk-js/src/core/modules/debug/__tests__/logger.test.ts +++ b/packages/clerk-js/src/core/modules/debug/__tests__/logger.spec.ts @@ -1,3 +1,5 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + import { DebugLogger } from '../logger'; import type { DebugLogEntry, DebugLogFilter } from '../types'; @@ -133,231 +135,202 @@ describe('DebugLogger', () => { describe('include pattern filtering', () => { it('should include messages matching string patterns', () => { - const filters: DebugLogFilter[] = [{ includePatterns: ['error', 'failed'] }]; + const filters: DebugLogFilter[] = [{ includePatterns: ['user', 'auth'] }]; const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - filteredLogger.info('User login failed'); - filteredLogger.info('Operation completed successfully'); - filteredLogger.error('Database connection error'); + filteredLogger.info('user login successful'); + filteredLogger.info('auth token refreshed'); + filteredLogger.info('database connection established'); expect(mockTransport.sentEntries).toHaveLength(2); - expect(mockTransport.sentEntries[0].message).toBe('User login failed'); - expect(mockTransport.sentEntries[1].message).toBe('Database connection error'); + expect(mockTransport.sentEntries[0].message).toBe('user login successful'); + expect(mockTransport.sentEntries[1].message).toBe('auth token refreshed'); }); it('should include messages matching RegExp patterns', () => { - const filters: DebugLogFilter[] = [{ includePatterns: [/error/i, /failed/i] }]; + const filters: DebugLogFilter[] = [{ includePatterns: [/user-.*/] }]; const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - filteredLogger.info('User login FAILED'); - filteredLogger.info('Operation completed successfully'); - filteredLogger.error('Database connection ERROR'); + filteredLogger.info('user-123 logged in'); + filteredLogger.info('user-456 logged out'); + filteredLogger.info('admin panel accessed'); expect(mockTransport.sentEntries).toHaveLength(2); - expect(mockTransport.sentEntries[0].message).toBe('User login FAILED'); - expect(mockTransport.sentEntries[1].message).toBe('Database connection ERROR'); + expect(mockTransport.sentEntries[0].message).toBe('user-123 logged in'); + expect(mockTransport.sentEntries[1].message).toBe('user-456 logged out'); }); + }); - it('should include messages matching mixed string and RegExp patterns', () => { - const filters: DebugLogFilter[] = [{ includePatterns: ['error', /failed/i] }]; + describe('exclude pattern filtering', () => { + it('should exclude messages matching string patterns', () => { + const filters: DebugLogFilter[] = [{ excludePatterns: ['debug', 'test'] }]; const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - filteredLogger.info('User login FAILED'); - filteredLogger.info('Operation completed successfully'); - filteredLogger.error('Database connection error'); + filteredLogger.info('user login successful'); + filteredLogger.info('debug information logged'); + filteredLogger.info('test data generated'); - expect(mockTransport.sentEntries).toHaveLength(2); - expect(mockTransport.sentEntries[0].message).toBe('User login FAILED'); - expect(mockTransport.sentEntries[1].message).toBe('Database connection error'); + expect(mockTransport.sentEntries).toHaveLength(1); + expect(mockTransport.sentEntries[0].message).toBe('user login successful'); }); - it('should not log when no include patterns match', () => { - const filters: DebugLogFilter[] = [{ includePatterns: ['error', 'failed'] }]; + it('should exclude messages matching RegExp patterns', () => { + const filters: DebugLogFilter[] = [{ excludePatterns: [/test-.*/] }]; const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - filteredLogger.info('Operation completed successfully'); - filteredLogger.info('User logged in'); + filteredLogger.info('user login successful'); + filteredLogger.info('test-123 created'); + filteredLogger.info('test-456 deleted'); - expect(mockTransport.sentEntries).toHaveLength(0); + expect(mockTransport.sentEntries).toHaveLength(1); + expect(mockTransport.sentEntries[0].message).toBe('user login successful'); }); }); - describe('exclude pattern filtering', () => { - it('should exclude messages matching string patterns', () => { - const filters: DebugLogFilter[] = [{ excludePatterns: ['debug', 'trace'] }]; + // Note: userId and sessionId filtering are defined in types but not yet implemented + // These tests are commented out until the feature is implemented + /* + describe('userId filtering', () => { + it('should filter by specific userId', () => { + const filters: DebugLogFilter[] = [{ userId: 'user-123' }]; const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - filteredLogger.info('User login debug info'); - filteredLogger.info('Operation completed successfully'); - filteredLogger.error('Database connection error'); + filteredLogger.info('message 1', { userId: 'user-123' }); + filteredLogger.info('message 2', { userId: 'user-456' }); + filteredLogger.info('message 3', { userId: 'user-123' }); expect(mockTransport.sentEntries).toHaveLength(2); - expect(mockTransport.sentEntries[0].message).toBe('Operation completed successfully'); - expect(mockTransport.sentEntries[1].message).toBe('Database connection error'); + expect(mockTransport.sentEntries[0].context?.userId).toBe('user-123'); + expect(mockTransport.sentEntries[1].context?.userId).toBe('user-123'); }); - it('should exclude messages matching RegExp patterns', () => { - const filters: DebugLogFilter[] = [{ excludePatterns: [/debug/i, /trace/i] }]; + it('should not log when userId is undefined and filter expects a userId', () => { + const filters: DebugLogFilter[] = [{ userId: 'user-123' }]; const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - filteredLogger.info('User login DEBUG info'); - filteredLogger.info('Operation completed successfully'); - filteredLogger.error('Database connection error'); + filteredLogger.info('message without userId'); - expect(mockTransport.sentEntries).toHaveLength(2); - expect(mockTransport.sentEntries[0].message).toBe('Operation completed successfully'); - expect(mockTransport.sentEntries[1].message).toBe('Database connection error'); + expect(mockTransport.sentEntries).toHaveLength(0); }); + }); - it('should exclude messages matching mixed string and RegExp patterns', () => { - const filters: DebugLogFilter[] = [{ excludePatterns: ['debug', /trace/i] }]; + describe('sessionId filtering', () => { + it('should filter by specific sessionId', () => { + const filters: DebugLogFilter[] = [{ sessionId: 'session-abc' }]; const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - filteredLogger.info('User login debug info'); - filteredLogger.info('Operation completed successfully'); - filteredLogger.error('Database connection error'); + filteredLogger.info('message 1', { sessionId: 'session-abc' }); + filteredLogger.info('message 2', { sessionId: 'session-xyz' }); + filteredLogger.info('message 3', { sessionId: 'session-abc' }); expect(mockTransport.sentEntries).toHaveLength(2); - expect(mockTransport.sentEntries[0].message).toBe('Operation completed successfully'); - expect(mockTransport.sentEntries[1].message).toBe('Database connection error'); + expect(mockTransport.sentEntries[0].context?.sessionId).toBe('session-abc'); + expect(mockTransport.sentEntries[1].context?.sessionId).toBe('session-abc'); }); - it('should exclude messages containing error in the message', () => { - const filters: DebugLogFilter[] = [{ excludePatterns: ['error'] }]; + it('should not log when sessionId is undefined and filter expects a sessionId', () => { + const filters: DebugLogFilter[] = [{ sessionId: 'session-abc' }]; const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - filteredLogger.info('User login successful'); - filteredLogger.info('Operation completed successfully'); - filteredLogger.error('Database connection error'); + filteredLogger.info('message without sessionId'); - expect(mockTransport.sentEntries).toHaveLength(2); - expect(mockTransport.sentEntries[0].message).toBe('User login successful'); - expect(mockTransport.sentEntries[1].message).toBe('Operation completed successfully'); + expect(mockTransport.sentEntries).toHaveLength(0); }); }); + */ - describe('complex filter combinations', () => { + describe('combined filtering', () => { it('should apply multiple filters with AND logic', () => { - const filters: DebugLogFilter[] = [{ level: 'error', source: 'auth-module' }, { includePatterns: ['failed'] }]; + const filters: DebugLogFilter[] = [ + { level: 'error' }, + { source: 'auth-module' }, + { includePatterns: ['failed'] }, + ]; const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - filteredLogger.error('Login failed', undefined, 'auth-module'); - filteredLogger.error('Database error', undefined, 'auth-module'); - filteredLogger.info('Login failed', undefined, 'auth-module'); - filteredLogger.error('Login failed', undefined, 'other-module'); + filteredLogger.error('login failed', undefined, 'auth-module'); + filteredLogger.error('login successful', undefined, 'auth-module'); + filteredLogger.warn('login failed', undefined, 'auth-module'); + filteredLogger.error('login failed', undefined, 'other-module'); expect(mockTransport.sentEntries).toHaveLength(1); - expect(mockTransport.sentEntries[0].message).toBe('Login failed'); + expect(mockTransport.sentEntries[0].message).toBe('login failed'); expect(mockTransport.sentEntries[0].level).toBe('error'); expect(mockTransport.sentEntries[0].source).toBe('auth-module'); }); - it('should handle empty filter arrays', () => { - const filteredLogger = new DebugLogger(mockTransport, 'debug', []); - - filteredLogger.info('test message'); - filteredLogger.warn('test warning'); - - expect(mockTransport.sentEntries).toHaveLength(2); - }); - - it('should handle undefined filters', () => { - const filteredLogger = new DebugLogger(mockTransport, 'debug', undefined); + it('should handle empty filters array', () => { + const filters: DebugLogFilter[] = []; + const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - filteredLogger.info('test message'); - filteredLogger.warn('test warning'); + filteredLogger.info('message 1'); + filteredLogger.warn('message 2'); + filteredLogger.error('message 3'); - expect(mockTransport.sentEntries).toHaveLength(2); + expect(mockTransport.sentEntries).toHaveLength(3); }); }); describe('edge cases', () => { - it('should handle empty string patterns', () => { - const filters: DebugLogFilter[] = [{ includePatterns: [''] }]; - const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - - filteredLogger.info('any message'); + it('should handle undefined context', () => { + logger.info('message with undefined context', undefined); expect(mockTransport.sentEntries).toHaveLength(1); + expect(mockTransport.sentEntries[0].context).toBeUndefined(); }); - it('should handle empty RegExp patterns', () => { - const filters: DebugLogFilter[] = [{ includePatterns: [/.*/] }]; - const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - - filteredLogger.info('any message'); + it('should handle undefined source', () => { + logger.info('message with undefined source', {}, undefined); expect(mockTransport.sentEntries).toHaveLength(1); + expect(mockTransport.sentEntries[0].source).toBeUndefined(); }); - it('should handle special RegExp characters in string patterns', () => { - const filters: DebugLogFilter[] = [{ includePatterns: ['user.*login'] }]; - const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - - filteredLogger.info('user.*login attempt'); - filteredLogger.info('user login attempt'); + it('should handle empty context object', () => { + logger.info('message with empty context', {}); expect(mockTransport.sentEntries).toHaveLength(1); - expect(mockTransport.sentEntries[0].message).toBe('user.*login attempt'); + expect(mockTransport.sentEntries[0].context).toEqual({}); }); - it('should handle case-sensitive RegExp patterns', () => { - const filters: DebugLogFilter[] = [{ includePatterns: [/ERROR/] }]; - const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - - filteredLogger.info('Database ERROR'); - filteredLogger.info('Database error'); + it('should handle empty source string', () => { + logger.info('message with empty source', {}, ''); expect(mockTransport.sentEntries).toHaveLength(1); - expect(mockTransport.sentEntries[0].message).toBe('Database ERROR'); + expect(mockTransport.sentEntries[0].source).toBe(''); }); + }); - it('should handle multiple include and exclude patterns', () => { - const filters: DebugLogFilter[] = [{ includePatterns: ['error', 'failed'], excludePatterns: ['debug'] }]; - const filteredLogger = new DebugLogger(mockTransport, 'debug', filters); - - filteredLogger.info('Login failed'); - filteredLogger.info('Database error debug info'); - filteredLogger.info('Operation completed successfully'); + describe('transport integration', () => { + it('should call transport.send for each log entry', async () => { + let sendCallCount = 0; + const countingTransport = { + async send(_entry: DebugLogEntry): Promise { + sendCallCount++; + }, + }; - expect(mockTransport.sentEntries).toHaveLength(1); - expect(mockTransport.sentEntries[0].message).toBe('Login failed'); - }); - }); - }); + const testLogger = new DebugLogger(countingTransport, 'info'); - describe('log entry structure', () => { - it('should generate proper log entry structure', () => { - const context = { userId: '123' }; - const source = 'test-module'; + testLogger.info('message 1'); + testLogger.warn('message 2'); + testLogger.error('message 3'); - logger.info('test message', context, source); + // Allow async operations to complete + await new Promise(resolve => setTimeout(resolve, 0)); - expect(mockTransport.sentEntries).toHaveLength(1); - const entry = mockTransport.sentEntries[0]; - - expect(entry).toHaveProperty('timestamp'); - expect(entry).toHaveProperty('level'); - expect(entry).toHaveProperty('message'); - expect(entry).toHaveProperty('context'); - expect(entry).toHaveProperty('source'); - - expect(typeof entry.timestamp).toBe('number'); - expect(entry.level).toBe('info'); - expect(entry.message).toBe('test message'); - expect(entry.context).toEqual(context); - expect(entry.source).toBe(source); - }); + expect(sendCallCount).toBe(3); + }); - it('should use current timestamp for log entries', () => { - const before = Date.now(); - logger.info('test message'); - const after = Date.now(); + it('should include timestamp in log entries', () => { + const beforeTime = Date.now(); + logger.info('test message'); + const afterTime = Date.now(); - expect(mockTransport.sentEntries).toHaveLength(1); - const timestamp = mockTransport.sentEntries[0].timestamp; - expect(timestamp).toBeGreaterThanOrEqual(before); - expect(timestamp).toBeLessThanOrEqual(after); + expect(mockTransport.sentEntries).toHaveLength(1); + expect(mockTransport.sentEntries[0].timestamp).toBeGreaterThanOrEqual(beforeTime); + expect(mockTransport.sentEntries[0].timestamp).toBeLessThanOrEqual(afterTime); + }); }); }); }); diff --git a/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.test.ts b/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.spec.ts similarity index 80% rename from packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.test.ts rename to packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.spec.ts index 78ad98f6c61..8b7fd39a561 100644 --- a/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.test.ts +++ b/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.spec.ts @@ -1,19 +1,26 @@ import type { TelemetryCollector } from '@clerk/shared/telemetry'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MockedFunction } from 'vitest'; import type { DebugLogEntry } from '../../types'; import { TelemetryTransport } from '../telemetry'; describe('TelemetryTransport', () => { - let mockCollector: jest.Mocked; + let mockCollector: { + recordLog: MockedFunction; + record: MockedFunction; + isEnabled: boolean; + isDebug: boolean; + }; let transport: TelemetryTransport; beforeEach(() => { mockCollector = { - recordLog: jest.fn(), - record: jest.fn(), + recordLog: vi.fn(), + record: vi.fn(), isEnabled: true, isDebug: false, - } as jest.Mocked; + }; transport = new TelemetryTransport(mockCollector); }); From ceb629e1ca5511d7a5320feebca1aff4e8c01f45 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 14 Aug 2025 07:40:58 -0500 Subject: [PATCH 7/7] fix lint --- .../core/modules/debug/transports/__tests__/telemetry.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.spec.ts b/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.spec.ts index 8b7fd39a561..4583f8abb92 100644 --- a/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.spec.ts +++ b/packages/clerk-js/src/core/modules/debug/transports/__tests__/telemetry.spec.ts @@ -1,6 +1,6 @@ import type { TelemetryCollector } from '@clerk/shared/telemetry'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { MockedFunction } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { DebugLogEntry } from '../../types'; import { TelemetryTransport } from '../telemetry';