summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.changeset/strange-laws-kick.md6
-rw-r--r--packages/astro/src/cli/index.ts113
-rw-r--r--packages/astro/src/cli/telemetry.ts2
-rw-r--r--packages/astro/src/events/index.ts (renamed from packages/telemetry/src/events/index.ts)1
-rw-r--r--packages/astro/src/events/session.ts (renamed from packages/telemetry/src/events/session.ts)26
-rw-r--r--packages/astro/test/events.test.js (renamed from packages/telemetry/test/session-event.test.js)78
-rw-r--r--packages/telemetry/events.d.ts1
-rw-r--r--packages/telemetry/package.json1
-rw-r--r--packages/telemetry/src/anonymous-meta.ts48
-rw-r--r--packages/telemetry/src/config-keys.ts8
-rw-r--r--packages/telemetry/src/config.ts6
-rw-r--r--packages/telemetry/src/events/build.ts2
-rw-r--r--packages/telemetry/src/index.ts142
-rw-r--r--packages/telemetry/src/keys.ts16
-rw-r--r--packages/telemetry/src/post.ts8
-rw-r--r--packages/telemetry/src/project-id.ts27
-rw-r--r--packages/telemetry/src/project-info.ts87
-rw-r--r--packages/telemetry/src/system-info.ts72
-rw-r--r--packages/telemetry/test/config.test.js9
-rw-r--r--packages/telemetry/test/index.test.js9
20 files changed, 311 insertions, 351 deletions
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
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
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<string, any>;
-
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<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),
+ ...(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
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<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/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!;
+}
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);
+ });
+});