diff options
Diffstat (limited to 'packages/telemetry/src')
-rw-r--r-- | packages/telemetry/src/anonymous-meta.ts | 48 | ||||
-rw-r--r-- | packages/telemetry/src/config-keys.ts | 8 | ||||
-rw-r--r-- | packages/telemetry/src/config.ts | 6 | ||||
-rw-r--r-- | packages/telemetry/src/events/build.ts | 2 | ||||
-rw-r--r-- | packages/telemetry/src/events/index.ts | 2 | ||||
-rw-r--r-- | packages/telemetry/src/events/session.ts | 135 | ||||
-rw-r--r-- | packages/telemetry/src/index.ts | 142 | ||||
-rw-r--r-- | packages/telemetry/src/keys.ts | 16 | ||||
-rw-r--r-- | packages/telemetry/src/post.ts | 8 | ||||
-rw-r--r-- | packages/telemetry/src/project-id.ts | 27 | ||||
-rw-r--r-- | packages/telemetry/src/project-info.ts | 87 | ||||
-rw-r--r-- | packages/telemetry/src/system-info.ts | 72 |
12 files changed, 222 insertions, 331 deletions
diff --git a/packages/telemetry/src/anonymous-meta.ts b/packages/telemetry/src/anonymous-meta.ts deleted file mode 100644 index 8f42d91bf..000000000 --- a/packages/telemetry/src/anonymous-meta.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { isCI, name as ciName } from 'ci-info'; -import isDocker from 'is-docker'; -import isWSL from 'is-wsl'; -import os from 'node:os'; - -type AnonymousMeta = { - systemPlatform: NodeJS.Platform; - systemRelease: string; - systemArchitecture: string; - cpuCount: number; - cpuModel: string | null; - cpuSpeed: number | null; - memoryInMb: number; - isDocker: boolean; - isWSL: boolean; - isCI: boolean; - ciName: string | null; - astroVersion: string; -}; - -let meta: AnonymousMeta | undefined; - -export function getAnonymousMeta(astroVersion: string): AnonymousMeta { - if (meta) { - return meta; - } - - const cpus = os.cpus() || []; - meta = { - // 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(), - isWSL, - isCI, - ciName, - astroVersion, - }; - - return meta!; -} 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 index 9317ab80d..d03f9102b 100644 --- a/packages/telemetry/src/config.ts +++ b/packages/telemetry/src/config.ts @@ -7,7 +7,6 @@ import process from 'node:process'; export interface ConfigOptions { name: string; - defaults: Map<string, any>; } // Adapted from https://github.com/sindresorhus/env-paths @@ -32,7 +31,7 @@ function getConfigDir(name: string) { } } -export class Config { +export class GlobalConfig { private dir: string; private file: string; @@ -49,9 +48,6 @@ export class Config { this._store = JSON.parse(fs.readFileSync(this.file).toString()); } else { const store = {}; - for (const [key, value] of this.project.defaults) { - dset(store, key, value); - } this._store = store; this.write(); } diff --git a/packages/telemetry/src/events/build.ts b/packages/telemetry/src/events/build.ts deleted file mode 100644 index 1d6b8b7fd..000000000 --- a/packages/telemetry/src/events/build.ts +++ /dev/null @@ -1,2 +0,0 @@ -// See https://github.com/vercel/next.js/blob/canary/packages/next/telemetry/events/build.ts -export {}; diff --git a/packages/telemetry/src/events/index.ts b/packages/telemetry/src/events/index.ts deleted file mode 100644 index 6c671ff6c..000000000 --- a/packages/telemetry/src/events/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './build.js'; -export * from './session.js'; diff --git a/packages/telemetry/src/events/session.ts b/packages/telemetry/src/events/session.ts deleted file mode 100644 index e8c222bf1..000000000 --- a/packages/telemetry/src/events/session.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); - -const EVENT_SESSION = 'ASTRO_CLI_SESSION_STARTED'; - -// :( We can't import the type because of TurboRepo circular dep limitation -type AstroUserConfig = Record<string, any>; - -interface EventCliSession { - astroVersion: string; - cliCommand: string; -} - -interface ConfigInfo { - markdownPlugins: string[]; - adapter: string | null; - integrations: string[]; - trailingSlash: undefined | 'always' | 'never' | 'ignore'; - build: - | undefined - | { - format: undefined | 'file' | 'directory'; - }; - markdown: - | undefined - | { - mode: undefined | 'md' | 'mdx'; - syntaxHighlight: undefined | 'shiki' | 'prism' | false; - }; -} - -interface EventCliSessionInternal extends EventCliSession { - nodeVersion: string; - viteVersion: string; - config?: ConfigInfo; - configKeys?: string[]; - flags?: string[]; - optionalIntegrations?: number; -} - -function getViteVersion() { - try { - const { version } = require('vite/package.json'); - return version; - } catch (e) {} - return undefined; -} - -const multiLevelKeys = new Set([ - 'build', - 'markdown', - 'markdown.shikiConfig', - 'server', - 'vite', - 'vite.resolve', - 'vite.css', - 'vite.json', - 'vite.server', - 'vite.server.fs', - 'vite.build', - 'vite.preview', - 'vite.optimizeDeps', - 'vite.ssr', - 'vite.worker', -]); -function configKeys(obj: Record<string, any> | undefined, parentKey: string): string[] { - if (!obj) { - return []; - } - - return Object.entries(obj) - .map(([key, value]) => { - if (typeof value === 'object' && !Array.isArray(value)) { - const localKey = parentKey ? parentKey + '.' + key : key; - if (multiLevelKeys.has(localKey)) { - let keys = configKeys(value, localKey).map((subkey) => key + '.' + subkey); - keys.unshift(key); - return keys; - } - } - - return key; - }) - .flat(1); -} - -export function eventCliSession( - event: EventCliSession, - userConfig?: AstroUserConfig, - flags?: Record<string, any> -): { eventName: string; payload: EventCliSessionInternal }[] { - // Filter out falsy integrations - const integrations = userConfig?.integrations?.filter?.(Boolean) ?? []; - const configValues = userConfig - ? { - markdownPlugins: [ - userConfig?.markdown?.remarkPlugins ?? [], - userConfig?.markdown?.rehypePlugins ?? [], - ].flat(1), - adapter: userConfig?.adapter?.name ?? null, - integrations: integrations?.map?.((i: any) => i?.name) ?? [], - trailingSlash: userConfig?.trailingSlash, - build: userConfig?.build - ? { - format: userConfig?.build?.format, - } - : undefined, - markdown: userConfig?.markdown - ? { - mode: userConfig?.markdown?.mode, - syntaxHighlight: userConfig.markdown?.syntaxHighlight, - } - : undefined, - } - : undefined; - - // Filter out yargs default `_` flag which is the cli command - const cliFlags = flags ? Object.keys(flags).filter((name) => name != '_') : undefined; - - const payload: EventCliSessionInternal = { - cliCommand: event.cliCommand, - // Versions - astroVersion: event.astroVersion, - viteVersion: getViteVersion(), - nodeVersion: process.version.replace(/^v?/, ''), - configKeys: userConfig ? configKeys(userConfig, '') : undefined, - // Config Values - config: configValues, - flags: cliFlags, - // Optional integrations - optionalIntegrations: userConfig?.integrations?.length - integrations?.length, - }; - return [{ eventName: EVENT_SESSION, payload }]; -} diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts index 26c0dd040..f0315e16c 100644 --- a/packages/telemetry/src/index.ts +++ b/packages/telemetry/src/index.ts @@ -1,44 +1,27 @@ -import type { BinaryLike } from 'node:crypto'; -import { createHash, randomBytes } from 'node:crypto'; - import { isCI } from 'ci-info'; import debug from 'debug'; -// @ts-ignore -import gitUp from 'git-up'; - -import { getAnonymousMeta } from './anonymous-meta.js'; -import { Config } from './config.js'; -import * as KEY from './keys.js'; +import { randomBytes } from 'node:crypto'; +import * as KEY from './config-keys.js'; +import { GlobalConfig } from './config.js'; import { post } from './post.js'; -import { getRawProjectId } from './project-id.js'; - -export interface AstroTelemetryOptions { - version: string; -} +import { getProjectInfo, ProjectInfo } from './project-info.js'; +import { getSystemInfo, SystemInfo } from './system-info.js'; +export type AstroTelemetryOptions = { version: string }; export type TelemetryEvent = { eventName: string; payload: Record<string, any> }; - interface EventContext { anonymousId: string; - projectId: string; - projectMetadata: any; - sessionId: string; + anonymousProjectId: string; + anonymousSessionId: string; } +interface EventMeta extends SystemInfo { + isGit: boolean; +} export class AstroTelemetry { - private rawProjectId = getRawProjectId(); - private sessionId = randomBytes(32).toString('hex'); - private config = new Config({ - name: 'astro', - // Use getter to defer generation of defaults unless needed - get defaults() { - return new Map<string, any>([ - [KEY.TELEMETRY_ENABLED, true], - [KEY.TELEMETRY_SALT, randomBytes(16).toString('hex')], - [KEY.TELEMETRY_ID, randomBytes(32).toString('hex')], - ]); - }, - }); + private _anonymousSessionId: string | undefined; + private _anonymousProjectInfo: ProjectInfo | undefined; + private config = new GlobalConfig({ name: 'astro' }); private debug = debug('astro:telemetry'); private get astroVersion() { @@ -53,65 +36,47 @@ export class AstroTelemetry { constructor(private opts: AstroTelemetryOptions) { // TODO: When the process exits, flush any queued promises - // This line caused a "cannot exist astro" error, needs to be revisited. + // This caused a "cannot exist astro" error when it ran, so it was removed. // process.on('SIGINT', () => this.flush()); } - // Util to get value from config or set it if missing - private getWithFallback<T>(key: string, value: T): T { - const val = this.config.get(key); - if (val) { - return val; + /** + * 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) { + return currentValue; } - this.config.set(key, value); - return value; + const newValue = getValue(); + this.config.set(key, newValue); + return newValue; } - private get salt(): string { - return this.getWithFallback(KEY.TELEMETRY_SALT, randomBytes(16).toString('hex')); - } private get enabled(): boolean { - return this.getWithFallback(KEY.TELEMETRY_ENABLED, true); - } - private get anonymousId(): string { - return this.getWithFallback(KEY.TELEMETRY_ID, randomBytes(32).toString('hex')); - } - private get notifyDate(): string { - return this.getWithFallback(KEY.TELEMETRY_NOTIFY_DATE, ''); + return this.getConfigWithFallback(KEY.TELEMETRY_ENABLED, () => true); } - private hash(payload: BinaryLike): string { - const hash = createHash('sha256'); - hash.update(payload); - return hash.digest('hex'); + private get notifyDate(): string { + return this.getConfigWithFallback(KEY.TELEMETRY_NOTIFY_DATE, () => ''); } - // Create a ONE-WAY hash so there is no way for Astro to decode the value later. - private oneWayHash(payload: BinaryLike): string { - const hash = createHash('sha256'); - // Always prepend the payload value with salt! This ensures the hash is one-way. - hash.update(this.salt); - hash.update(payload); - return hash.digest('hex'); + private get anonymousId(): string { + return this.getConfigWithFallback(KEY.TELEMETRY_ID, () => randomBytes(32).toString('hex')); } - // Instead of sending `rawProjectId`, we only ever reference a hashed value *derived* - // from `rawProjectId`. This ensures that `projectId` is ALWAYS anonymous and can't - // be reversed from the hashed value. - private get projectId(): string { - return this.oneWayHash(this.rawProjectId); + 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 projectMetadata(): undefined | { owner: string; name: string } { - const projectId = this.rawProjectId; - if (projectId === process.cwd()) { - return; - } - const { pathname, resource } = gitUp(projectId); - const parts = pathname.split('/').slice(1); - const owner = `${resource}${parts[0]}`; - const name = parts[1].replace('.git', ''); - return { owner: this.hash(owner), name: this.hash(name) }; + 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 { @@ -129,13 +94,6 @@ export class AstroTelemetry { return this.config.clear(); } - private queue: Promise<any>[] = []; - - // Wait for any in-flight promises to resolve - private async flush() { - await Promise.all(this.queue); - } - async notify(callback: () => Promise<boolean>) { if (this.isDisabled || isCI) { return; @@ -172,22 +130,24 @@ export class AstroTelemetry { return Promise.resolve(); } + const meta: EventMeta = { + ...getSystemInfo(this.astroVersion), + isGit: this.anonymousProjectInfo.isGit, + }; + const context: EventContext = { anonymousId: this.anonymousId, - projectId: this.projectId, - projectMetadata: this.projectMetadata, - sessionId: this.sessionId, + anonymousProjectId: this.anonymousProjectInfo.anonymousProjectId, + anonymousSessionId: this.anonymousSessionId, }; - const meta = getAnonymousMeta(this.astroVersion); - const req = post({ + return post({ context, meta, events, - }).then(() => { - this.queue = this.queue.filter((r) => r !== req); + }).catch((err) => { + // Log the error to the debugger, but otherwise do nothing. + this.debug(`Error sending event: ${err.message}`); }); - this.queue.push(req); - return req; } } diff --git a/packages/telemetry/src/keys.ts b/packages/telemetry/src/keys.ts deleted file mode 100644 index f1c9e2ad2..000000000 --- a/packages/telemetry/src/keys.ts +++ /dev/null @@ -1,16 +0,0 @@ -// This is the key that stores whether or not telemetry is enabled or disabled. -export const TELEMETRY_ENABLED = 'telemetry.enabled'; - -// This is the key that specifies when the user was informed about anonymous -// telemetry collection. -export const TELEMETRY_NOTIFY_DATE = 'telemetry.notifiedAt'; - -// This is a quasi-persistent identifier used to dedupe recurring events. It's -// generated from random data and completely anonymous. -export const TELEMETRY_ID = `telemetry.anonymousId`; - -// This is the cryptographic salt that is included within every hashed value. -// This salt value is never sent to us, ensuring privacy and the one-way nature -// of the hash (prevents dictionary lookups of pre-computed hashes). -// See the `oneWayHash` function. -export const TELEMETRY_SALT = `telemetry.salt`; diff --git a/packages/telemetry/src/post.ts b/packages/telemetry/src/post.ts index ae1626a40..a0647075f 100644 --- a/packages/telemetry/src/post.ts +++ b/packages/telemetry/src/post.ts @@ -1,13 +1,11 @@ import fetch from 'node-fetch'; + const ASTRO_TELEMETRY_ENDPOINT = `https://telemetry.astro.build/api/v1/record`; -const noop = () => {}; -export function post(body: Record<string, any>) { +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' }, - }) - .catch(noop) - .then(noop, noop); + }); } diff --git a/packages/telemetry/src/project-id.ts b/packages/telemetry/src/project-id.ts deleted file mode 100644 index 655a72fc6..000000000 --- a/packages/telemetry/src/project-id.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { execSync } from 'child_process'; - -// Why does Astro need a project ID? Why is it looking at my git remote? -// --- -// Astro's telemetry is and always will be completely anonymous. -// Differentiating unique projects helps us track feature usage accurately. -// -// We **never** read your actual git remote! The value is hashed one-way -// with random salt data, making it impossible for us to reverse or try to -// guess the remote by re-computing hashes. - -function getProjectIdFromGit() { - try { - const originBuffer = execSync(`git config --local --get remote.origin.url`, { - timeout: 1000, - stdio: `pipe`, - }); - - return String(originBuffer).trim(); - } catch (_) { - return null; - } -} - -export function getRawProjectId(): string { - return getProjectIdFromGit() ?? process.env.REPOSITORY_URL ?? process.cwd(); -} diff --git a/packages/telemetry/src/project-info.ts b/packages/telemetry/src/project-info.ts new file mode 100644 index 000000000..afb6c83bb --- /dev/null +++ b/packages/telemetry/src/project-info.ts @@ -0,0 +1,87 @@ +import { execSync } from 'child_process'; +import type { BinaryLike } from 'node:crypto'; +import { createHash } from 'node:crypto'; + +/** + * 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; + /* true if your project is connected to a git repository. false otherwise. */ + isGit: boolean; +} + +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: [0, 'pipe', 0]}); + return String(originBuffer).trim(); + } catch (_) { + return null; + } +} + +export function getProjectInfo(isCI: boolean): ProjectInfo { + const projectIdFromGit = getProjectIdFromGit(); + if (projectIdFromGit) { + return { + isGit: true, + anonymousProjectId: createAnonymousValue(projectIdFromGit), + }; + } + return { + isGit: false, + anonymousProjectId: isCI ? '' : process.cwd(), + }; +} diff --git a/packages/telemetry/src/system-info.ts b/packages/telemetry/src/system-info.ts new file mode 100644 index 000000000..0f0de7025 --- /dev/null +++ b/packages/telemetry/src/system-info.ts @@ -0,0 +1,72 @@ +import { isCI, name as ciName } from 'ci-info'; +import isDocker from 'is-docker'; +import isWSL from 'is-wsl'; +import os from 'node:os'; + +/** + * 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; + cpuCount: number; + cpuModel: string | null; + cpuSpeed: number | null; + memoryInMb: number; + isDocker: boolean; + isWSL: boolean; + isCI: boolean; + ciName: string | null; + astroVersion: string; +}; + +let meta: SystemInfo | undefined; + +export function getSystemInfo(astroVersion: string): SystemInfo { + if (meta) { + return meta; + } + + const cpus = os.cpus() || []; + meta = { + // 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(), + isWSL, + isCI, + ciName, + astroVersion, + }; + + return meta!; +} |