@salesforce/plugin-telemetry
Version:
Command usage and error telemetry for the Salesforce CLI
236 lines • 10.1 kB
JavaScript
/*
* Copyright 2025, Salesforce, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import cp from 'node:child_process';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { EOL, tmpdir } from 'node:os';
import { inspect } from 'node:util';
import { join } from 'node:path';
import { AsyncCreatable, env } from '@salesforce/kit';
import { SfError } from '@salesforce/core/sfError';
import { isBoolean, isNumber, isString } from '@salesforce/ts-types';
import { debug } from './debugger.js';
import { guessCISystem } from './guessCI.js';
const CLI_ID_FILE_NAME = 'CLIID.txt';
const USAGE_ACKNOWLEDGEMENT_FILE_NAME = 'acknowledgedUsageCollection.json';
const generateRandomId = () => randomBytes(20).toString('hex');
export default class Telemetry extends AsyncCreatable {
/**
* The name of event telemetry type.
*/
static EVENT = 'EVENT';
/**
* The name of exception telemetry type.
*/
static EXCEPTION = 'EXCEPTION';
/**
* The temporary directory where telemetry log files are stored.
*/
static tmpDir = env.getString('SF_TELEMETRY_PATH', join(tmpdir(), 'sf-telemetry'));
static cacheDir;
static executable = 'sfdx';
static telemetryTmpFile = join(Telemetry.tmpDir, `telemetry-${generateRandomId()}.log`);
static acknowledged = false;
firstRun = false;
fileDescriptor;
cliId;
constructor(options) {
super(options);
if (options.cacheDir && !Telemetry.cacheDir) {
Telemetry.cacheDir = options.cacheDir;
}
// We want to run off of a specific telemetry file, so override.
if (options.telemetryFilePath) {
Telemetry.telemetryTmpFile = options.telemetryFilePath;
}
if (options.executable) {
Telemetry.executable = options.executable;
}
}
/**
* Tell the user they acknowledge data collection.
*/
static async acknowledgeDataCollection() {
// Only check once per process, regardless of how often this is instantiated.
if (Telemetry.acknowledged) {
return;
}
if (!Telemetry.cacheDir) {
debug('Unable to check acknowledgment path because Telemetry.cacheDir is not set yet');
return;
}
const acknowledgementFilePath = join(Telemetry.cacheDir, USAGE_ACKNOWLEDGEMENT_FILE_NAME);
try {
await fs.promises.access(acknowledgementFilePath, fs.constants.R_OK);
debug('Usage acknowledgement file already exists');
}
catch (error) {
const err = error;
if (err.code === 'ENOENT') {
if (!env.getBoolean('SF_TELEMETRY_DISABLE_ACKNOWLEDGEMENT', false)) {
// eslint-disable-next-line no-console
console.warn(`You acknowledge and agree that the CLI tool may collect usage information, user environment, and crash reports for the purposes of providing services or functions that are relevant to use of the CLI tool and product improvements.${EOL}`);
}
Telemetry.acknowledged = true;
await fs.promises.mkdir(Telemetry.cacheDir, { recursive: true });
await fs.promises.writeFile(acknowledgementFilePath, JSON.stringify({ acknowledged: true }));
debug('Wrote usage acknowledgement file', acknowledgementFilePath);
}
else {
debug('Could not access', acknowledgementFilePath, 'DUE TO:', err.code, err.message);
}
}
}
// eslint-disable-next-line class-methods-use-this
getTelemetryFilePath() {
return Telemetry.telemetryTmpFile;
}
getCLIId() {
if (this.cliId)
return this.cliId;
const cliIdPath = join(Telemetry.cacheDir, CLI_ID_FILE_NAME);
try {
this.cliId = fs.readFileSync(cliIdPath, 'utf8');
}
catch (err) {
debug('Unique CLI ID not found, generating and writing new ID to ', cliIdPath);
this.cliId = generateRandomId();
fs.writeFileSync(cliIdPath, this.cliId, 'utf8');
// If there is not a unique ID for this CLI, consider it a first run.
this.firstRun = true;
}
return this.cliId;
}
/**
* Record data to the telemetry file. Only valid properties will be recorded to the file, which
* are strings, numbers, and booleans. All booleans get logged to App Insights as string representations.
*/
record(data) {
// Only store valid telemetry attributes to the log file.
const dataToRecord = Object.keys(data).reduce((map, key) => {
const value = data[key];
const isException = data.type === Telemetry.EXCEPTION && key === 'error';
const validType = isString(value) || isBoolean(value) || isNumber(value);
if (isException || validType) {
map[key] = value;
}
return map;
}, {});
if (!dataToRecord.type) {
dataToRecord.type = Telemetry.EVENT;
}
if (!dataToRecord.eventName) {
// This would mean a consumer forgot to set this.
// Still log it as unknown so we can try to fix it.
dataToRecord.eventName = 'UNKNOWN';
// Don't break this into a utility because the stack HAS to start from this method.
const stack = new Error().stack ?? '';
const locations = stack.split(/\r?\n/).filter((line) => /\s*at /.test(line));
if (locations.length >= 2) {
// The first location is this file, the second is the calling file.
// Replace HOME for GDPR.
dataToRecord.requestorLocation = locations[1].replace(process.env.HOME ?? '', '');
}
debug('Missing event name!');
}
// Unique to this CLI installation
dataToRecord.cliId = this.getCLIId();
dataToRecord.ci = guessCISystem();
dataToRecord.executable = Telemetry.executable;
try {
fs.writeSync(this.fileDescriptor, JSON.stringify(dataToRecord) + EOL);
}
catch (err) {
const error = err;
debug(`Error saving telemetry line to file: ${error.message}`);
}
}
recordError(error, data) {
data.type = Telemetry.EXCEPTION;
// Also have on custom attributes since app insights might parse differently
data.errorName = error.name;
data.errorMessage = error.message;
if (error instanceof SfError && error.data) {
data.errorData = JSON.stringify(error.data);
}
if (error.cause) {
data.errorCause = inspect(error.cause);
}
data.error = Object.assign({
name: error.name,
message: error.message,
stack: error.stack,
}, error);
this.record(data);
}
async clear() {
debug('Deleting the log file', this.getTelemetryFilePath());
await fs.promises.unlink(this.getTelemetryFilePath());
}
async read() {
try {
debug(`Reading ${this.getTelemetryFilePath()}`);
const data = await fs.promises.readFile(this.getTelemetryFilePath(), 'utf8');
return data
.split(EOL)
.filter((line) => !!line)
.map((line) => JSON.parse(line));
}
catch (error) {
const err = error;
debug(`Error reading: ${err.message}`);
// If anything goes wrong, it just means a couple of lost telemetry events.
return [];
}
}
upload() {
// Completely disconnect from this process so it doesn't wait for telemetry to upload
const processPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'processes', 'upload.js');
const telemetryDebug = env.getBoolean('SF_TELEMETRY_DEBUG', false);
// Don't spawn if we are in telemetry debug. This allows us to run the process manually with --inspect-brk.
if (!telemetryDebug) {
debug(`Spawning "${process.execPath} ${processPath} ${Telemetry.cacheDir} ${this.getTelemetryFilePath()}"`);
cp.spawn(process.execPath, [processPath, Telemetry.cacheDir, this.getTelemetryFilePath()], {
detached: true,
stdio: 'ignore',
}).unref();
}
else {
debug(`DEBUG MODE. Run the uploader manually with the following command:${EOL}${processPath} ${Telemetry.cacheDir} ${this.getTelemetryFilePath()}`);
}
}
async init() {
// If we are going to record telemetry, make sure the user is aware.
await Telemetry.acknowledgeDataCollection();
// Make sure the tmp dir is created.
try {
await fs.promises.access(Telemetry.tmpDir, fs.constants.W_OK);
}
catch (error) {
const err = error;
if (err.code === 'ENOENT') {
debug('Telemetry temp dir does not exist, creating...');
await fs.promises.mkdir(Telemetry.tmpDir, { recursive: true });
}
}
// Create a file descriptor to be used
this.fileDescriptor = fs.openSync(this.getTelemetryFilePath(), 'a');
debug(`Using telemetry logging file ${this.getTelemetryFilePath()}`);
}
}
//# sourceMappingURL=telemetry.js.map