Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions packages/nx/src/command-line/init/command-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import { recordStat } from '../../utils/ab-testing';
import { nxVersion } from '../../utils/versions';
import { isCI } from '../../utils/is-ci';
import { detectPackageManager } from '../../utils/package-manager';
import {
extractErrorName,
readErrorStderr,
toErrorString,
} from './implementation/utils';

export const yargsInitCommand: CommandModule = {
command: 'init',
Expand Down Expand Up @@ -81,9 +86,18 @@ export const yargsInitCommand: CommandModule = {
}
process.exit(0);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorMessage = toErrorString(error);
const errorCode = determineErrorCode(error);
// Append stderr tail (present when child-process errors ran with
// `stdio: 'pipe'`) so telemetry gets the real cause.
const stderr = readErrorStderr(error).trim();
const telemetryMessage = (
stderr ? `${errorMessage} | stderr: ${stderr.slice(-250)}` : errorMessage
).slice(0, 500);
// Structured code for bucketing. Prefer Node's `e.code` (set on
// syscall failures); fall back to E-codes/ERR_* extracted from full
// stderr (npm/pnpm/yarn); then `error.name`.
const errorName = extractErrorName(error, stderr);

await recordStat({
command: 'init',
Expand All @@ -92,8 +106,13 @@ export const yargsInitCommand: CommandModule = {
meta: {
type: 'error',
errorCode,
errorMessage: errorMessage.substring(0, 250),
errorName,
errorMessage: telemetryMessage,
aiAgent,
isCI: isCI(),
nodeVersion: process.versions.node,
os: process.platform,
packageManager: detectPackageManager(),
},
});

Expand Down
9 changes: 7 additions & 2 deletions packages/nx/src/command-line/init/configure-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,13 @@ export function installPluginPackages(
nxJson.installation.plugins[plugin] = nxVersion;
}
writeJsonFile(join(repoRoot, 'nx.json'), nxJson);
// Invoking nx wrapper to install plugins.
runNxSync('--version', { stdio: 'ignore' });
// Invoke the nx wrapper to install plugins; pipe stderr for telemetry.
try {
runNxSync('--version', { stdio: 'pipe' });
} catch (e) {
if ((e as any)?.stderr) process.stderr.write((e as any).stderr);
throw e;
}
}
}

Expand Down
85 changes: 84 additions & 1 deletion packages/nx/src/command-line/init/implementation/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ jest.mock('./deduce-default-base', () => ({
}));

import { NxJsonConfiguration } from '../../../config/nx-json';
import { createNxJsonFromTurboJson } from './utils';
import {
createNxJsonFromTurboJson,
extractErrorName,
readErrorStderr,
toErrorString,
} from './utils';

describe('utils', () => {
describe('createNxJsonFromTurboJson', () => {
Expand Down Expand Up @@ -266,4 +271,82 @@ describe('utils', () => {
expect(createNxJsonFromTurboJson(turbo)).toEqual(nx);
});
});

describe('toErrorString', () => {
it('returns error.message when present', () => {
expect(toErrorString(new Error('boom'))).toBe('boom');
});

it('returns "Error" for bare new Error() instead of empty string', () => {
expect(toErrorString(new Error())).toBe('Error');
});

it('returns "Unknown error" for null/undefined', () => {
expect(toErrorString(null)).toBe('Unknown error');
expect(toErrorString(undefined)).toBe('Unknown error');
});

it('coerces primitive throws', () => {
expect(toErrorString('str')).toBe('str');
expect(toErrorString(42)).toBe('42');
});

it('includes own-property code when message is empty', () => {
const e = new Error('') as Error & { code?: string };
e.code = 'E404';
expect(toErrorString(e)).toContain('E404');
});

it('serializes plain objects', () => {
expect(toErrorString({ foo: 'bar' })).toBe('{"foo":"bar"}');
});

it('falls through to toString() for unserializable objects', () => {
const circular: any = {};
circular.self = circular;
expect(toErrorString(circular)).toBe('[object Object]');
});
});

describe('readErrorStderr', () => {
it('returns string stderr as-is', () => {
expect(readErrorStderr({ stderr: 'hello' })).toBe('hello');
});

it('decodes Buffer stderr to utf8', () => {
expect(readErrorStderr({ stderr: Buffer.from('boom', 'utf8') })).toBe(
'boom'
);
});

it('returns "" when stderr is absent or nullish', () => {
expect(readErrorStderr({})).toBe('');
expect(readErrorStderr(null)).toBe('');
expect(readErrorStderr({ stderr: null })).toBe('');
});
});

describe('extractErrorName', () => {
it('prefers Node e.code when set', () => {
expect(extractErrorName({ code: 'EACCES' }, 'stderr E404')).toBe('EACCES');
});

it.each([
['npm error code E404', 'E404'],
['npm error code ERESOLVE', 'ERESOLVE'],
['npm error code EINTEGRITY sha512 failure', 'EINTEGRITY'],
['ERR_PNPM_PEER_DEP_ISSUES Unmet peer deps', 'ERR_PNPM_PEER_DEP_ISSUES'],
])('extracts %s as %s', (stderr, expected) => {
expect(extractErrorName({}, stderr)).toBe(expected);
});

it('falls back to error.name for plain Errors', () => {
expect(extractErrorName(new TypeError('x'), '')).toBe('TypeError');
});

it('returns typeof for non-Error throws', () => {
expect(extractErrorName('str', '')).toBe('string');
expect(extractErrorName(42, '')).toBe('number');
});
});
});
72 changes: 67 additions & 5 deletions packages/nx/src/command-line/init/implementation/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,73 @@ export function runInstall(
repoRoot: string,
pmc: PackageManagerCommands = getPackageManagerCommand()
) {
execSync(pmc.install, {
stdio: ['ignore', 'ignore', 'inherit'],
cwd: repoRoot,
windowsHide: true,
});
try {
// stderr piped so the error carries it for telemetry.
execSync(pmc.install, {
stdio: ['ignore', 'ignore', 'pipe'],
encoding: 'utf8',
cwd: repoRoot,
windowsHide: true,
});
} catch (e) {
if ((e as any)?.stderr) process.stderr.write((e as any).stderr);
throw e;
}
}

/**
* Coerce any thrown value into a non-empty telemetry string. The naive
* `error.message || String(error)` yields "" for bare `new Error()`.
*/
export function toErrorString(error: unknown): string {
if (error == null) return 'Unknown error';
if (error instanceof Error) {
if (error.message) return error.message;
if (error.name && error.name !== 'Error') return error.name;
// Drop `stack` — large and contains absolute paths (PII).
const keys = Object.getOwnPropertyNames(error).filter((k) => k !== 'stack');
const serialized = safeJsonStringify(error, keys);
if (serialized && serialized !== '{}') return serialized;
return error.name || 'Error';
}
if (typeof error === 'object') {
const serialized = safeJsonStringify(error);
if (serialized && serialized !== '{}') return serialized;
return Object.prototype.toString.call(error);
}
return String(error);
}

/** Read `.stderr` off a thrown child-process error; supports string or Buffer. */
export function readErrorStderr(error: unknown): string {
const raw = (error as any)?.stderr;
if (typeof raw === 'string') return raw;
if (raw && typeof (raw as Buffer).toString === 'function') {
return (raw as Buffer).toString('utf8');
}
return '';
}

/**
* Pick a structured name for telemetry bucketing: Node `e.code`,
* else an `E…`/`ERR_…` token from stderr (E404, ERESOLVE, EINTEGRITY,
* ERR_PNPM_*, ...), else `error.name`.
*/
export function extractErrorName(error: unknown, stderr: string): string {
const nodeCode = (error as any)?.code;
if (typeof nodeCode === 'string') return nodeCode;
const m = stderr.match(/\b(E[A-Z0-9_]{2,}|ERR_[A-Z0-9_]+)\b/);
if (m) return m[1];
if (error instanceof Error) return error.name;
return typeof error;
}

function safeJsonStringify(value: unknown, replacer?: string[]): string {
try {
return JSON.stringify(value, replacer);
} catch {
return '';
}
}

export async function initCloud(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Argv, CommandModule } from 'yargs';
import { handleImport } from '../../../utils/handle-import';
import { linkToNxDevAndExamples } from '../../yargs-utils/documentation';
import { nxVersion } from '../../../utils/versions';
import { withVerbose } from '../../yargs-utils/shared-options';

export const yargsConnectCommand: CommandModule = {
Expand All @@ -15,13 +14,6 @@ export const yargsConnectCommand: CommandModule = {
await (
await handleImport('./connect-to-nx-cloud.js', __dirname)
).connectToNxCloudCommand({ ...args, checkRemote });
await (
await handleImport('../../../utils/ab-testing.js', __dirname)
).recordStat({
command: 'connect',
nxVersion,
useCloud: true,
});
process.exit(0);
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,68 @@ export async function connectWorkspaceToCloud(
export async function connectToNxCloudCommand(
options: { generateToken?: boolean; checkRemote?: boolean },
command?: string
): Promise<boolean> {
// Prompt-based callers (init, generate-driven flows) record their own
// telemetry via `connectExistingRepoToNxCloudPrompt` / `connectToNxCloudWithPrompt`.
// Only self-record when invoked directly from the `nx connect` CLI.
const selfRecord = !command;
const baseMeta = {
nodeVersion: process.versions.node,
os: process.platform,
packageManager: detectPackageManager(),
aiAgent: isAiAgent(),
isCI: isCI(),
};
if (selfRecord) {
await recordStat({
command: 'connect',
nxVersion,
useCloud: true,
meta: { type: 'start', ...baseMeta },
});
}
try {
const result = await runConnectToNxCloud(options, command);
if (selfRecord) {
await recordStat({
command: 'connect',
nxVersion,
useCloud: result,
meta: { type: 'complete', ...baseMeta },
});
}
return result;
} catch (error) {
if (selfRecord) {
const message =
(error instanceof Error && error.message) ||
String(error ?? 'Unknown error');
const errorName =
typeof (error as any)?.code === 'string'
? ((error as any).code as string)
: error instanceof Error
? error.name
: typeof error;
await recordStat({
command: 'connect',
nxVersion,
useCloud: false,
meta: {
type: 'error',
errorCode: 'UNKNOWN',
errorName,
errorMessage: message.slice(0, 500),
...baseMeta,
},
});
}
throw error;
}
}

async function runConnectToNxCloud(
options: { generateToken?: boolean; checkRemote?: boolean },
command?: string
): Promise<boolean> {
const nxJson = readNxJson();

Expand Down
Loading