Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
18 changes: 10 additions & 8 deletions packages/nx/src/analytics/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readNxJson } from '../config/nx-json';
import { type NxJsonConfiguration, readNxJson } from '../config/nx-json';
import { workspaceRoot } from '../utils/workspace-root';
import { nxVersion } from '../utils/versions';
import { IS_WASM } from '../native';
Expand All @@ -20,7 +20,7 @@ import * as os from 'os';
import { createHash } from 'crypto';
import { getCurrentMachineId } from '../utils/machine-id-cache';
import { isCI } from '../utils/is-ci';
import { generateWorkspaceId } from '../utils/analytics-prompt';
import { generateWorkspaceId } from '../utils/workspace-id';
import { getDbConnection } from '../utils/db-connection';

// Conditionally import telemetry functions only on non-WASM platforms
Expand Down Expand Up @@ -71,18 +71,21 @@ export async function startAnalytics() {
// detection, machine id, telemetry init) may throw past this boundary -
// on any failure, continue without telemetry.
try {
if (!isAnalyticsEnabled()) {
const nxJson = readNxJson(workspaceRoot);
if (!isAnalyticsEnabled(nxJson)) {
return;
}

const nxJson = readNxJson(workspaceRoot);
const workspaceId = generateWorkspaceId();
const workspaceId = generateWorkspaceId(workspaceRoot, nxJson);
if (!workspaceId) {
// Not a git repo — no telemetry
return;
}
const isNxCloud = !!(nxJson?.nxCloudId ?? nxJson?.nxCloudAccessToken);
const userId = await getTelemetryUserId(workspaceId);
// A CI fleet is not a user: shared images bake in /etc/machine-id, so a
// uid would collapse whole fleets into one GA "user" (and trip per-user
// collection caps). GA falls back to cid = workspace for CI traffic.
const userId = isCI() ? undefined : await getTelemetryUserId(workspaceId);
const packageManagerInfo = getPackageManagerInfo();

const nodeVersion = parse(process.version);
Expand Down Expand Up @@ -274,8 +277,7 @@ function getPackageManagerInfo() {
};
}

function isAnalyticsEnabled(): boolean {
const nxJson = readNxJson(workspaceRoot);
function isAnalyticsEnabled(nxJson: NxJsonConfiguration | null): boolean {
return nxJson?.analytics === true;
}

Expand Down
4 changes: 2 additions & 2 deletions packages/nx/src/native/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,14 +492,14 @@ export interface HashInputs {
* so the caller can set it as an env var for child processes.
* Used by CLI and daemon.
*/
export declare function initializeTelemetry(connection: ExternalObject<NxDbConnection>, workspaceId: string, userId: string, nxVersion: string, packageManagerName: string, packageManagerVersion: string | undefined | null, nodeVersion: string, osArch: string, osPlatform: string, osRelease: string, isCi: boolean, isNxCloud: boolean): string
export declare function initializeTelemetry(connection: ExternalObject<NxDbConnection>, workspaceId: string, userId: string | undefined | null, nxVersion: string, packageManagerName: string, packageManagerVersion: string | undefined | null, nodeVersion: string, osArch: string, osPlatform: string, osRelease: string, isCi: boolean, isNxCloud: boolean): string

/**
* Initialize telemetry with a pre-fetched session ID.
* No DB connection — used by plugin workers that inherit the
* session ID from their parent process via env var.
*/
export declare function initializeTelemetryWithSessionId(sessionId: string, workspaceId: string, userId: string, nxVersion: string, packageManagerName: string, packageManagerVersion: string | undefined | null, nodeVersion: string, osArch: string, osPlatform: string, osRelease: string, isCi: boolean, isNxCloud: boolean): void
export declare function initializeTelemetryWithSessionId(sessionId: string, workspaceId: string, userId: string | undefined | null, nxVersion: string, packageManagerName: string, packageManagerVersion: string | undefined | null, nodeVersion: string, osArch: string, osPlatform: string, osRelease: string, isCi: boolean, isNxCloud: boolean): void

export interface InputsInput {
input: string
Expand Down
4 changes: 2 additions & 2 deletions packages/nx/src/native/telemetry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ pub fn initialize_telemetry(
Arc<Mutex<NxDbConnection>>,
>,
workspace_id: String,
user_id: String,
user_id: Option<String>,
nx_version: String,
package_manager_name: String,
package_manager_version: Option<String>,
Expand Down Expand Up @@ -126,7 +126,7 @@ pub fn initialize_telemetry(
pub fn initialize_telemetry_with_session_id(
session_id: String,
workspace_id: String,
user_id: String,
user_id: Option<String>,
nx_version: String,
package_manager_name: String,
package_manager_version: Option<String>,
Expand Down
10 changes: 7 additions & 3 deletions packages/nx/src/native/telemetry/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub(crate) struct PageViewData {
pub(crate) struct TelemetryOptions {
pub session_id: String,
pub workspace_id: String,
pub user_id: String,
pub user_id: Option<String>,
pub nx_version: String,
pub package_manager_name: String,
pub package_manager_version: Option<String>,
Expand Down Expand Up @@ -75,7 +75,9 @@ impl TelemetryService {
request_param::CLIENT_ID.to_string(),
opts.workspace_id.clone(),
);
common_request_parameters.insert(request_param::USER_ID.to_string(), opts.user_id.clone());
if let Some(user_id) = &opts.user_id {
common_request_parameters.insert(request_param::USER_ID.to_string(), user_id.clone());
}
common_request_parameters.insert(
request_param::TRACKING_ID.to_string(),
TRACKING_ID_PROD.to_string(),
Expand Down Expand Up @@ -106,7 +108,9 @@ impl TelemetryService {

let mut user_parameters = HashMap::new();
user_parameters.insert(user_dimension::OS_ARCHITECTURE.to_string(), opts.os_arch);
user_parameters.insert(user_dimension::USER_ID.to_string(), opts.user_id);
if let Some(user_id) = opts.user_id {
user_parameters.insert(user_dimension::USER_ID.to_string(), user_id);
}
user_parameters.insert(user_dimension::NODE_VERSION.to_string(), opts.node_version);
user_parameters.insert(
user_dimension::PACKAGE_MANAGER.to_string(),
Expand Down
55 changes: 0 additions & 55 deletions packages/nx/src/utils/analytics-prompt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { createHash } from 'crypto';
import { execSync } from 'child_process';
import { existsSync } from 'fs';
import { prompt } from 'enquirer';
import { join } from 'path';
Expand Down Expand Up @@ -92,56 +90,3 @@ async function saveAnalyticsPreference(
// Silently fail - don't block user's command
}
}

/**
* Generates a deterministic workspace ID.
* Priority: nxCloudId > git remote URL (hashed).
* Returns null if neither is available (no telemetry).
*/
export function generateWorkspaceId(cwd?: string): string | null {
const root = cwd ?? workspaceRoot;

// Use nxCloudId if available — most stable identifier
const nxJson = readNxJson(root);
const nxCloudId = nxJson?.nxCloudId ?? nxJson?.nxCloudAccessToken;
if (nxCloudId) {
return nxCloudId;
}

// Fall back to git remote URL hash
try {
const remoteUrl = execSync('git remote get-url origin', {
stdio: 'pipe',
cwd: root,
windowsHide: true,
})
.toString()
.trim();

if (remoteUrl) {
return createHash('sha256').update(remoteUrl).digest('hex').slice(0, 32);
}
} catch {
// No git remote available
}

// Fall back to first commit SHA — already a hash
try {
const firstCommit = execSync('git rev-list --max-parents=0 HEAD', {
stdio: 'pipe',
cwd: root,
windowsHide: true,
})
.toString()
.trim()
.split('\n')[0];

if (firstCommit) {
return firstCommit;
}
} catch {
// Not a git repo
}

return null;
}
71 changes: 64 additions & 7 deletions packages/nx/src/utils/git-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from 'child_process';
import * as crypto from 'crypto';
import * as fs from 'fs';
import { dirname, join, posix, sep } from 'path';
import { dirname, join, posix, relative, sep } from 'path';
import { logger } from './logger';

function execFileAsync(
Expand Down Expand Up @@ -60,12 +60,7 @@ export class GitRepository {
constructor(private directory: string) {}

getGitRootPath(cwd: string) {
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd,
windowsHide: true,
})
.toString()
.trim();
return getGitRootPath(cwd);
}

async hasUncommittedChanges() {
Expand Down Expand Up @@ -368,6 +363,68 @@ export function getVcsRemoteInfo(directory?: string): VcsRemoteInfo | null {
}
}

export function getGitRootPath(cwd?: string): string {
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd,
windowsHide: true,
})
.toString()
.trim();
}

/**
* Path of `directory` relative to its git root, posix-separated so it is
* identical on every OS, and '' when the directory is the git root itself.
* Null outside a git repository.
*/
export function getGitRootRelativePath(directory: string): string | null {
try {
return relative(getGitRootPath(directory), directory)
.split(sep)
.join(posix.sep);
} catch {
return null;
}
}

/** A shallow clone's truncated history has no stable root commit. */
export function isShallowRepository(directory?: string): boolean {
try {
return (
execFileSync('git', ['rev-parse', '--is-shallow-repository'], {
encoding: 'utf8',
stdio: 'pipe',
cwd: directory,
windowsHide: true,
}).trim() === 'true'
);
} catch {
return false;
}
}

/**
* SHA of the repository's first commit. Merged unrelated histories leave
* several root commits — the sorted-first one is picked so every clone
* agrees. Null when there are no commits, or outside a git repository.
*/
export function getFirstCommitSha(directory?: string): string | null {
try {
const roots = execFileSync('git', ['rev-list', '--max-parents=0', 'HEAD'], {
encoding: 'utf8',
stdio: 'pipe',
cwd: directory,
windowsHide: true,
})
.trim()
.split(/\r?\n/)
.filter(Boolean);
return roots.sort()[0] ?? null;
} catch {
return null;
}
}

export function isGitRepository(directory?: string): boolean {
try {
execSync('git rev-parse --is-inside-work-tree', {
Expand Down
102 changes: 102 additions & 0 deletions packages/nx/src/utils/workspace-id.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { execSync } from 'child_process';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { computeRepoKey, deriveRepoKey } from './workspace-id';

describe('computeRepoKey', () => {
it('should hash identity and relative path together', () => {
expect(computeRepoKey('github.com/nrwl/nx', '')).toEqual(
computeRepoKey('github.com/nrwl/nx', '')
);
expect(computeRepoKey('github.com/nrwl/nx', '')).not.toEqual(
computeRepoKey('github.com/nrwl/nx', 'packages/app')
);
expect(computeRepoKey('github.com/nrwl/nx', 'a')).not.toEqual(
computeRepoKey('github.com/other/repo', 'a')
);
});

it('should produce a 64-char hex sha256', () => {
expect(computeRepoKey('github.com/nrwl/nx', '')).toMatch(/^[0-9a-f]{64}$/);
});
});

describe('deriveRepoKey', () => {
let repo: string;

const git = (cmd: string, cwd: string = repo) =>
execSync(`git ${cmd}`, { cwd, stdio: 'pipe' }).toString().trim();

beforeEach(() => {
repo = mkdtempSync(join(tmpdir(), 'nx-repo-key-'));
git('init');
git('config user.email test@test.com');
git('config user.name Test');
});

afterEach(() => {
rmSync(repo, { recursive: true, force: true });
});

it('should key on the normalized remote regardless of protocol', () => {
git('remote add origin git@github.com:nrwl/nx.git');
const sshKey = deriveRepoKey(repo);

git('remote set-url origin https://github.com/nrwl/nx.git');
const httpsKey = deriveRepoKey(repo);

git('remote set-url origin https://token@github.com/nrwl/nx.git');
const tokenKey = deriveRepoKey(repo);

expect(sshKey).toEqual(computeRepoKey('github.com/nrwl/nx', ''));
expect(httpsKey).toEqual(sshKey);
expect(tokenKey).toEqual(sshKey);
});

it('should normalize remote casing and trailing slashes', () => {
git('remote add origin git@GitHub.com:NRWL/Nx.git');
expect(deriveRepoKey(repo)).toEqual(
computeRepoKey('github.com/nrwl/nx', '')
);

git('remote set-url origin https://github.com/nrwl/nx/');
expect(deriveRepoKey(repo)).toEqual(
computeRepoKey('github.com/nrwl/nx', '')
);
});

it('should include the workspace path relative to the git root', () => {
git('remote add origin git@github.com:nrwl/nx.git');
const nested = join(repo, 'apps', 'inner');
mkdirSync(nested, { recursive: true });

expect(deriveRepoKey(nested)).toEqual(
computeRepoKey('github.com/nrwl/nx', 'apps/inner')
);
expect(deriveRepoKey(nested)).not.toEqual(deriveRepoKey(repo));
});

it('should fall back to the first-commit SHA when there is no remote', () => {
writeFileSync(join(repo, 'f'), '');
git('add .');
git('commit -m first');
git('commit --allow-empty -m second');
const firstSha = git('rev-list --max-parents=0 HEAD');

expect(deriveRepoKey(repo)).toEqual(computeRepoKey(firstSha, ''));
});

it('should return null when there is no remote and no commits', () => {
expect(deriveRepoKey(repo)).toBeNull();
});

it('should return null outside a git repository', () => {
const notARepo = mkdtempSync(join(tmpdir(), 'nx-not-a-repo-'));
try {
expect(deriveRepoKey(notARepo)).toBeNull();
} finally {
rmSync(notARepo, { recursive: true, force: true });
}
});
});
Loading
Loading