Skip to content

[telemetry] Update dependencies, and optimize SSR handling #17008

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Mar 24, 2025
8 changes: 4 additions & 4 deletions packages/x-telemetry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
},
"sideEffects": false,
"packageScripts": {
"postinstall": "node ./postinstall/index.js"
"postinstall": "node ./esm/postinstall/index.js"
},
"repository": {
"type": "git",
Expand All @@ -34,11 +34,11 @@
},
"dependencies": {
"@babel/runtime": "^7.26.10",
"@fingerprintjs/fingerprintjs": "^4.6.1",
"@fingerprintjs/fingerprintjs": "^3.4.2",
"@mui/utils": "^7.0.0 || ^7.0.0-beta",
"ci-info": "^4.2.0",
"conf": "^5.0.0",
"is-docker": "^2.2.1",
"conf": "^11.0.0",
"is-docker": "^3.0.0",
"node-machine-id": "^1.1.12"
},
"devDependencies": {
Expand Down
5 changes: 5 additions & 0 deletions packages/x-telemetry/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export interface TelemetryContextType {
isInitialized: boolean;
};
traits: Record<string, any> & {
fingerprint?: {
fullHash?: string | null;
coreHash?: string | null;
components?: Record<string, any> | null;
} | null;
machineId?: string | null;
projectId?: string | null;
sessionId?: string | null;
Expand Down
4 changes: 2 additions & 2 deletions packages/x-telemetry/src/postinstall/get-machine-id.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { machineId } from 'node-machine-id';
import nodeMachineId from 'node-machine-id';
import { createHash } from 'crypto';

async function getRawMachineId(): Promise<string | null> {
try {
return await machineId(true);
return await nodeMachineId.machineId(true);
} catch (_) {
return null;
}
Expand Down
17 changes: 14 additions & 3 deletions packages/x-telemetry/src/postinstall/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import fs from 'fs';
import path from 'path';
import { randomBytes } from 'crypto';
import { fileURLToPath } from 'url';
import type { TelemetryContextType } from '../context';
import getEnvironmentInfo from './get-environment-info';
import getAnonymousProjectId from './get-project-id';
import getAnonymousMachineId from './get-machine-id';
import { TelemetryStorage } from './storage';

const dirname =
typeof __dirname === 'string'
? __dirname // cjs build in root dir
: (() => {
const filename = fileURLToPath(import.meta.url);

// esm build in `esm` directory, so we need to go up two levels
return path.dirname(path.dirname(filename));
})();

(async () => {
// If Node.js support permissions, we need to check if the current user has
// the necessary permissions to write to the file system.
Expand All @@ -17,8 +28,8 @@ import { TelemetryStorage } from './storage';
return;
}

const storage = new TelemetryStorage({
distDir: path.join(process.cwd()),
const storage = TelemetryStorage.init({
distDir: process.cwd(),
});

const [environmentInfo, projectId, machineId] = await Promise.all([
Expand All @@ -41,7 +52,7 @@ import { TelemetryStorage } from './storage';
};

const writeContextData = (filePath: string, format: (content: string) => string) => {
const targetPath = path.resolve(__dirname, '..', filePath, 'context.js');
const targetPath = path.resolve(dirname, '..', filePath, 'context.js');
fs.writeFileSync(targetPath, format(JSON.stringify(contextData, null, 2)));
};

Expand Down
23 changes: 13 additions & 10 deletions packages/x-telemetry/src/postinstall/storage.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { randomBytes } from 'crypto';
import path from 'path';
import Conf from 'conf';
import isDockerFunction from 'is-docker';
import ciEnvironment from 'ci-info';
import path from 'path';
import notifyAboutMuiXTelemetry from './notify';
import getEnvironmentInfo from './get-environment-info';

// This is the key that specifies when the user was informed about telemetry collection.
const TELEMETRY_KEY_NOTIFY_DATE = 'telemetry.notifiedAt';
Expand All @@ -13,7 +12,8 @@ const TELEMETRY_KEY_NOTIFY_DATE = 'telemetry.notifiedAt';
const TELEMETRY_KEY_ID = `telemetry.anonymousId`;

function getStorageDirectory(distDir: string): string | undefined {
const isLikelyEphemeral = ciEnvironment.isCI || isDockerFunction();
const env = getEnvironmentInfo();
const isLikelyEphemeral = env.isCI || env.isDocker;

if (isLikelyEphemeral) {
return path.join(distDir, 'cache');
Expand All @@ -23,19 +23,22 @@ function getStorageDirectory(distDir: string): string | undefined {
}

export class TelemetryStorage {
private readonly conf: Conf<any> | null;

constructor({ distDir }: { distDir: string }) {
public static init({ distDir }: { distDir: string }) {
const storageDirectory = getStorageDirectory(distDir);

let conf: Conf<any> | null = null;
try {
// `conf` incorrectly throws a permission error during initialization
// instead of waiting for first use. We need to handle it, otherwise the
// process may crash.
this.conf = new Conf({ projectName: 'mui-x', cwd: storageDirectory });
conf = new Conf({ projectName: 'mui-x', cwd: storageDirectory });
} catch (_) {
this.conf = null;
conf = null;
}

return new TelemetryStorage(conf);
}

private constructor(private readonly conf: Conf<any> | null) {
this.notify();
}

Expand Down
119 changes: 81 additions & 38 deletions packages/x-telemetry/src/runtime/get-context.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import telemetryContext from '../context';
import type { TelemetryContextType } from '../context';
import {
getWindowStorageItem,
setWindowStorageItem,
isWindowStorageAvailable,
} from './window-storage';
import { getWindowStorageItem, setWindowStorageItem } from './window-storage';

function generateId(length: number): string {
let result = '';
Expand All @@ -18,62 +14,109 @@ function generateId(length: number): string {
return result;
}

const getMachineId =
function pick(obj: any, keys: string[]) {
return keys.reduce((acc, key) => {
acc[key] = obj[key];
return acc;
}, {} as any);
}

const getBrowserFingerprint =
typeof window === 'undefined' || process.env.NODE_ENV === 'test'
? () => ''
? () => undefined
: async () => {
const FingerprintJS = await import('@fingerprintjs/fingerprintjs');
const fpPromise = FingerprintJS.load();
const fp = await fpPromise;
const result = await fp.get();
return result.visitorId;
const fingerprintLCKey = 'fingerprint';

try {
const existingFingerprint = getWindowStorageItem('localStorage', fingerprintLCKey);
if (existingFingerprint) {
return JSON.parse(existingFingerprint);
}

const FingerprintJS = await import('@fingerprintjs/fingerprintjs');
const fp = await FingerprintJS.load({ monitoring: false } as any);
const fpResult = await fp.get();

const components: any = { ...fpResult.components };
delete components.cookiesEnabled;

const fullHash = FingerprintJS.hashComponents(components);
const coreHash = FingerprintJS.hashComponents({
...pick(components, [
'fonts',
'audio',
'languages',
'deviceMemory',
'timezone',
'sessionStorage',
'localStorage',
'indexedDB',
'openDatabase',
'platform',
'canvas',
'vendor',
'vendorFlavors',
'colorGamut',
'forcedColors',
'monochrome',
'contrast',
'reducedMotion',
'math',
'videoCard',
'architecture',
]),
});

const result = { fullHash, coreHash };
setWindowStorageItem('localStorage', fingerprintLCKey, JSON.stringify(result));
return result;
} catch (_) {
return null;
}
};

function getAnonymousId(): string {
if (isWindowStorageAvailable('localStorage')) {
const localStorageKey = 'anonymous_id';
const existingAnonymousId = getWindowStorageItem('localStorage', localStorageKey);
if (existingAnonymousId) {
return existingAnonymousId;
}

const generated = generateId(32);
if (setWindowStorageItem('localStorage', localStorageKey, generated)) {
return generated;
}
const localStorageKey = 'anonymous_id';
const existingAnonymousId = getWindowStorageItem('localStorage', localStorageKey);
if (existingAnonymousId) {
return existingAnonymousId;
}

const generated = `anid_${generateId(32)}`;
if (setWindowStorageItem('localStorage', localStorageKey, generated)) {
return generated;
}

return '';
}

function getSessionId(): string {
if (isWindowStorageAvailable('sessionStorage')) {
const localStorageKey = 'session_id';
const existingSessionId = getWindowStorageItem('sessionStorage', localStorageKey);
if (existingSessionId) {
return existingSessionId;
}

const generated = generateId(32);
if (setWindowStorageItem('sessionStorage', localStorageKey, generated)) {
return generated;
}
const localStorageKey = 'session_id';
const existingSessionId = getWindowStorageItem('sessionStorage', localStorageKey);
if (existingSessionId) {
return existingSessionId;
}

return generateId(32);
const generated = `sesid_${generateId(32)}`;
if (setWindowStorageItem('sessionStorage', localStorageKey, generated)) {
return generated;
}

return `sestp_${generateId(32)}`;
}

async function getTelemetryContext(): Promise<TelemetryContextType> {
telemetryContext.traits.sessionId = getSessionId();

// Initialize the context if it hasn't been initialized yet
// (e.g. postinstall not run)
if (!telemetryContext.config.isInitialized) {
telemetryContext.traits.anonymousId = getAnonymousId();
telemetryContext.traits.sessionId = getSessionId();
telemetryContext.config.isInitialized = true;
}

if (!telemetryContext.traits.machineId) {
telemetryContext.traits.machineId = await getMachineId();
if (!telemetryContext.traits.fingerprint) {
telemetryContext.traits.fingerprint = await getBrowserFingerprint();
}

return telemetryContext;
Expand Down
13 changes: 9 additions & 4 deletions packages/x-telemetry/src/runtime/sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import { getTelemetryEnvConfigValue } from './config';
import { TelemetryEvent } from '../types';
import { fetchWithRetry } from './fetcher';

const sendMuiXTelemetryRetries = 3;

function shouldSendTelemetry(telemetryContext: TelemetryContextType): boolean {
// Disable reporting in SSR / Node.js
if (typeof window === 'undefined') {
return false;
}

// Priority to the config (e.g. in code, env)
const envIsCollecting = getTelemetryEnvConfigValue('IS_COLLECTING');
if (typeof envIsCollecting === 'boolean') {
Expand All @@ -20,8 +27,6 @@ function shouldSendTelemetry(telemetryContext: TelemetryContextType): boolean {
return false;
}

const sendMuiXTelemetryRetries = 3;

async function sendMuiXTelemetryEvent(event: TelemetryEvent | null) {
try {
// Disable collection of the telemetry
Expand Down Expand Up @@ -51,13 +56,13 @@ async function sendMuiXTelemetryEvent(event: TelemetryEvent | null) {

// TODO: batch events and send them in a single request when there will be more
await fetchWithRetry(
'https://x-telemetry.mui.com/api/v1/telemetry/record',
'https://x-telemetry.mui.com/v2/telemetry/record',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Telemetry-Client-Version': process.env.MUI_VERSION ?? '<dev>',
'X-Telemetry-Node-Env': (process.env.NODE_ENV as any) ?? '<unknown>',
'X-Telemetry-Node-Env': process.env.NODE_ENV ?? '<unknown>',
},
body: JSON.stringify([eventPayload]),
},
Expand Down
4 changes: 0 additions & 4 deletions packages/x-telemetry/src/runtime/window-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,3 @@ export function getWindowStorageItem(type: WindowStorageType, key: string): stri

return null;
}

export function isWindowStorageAvailable(type: WindowStorageType): boolean {
return typeof window !== 'undefined' && !!window[type];
}
Loading