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
This commit is contained in:
Fred K. Schott 2022-06-27 14:16:07 -07:00 committed by GitHub
parent 4d6d8644e6
commit ebd7e7ad81
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 312 additions and 352 deletions

View file

@ -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.

View file

@ -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 */

View file

@ -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;

View file

@ -1,2 +1 @@
export * from './build.js';
export * from './session.js';

View file

@ -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 }];
}

View file

@ -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

View file

@ -1 +0,0 @@
export * from './dist/types/events';

View file

@ -14,7 +14,6 @@
"homepage": "https://astro.build",
"exports": {
".": "./dist/index.js",
"./events": "./dist/events/index.js",
"./package.json": "./package.json"
},
"scripts": {

View file

@ -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!;
}

View file

@ -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`;

View file

@ -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();
}

View file

@ -1,2 +0,0 @@
// See https://github.com/vercel/next.js/blob/canary/packages/next/telemetry/events/build.ts
export {};

View file

@ -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'));
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<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;
}
}

View file

@ -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`;

View file

@ -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>) {
const ASTRO_TELEMETRY_ENDPOINT = `https://telemetry.astro.build/api/v1/record`;
export function post(body: Record<string, any>): Promise<any> {
return fetch(ASTRO_TELEMETRY_ENDPOINT, {
method: 'POST',
body: JSON.stringify(body),
headers: { 'content-type': 'application/json' },
})
.catch(noop)
.then(noop, noop);
});
}

View file

@ -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();
}

View file

@ -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(),
};
}

View file

@ -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!;
}

View file

@ -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);
});
});

View file

@ -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);
});
});