aboutsummaryrefslogtreecommitdiff
path: root/packages/telemetry/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/telemetry/src')
-rw-r--r--packages/telemetry/src/config-keys.ts8
-rw-r--r--packages/telemetry/src/config.ts88
-rw-r--r--packages/telemetry/src/index.ts172
-rw-r--r--packages/telemetry/src/post.ts9
-rw-r--r--packages/telemetry/src/project-info.ts116
-rw-r--r--packages/telemetry/src/system-info.ts78
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,
+ };
+}