diff --git a/.changeset/large-shoes-hammer.md b/.changeset/large-shoes-hammer.md new file mode 100644 index 000000000000..442b83ce2263 --- /dev/null +++ b/.changeset/large-shoes-hammer.md @@ -0,0 +1,6 @@ +--- +'@astrojs/telemetry': patch +'astro': patch +--- + +Improve config info telemetry diff --git a/packages/astro/src/cli/add/index.ts b/packages/astro/src/cli/add/index.ts index 62cec7a7193e..afd63716b1bf 100644 --- a/packages/astro/src/cli/add/index.ts +++ b/packages/astro/src/cli/add/index.ts @@ -9,7 +9,12 @@ import ora from 'ora'; import preferredPM from 'preferred-pm'; import prompts from 'prompts'; import type yargs from 'yargs-parser'; -import { loadTSConfig, resolveConfigPath, resolveRoot } from '../../core/config/index.js'; +import { + loadTSConfig, + resolveConfig, + resolveConfigPath, + resolveRoot, +} from '../../core/config/index.js'; import { defaultTSConfig, presets, @@ -23,7 +28,7 @@ import { appendForwardSlash } from '../../core/path.js'; import { apply as applyPolyfill } from '../../core/polyfill.js'; import { parseNpmName } from '../../core/util.js'; import { eventCliSession, telemetry } from '../../events/index.js'; -import { createLoggerFromFlags } from '../flags.js'; +import { createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js'; import { generate, parse, t, visit } from './babel.js'; import { ensureImport } from './imports.js'; import { wrapDefaultExport } from './wrapper.js'; @@ -87,7 +92,9 @@ async function getRegistry(): Promise { } export async function add(names: string[], { flags }: AddOptions) { - telemetry.record(eventCliSession('add')); + const inlineConfig = flagsToAstroInlineConfig(flags); + const { userConfig } = await resolveConfig(inlineConfig, 'add'); + telemetry.record(eventCliSession('add', userConfig)); applyPolyfill(); if (flags.help || names.length === 0) { printHelp({ diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index b36017c22abb..4ac40d4c513b 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -140,7 +140,6 @@ export const AstroConfigSchema = z.object({ .optional() .default(ASTRO_CONFIG_DEFAULTS.build.excludeMiddleware), }) - .optional() .default({}), server: z.preprocess( // preprocess @@ -158,7 +157,6 @@ export const AstroConfigSchema = z.object({ port: z.number().optional().default(ASTRO_CONFIG_DEFAULTS.server.port), headers: z.custom().optional(), }) - .optional() .default({}) ), redirects: z @@ -274,27 +272,11 @@ export const AstroConfigSchema = z.object({ .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.optimizeHoistedScript), }) - .passthrough() - .refine( - (d) => { - const validKeys = Object.keys(ASTRO_CONFIG_DEFAULTS.experimental); - const invalidKeys = Object.keys(d).filter((key) => !validKeys.includes(key)); - if (invalidKeys.length > 0) return false; - return true; - }, - (d) => { - const validKeys = Object.keys(ASTRO_CONFIG_DEFAULTS.experimental); - const invalidKeys = Object.keys(d).filter((key) => !validKeys.includes(key)); - return { - message: `Invalid experimental key: \`${invalidKeys.join( - ', ' - )}\`. \nMake sure the spelling is correct, and that your Astro version supports this experiment.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for more information.`, - }; - } + .strict( + `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.` ) - .optional() .default({}), - legacy: z.object({}).optional().default({}), + legacy: z.object({}).default({}), }); export type AstroConfigType = z.infer; diff --git a/packages/astro/src/events/session.ts b/packages/astro/src/events/session.ts index b3753034251b..d9c492cb0fda 100644 --- a/packages/astro/src/events/session.ts +++ b/packages/astro/src/events/session.ts @@ -1,25 +1,8 @@ -import type { AstroUserConfig } from '../@types/astro.js'; +import type { AstroIntegration, AstroUserConfig } from '../@types/astro.js'; +import { AstroConfigSchema } from '../core/config/schema.js'; const EVENT_SESSION = 'ASTRO_CLI_SESSION_STARTED'; -interface ConfigInfo { - markdownPlugins: string[]; - adapter: string | null; - integrations: string[]; - trailingSlash: undefined | 'always' | 'never' | 'ignore'; - build: - | undefined - | { - format: undefined | 'file' | 'directory'; - }; - markdown: - | undefined - | { - drafts: undefined | boolean; - syntaxHighlight: undefined | 'shiki' | 'prism' | false; - }; -} - interface EventPayload { cliCommand: string; config?: ConfigInfo; @@ -28,87 +11,126 @@ interface EventPayload { optionalIntegrations?: number; } -const multiLevelKeys = new Set([ - 'build', - 'markdown', - 'markdown.shikiConfig', - 'server', - 'vite', - 'vite.resolve', - 'vite.css', - 'vite.json', - 'vite.server', - 'vite.server.fs', - 'vite.build', - 'vite.preview', - 'vite.optimizeDeps', - 'vite.ssr', - 'vite.worker', -]); -function configKeys(obj: Record | undefined, parentKey: string): string[] { - if (!obj) { - return []; +type ConfigInfoValue = string | boolean | string[] | undefined; +type ConfigInfoRecord = Record; +type ConfigInfoBase = { + [alias in keyof AstroUserConfig]: ConfigInfoValue | ConfigInfoRecord; +}; +export interface ConfigInfo extends ConfigInfoBase { + build: ConfigInfoRecord; + image: ConfigInfoRecord; + markdown: ConfigInfoRecord; + experimental: ConfigInfoRecord; + legacy: ConfigInfoRecord; + vite: ConfigInfoRecord | undefined; +} + +function measureIsDefined(val: unknown) { + // if val is undefined, measure undefined as a value + if (val === undefined) { + return undefined; } + // otherwise, convert the value to a boolean + return Boolean(val); +} + +type StringLiteral = T extends string ? (string extends T ? never : T) : never; - return Object.entries(obj) - .map(([key, value]) => { - if (typeof value === 'object' && !Array.isArray(value)) { - const localKey = parentKey ? parentKey + '.' + key : key; - if (multiLevelKeys.has(localKey)) { - let keys = configKeys(value, localKey).map((subkey) => key + '.' + subkey); - keys.unshift(key); - return keys; - } - } +/** + * Measure supports string literal values. Passing a generic `string` type + * results in an error, to make sure generic user input is never measured directly. + */ +function measureStringLiteral( + val: StringLiteral | boolean | undefined +): string | boolean | undefined { + return val; +} - return key; - }) - .flat(1); +function measureIntegration(val: AstroIntegration | false | null | undefined): string | undefined { + if (!val || !val.name) { + return undefined; + } + return val.name; +} + +function sanitizeConfigInfo(obj: object | undefined, validKeys: string[]): ConfigInfoRecord { + if (!obj || validKeys.length === 0) { + return {}; + } + return validKeys.reduce( + (result, key) => { + result[key] = measureIsDefined((obj as Record)[key]); + return result; + }, + {} as Record + ); +} + +/** + * This function creates an anonymous ConfigInfo object from the user's config. + * All values are sanitized to preserve anonymity. Simple "exist" boolean checks + * are used by default, with a few additional sanitized values added manually. + * Helper functions should always be used to ensure correct sanitization. + */ +function createAnonymousConfigInfo(userConfig: AstroUserConfig) { + // Sanitize and measure the generic config object + // NOTE(fks): Using _def is the correct, documented way to get the `shape` + // from a Zod object that includes a wrapping default(), optional(), etc. + // Even though `_def` appears private, it is type-checked for us so that + // any changes between versions will be detected. + const configInfo: ConfigInfo = { + ...sanitizeConfigInfo(userConfig, Object.keys(AstroConfigSchema.shape)), + build: sanitizeConfigInfo( + userConfig.build, + Object.keys(AstroConfigSchema.shape.build._def.innerType.shape) + ), + image: sanitizeConfigInfo( + userConfig.image, + Object.keys(AstroConfigSchema.shape.image._def.innerType.shape) + ), + markdown: sanitizeConfigInfo( + userConfig.markdown, + Object.keys(AstroConfigSchema.shape.markdown._def.innerType.shape) + ), + experimental: sanitizeConfigInfo( + userConfig.experimental, + Object.keys(AstroConfigSchema.shape.experimental._def.innerType.shape) + ), + legacy: sanitizeConfigInfo( + userConfig.legacy, + Object.keys(AstroConfigSchema.shape.legacy._def.innerType.shape) + ), + vite: userConfig.vite + ? sanitizeConfigInfo(userConfig.vite, Object.keys(userConfig.vite)) + : undefined, + }; + // Measure string literal/enum configuration values + configInfo.build.format = measureStringLiteral(userConfig.build?.format); + configInfo.markdown.syntaxHighlight = measureStringLiteral(userConfig.markdown?.syntaxHighlight); + configInfo.output = measureStringLiteral(userConfig.output); + configInfo.scopedStyleStrategy = measureStringLiteral(userConfig.scopedStyleStrategy); + configInfo.trailingSlash = measureStringLiteral(userConfig.trailingSlash); + // Measure integration & adapter usage + configInfo.adapter = measureIntegration(userConfig.adapter); + configInfo.integrations = userConfig.integrations + ?.flat(100) + .map(measureIntegration) + .filter(Boolean) as string[]; + // Return the sanitized ConfigInfo object + return configInfo; } export function eventCliSession( cliCommand: string, - userConfig?: AstroUserConfig, + userConfig: AstroUserConfig, flags?: Record ): { eventName: string; payload: EventPayload }[] { - // Filter out falsy integrations - const configValues = userConfig - ? { - markdownPlugins: [ - ...(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: (userConfig?.integrations ?? []) - .filter(Boolean) - .flat() - .map((i: any) => i?.name), - trailingSlash: userConfig?.trailingSlash, - build: userConfig?.build - ? { - format: userConfig?.build?.format, - } - : undefined, - markdown: userConfig?.markdown - ? { - drafts: userConfig.markdown?.drafts, - syntaxHighlight: userConfig.markdown?.syntaxHighlight, - } - : undefined, - } - : undefined; - // Filter out yargs default `_` flag which is the cli command const cliFlags = flags ? Object.keys(flags).filter((name) => name != '_') : undefined; const payload: EventPayload = { cliCommand, - configKeys: userConfig ? configKeys(userConfig, '') : undefined, - config: configValues, + config: createAnonymousConfigInfo(userConfig), flags: cliFlags, }; return [{ eventName: EVENT_SESSION, payload }]; diff --git a/packages/astro/test/events.test.js b/packages/astro/test/events.test.js index 3bc1a6590aa3..a8eaafee62c2 100644 --- a/packages/astro/test/events.test.js +++ b/packages/astro/test/events.test.js @@ -5,44 +5,8 @@ import * as events from '../dist/events/index.js'; describe('Events', () => { describe('eventCliSession()', () => { - it('All top-level keys added', () => { - const config = { - root: 1, - srcDir: 2, - publicDir: 3, - outDir: 4, - site: 5, - base: 6, - trailingSlash: 7, - experimental: 8, - }; - const expected = Object.keys(config); - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - }, - config - ); - expect(payload.configKeys).to.deep.equal(expected); - }); - - it('configKeys includes format', () => { - const config = { - srcDir: 1, - build: { - format: 'file', - }, - }; - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - }, - config - ); - expect(payload.configKeys).to.deep.equal(['srcDir', 'build', 'build.format']); - }); - it('config.build.format', () => { + it('string literal "build.format" is included', () => { const config = { srcDir: 1, build: { @@ -58,59 +22,8 @@ describe('Events', () => { expect(payload.config.build.format).to.equal('file'); }); - it('configKeys includes server props', () => { - const config = { - srcDir: 1, - server: { - host: 'example.com', - port: 8033, - }, - }; - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - }, - config - ); - expect(payload.configKeys).to.deep.equal(['srcDir', 'server', 'server.host', 'server.port']); - }); - it('configKeys is deep', () => { - const config = { - publicDir: 1, - markdown: { - drafts: true, - shikiConfig: { - lang: 1, - theme: 2, - wrap: 3, - }, - syntaxHighlight: 'shiki', - remarkPlugins: [], - rehypePlugins: [], - }, - }; - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - }, - config - ); - expect(payload.configKeys).to.deep.equal([ - 'publicDir', - 'markdown', - 'markdown.drafts', - 'markdown.shikiConfig', - 'markdown.shikiConfig.lang', - 'markdown.shikiConfig.theme', - 'markdown.shikiConfig.wrap', - 'markdown.syntaxHighlight', - 'markdown.remarkPlugins', - 'markdown.rehypePlugins', - ]); - }); - - it('syntaxHighlight', () => { + it('string literal "markdown.syntaxHighlight" is included', () => { const config = { markdown: { syntaxHighlight: 'shiki', @@ -145,233 +58,16 @@ describe('Events', () => { }, config ); - expect(payload.configKeys).is.deep.equal([ - 'root', - 'vite', - 'vite.css', - 'vite.css.modules', - 'vite.base', - 'vite.mode', - 'vite.define', - 'vite.publicDir', - ]); - }); - - it('vite.resolve keys are captured', async () => { - const config = { - vite: { - resolve: { - alias: { - a: 'b', - }, - dedupe: ['one', 'two'], - }, - }, - }; - - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - }, - config - ); - expect(payload.configKeys).is.deep.equal([ - 'vite', - 'vite.resolve', - 'vite.resolve.alias', - 'vite.resolve.dedupe', - ]); - }); - - it('vite.css keys are captured', async () => { - const config = { - vite: { - resolve: { - dedupe: ['one', 'two'], - }, - css: { - modules: [], - postcss: {}, - }, - }, - }; - - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - }, - config - ); - expect(payload.configKeys).is.deep.equal([ - 'vite', - 'vite.resolve', - 'vite.resolve.dedupe', - 'vite.css', - 'vite.css.modules', - 'vite.css.postcss', - ]); - }); - - it('vite.server keys are captured', async () => { - const config = { - vite: { - server: { - host: 'example.com', - open: true, - fs: { - strict: true, - allow: ['a', 'b'], - }, - }, - }, - }; - - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - }, - config - ); - expect(payload.configKeys).is.deep.equal([ - 'vite', - 'vite.server', - 'vite.server.host', - 'vite.server.open', - 'vite.server.fs', - 'vite.server.fs.strict', - 'vite.server.fs.allow', - ]); - }); - - it('vite.build keys are captured', async () => { - const config = { - vite: { - build: { - target: 'one', - outDir: 'some/dir', - cssTarget: { - one: 'two', - }, - }, - }, - }; - - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - }, - config - ); - expect(payload.configKeys).is.deep.equal([ - 'vite', - 'vite.build', - 'vite.build.target', - 'vite.build.outDir', - 'vite.build.cssTarget', - ]); - }); - - it('vite.preview keys are captured', async () => { - const config = { - vite: { - preview: { - host: 'example.com', - port: 8080, - another: { - a: 'b', - }, - }, - }, - }; - - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - }, - config - ); - expect(payload.configKeys).is.deep.equal([ - 'vite', - 'vite.preview', - 'vite.preview.host', - 'vite.preview.port', - 'vite.preview.another', - ]); - }); - - it('vite.optimizeDeps keys are captured', async () => { - const config = { - vite: { - optimizeDeps: { - entries: ['one', 'two'], - exclude: ['secret', 'name'], - }, - }, - }; - - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - }, - config - ); - expect(payload.configKeys).is.deep.equal([ - 'vite', - 'vite.optimizeDeps', - 'vite.optimizeDeps.entries', - 'vite.optimizeDeps.exclude', - ]); - }); - - it('vite.ssr keys are captured', async () => { - const config = { - vite: { - ssr: { - external: ['a'], - target: { one: 'two' }, - }, - }, - }; - - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - }, - config - ); - expect(payload.configKeys).is.deep.equal([ - 'vite', - 'vite.ssr', - 'vite.ssr.external', - 'vite.ssr.target', - ]); - }); - - it('vite.worker keys are captured', async () => { - const config = { - vite: { - worker: { - format: { a: 'b' }, - plugins: ['a', 'b'], - }, - }, - }; - - const [{ payload }] = events.eventCliSession( - { - cliCommand: 'dev', - }, - config - ); - expect(payload.configKeys).is.deep.equal([ - 'vite', - 'vite.worker', - 'vite.worker.format', - 'vite.worker.plugins', + expect(Object.keys(payload.config.vite)).is.deep.equal([ + 'css', + 'base', + 'mode', + 'define', + 'publicDir', ]); }); - it('falsy integrations', () => { + it('falsy integrations are handled', () => { const config = { srcDir: 1, integrations: [null, undefined, false], @@ -385,7 +81,7 @@ describe('Events', () => { expect(payload.config.integrations.length).to.equal(0); }); - it('finds names for integration arrays', () => { + it('only integration names are included', () => { const config = { integrations: [{ name: 'foo' }, [{ name: 'bar' }, { name: 'baz' }]], }; @@ -393,6 +89,14 @@ describe('Events', () => { expect(payload.config.integrations).to.deep.equal(['foo', 'bar', 'baz']); }); + it('only adapter name is included', () => { + const config = { + adapter: {name: 'ADAPTER_NAME'}, + }; + const [{ payload }] = events.eventCliSession({ cliCommand: 'dev' }, config); + expect(payload.config.adapter).to.equal('ADAPTER_NAME'); + }); + it('includes cli flags in payload', () => { const config = {}; const flags = { diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md index 6adc8b96f465..c9dc896fc0b1 100644 --- a/packages/telemetry/README.md +++ b/packages/telemetry/README.md @@ -1,9 +1,17 @@ # Astro Telemetry -This package is used to collect anonymous telemetry data within the Astro CLI. It is enabled by default. Telemetry data does not contain any personal identifying information and can be disabled via: +This package is used to collect anonymous telemetry data within the Astro CLI. + +It can be disabled in Astro using either method documented below: ```shell +# Option 1: Run this to disable telemetry globally across your entire machine. astro telemetry disable ``` -See the [CLI documentation](https://docs.astro.build/en/reference/cli-reference/#astro-telemetry) for more options on configuration telemetry. +```shell +# Option 2: The ASTRO_TELEMETRY_DISABLED environment variable disables telemetry when set. +ASTRO_TELEMETRY_DISABLED=1 astro dev +``` + +Visit https://astro.build/telemetry/ for more information about our approach to anonymous telemetry in Astro.