diff options
Diffstat (limited to 'packages/telemetry/src')
-rw-r--r-- | packages/telemetry/src/config-keys.ts | 8 | ||||
-rw-r--r-- | packages/telemetry/src/config.ts | 88 | ||||
-rw-r--r-- | packages/telemetry/src/index.ts | 172 | ||||
-rw-r--r-- | packages/telemetry/src/post.ts | 9 | ||||
-rw-r--r-- | packages/telemetry/src/project-info.ts | 116 | ||||
-rw-r--r-- | packages/telemetry/src/system-info.ts | 78 |
6 files changed, 471 insertions, 0 deletions
diff --git a/packages/telemetry/src/config-keys.ts b/packages/telemetry/src/config-keys.ts new file mode 100644 index 000000000..932e602e2 --- /dev/null +++ b/packages/telemetry/src/config-keys.ts @@ -0,0 +1,8 @@ +// Global Config Keys + +/** Specifies whether or not telemetry is enabled or disabled. */ +export const TELEMETRY_ENABLED = 'telemetry.enabled'; +/** Specifies when the user was informed of anonymous telemetry. */ +export const TELEMETRY_NOTIFY_DATE = 'telemetry.notifiedAt'; +/** Specifies an anonymous identifier used to dedupe events for a user. */ +export const TELEMETRY_ID = `telemetry.anonymousId`; diff --git a/packages/telemetry/src/config.ts b/packages/telemetry/src/config.ts new file mode 100644 index 000000000..359b1e11f --- /dev/null +++ b/packages/telemetry/src/config.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import dget from 'dlv'; +import { dset } from 'dset'; + +interface ConfigOptions { + name: string; +} + +// Adapted from https://github.com/sindresorhus/env-paths +function getConfigDir(name: string) { + const homedir = os.homedir(); + const macos = () => path.join(homedir, 'Library', 'Preferences', name); + const win = () => { + const { APPDATA = path.join(homedir, 'AppData', 'Roaming') } = process.env; + return path.join(APPDATA, name, 'Config'); + }; + const linux = () => { + const { XDG_CONFIG_HOME = path.join(homedir, '.config') } = process.env; + return path.join(XDG_CONFIG_HOME, name); + }; + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check + switch (process.platform) { + case 'darwin': + return macos(); + case 'win32': + return win(); + default: + return linux(); + } +} + +export class GlobalConfig { + private dir: string; + private file: string; + + constructor(private project: ConfigOptions) { + this.dir = getConfigDir(this.project.name); + this.file = path.join(this.dir, 'config.json'); + } + + private _store?: Record<string, any>; + private get store(): Record<string, any> { + if (this._store) return this._store; + this.ensureDir(); + if (fs.existsSync(this.file)) { + try { + this._store = JSON.parse(fs.readFileSync(this.file).toString()); + } catch {} + } + if (!this._store) { + this._store = {}; + this.write(); + } + return this._store; + } + private set store(value: Record<string, any>) { + this._store = value; + this.write(); + } + private ensureDir() { + fs.mkdirSync(this.dir, { recursive: true }); + } + write() { + fs.writeFileSync(this.file, JSON.stringify(this.store, null, '\t')); + } + clear(): void { + this.store = {}; + fs.rmSync(this.file, { recursive: true }); + } + delete(key: string): boolean { + dset(this.store, key, undefined); + this.write(); + return true; + } + get(key: string): any { + return dget(this.store, key); + } + has(key: string): boolean { + return typeof this.get(key) !== 'undefined'; + } + set(key: string, value: any): void { + dset(this.store, key, value); + this.write(); + } +} diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts new file mode 100644 index 000000000..ba7cea108 --- /dev/null +++ b/packages/telemetry/src/index.ts @@ -0,0 +1,172 @@ +import { randomBytes } from 'node:crypto'; +import { isCI } from 'ci-info'; +import debug from 'debug'; +import * as KEY from './config-keys.js'; +import { GlobalConfig } from './config.js'; +import { post } from './post.js'; +import { type ProjectInfo, getProjectInfo } from './project-info.js'; +import { type SystemInfo, getSystemInfo } from './system-info.js'; + +export type AstroTelemetryOptions = { astroVersion: string; viteVersion: string }; +export type TelemetryEvent = { eventName: string; payload: Record<string, any> }; + +// In the event of significant policy changes, update this! +const VALID_TELEMETRY_NOTICE_DATE = '2023-08-25'; + +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 isCI = isCI; + private env = process.env; + + private get astroVersion() { + return this.opts.astroVersion; + } + private get viteVersion() { + return this.opts.viteVersion; + } + private get ASTRO_TELEMETRY_DISABLED() { + return this.env.ASTRO_TELEMETRY_DISABLED; + } + private get TELEMETRY_DISABLED() { + return this.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<T>(key: string, getValue: () => T): T { + const currentValue = this.config.get(key); + if (currentValue !== undefined) { + 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(this.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(); + } + + isValidNotice() { + if (!this.notifyDate) return false; + const current = Number(this.notifyDate); + const valid = new Date(VALID_TELEMETRY_NOTICE_DATE).valueOf(); + + return current > valid; + } + + async notify(callback: () => boolean | Promise<boolean>) { + if (this.isDisabled || this.isCI) { + this.debug(`[notify] telemetry has been disabled`); + return; + } + // The end-user has already been notified about our telemetry integration! + // Don't bother them about it again. + if (this.isValidNotice()) { + this.debug(`[notify] last notified on ${this.notifyDate}`); + return; + } + const enabled = await callback(); + this.config.set(KEY.TELEMETRY_NOTIFY_DATE, new Date().valueOf().toString()); + this.config.set(KEY.TELEMETRY_ENABLED, enabled); + this.debug(`[notify] telemetry has been ${enabled ? 'enabled' : 'disabled'}`); + } + + 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('[record] telemetry has been 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}`); + }); + } +} diff --git a/packages/telemetry/src/post.ts b/packages/telemetry/src/post.ts new file mode 100644 index 000000000..6aef03bc9 --- /dev/null +++ b/packages/telemetry/src/post.ts @@ -0,0 +1,9 @@ +const ASTRO_TELEMETRY_ENDPOINT = `https://telemetry.astro.build/api/v1/record`; + +export function post(body: Record<string, any>): Promise<any> { + return fetch(ASTRO_TELEMETRY_ENDPOINT, { + method: 'POST', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + }); +} diff --git a/packages/telemetry/src/project-info.ts b/packages/telemetry/src/project-info.ts new file mode 100644 index 000000000..79b9e4f44 --- /dev/null +++ b/packages/telemetry/src/project-info.ts @@ -0,0 +1,116 @@ +import { execSync } from 'node:child_process'; +import type { BinaryLike } from 'node:crypto'; +import { createHash } from 'node:crypto'; +import detectPackageManager from 'which-pm-runs'; + +/** + * Astro Telemetry -- Project Info + * + * To better understand our telemetry insights, Astro attempts to create an anonymous identifier + * for each Astro project. This value is meant to be unique to each project but common across + * multiple different users on the same project. + * + * To do this, we generate a unique, anonymous hash from your working git repository data. This is + * ideal because git data is shared across all users on the same repository, but the data itself + * that we generate our hash from does not contain any personal or otherwise identifying information. + * + * We do not use your repository's remote URL, GitHub URL, or any other personally identifying + * information to generate the project identifier hash. In this way it is almost completely anonymous. + * + * If you are running Astro outside of a git repository, then we will generate a unique, anonymous project + * identifier by hashing your project's file path on your machine. + * + * ~~~ + * + * Q: Can this project identifier be traced back to me? + * + * A: If your repository is private, there is no way for anyone to trace your unique + * project identifier back to you, your organization, or your project. This is because it is itself + * a hash of a commit hash, and a commit hash does not include any identifying information. + * + * If your repository is publicly available, then it is possible for someone to generate this unique + * project identifier themselves by cloning your repo. Specifically, someone would need access to run + * the `git rev-list` command below to generate this hash. Without this access, it is impossible to + * trace the project identifier back to you or your project. + * + * If you are running Astro outside of a git repository, then the project identifier could be matched + * back to the exact file path on your machine. It is unlikely (but still possible) for this to happen + * without access to your machine or knowledge of your machine's file system. + * + * ~~~ + * + * Q: I don't want Astro to collect a project identifier. How can I disable it? + * + * A: You can disable telemetry completely at any time by running `astro telemetry disable`. There is + * currently no way to disable just this identifier while keeping the rest of telemetry enabled. + */ + +export interface ProjectInfo { + /* Your unique project identifier. This will be hashed again before sending. */ + anonymousProjectId: string | undefined; + /* true if your project is connected to a git repository. false otherwise. */ + isGit: boolean; + /* The package manager used to run Astro */ + packageManager: string | undefined; + /* The version of the package manager used to run Astro */ + packageManagerVersion: string | undefined; +} + +function createAnonymousValue(payload: BinaryLike): string { + // We use empty string to represent an empty value. Avoid hashing this + // since that would create a real hash and remove its "empty" meaning. + if (payload === '') { + return payload; + } + // Otherwise, create a new hash from the payload and return it. + const hash = createHash('sha256'); + hash.update(payload); + return hash.digest('hex'); +} + +function getProjectIdFromGit(): string | null { + try { + const originBuffer = execSync(`git rev-list --max-parents=0 HEAD`, { + timeout: 500, + stdio: ['ignore', 'pipe', 'ignore'], + }); + return String(originBuffer).trim(); + } catch (_) { + return null; + } +} + +function getProjectId(isCI: boolean): Pick<ProjectInfo, 'anonymousProjectId' | 'isGit'> { + const projectIdFromGit = getProjectIdFromGit(); + if (projectIdFromGit) { + return { + isGit: true, + anonymousProjectId: createAnonymousValue(projectIdFromGit), + }; + } + // If we're running in CI, the current working directory is not unique. + // If the cwd is a single level deep (ex: '/app'), it's probably not unique. + const cwd = process.cwd(); + const isCwdGeneric = (cwd.match(/[/|\\]/g) || []).length === 1; + if (isCI || isCwdGeneric) { + return { + isGit: false, + anonymousProjectId: undefined, + }; + } + return { + isGit: false, + anonymousProjectId: createAnonymousValue(cwd), + }; +} + +export function getProjectInfo(isCI: boolean): ProjectInfo { + const projectId = getProjectId(isCI); + const packageManager = detectPackageManager(); + return { + ...projectId, + packageManager: packageManager?.name, + packageManagerVersion: packageManager?.version, + }; +} +// diff --git a/packages/telemetry/src/system-info.ts b/packages/telemetry/src/system-info.ts new file mode 100644 index 000000000..2913b6941 --- /dev/null +++ b/packages/telemetry/src/system-info.ts @@ -0,0 +1,78 @@ +import os from 'node:os'; +import { name as ciName, isCI } from 'ci-info'; +import isDocker from 'is-docker'; +import isWSL from 'is-wsl'; + +/** + * Astro Telemetry -- System Info + * + * To better understand our telemetry insights, Astro collects the following anonymous information + * about the system that it runs on. This helps us prioritize fixes and new features based on a + * better understanding of our real-world system requirements. + * + * ~~~ + * + * Q: Can this system info be traced back to me? + * + * A: No personally identifiable information is contained in the system info that we collect. It could + * be possible for someone with direct access to your machine to collect this information themselves + * and then attempt to match it all together with our collected telemetry data, however most users' + * systems are probably not uniquely identifiable via their system info alone. + * + * ~~~ + * + * Q: I don't want Astro to collect system info. How can I disable it? + * + * A: You can disable telemetry completely at any time by running `astro telemetry disable`. There is + * currently no way to disable this otherwise while keeping the rest of telemetry enabled. + */ + +export type SystemInfo = { + systemPlatform: NodeJS.Platform; + systemRelease: string; + systemArchitecture: string; + astroVersion: string; + nodeVersion: string; + viteVersion: string; + cpuCount: number; + cpuModel: string | null; + cpuSpeed: number | null; + memoryInMb: number; + isDocker: boolean; + isTTY: boolean; + isWSL: boolean; + isCI: boolean; + ciName: string | null; +}; + +let meta: SystemInfo | undefined; + +export function getSystemInfo(versions: { viteVersion: string; astroVersion: string }): SystemInfo { + if (meta) { + return meta; + } + + const cpus = os.cpus() || []; + + return { + // Version information + nodeVersion: process.version.replace(/^v?/, ''), + viteVersion: versions.viteVersion, + astroVersion: versions.astroVersion, + // Software information + systemPlatform: os.platform(), + systemRelease: os.release(), + systemArchitecture: os.arch(), + // Machine information + cpuCount: cpus.length, + cpuModel: cpus.length ? cpus[0].model : null, + cpuSpeed: cpus.length ? cpus[0].speed : null, + memoryInMb: Math.trunc(os.totalmem() / Math.pow(1024, 2)), + // Environment information + isDocker: isDocker(), + isTTY: process.stdout.isTTY, + isWSL, + isCI, + ciName, + }; +} |