import { isCI } from 'ci-info'; import debug from 'debug'; import { randomBytes } from 'node:crypto'; import * as KEY from './config-keys.js'; import { GlobalConfig } from './config.js'; import { post } from './post.js'; import { getProjectInfo, ProjectInfo } from './project-info.js'; import { getSystemInfo, SystemInfo } from './system-info.js'; export type AstroTelemetryOptions = { astroVersion: string; viteVersion: string }; export type TelemetryEvent = { eventName: string; payload: Record }; type EventMeta = SystemInfo; interface EventContext extends ProjectInfo { anonymousId: string; anonymousSessionId: string; } export class AstroTelemetry { private _anonymousSessionId: string | undefined; private _anonymousProjectInfo: ProjectInfo | undefined; private config = new GlobalConfig({ name: 'astro' }); private debug = debug('astro:telemetry'); private get astroVersion() { return this.opts.astroVersion; } private get viteVersion() { return this.opts.viteVersion; } private get ASTRO_TELEMETRY_DISABLED() { return process.env.ASTRO_TELEMETRY_DISABLED; } private get TELEMETRY_DISABLED() { return process.env.TELEMETRY_DISABLED; } constructor(private opts: AstroTelemetryOptions) { // TODO: When the process exits, flush any queued promises // This caused a "cannot exist astro" error when it ran, so it was removed. // process.on('SIGINT', () => this.flush()); } /** * Get value from either the global config or the provided fallback. * If value is not set, the fallback is saved to the global config, * persisted for later sessions. */ private getConfigWithFallback(key: string, getValue: () => T): T { const currentValue = this.config.get(key); if (currentValue) { return currentValue; } const newValue = getValue(); this.config.set(key, newValue); return newValue; } private get enabled(): boolean { return this.getConfigWithFallback(KEY.TELEMETRY_ENABLED, () => true); } private get notifyDate(): string { return this.getConfigWithFallback(KEY.TELEMETRY_NOTIFY_DATE, () => ''); } private get anonymousId(): string { return this.getConfigWithFallback(KEY.TELEMETRY_ID, () => randomBytes(32).toString('hex')); } private get anonymousSessionId(): string { // NOTE(fks): this value isn't global, so it can't use getConfigWithFallback(). this._anonymousSessionId = this._anonymousSessionId || randomBytes(32).toString('hex'); return this._anonymousSessionId; } private get anonymousProjectInfo(): ProjectInfo { // NOTE(fks): this value isn't global, so it can't use getConfigWithFallback(). this._anonymousProjectInfo = this._anonymousProjectInfo || getProjectInfo(isCI); return this._anonymousProjectInfo; } private get isDisabled(): boolean { if (Boolean(this.ASTRO_TELEMETRY_DISABLED || this.TELEMETRY_DISABLED)) { return true; } return this.enabled === false; } setEnabled(value: boolean) { this.config.set(KEY.TELEMETRY_ENABLED, value); } clear() { return this.config.clear(); } async notify(callback: () => Promise) { if (this.isDisabled || isCI) { return; } // The end-user has already been notified about our telemetry integration! // Don't bother them about it again. // In the event of significant changes, we should invalidate old dates. if (this.notifyDate) { return; } const enabled = await callback(); this.config.set(KEY.TELEMETRY_NOTIFY_DATE, Date.now().toString()); this.config.set(KEY.TELEMETRY_ENABLED, enabled); } async record(event: TelemetryEvent | TelemetryEvent[] = []) { const events: TelemetryEvent[] = Array.isArray(event) ? event : [event]; if (events.length < 1) { return Promise.resolve(); } // Skip recording telemetry if the feature is disabled if (this.isDisabled) { this.debug('telemetry disabled'); return Promise.resolve(); } const meta: EventMeta = { ...getSystemInfo({ astroVersion: this.astroVersion, viteVersion: this.viteVersion }), }; const context: EventContext = { ...this.anonymousProjectInfo, anonymousId: this.anonymousId, anonymousSessionId: this.anonymousSessionId, }; // Every CI session also creates a new user, which blows up telemetry. // To solve this, we track all CI runs under a single "CI" anonymousId. if (meta.isCI) { context.anonymousId = `CI.${meta.ciName || 'UNKNOWN'}`; } if (this.debug.enabled) { // Print to standard error to simplify selecting the output this.debug({ context, meta }); this.debug(JSON.stringify(events, null, 2)); // Do not send the telemetry data if debugging. Users may use this feature // to preview what data would be sent. return Promise.resolve(); } return post({ context, meta, events, }).catch((err) => { // Log the error to the debugger, but otherwise do nothing. this.debug(`Error sending event: ${err.message}`); }); } }