Skip to content

Commit 2c4a2eb

Browse files
chore(misc): richer telemetry for init and connect commands (#35389)
## Current Behavior `nx init` error telemetry is largely opaque: ~22% of starts land in a bare `Command failed: npm install` bucket, and ~5% record an empty `errorMessage`. We can't tell what's actually going wrong. `nx connect` has no start/error events at all — failures (missing remote, auth, network) go untracked. ## Expected Behavior Telemetry-only change. Child-process calls in init pipe stderr so the captured output reaches the error payload; error events now include `errorName` (from Node `e.code` or an extracted `E…`/`ERR_…` token like `E404`, `ERESOLVE`, `EINTEGRITY`, `ERR_PNPM_*`) and the same env context (`nodeVersion`, `os`, `packageManager`, `isCI`, `aiAgent`) as start events. `toErrorString` fixes the empty-message bucket. `nx connect` gains proper start/complete/error events. No behavioral fixes — once the enriched data comes in we'll prioritize real fixes by actual failure distribution. ## Related Issue(s) Fixes NXC-4262 --------- Co-authored-by: nx-cloud[bot] <71083854+nx-cloud[bot]@users.noreply.github.com> Co-authored-by: jaysoo <jaysoo@users.noreply.github.com>
1 parent 163ed4b commit 2c4a2eb

7 files changed

Lines changed: 283 additions & 92 deletions

File tree

packages/nx/src/command-line/init/command-object.ts

Lines changed: 7 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,6 @@
11
import { Argv, CommandModule } from 'yargs';
22
import { handleImport } from '../../utils/handle-import';
33
import { parseCSV } from '../yargs-utils/shared-options';
4-
import { isAiAgent } from '../../native';
5-
import {
6-
writeAiOutput,
7-
buildErrorResult,
8-
writeErrorLog,
9-
determineErrorCode,
10-
} from './utils/ai-output';
11-
import { recordStat } from '../../utils/ab-testing';
12-
import { nxVersion } from '../../utils/versions';
13-
import { isCI } from '../../utils/is-ci';
14-
import { detectPackageManager } from '../../utils/package-manager';
154

165
export const yargsInitCommand: CommandModule = {
176
command: 'init',
@@ -42,72 +31,14 @@ export const yargsInitCommand: CommandModule = {
4231
throw error;
4332
});
4433

45-
const aiAgent = isAiAgent();
46-
47-
recordStat({
48-
command: 'init',
49-
nxVersion,
50-
useCloud: false,
51-
meta: {
52-
type: 'start',
53-
nodeVersion: process.versions.node,
54-
os: process.platform,
55-
packageManager: detectPackageManager(),
56-
aiAgent,
57-
isCI: isCI(),
58-
},
59-
});
60-
61-
try {
62-
const useV2 = await isInitV2();
63-
if (useV2) {
64-
// v2 records its own complete event with richer context
65-
await require('./init-v2').initHandler(args);
66-
} else {
67-
await require('./init-v1').initHandler(args);
68-
await recordStat({
69-
command: 'init',
70-
nxVersion,
71-
useCloud: false,
72-
meta: {
73-
type: 'complete',
74-
nodeVersion: process.versions.node,
75-
os: process.platform,
76-
packageManager: detectPackageManager(),
77-
aiAgent,
78-
isCI: isCI(),
79-
},
80-
});
81-
}
82-
process.exit(0);
83-
} catch (error) {
84-
const errorMessage =
85-
error instanceof Error ? error.message : String(error);
86-
const errorCode = determineErrorCode(error);
87-
88-
await recordStat({
89-
command: 'init',
90-
nxVersion,
91-
useCloud: false,
92-
meta: {
93-
type: 'error',
94-
errorCode,
95-
errorMessage: errorMessage.substring(0, 250),
96-
aiAgent,
97-
},
98-
});
99-
100-
// Output structured error for AI agents
101-
if (aiAgent) {
102-
const errorLogPath = writeErrorLog(error);
103-
writeAiOutput(buildErrorResult(errorMessage, errorCode, errorLogPath));
104-
} else {
105-
// Ensure the cursor is always restored just in case the user has bailed during interactive prompts
106-
// Skip for AI agents to avoid corrupting NDJSON output
107-
process.stdout.write('\x1b[?25h');
108-
}
109-
process.exit(1);
34+
const useV2 = await isInitV2();
35+
if (useV2) {
36+
await require('./init-v2').initHandler(args);
37+
} else {
38+
// v1 path retained for `NX_ADD_PLUGINS=false`; slated for removal.
39+
await require('./init-v1').initHandler(args);
11040
}
41+
process.exit(0);
11142
},
11243
};
11344

packages/nx/src/command-line/init/configure-plugins.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,12 @@ export function installPluginPackages(
4141
nxJson.installation.plugins[plugin] = nxVersion;
4242
}
4343
writeJsonFile(join(repoRoot, 'nx.json'), nxJson);
44-
// Invoking nx wrapper to install plugins.
45-
runNxSync('--version', { stdio: 'ignore' });
44+
try {
45+
runNxSync('--version', { stdio: 'pipe' });
46+
} catch (e) {
47+
if ((e as any)?.stderr) process.stderr.write((e as any).stderr);
48+
throw e;
49+
}
4650
}
4751
}
4852

packages/nx/src/command-line/init/implementation/utils.spec.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ jest.mock('./deduce-default-base', () => ({
33
}));
44

55
import { NxJsonConfiguration } from '../../../config/nx-json';
6-
import { createNxJsonFromTurboJson } from './utils';
6+
import {
7+
createNxJsonFromTurboJson,
8+
extractErrorName,
9+
readErrorStderr,
10+
toErrorString,
11+
} from './utils';
712

813
describe('utils', () => {
914
describe('createNxJsonFromTurboJson', () => {
@@ -266,4 +271,84 @@ describe('utils', () => {
266271
expect(createNxJsonFromTurboJson(turbo)).toEqual(nx);
267272
});
268273
});
274+
275+
describe('toErrorString', () => {
276+
it('returns error.message when present', () => {
277+
expect(toErrorString(new Error('boom'))).toBe('boom');
278+
});
279+
280+
it('returns "Error" for bare new Error() instead of empty string', () => {
281+
expect(toErrorString(new Error())).toBe('Error');
282+
});
283+
284+
it('returns "Unknown error" for null/undefined', () => {
285+
expect(toErrorString(null)).toBe('Unknown error');
286+
expect(toErrorString(undefined)).toBe('Unknown error');
287+
});
288+
289+
it('coerces primitive throws', () => {
290+
expect(toErrorString('str')).toBe('str');
291+
expect(toErrorString(42)).toBe('42');
292+
});
293+
294+
it('includes own-property code when message is empty', () => {
295+
const e = new Error('') as Error & { code?: string };
296+
e.code = 'E404';
297+
expect(toErrorString(e)).toContain('E404');
298+
});
299+
300+
it('serializes plain objects', () => {
301+
expect(toErrorString({ foo: 'bar' })).toBe('{"foo":"bar"}');
302+
});
303+
304+
it('falls through to toString() for unserializable objects', () => {
305+
const circular: any = {};
306+
circular.self = circular;
307+
expect(toErrorString(circular)).toBe('[object Object]');
308+
});
309+
});
310+
311+
describe('readErrorStderr', () => {
312+
it('returns string stderr as-is', () => {
313+
expect(readErrorStderr({ stderr: 'hello' })).toBe('hello');
314+
});
315+
316+
it('decodes Buffer stderr to utf8', () => {
317+
expect(readErrorStderr({ stderr: Buffer.from('boom', 'utf8') })).toBe(
318+
'boom'
319+
);
320+
});
321+
322+
it('returns "" when stderr is absent or nullish', () => {
323+
expect(readErrorStderr({})).toBe('');
324+
expect(readErrorStderr(null)).toBe('');
325+
expect(readErrorStderr({ stderr: null })).toBe('');
326+
});
327+
});
328+
329+
describe('extractErrorName', () => {
330+
it('prefers Node e.code when set', () => {
331+
expect(extractErrorName({ code: 'EACCES' }, 'stderr E404')).toBe(
332+
'EACCES'
333+
);
334+
});
335+
336+
it.each([
337+
['npm error code E404', 'E404'],
338+
['npm error code ERESOLVE', 'ERESOLVE'],
339+
['npm error code EINTEGRITY sha512 failure', 'EINTEGRITY'],
340+
['ERR_PNPM_PEER_DEP_ISSUES Unmet peer deps', 'ERR_PNPM_PEER_DEP_ISSUES'],
341+
])('extracts %s as %s', (stderr, expected) => {
342+
expect(extractErrorName({}, stderr)).toBe(expected);
343+
});
344+
345+
it('falls back to error.name for plain Errors', () => {
346+
expect(extractErrorName(new TypeError('x'), '')).toBe('TypeError');
347+
});
348+
349+
it('returns typeof for non-Error throws', () => {
350+
expect(extractErrorName('str', '')).toBe('string');
351+
expect(extractErrorName(42, '')).toBe('number');
352+
});
353+
});
269354
});

packages/nx/src/command-line/init/implementation/utils.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -227,11 +227,66 @@ export function runInstall(
227227
repoRoot: string,
228228
pmc: PackageManagerCommands = getPackageManagerCommand()
229229
) {
230-
execSync(pmc.install, {
231-
stdio: ['ignore', 'ignore', 'inherit'],
232-
cwd: repoRoot,
233-
windowsHide: true,
234-
});
230+
try {
231+
execSync(pmc.install, {
232+
stdio: ['ignore', 'ignore', 'pipe'],
233+
encoding: 'utf8',
234+
cwd: repoRoot,
235+
windowsHide: true,
236+
});
237+
} catch (e) {
238+
if ((e as any)?.stderr) process.stderr.write((e as any).stderr);
239+
throw e;
240+
}
241+
}
242+
243+
/**
244+
* Coerce any thrown value into a non-empty telemetry string. The naive
245+
* `error.message || String(error)` yields "" for bare `new Error()`.
246+
*/
247+
export function toErrorString(error: unknown): string {
248+
if (error == null) return 'Unknown error';
249+
if (error instanceof Error) {
250+
if (error.message) return error.message;
251+
if (error.name && error.name !== 'Error') return error.name;
252+
// Drop `stack` — large and contains absolute paths (PII).
253+
const keys = Object.getOwnPropertyNames(error).filter((k) => k !== 'stack');
254+
const serialized = safeJsonStringify(error, keys);
255+
if (serialized && serialized !== '{}') return serialized;
256+
return error.name || 'Error';
257+
}
258+
if (typeof error === 'object') {
259+
const serialized = safeJsonStringify(error);
260+
if (serialized && serialized !== '{}') return serialized;
261+
return Object.prototype.toString.call(error);
262+
}
263+
return String(error);
264+
}
265+
266+
export function readErrorStderr(error: unknown): string {
267+
const raw = (error as any)?.stderr;
268+
if (typeof raw === 'string') return raw;
269+
if (raw && typeof (raw as Buffer).toString === 'function') {
270+
return (raw as Buffer).toString('utf8');
271+
}
272+
return '';
273+
}
274+
275+
export function extractErrorName(error: unknown, stderr: string): string {
276+
const nodeCode = (error as any)?.code;
277+
if (typeof nodeCode === 'string') return nodeCode;
278+
const m = stderr.match(/\b(E[A-Z0-9_]{2,}|ERR_[A-Z0-9_]+)\b/);
279+
if (m) return m[1];
280+
if (error instanceof Error) return error.name;
281+
return typeof error;
282+
}
283+
284+
function safeJsonStringify(value: unknown, replacer?: string[]): string {
285+
try {
286+
return JSON.stringify(value, replacer);
287+
} catch {
288+
return '';
289+
}
235290
}
236291

237292
export async function initCloud(

packages/nx/src/command-line/init/init-v2.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ import { addNxToAngularCliRepo } from './implementation/angular';
2323
import { generateDotNxSetup } from './implementation/dot-nx/add-nx-scripts';
2424
import {
2525
createNxJsonFile,
26+
extractErrorName,
2627
initCloud,
2728
isMonorepo,
2829
printFinalMessage,
30+
readErrorStderr,
2931
setNeverConnectToCloud,
32+
toErrorString,
3033
updateGitIgnore,
3134
} from './implementation/utils';
3235
import { ensurePackageHasProvenance } from '../../utils/provenance';
@@ -92,8 +95,69 @@ export async function initHandler(
9295
}
9396
}
9497

98+
async function recordInitError(
99+
error: unknown,
100+
baseMeta: Record<string, string | boolean>
101+
): Promise<void> {
102+
const errorMessage = toErrorString(error);
103+
const errorCode = determineErrorCode(error);
104+
const stderr = readErrorStderr(error).trim();
105+
const telemetryMessage = (
106+
stderr ? `${errorMessage} | stderr: ${stderr.slice(-250)}` : errorMessage
107+
).slice(0, 500);
108+
const errorName = extractErrorName(error, stderr);
109+
110+
await recordStat({
111+
command: 'init',
112+
nxVersion,
113+
useCloud: false,
114+
meta: {
115+
type: 'error',
116+
errorCode,
117+
errorName,
118+
errorMessage: telemetryMessage,
119+
...baseMeta,
120+
},
121+
});
122+
123+
if (baseMeta.aiAgent) {
124+
const errorLogPath = writeErrorLog(error);
125+
writeAiOutput(buildErrorResult(errorMessage, errorCode, errorLogPath));
126+
} else {
127+
// Restore the cursor in case the user bailed during an interactive
128+
// prompt. Skip for AI agents — it would corrupt NDJSON output.
129+
process.stdout.write('\x1b[?25h');
130+
}
131+
process.exit(1);
132+
}
133+
95134
async function initHandlerImpl(options: InitArgs): Promise<void> {
96135
process.env.NX_RUNNING_NX_INIT = 'true';
136+
const baseMeta = {
137+
nodeVersion: process.versions.node,
138+
os: process.platform,
139+
packageManager: detectPackageManager(),
140+
aiAgent: isAiAgent(),
141+
isCI: isCI(),
142+
};
143+
recordStat({
144+
command: 'init',
145+
nxVersion,
146+
useCloud: false,
147+
meta: { type: 'start', ...baseMeta },
148+
});
149+
150+
try {
151+
return await runInit(options, baseMeta);
152+
} catch (error) {
153+
await recordInitError(error, baseMeta);
154+
}
155+
}
156+
157+
async function runInit(
158+
options: InitArgs,
159+
baseMeta: Record<string, string | boolean>
160+
): Promise<void> {
97161
const version =
98162
process.env.NX_VERSION ?? (prerelease(nxVersion) ? nxVersion : 'latest');
99163
if (process.env.NX_VERSION) {

packages/nx/src/command-line/nx-cloud/connect/command-object.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Argv, CommandModule } from 'yargs';
22
import { handleImport } from '../../../utils/handle-import';
33
import { linkToNxDevAndExamples } from '../../yargs-utils/documentation';
4-
import { nxVersion } from '../../../utils/versions';
54
import { withVerbose } from '../../yargs-utils/shared-options';
65

76
export const yargsConnectCommand: CommandModule = {
@@ -15,13 +14,6 @@ export const yargsConnectCommand: CommandModule = {
1514
await (
1615
await handleImport('./connect-to-nx-cloud.js', __dirname)
1716
).connectToNxCloudCommand({ ...args, checkRemote });
18-
await (
19-
await handleImport('../../../utils/ab-testing.js', __dirname)
20-
).recordStat({
21-
command: 'connect',
22-
nxVersion,
23-
useCloud: true,
24-
});
2517
process.exit(0);
2618
},
2719
};

0 commit comments

Comments
 (0)