From ebd7e7ad81e5245deffa331f11e5196ff1b21d84 Mon Sep 17 00:00:00 2001 From: "Fred K. Schott" Date: Mon, 27 Jun 2022 14:16:07 -0700 Subject: [PATCH] update telemetry to support more anonymized project id (#3713) * update telemetry to support more anonymized project id * Create strange-laws-kick.md * respond to nate feedback --- .changeset/strange-laws-kick.md | 6 + packages/astro/src/cli/index.ts | 113 ++++++-------- packages/astro/src/cli/telemetry.ts | 2 - .../{telemetry => astro}/src/events/index.ts | 1 - .../src/events/session.ts | 26 ++-- .../test/events.test.js} | 78 ++-------- packages/telemetry/events.d.ts | 1 - packages/telemetry/package.json | 1 - packages/telemetry/src/anonymous-meta.ts | 48 ------ packages/telemetry/src/config-keys.ts | 8 + packages/telemetry/src/config.ts | 6 +- packages/telemetry/src/events/build.ts | 2 - packages/telemetry/src/index.ts | 142 +++++++----------- packages/telemetry/src/keys.ts | 16 -- packages/telemetry/src/post.ts | 10 +- packages/telemetry/src/project-id.ts | 27 ---- packages/telemetry/src/project-info.ts | 87 +++++++++++ packages/telemetry/src/system-info.ts | 72 +++++++++ packages/telemetry/test/config.test.js | 9 ++ packages/telemetry/test/index.test.js | 9 ++ 20 files changed, 312 insertions(+), 352 deletions(-) create mode 100644 .changeset/strange-laws-kick.md rename packages/{telemetry => astro}/src/events/index.ts (51%) rename packages/{telemetry => astro}/src/events/session.ts (81%) rename packages/{telemetry/test/session-event.test.js => astro/test/events.test.js} (82%) delete mode 100644 packages/telemetry/events.d.ts delete mode 100644 packages/telemetry/src/anonymous-meta.ts create mode 100644 packages/telemetry/src/config-keys.ts delete mode 100644 packages/telemetry/src/events/build.ts delete mode 100644 packages/telemetry/src/keys.ts delete mode 100644 packages/telemetry/src/project-id.ts create mode 100644 packages/telemetry/src/project-info.ts create mode 100644 packages/telemetry/src/system-info.ts create mode 100644 packages/telemetry/test/config.test.js create mode 100644 packages/telemetry/test/index.test.js diff --git a/.changeset/strange-laws-kick.md b/.changeset/strange-laws-kick.md new file mode 100644 index 000000000..5a7ec191d --- /dev/null +++ b/.changeset/strange-laws-kick.md @@ -0,0 +1,6 @@ +--- +"astro": patch +"@astrojs/telemetry": minor +--- + +Update telemetry to support a more anonymized project id. `anonymousProjectId` is now hashed based on anonymous git data instead of your git remote URL. diff --git a/packages/astro/src/cli/index.ts b/packages/astro/src/cli/index.ts index ab38daa09..422d057cf 100644 --- a/packages/astro/src/cli/index.ts +++ b/packages/astro/src/cli/index.ts @@ -3,7 +3,7 @@ import { LogOptions } from '../core/logger/core.js'; import { AstroTelemetry } from '@astrojs/telemetry'; -import * as event from '@astrojs/telemetry/events'; +import * as event from '../events/index.js'; import * as colors from 'kleur/colors'; import yargs from 'yargs-parser'; import { z } from 'zod'; @@ -19,6 +19,7 @@ import { createSafeError } from '../core/util.js'; import { check } from './check.js'; import { openInBrowser } from './open.js'; import * as telemetryHandler from './telemetry.js'; +import { AstroUserConfig } from '../@types/astro.js'; type Arguments = yargs.Arguments; type CLICommand = @@ -61,12 +62,13 @@ function printAstroHelp() { }); } +// PACKAGE_VERSION is injected when we build and publish the astro package. +const ASTRO_VERSION = process.env.PACKAGE_VERSION ?? 'development'; + /** Display --version flag */ async function printVersion() { - // PACKAGE_VERSION is injected at build time - const version = process.env.PACKAGE_VERSION ?? ''; console.log(); - console.log(` ${colors.bgGreen(colors.black(` astro `))} ${colors.green(`v${version}`)}`); + console.log(` ${colors.bgGreen(colors.black(` astro `))} ${colors.green(`v${ASTRO_VERSION}`)}`); } /** Determine which command the user requested */ @@ -110,43 +112,51 @@ export async function cli(args: string[]) { } else if (flags.silent) { logging.level = 'silent'; } - const telemetry = new AstroTelemetry({ version: process.env.PACKAGE_VERSION ?? '' }); - - if (cmd === 'telemetry') { - try { - const subcommand = flags._[3]?.toString(); - return await telemetryHandler.update(subcommand, { flags, telemetry }); - } catch (err) { - return throwAndExit(err); - } - } + const telemetry = new AstroTelemetry({ version: ASTRO_VERSION }); + // Special CLI Commands: "add", "docs", "telemetry" + // These commands run before the user's config is parsed, and may have other special + // conditions that should be handled here, before the others. + // switch (cmd) { case 'add': { try { + telemetry.record(event.eventCliSession({ cliCommand: cmd })); const packages = flags._.slice(3) as string[]; - telemetry.record( - event.eventCliSession({ - astroVersion: process.env.PACKAGE_VERSION ?? '', - cliCommand: 'add', - }) - ); return await add(packages, { cwd: root, flags, logging, telemetry }); } catch (err) { return throwAndExit(err); } } + case 'docs': { + try { + telemetry.record(event.eventCliSession({ cliCommand: cmd })); + return await openInBrowser('https://docs.astro.build/'); + } catch (err) { + return throwAndExit(err); + } + } + case 'telemetry': { + try { + // Do not track session start, since the user may be trying to enable, + // disable, or modify telemetry settings. + const subcommand = flags._[3]?.toString(); + return await telemetryHandler.update(subcommand, { flags, telemetry }); + } catch (err) { + return throwAndExit(err); + } + } + } + + const { astroConfig, userConfig } = await openConfig({ cwd: root, flags, cmd }); + telemetry.record(event.eventCliSession({ cliCommand: cmd }, userConfig, flags)); + + // Common CLI Commands: + // These commands run normally. All commands are assumed to have been handled + // by the end of this switch statement. + switch (cmd) { case 'dev': { try { - const { astroConfig, userConfig } = await openConfig({ cwd: root, flags, cmd }); - - telemetry.record( - event.eventCliSession( - { astroVersion: process.env.PACKAGE_VERSION ?? '', cliCommand: 'dev' }, - userConfig, - flags - ) - ); await devServer(astroConfig, { logging, telemetry }); return await new Promise(() => {}); // lives forever } catch (err) { @@ -156,14 +166,6 @@ export async function cli(args: string[]) { case 'build': { try { - const { astroConfig, userConfig } = await openConfig({ cwd: root, flags, cmd }); - telemetry.record( - event.eventCliSession( - { astroVersion: process.env.PACKAGE_VERSION ?? '', cliCommand: 'build' }, - userConfig, - flags - ) - ); return await build(astroConfig, { logging, telemetry }); } catch (err) { return throwAndExit(err); @@ -171,53 +173,22 @@ export async function cli(args: string[]) { } case 'check': { - const { astroConfig, userConfig } = await openConfig({ cwd: root, flags, cmd }); - telemetry.record( - event.eventCliSession( - { astroVersion: process.env.PACKAGE_VERSION ?? '', cliCommand: 'check' }, - userConfig, - flags - ) - ); const ret = await check(astroConfig); return process.exit(ret); } case 'preview': { try { - const { astroConfig, userConfig } = await openConfig({ cwd: root, flags, cmd }); - telemetry.record( - event.eventCliSession( - { astroVersion: process.env.PACKAGE_VERSION ?? '', cliCommand: 'preview' }, - userConfig, - flags - ) - ); const server = await preview(astroConfig, { logging, telemetry }); return await server.closed(); // keep alive until the server is closed } catch (err) { return throwAndExit(err); } } - - case 'docs': { - try { - await telemetry.record( - event.eventCliSession({ - astroVersion: process.env.PACKAGE_VERSION ?? '', - cliCommand: 'docs', - }) - ); - return await openInBrowser('https://docs.astro.build/'); - } catch (err) { - return throwAndExit(err); - } - } - - default: { - throw new Error(`Error running ${cmd}`); - } } + + // No command handler matched! This is unexpected. + throwAndExit(new Error(`Error running ${cmd} -- no command found.`)); } /** Display error and exit */ diff --git a/packages/astro/src/cli/telemetry.ts b/packages/astro/src/cli/telemetry.ts index ded7bc7a1..ee3ab47ff 100644 --- a/packages/astro/src/cli/telemetry.ts +++ b/packages/astro/src/cli/telemetry.ts @@ -1,9 +1,7 @@ /* eslint-disable no-console */ import type { AstroTelemetry } from '@astrojs/telemetry'; import type yargs from 'yargs-parser'; - import * as msg from '../core/messages.js'; - export interface TelemetryOptions { flags: yargs.Arguments; telemetry: AstroTelemetry; diff --git a/packages/telemetry/src/events/index.ts b/packages/astro/src/events/index.ts similarity index 51% rename from packages/telemetry/src/events/index.ts rename to packages/astro/src/events/index.ts index 6c671ff6c..dc768aa2d 100644 --- a/packages/telemetry/src/events/index.ts +++ b/packages/astro/src/events/index.ts @@ -1,2 +1 @@ -export * from './build.js'; export * from './session.js'; diff --git a/packages/telemetry/src/events/session.ts b/packages/astro/src/events/session.ts similarity index 81% rename from packages/telemetry/src/events/session.ts rename to packages/astro/src/events/session.ts index e8c222bf1..6a246f581 100644 --- a/packages/telemetry/src/events/session.ts +++ b/packages/astro/src/events/session.ts @@ -1,14 +1,10 @@ import { createRequire } from 'node:module'; - +import type { AstroUserConfig } from '../@types/astro'; 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; - interface EventCliSession { - astroVersion: string; cliCommand: string; } @@ -25,7 +21,7 @@ interface ConfigInfo { markdown: | undefined | { - mode: undefined | 'md' | 'mdx'; + drafts: undefined | boolean; syntaxHighlight: undefined | 'shiki' | 'prism' | false; }; } @@ -91,15 +87,18 @@ export function eventCliSession( flags?: Record ): { 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), + ...(userConfig?.markdown?.remarkPlugins?.map((p) => + typeof p === 'string' ? p : typeof p + ) ?? []), + ...(userConfig?.markdown?.rehypePlugins?.map((p) => + typeof p === 'string' ? p : typeof p + ) ?? []), + ] as string[], adapter: userConfig?.adapter?.name ?? null, - integrations: integrations?.map?.((i: any) => i?.name) ?? [], + integrations: (userConfig?.integrations ?? []).filter(Boolean).map((i: any) => i?.name), trailingSlash: userConfig?.trailingSlash, build: userConfig?.build ? { @@ -108,7 +107,7 @@ export function eventCliSession( : undefined, markdown: userConfig?.markdown ? { - mode: userConfig?.markdown?.mode, + drafts: userConfig.markdown?.drafts, syntaxHighlight: userConfig.markdown?.syntaxHighlight, } : undefined, @@ -121,15 +120,12 @@ export function eventCliSession( 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/test/session-event.test.js b/packages/astro/test/events.test.js similarity index 82% rename from packages/telemetry/test/session-event.test.js rename to packages/astro/test/events.test.js index 5702f5fa5..3eeef269c 100644 --- a/packages/telemetry/test/session-event.test.js +++ b/packages/astro/test/events.test.js @@ -18,7 +18,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -37,7 +36,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -54,7 +52,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -74,7 +71,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -88,7 +84,6 @@ describe('Session event', () => { publicDir: 1, markdown: { drafts: true, - mode: 'mdx', shikiConfig: { lang: 1, theme: 2, @@ -102,7 +97,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -110,7 +104,6 @@ describe('Session event', () => { 'publicDir', 'markdown', 'markdown.drafts', - 'markdown.mode', 'markdown.shikiConfig', 'markdown.shikiConfig.lang', 'markdown.shikiConfig.theme', @@ -121,22 +114,6 @@ describe('Session event', () => { ]); }); - it('mode', () => { - const config = { - markdown: { - mode: 'mdx', - }, - }; - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - astroVersion: '0.0.0', - }, - config - ); - expect(payload.config.markdown.mode).to.equal('mdx'); - }); - it('syntaxHighlight', () => { const config = { markdown: { @@ -146,7 +123,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -172,7 +148,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -203,7 +178,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -231,7 +205,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -262,7 +235,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -293,7 +265,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -322,7 +293,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -348,7 +318,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -373,7 +342,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -398,7 +366,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config ); @@ -411,38 +378,18 @@ describe('Session event', () => { }); }); - describe('config.integrations + optionalIntegrations', () => { - it('optional/conditional integrations', () => { - const config = { - srcDir: 1, - integrations: [null, undefined, { name: 'example-integration' }], - }; - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - astroVersion: '0.0.0', - }, - config - ); - expect(payload.config.integrations).deep.equal(['example-integration']); - expect(payload.optionalIntegrations).to.equal(2); - }); - - it('falsy integrations', () => { - const config = { - srcDir: 1, - integrations: [null, undefined, false], - }; - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - astroVersion: '0.0.0', - }, - config - ); - expect(payload.config.integrations.length).to.equal(0); - expect(payload.optionalIntegrations).to.equal(3); - }); + it('falsy integrations', () => { + const config = { + srcDir: 1, + integrations: [null, undefined, false], + }; + const [{ payload }] = events.eventCliSession( + { + cliCommand: 'dev', + }, + config + ); + expect(payload.config.integrations.length).to.equal(0); }); describe('flags', () => { @@ -461,7 +408,6 @@ describe('Session event', () => { const [{ payload }] = events.eventCliSession( { cliCommand: 'dev', - astroVersion: '0.0.0', }, config, flags diff --git a/packages/telemetry/events.d.ts b/packages/telemetry/events.d.ts deleted file mode 100644 index e1bf09518..000000000 --- a/packages/telemetry/events.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './dist/types/events'; diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index ac80025a9..31faf49fc 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -14,7 +14,6 @@ "homepage": "https://astro.build", "exports": { ".": "./dist/index.js", - "./events": "./dist/events/index.js", "./package.json": "./package.json" }, "scripts": { 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; } // 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/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 }; - 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([ - [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(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(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')); + return this.getConfigWithFallback(KEY.TELEMETRY_ENABLED, () => true); } + private get notifyDate(): string { - return this.getWithFallback(KEY.TELEMETRY_NOTIFY_DATE, ''); + return this.getConfigWithFallback(KEY.TELEMETRY_NOTIFY_DATE, () => ''); } - private hash(payload: BinaryLike): string { - const hash = createHash('sha256'); - hash.update(payload); - return hash.digest('hex'); + private get anonymousId(): string { + return this.getConfigWithFallback(KEY.TELEMETRY_ID, () => randomBytes(32).toString('hex')); } - // 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 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; } - // 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 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[] = []; - - // Wait for any in-flight promises to resolve - private async flush() { - await Promise.all(this.queue); - } - async notify(callback: () => Promise) { 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) { +const ASTRO_TELEMETRY_ENDPOINT = `https://telemetry.astro.build/api/v1/record`; + +export function post(body: Record): Promise { 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!; +} diff --git a/packages/telemetry/test/config.test.js b/packages/telemetry/test/config.test.js new file mode 100644 index 000000000..97408ec0d --- /dev/null +++ b/packages/telemetry/test/config.test.js @@ -0,0 +1,9 @@ +import { expect } from 'chai'; +import {GlobalConfig} from '../dist/config.js'; + +describe('GlobalConfig', () => { + it('initializes when expected arguments are given', () => { + const config = new GlobalConfig({ name: 'TEST_NAME' }); + expect(config).to.be.instanceOf(GlobalConfig); + }); +}); diff --git a/packages/telemetry/test/index.test.js b/packages/telemetry/test/index.test.js new file mode 100644 index 000000000..208522136 --- /dev/null +++ b/packages/telemetry/test/index.test.js @@ -0,0 +1,9 @@ +import { expect } from 'chai'; +import {AstroTelemetry} from '../dist/index.js'; + +describe('AstroTelemetry', () => { + it('initializes when expected arguments are given', () => { + const telemetry = new AstroTelemetry({ version: '0.0.0-test.1' }); + expect(telemetry).to.be.instanceOf(AstroTelemetry); + }); +});