Skip to content

Commit 4bb7c76

Browse files
Coly010nx-cloud[bot]FrozenPandaz
authored
feat(core): add analytics (#34144)
## Current Behavior Nx CLI has no mechanism for collecting usage analytics, and users are not prompted about their analytics preferences. There is no way for the Nx team to understand which commands, generators, and features are most used. ## Expected Behavior This PR adds opt-in analytics collection to the Nx CLI with two main components: ### 1. Analytics Prompt Users are prompted for their analytics preference on first interactive CLI run when `analytics` is not yet configured in `nx.json`: - Only appears when `analytics` is undefined in `nx.json` - Skipped in CI environments - Skipped in non-interactive terminals (piped input/output) - Stores the user's choice as a boolean (`true`/`false`) in the `analytics` field of `nx.json` - Defaults to `false` if the user cancels (Ctrl+C) - Includes a migration (`update-22-6-0/enable-analytics-prompt`) for existing workspaces ### 2. Analytics Collector When analytics is enabled, the CLI collects usage data via a Rust-based telemetry service and sends it to GA4. Data collected includes: - **Commands run** (e.g. `build`, `test`, `generate`, `add`) — tracked as page views - **Command arguments** — with aggressive sanitization of sensitive values (project names, file paths, URLs, credentials, free-form text are all redacted; only boolean flags and safe enum values are preserved) - **Generator and package names** for `nx add` and `nx generate` (as custom dimensions) - **Project graph creation duration** - **Environment metadata**: Nx version, Node version, package manager, OS, architecture, CI detection ### Workspace Identification Each workspace is identified by a deterministic ID (used as the GA4 client ID) with the following priority: 1. **`nxCloudId`** (or `nxCloudAccessToken`) from `nx.json` — used directly, most stable 2. **Git remote URL** (`git remote get-url origin`) — SHA-256 hashed for privacy 3. **First commit SHA** (`git rev-list --max-parents=0 HEAD`) — used directly as a fallback Each user/machine is identified separately via `node-machine-id`. ### Privacy & Safety - No project names, file paths, or other PII is collected - Sensitive CLI arguments are redacted (see `SENSITIVE_ARGS_KEYS` list) - Analytics is strictly opt-in (must be `true` in `nx.json`) - Telemetry failures are silently ignored — never blocks or crashes the CLI - WASM builds are excluded (no native telemetry module available) - The native telemetry functions are loaded with optional chaining to prevent crashes when running against published Nx binaries that don't include them yet ### Key Files - `packages/nx/src/utils/analytics-prompt.ts` — Prompt logic and workspace ID generation - `packages/nx/src/analytics/analytics.ts` — Analytics collector, event tracking, argument sanitization - `packages/nx/src/native/telemetry/` — Rust telemetry service (constants, service, mod) - `packages/nx/src/utils/machine-id-cache.ts` — Machine ID for user identification - `packages/nx/src/migrations/update-22-6-0/enable-analytics-prompt.ts` — Migration for existing workspaces ## Related Issue(s) Closes NXC-3731 Closes NXC-3732 Closes NXC-3733 Closes NXC-3734 --------- Co-authored-by: nx-cloud[bot] <71083854+nx-cloud[bot]@users.noreply.github.com> Co-authored-by: Coly010 <Coly010@users.noreply.github.com> Co-authored-by: Jason Jean <jasonjean1993@gmail.com>
1 parent b3d1b1a commit 4bb7c76

File tree

28 files changed

+1692
-19
lines changed

28 files changed

+1692
-19
lines changed

Cargo.lock

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

packages/nx/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
6161
tokio-util = "0.7.9"
6262
tracing-appender = "0.2"
6363
tui-logger = { version = "0.17.2", features = ["tracing-support"] }
64+
urlencoding = "2.1"
65+
uuid = { version = "1.0", features = ["v4"] }
6466
tui-term = { git = "https://github.com/JamesHenry/tui-term", rev = "88e3b61425c97220c528ef76c188df10032a75dd" }
6567
walkdir = '2.3.3'
6668
xxhash-rust = { version = '0.8.5', features = ['xxh3', 'xxh64'] }

packages/nx/bin/init-local.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { stripIndents } from '../src/utils/strip-indents';
66
import { daemonClient } from '../src/daemon/client/client';
77
import { prompt } from 'enquirer';
88
import { output } from '../src/utils/output';
9+
import { flushAnalytics } from '../src/analytics';
910

1011
/**
1112
* Nx is being run inside a workspace.
@@ -56,6 +57,7 @@ export async function initLocal(workspace: WorkspaceTypeAndRoot) {
5657
}
5758
} catch (e) {
5859
console.error(e.message);
60+
flushAnalytics();
5961
process.exit(1);
6062
}
6163
}

packages/nx/bin/nx.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { performance } from 'perf_hooks';
2929
import { setupWorkspaceContext } from '../src/utils/workspace-context';
3030
import { daemonClient } from '../src/daemon/client/client';
3131
import { removeDbConnections } from '../src/utils/db-connection';
32+
import { ensureAnalyticsPreferenceSet } from '../src/utils/analytics-prompt';
33+
import { flushAnalytics, startAnalytics } from '../src/analytics';
3234

3335
async function main() {
3436
if (
@@ -102,6 +104,12 @@ async function main() {
102104
handleMissingLocalInstallation(workspace ? workspace.dir : null);
103105
}
104106

107+
// Prompt for analytics preference if not set
108+
try {
109+
await ensureAnalyticsPreferenceSet();
110+
} catch {}
111+
await startAnalytics();
112+
105113
// this file is already in the local workspace
106114
if (isNxCloudCommand(process.argv[2])) {
107115
// nx-cloud commands can run without local Nx installation
@@ -313,5 +321,6 @@ process.on('exit', () => {
313321

314322
main().catch((error) => {
315323
console.error(error);
324+
flushAnalytics();
316325
process.exit(1);
317326
});

packages/nx/migrations.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@
148148
"version": "22.7.0-beta.0",
149149
"description": "Adds .nx/polygraph to .gitignore",
150150
"implementation": "./src/migrations/update-22-7-0/add-polygraph-to-git-ignore"
151+
},
152+
"22-6-0-enable-analytics-prompt": {
153+
"cli": "nx",
154+
"version": "22.6.0-beta.11",
155+
"description": "Prompts to enable usage analytics",
156+
"implementation": "./src/migrations/update-22-6-0/enable-analytics-prompt"
151157
}
152158
}
153159
}

packages/nx/schemas/nx-schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@
142142
"type": "boolean",
143143
"description": "Specifies whether to add inference plugins when generating new projects."
144144
},
145+
"analytics": {
146+
"type": "boolean",
147+
"description": "Set this to true to allow Nx to collect usage analytics."
148+
},
145149
"release": {
146150
"type": "object",
147151
"description": "Configuration for the nx release commands.",

packages/nx/src/adapter/compat.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export const allowedWorkspaceExtensions = [
8282
'useDaemonProcess',
8383
'useInferencePlugins',
8484
'neverConnectToCloud',
85+
'analytics',
8586
'sync',
8687
'useLegacyCache',
8788
'maxCacheSize',
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { argsToQueryString } from './analytics';
2+
3+
describe('argsToQueryString', () => {
4+
it('should convert simple non-sensitive args to query string', () => {
5+
const result = argsToQueryString({ verbose: true, parallel: 3 });
6+
expect(result).toBe('verbose=true&parallel=3');
7+
});
8+
9+
it('should handle array values by repeating the key', () => {
10+
const result = argsToQueryString({
11+
targets: ['build', 'test'],
12+
});
13+
expect(result).toBe('targets=build&targets=test');
14+
});
15+
16+
it('should filter out internal yargs keys', () => {
17+
const result = argsToQueryString({
18+
verbose: true,
19+
$0: 'nx',
20+
_: ['run-many'],
21+
__overrides_unparsed__: [],
22+
__overrides__: [],
23+
});
24+
expect(result).toBe('verbose=true');
25+
});
26+
27+
it('should skip null and undefined values', () => {
28+
const result = argsToQueryString({
29+
verbose: true,
30+
missing: null,
31+
absent: undefined,
32+
});
33+
expect(result).toBe('verbose=true');
34+
});
35+
36+
it('should skip nested objects (non-array)', () => {
37+
const result = argsToQueryString({
38+
verbose: true,
39+
nested: { deep: 'value' },
40+
});
41+
expect(result).toBe('verbose=true');
42+
});
43+
44+
it('should return empty string for empty args', () => {
45+
const result = argsToQueryString({});
46+
expect(result).toBe('');
47+
});
48+
49+
it('should return empty string when all keys are internal', () => {
50+
const result = argsToQueryString({
51+
$0: 'nx',
52+
_: [],
53+
});
54+
expect(result).toBe('');
55+
});
56+
57+
it('should handle numeric values for non-sensitive keys', () => {
58+
const result = argsToQueryString({ port: 4200 });
59+
expect(result).toBe('port=4200');
60+
});
61+
62+
it('should handle false boolean values', () => {
63+
const result = argsToQueryString({ verbose: false });
64+
expect(result).toBe('verbose=false');
65+
});
66+
67+
describe('sensitive args sanitization', () => {
68+
it('should redact sensitive file paths', () => {
69+
const result = argsToQueryString({ file: '/path/to/file.json' });
70+
expect(result).toBe('file=%3Credacted%3E');
71+
});
72+
73+
it('should redact sensitive project identifiers', () => {
74+
const result = argsToQueryString({
75+
project: 'my-secret-app',
76+
focus: 'internal-lib',
77+
});
78+
expect(result).toBe('project=%3Credacted%3E&focus=%3Credacted%3E');
79+
});
80+
81+
it('should preserve boolean values on sensitive keys', () => {
82+
const result = argsToQueryString({ runMigrations: true });
83+
expect(result).toBe('runMigrations=true');
84+
});
85+
86+
it('should preserve false boolean values on sensitive keys', () => {
87+
const result = argsToQueryString({ graph: false });
88+
expect(result).toBe('graph=false');
89+
});
90+
91+
it('should redact non-boolean values on sensitive keys', () => {
92+
const result = argsToQueryString({
93+
runMigrations: 'migrations.json',
94+
});
95+
expect(result).toBe('runMigrations=%3Credacted%3E');
96+
});
97+
98+
it('should pass through non-sensitive keys unchanged', () => {
99+
const result = argsToQueryString({
100+
verbose: true,
101+
port: 4200,
102+
parallel: 3,
103+
});
104+
expect(result).toBe('verbose=true&port=4200&parallel=3');
105+
});
106+
107+
it('should redact each element of array values for sensitive keys', () => {
108+
const result = argsToQueryString({
109+
projects: ['secret-app', 'internal-lib'],
110+
});
111+
expect(result).toBe('projects=%3Credacted%3E&projects=%3Credacted%3E');
112+
});
113+
114+
it('should preserve each element of array values for non-sensitive keys', () => {
115+
const result = argsToQueryString({
116+
targets: ['build', 'test'],
117+
});
118+
expect(result).toBe('targets=build&targets=test');
119+
});
120+
121+
it('should catch kebab-case sensitive keys', () => {
122+
const result = argsToQueryString({ 'output-path': 'dist/app' });
123+
expect(result).toBe('output-path=%3Credacted%3E');
124+
});
125+
126+
it('should handle a real-world graph command scenario', () => {
127+
const result = argsToQueryString({
128+
file: 'output.json',
129+
focus: 'my-secret-app',
130+
exclude: 'internal-lib',
131+
host: '192.168.1.1',
132+
port: 4200,
133+
targets: 'build',
134+
watch: true,
135+
});
136+
expect(result).toBe(
137+
'file=%3Credacted%3E&focus=%3Credacted%3E&exclude=%3Credacted%3E&host=%3Credacted%3E&port=4200&targets=build&watch=true'
138+
);
139+
});
140+
141+
it('should always redact credentials', () => {
142+
const result = argsToQueryString({ otp: '123456' });
143+
expect(result).toBe('otp=%3Credacted%3E');
144+
});
145+
146+
it('should redact git refs', () => {
147+
const result = argsToQueryString({
148+
base: 'feature/secret-branch',
149+
head: 'main',
150+
});
151+
expect(result).toBe('base=%3Credacted%3E&head=%3Credacted%3E');
152+
});
153+
154+
it('should redact URLs and hosts', () => {
155+
const result = argsToQueryString({
156+
baseUrl: 'https://internal.company.com',
157+
deployUrl: 'https://staging.company.com/app',
158+
});
159+
expect(result).toBe('baseUrl=%3Credacted%3E&deployUrl=%3Credacted%3E');
160+
});
161+
162+
it('should handle kebab-case config keys', () => {
163+
const result = argsToQueryString({
164+
'ts-config': 'tsconfig.app.json',
165+
'webpack-config': 'webpack.config.js',
166+
});
167+
expect(result).toBe(
168+
'ts-config=%3Credacted%3E&webpack-config=%3Credacted%3E'
169+
);
170+
});
171+
});
172+
});

0 commit comments

Comments
 (0)