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:
parent
4d6d8644e6
commit
ebd7e7ad81
20 changed files with 312 additions and 352 deletions
6
.changeset/strange-laws-kick.md
Normal file
6
.changeset/strange-laws-kick.md
Normal 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.
|
|
@ -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 */
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
export * from './build.js';
|
||||
export * from './session.js';
|
|
@ -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 }];
|
||||
}
|
|
@ -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
|
1
packages/telemetry/events.d.ts
vendored
1
packages/telemetry/events.d.ts
vendored
|
@ -1 +0,0 @@
|
|||
export * from './dist/types/events';
|
|
@ -14,7 +14,6 @@
|
|||
"homepage": "https://astro.build",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./events": "./dist/events/index.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
@ -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!;
|
||||
}
|
8
packages/telemetry/src/config-keys.ts
Normal file
8
packages/telemetry/src/config-keys.ts
Normal 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`;
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
// See https://github.com/vercel/next.js/blob/canary/packages/next/telemetry/events/build.ts
|
||||
export {};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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`;
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
87
packages/telemetry/src/project-info.ts
Normal file
87
packages/telemetry/src/project-info.ts
Normal 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(),
|
||||
};
|
||||
}
|
72
packages/telemetry/src/system-info.ts
Normal file
72
packages/telemetry/src/system-info.ts
Normal 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!;
|
||||
}
|
9
packages/telemetry/test/config.test.js
Normal file
9
packages/telemetry/test/config.test.js
Normal 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);
|
||||
});
|
||||
});
|
9
packages/telemetry/test/index.test.js
Normal file
9
packages/telemetry/test/index.test.js
Normal 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);
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue