@tanstack/cli
Version:
TanStack CLI
196 lines (195 loc) • 6.47 kB
JavaScript
import { version as nodeVersion } from 'node:process';
import { getTelemetryStatus, markTelemetryNoticeSeen, TELEMETRY_NOTICE_VERSION, } from './telemetry-config.js';
const POSTHOG_API_HOST = 'https://us.i.posthog.com';
const POSTHOG_CAPTURE_ENDPOINT = `${POSTHOG_API_HOST}/capture/`;
const POSTHOG_PROJECT_TOKEN = 'phc_xJ2VBahJBzy3BShLhuGpw7EyoSuQtgwXXvhE9BYtHuKQ';
const TELEMETRY_NOTICE = 'TanStack CLI sends anonymous usage telemetry by default. It never sends project names, paths, raw search text, template URLs, add-on config values, or raw error messages. Disable it with `tanstack telemetry disable` or `TANSTACK_CLI_TELEMETRY_DISABLED=1`.';
const TELEMETRY_TIMEOUT_MS = 1200;
let telemetryStatusPromise;
function getNodeMajorVersion() {
return Number.parseInt(nodeVersion.replace(/^v/, '').split('.')[0] || '0', 10);
}
function cleanProperties(value) {
if (Array.isArray(value)) {
return value
.map((entry) => cleanProperties(entry))
.filter((entry) => entry !== undefined);
}
if (value && typeof value === 'object') {
const cleanedEntries = Object.entries(value)
.map(([key, entry]) => [key, cleanProperties(entry)])
.filter(([, entry]) => entry !== undefined);
return Object.fromEntries(cleanedEntries);
}
if (value === undefined) {
return undefined;
}
return value;
}
function getErrorCode(error) {
if (!error || typeof error !== 'object') {
return 'unknown_error';
}
const message = String(error.message || '').toLowerCase();
if (message.includes('cancel')) {
return 'cancelled';
}
if (message.includes('invalid')) {
return 'invalid_input';
}
if (message.includes('not found')) {
return 'not_found';
}
if (message.includes('timed out')) {
return 'timeout';
}
if (message.includes('fetch') ||
message.includes('network') ||
message.includes('econn')) {
return 'network_error';
}
if (message.includes('permission') || message.includes('eacces')) {
return 'permission_error';
}
return 'unknown_error';
}
async function fetchTelemetryStatus() {
telemetryStatusPromise ?? (telemetryStatusPromise = getTelemetryStatus());
return telemetryStatusPromise;
}
async function postEvent(event, distinctId, properties) {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, TELEMETRY_TIMEOUT_MS);
try {
await fetch(POSTHOG_CAPTURE_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: POSTHOG_PROJECT_TOKEN,
distinct_id: distinctId,
event,
properties,
}),
signal: controller.signal,
});
}
catch {
// Telemetry must never affect CLI behavior.
}
finally {
clearTimeout(timeout);
}
}
export class TelemetryClient {
constructor(status) {
this.commandProperties = {};
this.pendingSteps = new Map();
this.completedSteps = [];
this.disabledBy = status.disabledBy;
this.distinctId = status.distinctId;
this.enabled = status.enabled && Boolean(status.distinctId);
}
mergeProperties(properties) {
this.commandProperties = {
...this.commandProperties,
...properties,
};
}
startStep(info) {
if (!this.enabled) {
return;
}
this.pendingSteps.set(info.id, {
startedAt: Date.now(),
type: info.type,
});
}
finishStep(id) {
if (!this.enabled) {
return;
}
const step = this.pendingSteps.get(id);
if (!step) {
return;
}
this.pendingSteps.delete(id);
this.completedSteps.push({
durationMs: Math.max(Date.now() - step.startedAt, 0),
id,
type: step.type,
});
}
async captureCommandStarted(command, properties) {
this.mergeProperties(properties);
if (!this.enabled || !this.distinctId) {
return;
}
void postEvent('command_started', this.distinctId, cleanProperties({
...this.baseProperties(),
...this.commandProperties,
command,
}));
}
async captureCommandCompleted(command, durationMs) {
if (!this.enabled || !this.distinctId) {
return;
}
await postEvent('command_completed', this.distinctId, cleanProperties({
...this.baseProperties(),
...this.commandProperties,
command,
duration_ms: durationMs,
result: 'success',
steps: this.completedSteps.map((step) => ({
duration_ms: step.durationMs,
id: step.id,
type: step.type,
})),
}));
}
async captureCommandFailed(command, durationMs, error) {
if (!this.enabled || !this.distinctId) {
return;
}
await postEvent('command_failed', this.distinctId, cleanProperties({
...this.baseProperties(),
...this.commandProperties,
command,
duration_ms: durationMs,
error_code: getErrorCode(error),
result: 'failed',
steps: this.completedSteps.map((step) => ({
duration_ms: step.durationMs,
id: step.id,
type: step.type,
})),
}));
}
baseProperties() {
return {
$lib: 'tanstack-cli',
disabled_by: this.disabledBy,
node_major: getNodeMajorVersion(),
os_arch: process.arch,
os_platform: process.platform,
};
}
}
export async function createTelemetryClient(opts) {
const status = await fetchTelemetryStatus();
if (status.enabled &&
status.noticeVersion < TELEMETRY_NOTICE_VERSION &&
!opts?.json) {
console.error(TELEMETRY_NOTICE);
await markTelemetryNoticeSeen();
telemetryStatusPromise = undefined;
}
return new TelemetryClient(await fetchTelemetryStatus());
}
export function resetTelemetryStateForTests() {
telemetryStatusPromise = undefined;
}