diff --git a/.changeset/sdk-84-remove-telemetry-postinstall.md b/.changeset/sdk-84-remove-telemetry-postinstall.md new file mode 100644 index 00000000000..53bb56dd5b9 --- /dev/null +++ b/.changeset/sdk-84-remove-telemetry-postinstall.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Remove the `postinstall` lifecycle script that displayed the telemetry disclosure notice. The same one-time disclosure is now surfaced server-side at runtime when the telemetry collector activates on a development instance, deduped per process via a `globalThis` flag. The notice is intentionally not emitted in browser consoles to keep the noise profile in line with the original postinstall (terminal-only). This removes install-time code from the published package and drops the `std-env` dependency. diff --git a/packages/shared/package.json b/packages/shared/package.json index 3f61acd4f0c..c865719a744 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -120,8 +120,7 @@ } }, "files": [ - "dist", - "scripts" + "dist" ], "scripts": { "build": "tsdown", @@ -131,7 +130,6 @@ "dev:pub": "pnpm dev -- --env.publish", "format": "node ../../scripts/format-package.mjs", "format:check": "node ../../scripts/format-package.mjs --check", - "postinstall": "node ./scripts/postinstall.mjs", "lint": "eslint src", "lint:attw": "attw --pack . --profile node16", "lint:publint": "publint", @@ -143,8 +141,7 @@ "@tanstack/query-core": "catalog:repo", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", - "js-cookie": "3.0.5", - "std-env": "^3.9.0" + "js-cookie": "3.0.5" }, "devDependencies": { "@base-org/account": "catalog:module-manager", diff --git a/packages/shared/scripts/postinstall.mjs b/packages/shared/scripts/postinstall.mjs deleted file mode 100755 index cafc989333c..00000000000 --- a/packages/shared/scripts/postinstall.mjs +++ /dev/null @@ -1,77 +0,0 @@ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; - -import { isCI } from 'std-env'; - -// If we make significant changes to how telemetry is collected in the future, bump this version. -const TELEMETRY_NOTICE_VERSION = '1'; - -function telemetryNotice() { - console.log(`Attention: Clerk now collects telemetry data from its SDKs when connected to development instances.`); - console.log(`The data collected is used to inform Clerk's product roadmap.`); - console.log( - `To learn more, including how to opt-out from the telemetry program, visit: https://clerk.com/docs/telemetry.`, - ); - console.log(''); -} - -// Adapted from https://github.com/sindresorhus/env-paths -function getConfigDir(name) { - const homedir = os.homedir(); - const macos = () => path.join(homedir, 'Library', 'Preferences', name); - const win = () => { - // eslint-disable-next-line turbo/no-undeclared-env-vars - const { APPDATA = path.join(homedir, 'AppData', 'Roaming') } = process.env; - return path.join(APPDATA, name, 'Config'); - }; - const linux = () => { - // eslint-disable-next-line turbo/no-undeclared-env-vars - const { XDG_CONFIG_HOME = path.join(homedir, '.config') } = process.env; - return path.join(XDG_CONFIG_HOME, name); - }; - switch (process.platform) { - case 'darwin': - return macos(); - case 'win32': - return win(); - default: - return linux(); - } -} - -async function notifyAboutTelemetry() { - const configDir = getConfigDir('clerk'); - const configFile = path.join(configDir, 'config.json'); - - await fs.mkdir(configDir, { recursive: true }); - - let config = {}; - try { - config = JSON.parse(await fs.readFile(configFile, 'utf8')); - } catch (err) { - // File can't be read and parsed, continue - } - - if (parseInt(config.telemetryNoticeVersion, 10) >= TELEMETRY_NOTICE_VERSION) { - return; - } - - config.telemetryNoticeVersion = TELEMETRY_NOTICE_VERSION; - - if (!isCI) { - telemetryNotice(); - } - - await fs.writeFile(configFile, JSON.stringify(config, null, '\t')); -} - -async function main() { - try { - await notifyAboutTelemetry(); - } catch { - // Do nothing, we _really_ don't want to log errors during install. - } -} - -main(); diff --git a/packages/shared/src/__tests__/telemetry.notice.spec.ts b/packages/shared/src/__tests__/telemetry.notice.spec.ts new file mode 100644 index 00000000000..b8ef35457b5 --- /dev/null +++ b/packages/shared/src/__tests__/telemetry.notice.spec.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { __resetTelemetryNoticeForTests, maybeShowTelemetryNotice } from '../telemetry/notice'; + +const CI_VARS = [ + 'CI', + 'CONTINUOUS_INTEGRATION', + 'BUILD_NUMBER', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'BUILDKITE', + 'JENKINS_URL', + 'TF_BUILD', + 'DRONE', + 'CODEBUILD_BUILD_ID', +]; + +function clearCIEnv() { + for (const name of CI_VARS) { + delete process.env[name]; + } +} + +describe('maybeShowTelemetryNotice', () => { + let logSpy: ReturnType; + let originalCIEnv: Record; + + beforeEach(() => { + originalCIEnv = Object.fromEntries(CI_VARS.map(name => [name, process.env[name]])); + clearCIEnv(); + __resetTelemetryNoticeForTests(); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + for (const [name, value] of Object.entries(originalCIEnv)) { + if (typeof value === 'string') { + process.env[name] = value; + } else { + delete process.env[name]; + } + } + }); + + test('prints the disclosure on Node', () => { + maybeShowTelemetryNotice(); + + expect(logSpy).toHaveBeenCalled(); + const printed = logSpy.mock.calls.map(call => String(call[0])).join('\n'); + expect(printed).toMatch(/Clerk collects telemetry/); + expect(printed).toMatch(/clerk\.com\/docs\/telemetry/); + }); + + test('does not print again on subsequent calls in the same process', () => { + maybeShowTelemetryNotice(); + maybeShowTelemetryNotice(); + maybeShowTelemetryNotice(); + + const disclosureCalls = logSpy.mock.calls.filter(call => /Clerk collects telemetry/.test(String(call[0]))); + expect(disclosureCalls).toHaveLength(1); + }); + + test('skips entirely when skip:true is passed', () => { + maybeShowTelemetryNotice({ skip: true }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + test('skips when a CI env var is set', () => { + // eslint-disable-next-line turbo/no-undeclared-env-vars + process.env.CI = 'true'; + + maybeShowTelemetryNotice(); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + test.each(CI_VARS)('skips when %s is set', name => { + process.env[name] = '1'; + + maybeShowTelemetryNotice(); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + test('skips in a browser-like environment', () => { + const original = (globalThis as { window?: unknown }).window; + (globalThis as { window?: unknown }).window = {}; + + try { + maybeShowTelemetryNotice(); + expect(logSpy).not.toHaveBeenCalled(); + } finally { + if (typeof original === 'undefined') { + delete (globalThis as { window?: unknown }).window; + } else { + (globalThis as { window?: unknown }).window = original; + } + } + }); + + test('skips in Next.js Edge Runtime', () => { + (globalThis as { EdgeRuntime?: string }).EdgeRuntime = 'edge-runtime'; + + try { + maybeShowTelemetryNotice(); + expect(logSpy).not.toHaveBeenCalled(); + } finally { + delete (globalThis as { EdgeRuntime?: string }).EdgeRuntime; + } + }); + + test('does not throw if console.log fails', () => { + logSpy.mockImplementation(() => { + throw new Error('console broken'); + }); + + expect(() => maybeShowTelemetryNotice()).not.toThrow(); + }); +}); diff --git a/packages/shared/src/telemetry/collector.ts b/packages/shared/src/telemetry/collector.ts index e07ace47334..d798890f39c 100644 --- a/packages/shared/src/telemetry/collector.ts +++ b/packages/shared/src/telemetry/collector.ts @@ -20,6 +20,7 @@ import type { TelemetryLogEntry, } from '../types'; import { isTruthy } from '../underscore'; +import { maybeShowTelemetryNotice } from './notice'; import { InMemoryThrottlerCache, LocalStorageThrottlerCache, TelemetryEventThrottler } from './throttler'; import type { TelemetryCollectorOptions } from './types'; @@ -145,6 +146,11 @@ export class TelemetryCollector implements TelemetryCollectorInterface { ? new LocalStorageThrottlerCache() : new InMemoryThrottlerCache(); this.#eventThrottler = new TelemetryEventThrottler(cache); + + // Surface the one-time telemetry disclosure at runtime instead of via a postinstall script. + // Gated on `isEnabled` so users who opted out (or are not on a development instance) are not + // shown a notice for collection that will never happen. + maybeShowTelemetryNotice({ skip: !this.isEnabled }); } get isEnabled(): boolean { diff --git a/packages/shared/src/telemetry/notice.ts b/packages/shared/src/telemetry/notice.ts new file mode 100644 index 00000000000..8efa5344b84 --- /dev/null +++ b/packages/shared/src/telemetry/notice.ts @@ -0,0 +1,123 @@ +/** + * 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. + * + * 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. + */ + +import { isTruthy } from '../underscore'; + +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.', +]; + +const CI_ENV_VARS = [ + 'CI', + 'CONTINUOUS_INTEGRATION', + 'BUILD_NUMBER', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'BUILDKITE', + 'JENKINS_URL', + 'TF_BUILD', + 'DRONE', + 'CODEBUILD_BUILD_ID', +]; + +function isServerRuntime(): boolean { + // Skip in browsers. + if (typeof window !== 'undefined') { + return false; + } + // Skip in Next.js Edge Runtime, which exposes a global `EdgeRuntime` marker. We detect via + // this marker (rather than checking `process.versions`) because the Edge Runtime build-time + // analyzer flags any reachable read of `process.versions` even when it sits behind a guard. + if (typeof (globalThis as { EdgeRuntime?: string }).EdgeRuntime !== 'undefined') { + return false; + } + return true; +} + +function isCI(): boolean { + if (typeof process === 'undefined' || !process.env) { + return false; + } + return CI_ENV_VARS.some(name => isTruthy(process.env[name])); +} + +function hasSeen(): boolean { + return Boolean((globalThis as Record)[PROCESS_FLAG]); +} + +function markSeen(): void { + (globalThis as Record)[PROCESS_FLAG] = true; +} + +function printNotice(): void { + if (typeof console === 'undefined' || typeof console.log !== 'function') { + return; + } + for (const line of NOTICE_LINES) { + console.log(line); + } + console.log(''); +} + +export type MaybeShowTelemetryNoticeOptions = { + /** + * Skip the notice entirely. Used when the caller has already determined that no + * telemetry will be sent (e.g. opt-out, non-development instance), in which case + * there is nothing to disclose. + */ + skip?: boolean; +}; + +/** + * 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. + */ +export function maybeShowTelemetryNotice(options: MaybeShowTelemetryNoticeOptions = {}): void { + if (options.skip) { + return; + } + try { + if (!isServerRuntime()) { + return; + } + if (isCI()) { + return; + } + if (hasSeen()) { + return; + } + printNotice(); + markSeen(); + } catch { + // never let disclosure break the SDK + } +} + +/** + * Test-only: clear the in-process flag so the next call re-runs the gating logic. + * + * @internal + */ +export function __resetTelemetryNoticeForTests(): void { + delete (globalThis as Record)[PROCESS_FLAG]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8073536fa5f..56b9bf506f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -832,9 +832,6 @@ importers: react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) - std-env: - specifier: ^3.9.0 - version: 3.10.0 devDependencies: '@base-org/account': specifier: catalog:module-manager