Skip to content

Commit 43dfefa

Browse files
authored
chore(shared): replace telemetry postinstall with runtime notice (#8549)
1 parent 86fd38f commit 43dfefa

7 files changed

Lines changed: 254 additions & 85 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@clerk/shared': patch
3+
---
4+
5+
Replace the telemetry `postinstall` script with a one-time runtime notice, printed once per process on server runtimes (Node, excluding CI) when the telemetry collector boots against a development instance. Drops the `std-env` dependency.
6+
7+
Removing `postinstall` improves the package's supply-chain posture: `@clerk/shared` no longer executes arbitrary code at install time, aligning with package-manager defaults that increasingly disable install scripts.
8+
9+
Browser-only applications with no server-side Clerk runtime (e.g. a Vite SPA) will not surface an in-band notice. Telemetry behavior and opt-out (`telemetry={false}` or `*_CLERK_TELEMETRY_DISABLED`) are unchanged; disclosure for these setups is provided at https://clerk.com/docs/telemetry.

packages/shared/package.json

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,7 @@
121121
}
122122
},
123123
"files": [
124-
"dist",
125-
"scripts"
124+
"dist"
126125
],
127126
"scripts": {
128127
"build": "tsdown",
@@ -131,7 +130,6 @@
131130
"dev:pub": "pnpm dev -- --env.publish",
132131
"format": "node ../../scripts/format-package.mjs",
133132
"format:check": "node ../../scripts/format-package.mjs --check",
134-
"postinstall": "node ./scripts/postinstall.mjs",
135133
"lint": "eslint src",
136134
"lint:attw": "attw --pack . --profile node16",
137135
"lint:publint": "publint",
@@ -143,8 +141,7 @@
143141
"@tanstack/query-core": "catalog:repo",
144142
"dequal": "2.0.3",
145143
"glob-to-regexp": "0.4.1",
146-
"js-cookie": "3.0.7",
147-
"std-env": "^3.9.0"
144+
"js-cookie": "3.0.7"
148145
},
149146
"devDependencies": {
150147
"@base-org/account": "catalog:module-manager",

packages/shared/scripts/postinstall.mjs

Lines changed: 0 additions & 77 deletions
This file was deleted.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
5+
6+
import { __resetTelemetryNoticeForTests, maybeShowTelemetryNotice } from '../telemetry/notice';
7+
import { automatedEnvironmentVariables } from '../utils/runtimeEnvironment';
8+
9+
const CI_VARS = automatedEnvironmentVariables;
10+
11+
function clearCIEnv() {
12+
for (const name of CI_VARS) {
13+
delete process.env[name];
14+
}
15+
}
16+
17+
describe('maybeShowTelemetryNotice', () => {
18+
let logSpy: ReturnType<typeof vi.spyOn>;
19+
let originalCIEnv: Record<string, string | undefined>;
20+
21+
beforeEach(() => {
22+
originalCIEnv = Object.fromEntries(CI_VARS.map(name => [name, process.env[name]]));
23+
clearCIEnv();
24+
__resetTelemetryNoticeForTests();
25+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
26+
});
27+
28+
afterEach(() => {
29+
logSpy.mockRestore();
30+
for (const [name, value] of Object.entries(originalCIEnv)) {
31+
if (typeof value === 'string') {
32+
process.env[name] = value;
33+
} else {
34+
delete process.env[name];
35+
}
36+
}
37+
});
38+
39+
test('prints the disclosure on Node', () => {
40+
maybeShowTelemetryNotice();
41+
42+
expect(logSpy).toHaveBeenCalled();
43+
const printed = logSpy.mock.calls.map(call => String(call[0])).join('\n');
44+
expect(printed).toMatch(/Clerk collects telemetry/);
45+
expect(printed).toMatch(/clerk\.com\/docs\/telemetry/);
46+
});
47+
48+
test('does not print again on subsequent calls in the same process', () => {
49+
maybeShowTelemetryNotice();
50+
maybeShowTelemetryNotice();
51+
maybeShowTelemetryNotice();
52+
53+
const disclosureCalls = logSpy.mock.calls.filter(call => /Clerk collects telemetry/.test(String(call[0])));
54+
expect(disclosureCalls).toHaveLength(1);
55+
});
56+
57+
test('skips entirely when skip:true is passed', () => {
58+
maybeShowTelemetryNotice({ skip: true });
59+
60+
expect(logSpy).not.toHaveBeenCalled();
61+
});
62+
63+
test('skips when a CI env var is set', () => {
64+
// eslint-disable-next-line turbo/no-undeclared-env-vars
65+
process.env.CI = 'true';
66+
67+
maybeShowTelemetryNotice();
68+
69+
expect(logSpy).not.toHaveBeenCalled();
70+
});
71+
72+
test.each(CI_VARS)('skips when %s is set', name => {
73+
process.env[name] = '1';
74+
75+
maybeShowTelemetryNotice();
76+
77+
expect(logSpy).not.toHaveBeenCalled();
78+
});
79+
80+
test('skips in a browser-like environment', () => {
81+
const original = (globalThis as { window?: unknown }).window;
82+
(globalThis as { window?: unknown }).window = {};
83+
84+
try {
85+
maybeShowTelemetryNotice();
86+
expect(logSpy).not.toHaveBeenCalled();
87+
} finally {
88+
if (typeof original === 'undefined') {
89+
delete (globalThis as { window?: unknown }).window;
90+
} else {
91+
(globalThis as { window?: unknown }).window = original;
92+
}
93+
}
94+
});
95+
96+
test('skips in Next.js Edge Runtime', () => {
97+
(globalThis as { EdgeRuntime?: string }).EdgeRuntime = 'edge-runtime';
98+
99+
try {
100+
maybeShowTelemetryNotice();
101+
expect(logSpy).not.toHaveBeenCalled();
102+
} finally {
103+
delete (globalThis as { EdgeRuntime?: string }).EdgeRuntime;
104+
}
105+
});
106+
107+
test('does not throw if console.log fails', () => {
108+
logSpy.mockImplementation(() => {
109+
throw new Error('console broken');
110+
});
111+
112+
expect(() => maybeShowTelemetryNotice()).not.toThrow();
113+
});
114+
});

packages/shared/src/telemetry/collector.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
TelemetryLogEntry,
2121
} from '../types';
2222
import { isTruthy } from '../underscore';
23+
import { maybeShowTelemetryNotice } from './notice';
2324
import { InMemoryThrottlerCache, LocalStorageThrottlerCache, TelemetryEventThrottler } from './throttler';
2425
import type { TelemetryCollectorOptions } from './types';
2526

@@ -145,6 +146,11 @@ export class TelemetryCollector implements TelemetryCollectorInterface {
145146
? new LocalStorageThrottlerCache()
146147
: new InMemoryThrottlerCache();
147148
this.#eventThrottler = new TelemetryEventThrottler(cache);
149+
150+
// Surface the one-time telemetry disclosure at runtime instead of via a postinstall script.
151+
// Gated on `isEnabled` so users who opted out (or are not on a development instance) are not
152+
// shown a notice for collection that will never happen.
153+
maybeShowTelemetryNotice({ skip: !this.isEnabled });
148154
}
149155

150156
get isEnabled(): boolean {
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* One-time runtime disclosure that Clerk collects telemetry from development instances.
3+
*
4+
* Replaces the previous `postinstall` script. Disclosure is intentionally surfaced
5+
* only on Node (server-side) so the noise profile matches the original postinstall
6+
* (terminal-only, dev-eyes-only). Browser consoles are not used because they are
7+
* frequently observed by non-developers (QA, screenshots, demos), and adding another
8+
* console warning is a common source of customer complaints.
9+
*
10+
* Known gap: pure browser-only setups with no server-side Clerk runtime (e.g. a Vite
11+
* SPA using `@clerk/clerk-react` or `@clerk/clerk-js` directly, without any Node/Edge
12+
* backend that imports `@clerk/shared`) will never hit this code path and therefore
13+
* see no in-band disclosure. This is an accepted trade-off: the original postinstall
14+
* already fired only once at install time and was easily missed, so the practical
15+
* delta is small. Authoritative disclosure for those setups lives in the Clerk
16+
* telemetry docs (https://clerk.com/docs/telemetry). Opt-out continues to work the
17+
* same way (`telemetry={false}` on `<ClerkProvider>` or the framework-specific
18+
* `*_CLERK_TELEMETRY_DISABLED` env var).
19+
*
20+
* Persistence is in-process via a `globalThis` Symbol, which survives Next.js HMR
21+
* module reloads. No filesystem access, no `node:` imports, no dynamic-code APIs, so
22+
* the module remains safe to bundle for Edge Runtime, Workers, and any browser path.
23+
*
24+
* All work is wrapped in try/catch. Failure to display the notice must never affect
25+
* the SDK.
26+
*/
27+
28+
import { isTruthy } from '../underscore';
29+
import { automatedEnvironmentVariables } from '../utils/runtimeEnvironment';
30+
31+
const PROCESS_FLAG = Symbol.for('@clerk/shared.telemetryNoticeShown');
32+
33+
const NOTICE_LINES = [
34+
'Attention: Clerk collects telemetry data from its SDKs when connected to development instances.',
35+
"The data collected is used to inform Clerk's product roadmap.",
36+
'To learn more, including how to opt-out from the telemetry program, visit: https://clerk.com/docs/telemetry.',
37+
];
38+
39+
function isServerRuntime(): boolean {
40+
// Skip in browsers.
41+
if (typeof window !== 'undefined') {
42+
return false;
43+
}
44+
// Skip in Next.js Edge Runtime, which exposes a global `EdgeRuntime` marker. We detect via
45+
// this marker (rather than checking `process.versions`) because the Edge Runtime build-time
46+
// analyzer flags any reachable read of `process.versions` even when it sits behind a guard.
47+
if (typeof (globalThis as { EdgeRuntime?: string }).EdgeRuntime !== 'undefined') {
48+
return false;
49+
}
50+
return true;
51+
}
52+
53+
// Server-only notice: read process.env directly rather than going through
54+
// getEnvVariable(), which would pull the multi-runtime env resolver into the
55+
// clerk.browser.js bundle (it ships there via TelemetryCollector). We reuse the
56+
// shared CI env-var list so the set of detected providers stays in one place.
57+
function isCI(): boolean {
58+
if (typeof process === 'undefined' || !process.env) {
59+
return false;
60+
}
61+
return automatedEnvironmentVariables.some(name => isTruthy(process.env[name]));
62+
}
63+
64+
function hasSeen(): boolean {
65+
return Boolean((globalThis as Record<symbol, unknown>)[PROCESS_FLAG]);
66+
}
67+
68+
function markSeen(): void {
69+
(globalThis as Record<symbol, unknown>)[PROCESS_FLAG] = true;
70+
}
71+
72+
function printNotice(): void {
73+
if (typeof console === 'undefined' || typeof console.log !== 'function') {
74+
return;
75+
}
76+
for (const line of NOTICE_LINES) {
77+
console.log(line);
78+
}
79+
console.log('');
80+
}
81+
82+
export type MaybeShowTelemetryNoticeOptions = {
83+
/**
84+
* Skip the notice entirely. Used when the caller has already determined that no
85+
* telemetry will be sent (e.g. opt-out, non-development instance), in which case
86+
* there is nothing to disclose.
87+
*/
88+
skip?: boolean;
89+
};
90+
91+
/**
92+
* Display the one-time telemetry disclosure on server runtimes if it has not already been
93+
* shown in this process. Browser and Edge Runtime callers are silently skipped. Never throws.
94+
*/
95+
export function maybeShowTelemetryNotice(options: MaybeShowTelemetryNoticeOptions = {}): void {
96+
if (options.skip) {
97+
return;
98+
}
99+
try {
100+
if (!isServerRuntime()) {
101+
return;
102+
}
103+
if (isCI()) {
104+
return;
105+
}
106+
if (hasSeen()) {
107+
return;
108+
}
109+
printNotice();
110+
markSeen();
111+
} catch {
112+
// never let disclosure break the SDK
113+
}
114+
}
115+
116+
/**
117+
* Test-only: clear the in-process flag so the next call re-runs the gating logic.
118+
*
119+
* @internal
120+
*/
121+
export function __resetTelemetryNoticeForTests(): void {
122+
delete (globalThis as Record<symbol, unknown>)[PROCESS_FLAG];
123+
}

pnpm-lock.yaml

Lines changed: 0 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)