UNPKG

@clerk/shared

Version:

Internal package utils used by the Clerk SDKs

618 lines (609 loc) • 21.3 kB
import { parsePublishableKey } from "../keys.mjs"; import { t as automatedEnvironmentVariables } from "./runtimeEnvironment-CTVGzENl.mjs"; import { isTruthy } from "../underscore.mjs"; //#region src/telemetry/notice.ts /** * One-time runtime disclosure that Clerk collects telemetry from development instances. * * Replaces the previous `postinstall` script. Disclosure is intentionally surfaced * only on Node (server-side) so the noise profile matches the original postinstall * (terminal-only, dev-eyes-only). Browser consoles are not used because they are * frequently observed by non-developers (QA, screenshots, demos), and adding another * console warning is a common source of customer complaints. * * Known gap: pure browser-only setups with no server-side Clerk runtime (e.g. a Vite * SPA using `@clerk/clerk-react` or `@clerk/clerk-js` directly, without any Node/Edge * backend that imports `@clerk/shared`) will never hit this code path and therefore * see no in-band disclosure. This is an accepted trade-off: the original postinstall * already fired only once at install time and was easily missed, so the practical * delta is small. Authoritative disclosure for those setups lives in the Clerk * telemetry docs (https://clerk.com/docs/telemetry). Opt-out continues to work the * same way (`telemetry={false}` on `<ClerkProvider>` or the framework-specific * `*_CLERK_TELEMETRY_DISABLED` env var). * * Persistence is in-process via a `globalThis` Symbol, which survives Next.js HMR * module reloads. No filesystem access, no `node:` imports, no dynamic-code APIs, so * the module remains safe to bundle for Edge Runtime, Workers, and any browser path. * * All work is wrapped in try/catch. Failure to display the notice must never affect * the SDK. */ const PROCESS_FLAG = Symbol.for("@clerk/shared.telemetryNoticeShown"); const NOTICE_LINES = [ "Attention: Clerk collects telemetry data from its SDKs when connected to development instances.", "The data collected is used to inform Clerk's product roadmap.", "To learn more, including how to opt-out from the telemetry program, visit: https://clerk.com/docs/telemetry." ]; function isServerRuntime() { if (typeof window !== "undefined") return false; if (typeof globalThis.EdgeRuntime !== "undefined") return false; return true; } function isCI() { if (typeof process === "undefined" || !process.env) return false; return automatedEnvironmentVariables.some((name) => isTruthy(process.env[name])); } function hasSeen() { return Boolean(globalThis[PROCESS_FLAG]); } function markSeen() { globalThis[PROCESS_FLAG] = true; } function printNotice() { if (typeof console === "undefined" || typeof console.log !== "function") return; for (const line of NOTICE_LINES) console.log(line); console.log(""); } /** * Display the one-time telemetry disclosure on server runtimes if it has not already been * shown in this process. Browser and Edge Runtime callers are silently skipped. Never throws. */ function maybeShowTelemetryNotice(options = {}) { if (options.skip) return; try { if (!isServerRuntime()) return; if (isCI()) return; if (hasSeen()) return; printNotice(); markSeen(); } catch {} } //#endregion //#region src/telemetry/throttler.ts const DEFAULT_CACHE_TTL_MS = 864e5; /** * Manages throttling for telemetry events using a configurable cache implementation * to mitigate event flooding in frequently executed code paths. */ var TelemetryEventThrottler = class { #cache; #cacheTtl = DEFAULT_CACHE_TTL_MS; constructor(cache) { this.#cache = cache; } isEventThrottled(payload) { const now = Date.now(); const key = this.#generateKey(payload); const entry = this.#cache.getItem(key); if (!entry) { this.#cache.setItem(key, now); return false; } if (now - entry > this.#cacheTtl) { this.#cache.setItem(key, now); return false; } return true; } /** * Generates a consistent unique key for telemetry events by sorting payload properties. * This ensures that payloads with identical content in different orders produce the same key. */ #generateKey(event) { const { sk: _sk, pk: _pk, payload, ...rest } = event; const sanitizedEvent = { ...payload, ...rest }; return JSON.stringify(Object.keys({ ...payload, ...rest }).sort().map((key) => sanitizedEvent[key])); } }; /** * LocalStorage-based cache implementation for browser environments. */ var LocalStorageThrottlerCache = class { #storageKey = "clerk_telemetry_throttler"; getItem(key) { return this.#getCache()[key]; } setItem(key, value) { try { const cache = this.#getCache(); cache[key] = value; localStorage.setItem(this.#storageKey, JSON.stringify(cache)); } catch (err) { if (err instanceof DOMException && (err.name === "QuotaExceededError" || err.name === "NS_ERROR_DOM_QUOTA_REACHED") && localStorage.length > 0) localStorage.removeItem(this.#storageKey); } } removeItem(key) { try { const cache = this.#getCache(); delete cache[key]; localStorage.setItem(this.#storageKey, JSON.stringify(cache)); } catch {} } #getCache() { try { const cacheString = localStorage.getItem(this.#storageKey); if (!cacheString) return {}; return JSON.parse(cacheString); } catch { return {}; } } static isSupported() { return typeof window !== "undefined" && !!window.localStorage; } }; /** * In-memory cache implementation for non-browser environments (e.g., React Native). */ var InMemoryThrottlerCache = class { #cache = /* @__PURE__ */ new Map(); #maxSize = 1e4; getItem(key) { if (this.#cache.size > this.#maxSize) { this.#cache.clear(); return; } return this.#cache.get(key); } setItem(key, value) { this.#cache.set(key, value); } removeItem(key) { this.#cache.delete(key); } }; //#endregion //#region src/telemetry/collector.ts /** * The `TelemetryCollector` class handles collection of telemetry events from Clerk SDKs. Telemetry is opt-out and can be disabled by setting a CLERK_TELEMETRY_DISABLED environment variable. * The `ClerkProvider` also accepts a `telemetry` prop that will be passed to the collector during initialization:. * * ```jsx * <ClerkProvider telemetry={false}> * ... * </ClerkProvider> * ``` * * For more information, please see the telemetry documentation page: https://clerk.com/docs/telemetry. */ /** * Type guard to check if window.Clerk exists and has the expected structure. */ function isWindowClerkWithMetadata(clerk) { return typeof clerk === "object" && clerk !== null && "constructor" in clerk && typeof clerk.constructor === "function"; } const VALID_LOG_LEVELS = new Set([ "error", "warn", "info", "debug", "trace" ]); const DEFAULT_CONFIG = { samplingRate: 1, maxBufferSize: 5, endpoint: "https://clerk-telemetry.com" }; var TelemetryCollector = class { #config; #eventThrottler; #metadata = {}; #buffer = []; #pendingFlush = null; constructor(options) { this.#config = { maxBufferSize: options.maxBufferSize ?? DEFAULT_CONFIG.maxBufferSize, samplingRate: options.samplingRate ?? DEFAULT_CONFIG.samplingRate, perEventSampling: options.perEventSampling ?? true, disabled: options.disabled ?? false, debug: options.debug ?? false, endpoint: DEFAULT_CONFIG.endpoint }; if (!options.clerkVersion && typeof window === "undefined") this.#metadata.clerkVersion = ""; else this.#metadata.clerkVersion = options.clerkVersion ?? ""; this.#metadata.sdk = options.sdk; this.#metadata.sdkVersion = options.sdkVersion; this.#metadata.publishableKey = options.publishableKey ?? ""; const parsedKey = parsePublishableKey(options.publishableKey); if (parsedKey) this.#metadata.instanceType = parsedKey.instanceType; if (options.secretKey) this.#metadata.secretKey = options.secretKey.substring(0, 16); const cache = LocalStorageThrottlerCache.isSupported() ? new LocalStorageThrottlerCache() : new InMemoryThrottlerCache(); this.#eventThrottler = new TelemetryEventThrottler(cache); maybeShowTelemetryNotice({ skip: !this.isEnabled }); } get isEnabled() { if (this.#metadata.instanceType !== "development") return false; if (this.#config.disabled || typeof process !== "undefined" && process.env && isTruthy(process.env.CLERK_TELEMETRY_DISABLED)) return false; if (typeof window !== "undefined" && !!window?.navigator?.webdriver) return false; return true; } get isDebug() { return this.#config.debug || typeof process !== "undefined" && process.env && isTruthy(process.env.CLERK_TELEMETRY_DEBUG); } record(event) { try { const preparedPayload = this.#preparePayload(event.event, event.payload); this.#logEvent(preparedPayload.event, preparedPayload); if (!this.#shouldRecord(preparedPayload, event.eventSamplingRate)) return; this.#buffer.push({ kind: "event", value: preparedPayload }); this.#scheduleFlush(); } catch (error) { console.error("[clerk/telemetry] Error recording telemetry event", error); } } /** * Records a telemetry log entry if logging is enabled and not in debug mode. * * @param entry - The telemetry log entry to record. */ recordLog(entry) { try { if (!this.#shouldRecordLog(entry)) return; 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 = null; const timestampInput = entry?.timestamp; if (typeof timestampInput === "number" || typeof timestampInput === "string") { const candidate = new Date(timestampInput); if (!Number.isNaN(candidate.getTime())) normalizedTimestamp = candidate; } if (!levelIsValid || !messageIsValid || normalizedTimestamp === null) { if (this.isDebug && typeof console !== "undefined") console.warn("[clerk/telemetry] Dropping invalid telemetry log entry", { levelIsValid, messageIsValid, timestampIsValid: normalizedTimestamp !== null }); return; } const sdkMetadata = this.#getSDKMetadata(); const logData = { sdk: sdkMetadata.name, sdkv: sdkMetadata.version, cv: this.#metadata.clerkVersion ?? "", lvl: entry.level, msg: entry.message, ts: normalizedTimestamp.toISOString(), pk: this.#metadata.publishableKey || null, payload: this.#sanitizeContext(entry.context) }; this.#buffer.push({ kind: "log", value: logData }); this.#scheduleFlush(); } catch (error) { console.error("[clerk/telemetry] Error recording telemetry log entry", error); } } #shouldRecord(preparedPayload, eventSamplingRate) { return this.isEnabled && !this.isDebug && this.#shouldBeSampled(preparedPayload, eventSamplingRate); } #shouldRecordLog(_entry) { return true; } #shouldBeSampled(preparedPayload, eventSamplingRate) { const randomSeed = Math.random(); if (!(randomSeed <= this.#config.samplingRate && (this.#config.perEventSampling === false || typeof eventSamplingRate === "undefined" || randomSeed <= eventSamplingRate))) return false; return !this.#eventThrottler.isEventThrottled(preparedPayload); } #scheduleFlush() { if (typeof window === "undefined") { this.#flush(); return; } if (this.#buffer.length >= this.#config.maxBufferSize) { if (this.#pendingFlush) if (typeof cancelIdleCallback !== "undefined") cancelIdleCallback(Number(this.#pendingFlush)); else clearTimeout(Number(this.#pendingFlush)); this.#flush(); return; } if (this.#pendingFlush) return; if ("requestIdleCallback" in window) this.#pendingFlush = requestIdleCallback(() => { this.#flush(); this.#pendingFlush = null; }); else this.#pendingFlush = setTimeout(() => { this.#flush(); this.#pendingFlush = null; }, 0); } #flush() { const itemsToSend = [...this.#buffer]; this.#buffer = []; this.#pendingFlush = null; if (itemsToSend.length === 0) return; const eventsToSend = itemsToSend.filter((item) => item.kind === "event").map((item) => item.value); const logsToSend = itemsToSend.filter((item) => item.kind === "log").map((item) => item.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", 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); } } /** * If running in debug mode, log the event and its payload to the console. */ #logEvent(event, payload) { if (!this.isDebug) return; if (typeof console.groupCollapsed !== "undefined") { console.groupCollapsed("[clerk/telemetry]", event); console.log(payload); console.groupEnd(); } else console.log("[clerk/telemetry]", event, payload); } /** * If in browser, attempt to lazily grab the SDK metadata from the Clerk singleton, otherwise fallback to the initially passed in values. * * This is necessary because the sdkMetadata can be set by the host SDK after the TelemetryCollector is instantiated. */ #getSDKMetadata() { const sdkMetadata = { name: this.#metadata.sdk, version: this.#metadata.sdkVersion }; if (typeof window !== "undefined") { const windowWithClerk = window; if (windowWithClerk.Clerk) { const windowClerk = windowWithClerk.Clerk; if (isWindowClerkWithMetadata(windowClerk) && windowClerk.constructor.sdkMetadata) { const { name, version } = windowClerk.constructor.sdkMetadata; if (name !== void 0) sdkMetadata.name = name; if (version !== void 0) sdkMetadata.version = version; } } } return sdkMetadata; } /** * Append relevant metadata from the Clerk singleton to the event payload. */ #preparePayload(event, payload) { const sdkMetadata = this.#getSDKMetadata(); return { event, cv: this.#metadata.clerkVersion ?? "", it: this.#metadata.instanceType ?? "", sdk: sdkMetadata.name, sdkv: sdkMetadata.version, ...this.#metadata.publishableKey ? { pk: this.#metadata.publishableKey } : {}, ...this.#metadata.secretKey ? { sk: this.#metadata.secretKey } : {}, 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) { 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; return null; } catch { return null; } } }; //#endregion //#region src/telemetry/events/component-mounted.ts const EVENT_COMPONENT_MOUNTED = "COMPONENT_MOUNTED"; const EVENT_COMPONENT_OPENED = "COMPONENT_OPENED"; const EVENT_SAMPLING_RATE$4 = .1; /** Increase sampling for high-signal auth components on mount. */ const AUTH_COMPONENTS = new Set(["SignIn", "SignUp"]); /** * Returns the per-event sampling rate for component-mounted telemetry events. * Uses a higher rate for SignIn/SignUp to improve signal quality. * * @internal */ function getComponentMountedSamplingRate(component) { return AUTH_COMPONENTS.has(component) ? 1 : EVENT_SAMPLING_RATE$4; } /** * Factory for prebuilt component telemetry events. * * @internal */ function createPrebuiltComponentEvent(event) { return function(component, props, additionalPayload) { return { event, eventSamplingRate: event === EVENT_COMPONENT_MOUNTED ? getComponentMountedSamplingRate(component) : EVENT_SAMPLING_RATE$4, payload: { component, appearanceProp: Boolean(props?.appearance), theme: Boolean(props?.appearance?.theme), elements: Boolean(props?.appearance?.elements), variables: Boolean(props?.appearance?.variables), ...additionalPayload } }; }; } /** * Helper function for `telemetry.record()`. Create a consistent event object for when a prebuilt (AIO) component is mounted. * * @param component - The name of the component. * @param props - The props passed to the component. Will be filtered to a known list of props. * @param additionalPayload - Additional data to send with the event. * @example * telemetry.record(eventPrebuiltComponentMounted('SignUp', props)); */ function eventPrebuiltComponentMounted(component, props, additionalPayload) { return createPrebuiltComponentEvent(EVENT_COMPONENT_MOUNTED)(component, props, additionalPayload); } /** * Helper function for `telemetry.record()`. Create a consistent event object for when a prebuilt (AIO) component is opened as a modal. * * @param component - The name of the component. * @param props - The props passed to the component. Will be filtered to a known list of props. * @param additionalPayload - Additional data to send with the event. * @example * telemetry.record(eventPrebuiltComponentOpened('GoogleOneTap', props)); */ function eventPrebuiltComponentOpened(component, props, additionalPayload) { return createPrebuiltComponentEvent(EVENT_COMPONENT_OPENED)(component, props, additionalPayload); } /** * Helper function for `telemetry.record()`. Create a consistent event object for when a component is mounted. Use `eventPrebuiltComponentMounted` for prebuilt components. * * **Caution:** Filter the `props` you pass to this function to avoid sending too much data. * * @param component - The name of the component. * @param props - The props passed to the component. Ideally you only pass a handful of props here. * @example * telemetry.record(eventComponentMounted('SignUp', props)); */ function eventComponentMounted(component, props = {}) { return { event: EVENT_COMPONENT_MOUNTED, eventSamplingRate: getComponentMountedSamplingRate(component), payload: { component, ...props } }; } //#endregion //#region src/telemetry/events/flow-step.ts const EVENT_FLOW_STEP_MOUNTED = "FLOW_STEP_MOUNTED"; const EVENT_SAMPLING_RATE$3 = 1; /** * Fires an event from a part of a multi-step flow. * * @param flow - The flow identifier (matches `Flow.Root`'s `flow`). * @param step - The step/part that mounted. * @param metadata - Flow-specific metadata sent under `payload.metadata`. * @param eventSamplingRate - Override the default full-capture sampling rate. * @example * telemetry.record(eventFlowStepMounted('configureSSO', 'verify-domain', { timestamp: new Date().toISOString(), connectionStatus: 'unconfigured' })); */ function eventFlowStepMounted(flow, step, metadata = {}, eventSamplingRate = EVENT_SAMPLING_RATE$3) { return { event: EVENT_FLOW_STEP_MOUNTED, eventSamplingRate, payload: { flow, step, metadata } }; } //#endregion //#region src/telemetry/events/method-called.ts const EVENT_METHOD_CALLED = "METHOD_CALLED"; const EVENT_SAMPLING_RATE$2 = .1; /** * Fired when a helper method is called from a Clerk SDK. */ function eventMethodCalled(method, payload) { return { event: EVENT_METHOD_CALLED, eventSamplingRate: EVENT_SAMPLING_RATE$2, payload: { method, ...payload } }; } //#endregion //#region src/telemetry/events/framework-metadata.ts const EVENT_FRAMEWORK_METADATA = "FRAMEWORK_METADATA"; const EVENT_SAMPLING_RATE$1 = .1; /** * Fired when a helper method is called from a Clerk SDK. */ function eventFrameworkMetadata(payload) { return { event: EVENT_FRAMEWORK_METADATA, eventSamplingRate: EVENT_SAMPLING_RATE$1, payload }; } //#endregion //#region src/telemetry/events/theme-usage.ts const EVENT_THEME_USAGE = "THEME_USAGE"; const EVENT_SAMPLING_RATE = 1; /** * Helper function for `telemetry.record()`. Create a consistent event object for tracking theme usage in ClerkProvider. * * @param appearance - The appearance prop from ClerkProvider. * @example * telemetry.record(eventThemeUsage(appearance)); */ function eventThemeUsage(appearance) { return { event: EVENT_THEME_USAGE, eventSamplingRate: 1, payload: analyzeThemeUsage(appearance) }; } /** * Analyzes the appearance prop to extract theme usage information for telemetry. * * @internal */ function analyzeThemeUsage(appearance) { if (!appearance || typeof appearance !== "object") return {}; const themeProperty = appearance.theme; if (!themeProperty) return {}; let themeName; if (Array.isArray(themeProperty)) for (const theme of themeProperty) { const name = extractThemeName(theme); if (name) { themeName = name; break; } } else themeName = extractThemeName(themeProperty); return { themeName }; } /** * Extracts the theme name from a theme object. * * @internal */ function extractThemeName(theme) { if (typeof theme === "string") return theme; if (typeof theme === "object" && theme !== null) { if ("name" in theme && typeof theme.name === "string") return theme.name; } } //#endregion export { eventMethodCalled as a, eventPrebuiltComponentMounted as c, eventFrameworkMetadata as i, eventPrebuiltComponentOpened as l, EVENT_THEME_USAGE as n, eventFlowStepMounted as o, eventThemeUsage as r, eventComponentMounted as s, EVENT_SAMPLING_RATE as t, TelemetryCollector as u }; //# sourceMappingURL=telemetry-9C6N5ppw.mjs.map