diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index d4067a3597c..1dbdabd56c4 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -173,7 +173,7 @@ export default class Clerk implements ClerkInterface { #devBrowserHandler: DevBrowserHandler | null = null; #environment?: EnvironmentResource | null; #fapiClient: FapiClient; - #telemetry: TelemetryCollector; + #telemetry?: TelemetryCollector; #instanceType: InstanceType; #isReady = false; @@ -308,12 +308,6 @@ export default class Clerk implements ClerkInterface { this.#instanceType = instanceType; } this.#fapiClient = createFapiClient(this); - this.#telemetry = new TelemetryCollector({ - clerkVersion: Clerk.version, - verbose: true, - samplingRate: 1, - publishableKey: key, - }); BaseResource.clerk = this; } @@ -331,6 +325,19 @@ export default class Clerk implements ClerkInterface { ...options, }; + if (this.#options.sdkMetadata) { + Clerk.sdkMetadata = this.#options.sdkMetadata; + } + + this.#telemetry = new TelemetryCollector({ + clerkVersion: Clerk.version, + verbose: true, + samplingRate: 1, + // @ts-expect-error -- in v5 this will always be defined + publishableKey: this.publishableKey, + ...this.#options.telemetry, + }); + if (this.#options.standardBrowser) { this.#isReady = await this.#loadInStandardBrowser(); } else { diff --git a/packages/gatsby-plugin-clerk/src/GatsbyClerkProvider.tsx b/packages/gatsby-plugin-clerk/src/GatsbyClerkProvider.tsx index 59f09296af3..74d558f0989 100644 --- a/packages/gatsby-plugin-clerk/src/GatsbyClerkProvider.tsx +++ b/packages/gatsby-plugin-clerk/src/GatsbyClerkProvider.tsx @@ -7,6 +7,8 @@ import { import { navigate } from 'gatsby'; import React from 'react'; +import { TELEMETRY_DEBUG, TELEMETRY_DISABLED } from './constants'; + const SDK_METADATA = { name: PACKAGE_NAME, version: PACKAGE_VERSION, @@ -28,6 +30,10 @@ export function ClerkProvider({ children, ...rest }: GatsbyClerkProviderProps) { navigate={to => navigate(to)} initialState={__clerk_ssr_state || {}} sdkMetadata={SDK_METADATA} + telemetry={{ + disabled: TELEMETRY_DISABLED, + debug: TELEMETRY_DEBUG, + }} {...restProps} > {__clerk_ssr_interstitial_html ? ( diff --git a/packages/gatsby-plugin-clerk/src/constants.ts b/packages/gatsby-plugin-clerk/src/constants.ts index 00d3f788c55..d3423677597 100644 --- a/packages/gatsby-plugin-clerk/src/constants.ts +++ b/packages/gatsby-plugin-clerk/src/constants.ts @@ -1,4 +1,5 @@ import { apiUrlFromPublishableKey } from '@clerk/shared/apiUrlFromPublishableKey'; +import { isTruthy } from '@clerk/shared/underscore'; export const PUBLISHABLE_KEY = process.env.GATSBY_CLERK_PUBLISHABLE_KEY || ''; export const API_URL = process.env.CLERK_API_URL || apiUrlFromPublishableKey(PUBLISHABLE_KEY); @@ -7,3 +8,6 @@ export const SECRET_KEY = process.env.CLERK_SECRET_KEY || ''; export const CLERK_JS = process.env.GATSBY_CLERK_JS; export const PROXY_URL = process.env.GATSBY_CLERK_PROXY_URL; + +export const TELEMETRY_DISABLED = isTruthy(process.env.GATSBY_CLERK_TELEMETRY_DISABLED); +export const TELEMETRY_DEBUG = isTruthy(process.env.GATSBY_CLERK_TELEMETRY_DEBUG); diff --git a/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts b/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts index 0cdd5894515..d2b742bd21c 100644 --- a/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts +++ b/packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts @@ -15,6 +15,10 @@ export const mergeNextClerkPropsWithEnv = (props: Omit { @@ -77,6 +79,10 @@ export function ClerkProvider({ children, ...rest }: RemixClerkProviderProps): J afterSignUpUrl: __afterSignUpUrl, clerkJSUrl: __clerkJSUrl, clerkJSVersion: __clerkJSVersion, + telemetry: { + disabled: __telemetryDisabled, + debug: __telemetryDebug, + }, }; return ( diff --git a/packages/remix/src/client/types.ts b/packages/remix/src/client/types.ts index 4fca0c9f8d2..beb58f4e29f 100644 --- a/packages/remix/src/client/types.ts +++ b/packages/remix/src/client/types.ts @@ -18,6 +18,8 @@ export type ClerkState = { __clerk_debug: any; __clerkJSUrl: string | undefined; __clerkJSVersion: string | undefined; + __telemetryDisabled: boolean | undefined; + __telemetryDebug: boolean | undefined; }; }; diff --git a/packages/remix/src/ssr/utils.ts b/packages/remix/src/ssr/utils.ts index 5c30cbaf225..37d08f55b53 100644 --- a/packages/remix/src/ssr/utils.ts +++ b/packages/remix/src/ssr/utils.ts @@ -1,5 +1,6 @@ import type { AuthObject, RequestState } from '@clerk/backend'; import { constants, debugRequestState, loadInterstitialFromLocal } from '@clerk/backend'; +import { isTruthy } from '@clerk/shared/underscore'; import type { AppLoadContext, defer } from '@remix-run/server-runtime'; import { json } from '@remix-run/server-runtime'; import cookie from 'cookie'; @@ -165,6 +166,8 @@ export function getResponseClerkState(requestState: RequestState, context: AppLo __clerk_debug: debugRequestState(requestState), __clerkJSUrl: getEnvVariable('CLERK_JS', context), __clerkJSVersion: getEnvVariable('CLERK_JS_VERSION', context), + __telemetryDisabled: isTruthy(getEnvVariable('CLERK_TELEMETRY_DISABLED', context)), + __telemetryDebug: isTruthy(getEnvVariable('CLERK_TELEMETRY_DEBUG', context)), }); const headers = observabilityHeadersFromRequestState(requestState); diff --git a/packages/shared/.gitignore b/packages/shared/.gitignore index 87400d96af4..a525d2ce5cb 100644 --- a/packages/shared/.gitignore +++ b/packages/shared/.gitignore @@ -1,2 +1,2 @@ -*/ +/*/ !src/ diff --git a/packages/shared/jest.config.js b/packages/shared/jest.config.js index f9da269204a..9dd261d1d2f 100644 --- a/packages/shared/jest.config.js +++ b/packages/shared/jest.config.js @@ -4,6 +4,9 @@ const { name } = require('./package.json'); const config = { displayName: name.replace('@clerk', ''), injectGlobals: true, + globals: { + fetch: global.fetch, + }, testEnvironment: 'jsdom', roots: ['/src'], diff --git a/packages/shared/src/__tests__/telemetry.test.ts b/packages/shared/src/__tests__/telemetry.test.ts new file mode 100644 index 00000000000..16e0211ff29 --- /dev/null +++ b/packages/shared/src/__tests__/telemetry.test.ts @@ -0,0 +1,149 @@ +import { TelemetryCollector } from '../telemetry'; + +jest.useFakeTimers(); + +const TEST_PK = 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk'; + +describe('TelemetryCollector', () => { + test('does nothing when disabled', async () => { + const fetchSpy = jest.spyOn(global, 'fetch'); + + const collector = new TelemetryCollector({ + disabled: true, + publishableKey: TEST_PK, + }); + + collector.record('TEST_EVENT', {}); + + jest.runAllTimers(); + + expect(fetchSpy).not.toHaveBeenCalled(); + + fetchSpy.mockRestore(); + }); + + test('does nothing when CLERK_TELEMETRY_DISABLED is set', async () => { + process.env.CLERK_TELEMETRY_DISABLED = '1'; + + const fetchSpy = jest.spyOn(global, 'fetch'); + + const collector = new TelemetryCollector({ + publishableKey: TEST_PK, + }); + + collector.record('TEST_EVENT', {}); + + jest.runAllTimers(); + + expect(fetchSpy).not.toHaveBeenCalled(); + + fetchSpy.mockRestore(); + + process.env.CLERK_TELEMETRY_DISABLED = undefined; + }); + + test('does not send events when debug is enabled, logs them instead', async () => { + const fetchSpy = jest.spyOn(global, 'fetch'); + const consoleSpy = jest.spyOn(global.console, 'log'); + + const collector = new TelemetryCollector({ + debug: true, + publishableKey: TEST_PK, + }); + + collector.record('TEST_EVENT', {}); + + jest.runAllTimers(); + + expect(fetchSpy).not.toHaveBeenCalled(); + + expect(consoleSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + { + "cv": "", + "event": "TEST_EVENT", + "it": "development", + "payload": {}, + "pk": "pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk", + "sdk": undefined, + "sdkv": undefined, + }, + ], + ] + `); + + consoleSpy.mockRestore(); + fetchSpy.mockRestore(); + }); + + test('enables debug via environment variable', async () => { + process.env.CLERK_TELEMETRY_DEBUG = '1'; + + const fetchSpy = jest.spyOn(global, 'fetch'); + const consoleSpy = jest.spyOn(global.console, 'log'); + + const collector = new TelemetryCollector({ + publishableKey: TEST_PK, + }); + + collector.record('TEST_EVENT', {}); + + jest.runAllTimers(); + + expect(fetchSpy).not.toHaveBeenCalled(); + + expect(consoleSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + { + "cv": "", + "event": "TEST_EVENT", + "it": "development", + "payload": {}, + "pk": "pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk", + "sdk": undefined, + "sdkv": undefined, + }, + ], + ] + `); + + consoleSpy.mockRestore(); + fetchSpy.mockRestore(); + + process.env.CLERK_TELEMETRY_DEBUG = undefined; + }); + + test('sends events after a delay when buffer is not full', async () => { + const fetchSpy = jest.spyOn(global, 'fetch'); + + const collector = new TelemetryCollector({ + publishableKey: TEST_PK, + }); + + collector.record('TEST_EVENT', {}); + + jest.runAllTimers(); + + expect(fetchSpy).toHaveBeenCalled(); + + fetchSpy.mockRestore(); + }); + + test('sends events immediately when the buffer limit is reached', async () => { + const fetchSpy = jest.spyOn(global, 'fetch'); + + const collector = new TelemetryCollector({ + maxBufferSize: 2, + publishableKey: TEST_PK, + }); + + collector.record('TEST_EVENT', {}); + collector.record('TEST_EVENT', {}); + + expect(fetchSpy).toHaveBeenCalled(); + + fetchSpy.mockRestore(); + }); +}); diff --git a/packages/shared/src/telemetry.ts b/packages/shared/src/telemetry.ts index 9fafeef0a04..3be742efd88 100644 --- a/packages/shared/src/telemetry.ts +++ b/packages/shared/src/telemetry.ts @@ -1,12 +1,25 @@ import type { InstanceType } from '@clerk/types'; import { parsePublishableKey } from './keys'; +import { isTruthy } from './underscore'; type TelemetryCollectorOptions = { + /** + * If true, telemetry will not be collected. + */ + disabled?: boolean; + /** + * If true, telemetry will not be sent, but collected events will be logged to the console. + */ + debug?: boolean; /** * Sampling rate, 0-1 */ samplingRate?: number; + /** + * Set a custom buffer size to control how often events are sent + */ + maxBufferSize?: number; /** * Determines whether or not events will be logged to the console. */ @@ -29,9 +42,11 @@ type TelemetryCollectorOptions = { sdkVersion?: string; }; -type TelemetryCollectorConfig = Pick & { +type TelemetryCollectorConfig = Pick< + TelemetryCollectorOptions, + 'samplingRate' | 'verbose' | 'disabled' | 'debug' | 'maxBufferSize' +> & { endpoint: string; - maxBufferSize: number; }; type TelemetryMetadata = Required< @@ -83,9 +98,11 @@ export class TelemetryCollector { constructor(options: TelemetryCollectorOptions) { this.#config = { - ...DEFAULT_CONFIG, - samplingRate: options.samplingRate, - verbose: options.verbose, + maxBufferSize: options.maxBufferSize ?? DEFAULT_CONFIG.maxBufferSize, + samplingRate: options.samplingRate ?? DEFAULT_CONFIG.samplingRate, + verbose: options.verbose ?? DEFAULT_CONFIG.verbose, + disabled: options.disabled ?? false, + debug: options.debug ?? false, } as Required; if (!options.clerkVersion && typeof window === 'undefined') { @@ -115,15 +132,20 @@ export class TelemetryCollector { return false; } - // TODO: check clerk environment / environment variable + // In browser or client environments, we most likely pass the disabled option to the collector, but in environments + // where environment variables are available we also check for `CLERK_TELEMETRY_DISABLED`. + if (this.#config.disabled || (typeof process !== 'undefined' && isTruthy(process.env.CLERK_TELEMETRY_DISABLED))) { + return false; + } + return true; } - record(event: TelemetryEvent['event'], payload: TelemetryEvent['payload']) { - if (!this.isEnabled) { - return; - } + get isDebug(): boolean { + return this.#config.debug || (typeof process !== 'undefined' && isTruthy(process.env.CLERK_TELEMETRY_DEBUG)); + } + record(event: TelemetryEvent['event'], payload: TelemetryEvent['payload']) { const preparedPayload = this.#preparePayload(event, payload); this.#logEvent(preparedPayload.event, preparedPayload); @@ -138,7 +160,7 @@ export class TelemetryCollector { } #shouldRecord(): boolean { - return Math.random() <= this.#config.samplingRate; + return this.isEnabled && !this.isDebug && Math.random() <= this.#config.samplingRate; } #scheduleFlush(): void { @@ -153,7 +175,7 @@ export class TelemetryCollector { // 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 = cancelIdleCallback || clearTimeout; + const cancel = typeof cancelIdleCallback !== 'undefined' ? cancelIdleCallback : clearTimeout; cancel(this.#pendingFlush); } this.#flush(); @@ -186,7 +208,7 @@ export class TelemetryCollector { 'Content-Type': 'application/json', }, }) - .catch(err => console.error(err)) + .catch(() => void 0) .then(() => { this.#buffer = []; }) @@ -194,10 +216,10 @@ export class TelemetryCollector { } /** - * If running in verbose mode, log the event and its payload to the console. + * If running in debug mode, log the event and its payload to the console. */ #logEvent(event: TelemetryEvent['event'], payload: Record) { - if (!this.#config.verbose) { + if (!this.isDebug) { return; } @@ -224,7 +246,7 @@ export class TelemetryCollector { // @ts-expect-error -- The global window.Clerk type is declared in clerk-js, but we can't rely on that here if (typeof window !== 'undefined' && window.Clerk) { // @ts-expect-error -- The global window.Clerk type is declared in clerk-js, but we can't rely on that here - sdkMetadata = { ...sdkMetadata, ...window.Clerk.constructor.prototype.sdkMetadata }; + sdkMetadata = { ...sdkMetadata, ...window.Clerk.constructor.sdkMetadata }; } return sdkMetadata; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 044d1b936a4..bd26d270d92 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -552,6 +552,16 @@ export interface ClerkOptions { */ isInterstitial?: boolean; isSatellite?: boolean | ((url: URL) => boolean); + + /** + * Telemetry options + */ + telemetry?: { + disabled?: boolean; + debug?: boolean; + }; + + sdkMetadata?: SDKMetadata; } export interface Resources {