diff --git a/.changeset/fix-telemetry-disable.md b/.changeset/fix-telemetry-disable.md new file mode 100644 index 00000000000..e58b20d7127 --- /dev/null +++ b/.changeset/fix-telemetry-disable.md @@ -0,0 +1,5 @@ +--- +"@keystone-6/core": patch +--- + +Fixes the `keystone telemetry disable` command for opting out of telemetry diff --git a/packages/core/src/lib/telemetry.ts b/packages/core/src/lib/telemetry.ts index 6b6957c9592..475a24c278f 100644 --- a/packages/core/src/lib/telemetry.ts +++ b/packages/core/src/lib/telemetry.ts @@ -3,13 +3,19 @@ import https from 'node:https' import ci from 'ci-info' import Conf from 'conf' -import chalk from 'chalk' +import { + bold, + yellow as y, + red as r, + green as g +} from 'chalk' import { type Configuration, type Device, type PackageName, type Project, - type Telemetry, + type TelemetryVersion1, + type TelemetryVersion2and3, } from '../types/telemetry' import { type DatabaseProvider } from '../types' import { type InitialisedList } from './core/initialise-lists' @@ -25,17 +31,6 @@ const packageNames: PackageName[] = [ '@opensaas/keystone-nextjs-auth', ] -type TelemetryVersion1 = - | undefined - | false - | { - device: { lastSentDate?: string, informedAt: string } - projects: { - default: { lastSentDate?: string, informedAt: string } - [projectPath: string]: { lastSentDate?: string, informedAt: string } - } - } - function log (message: unknown) { if (process.env.KEYSTONE_TELEMETRY_DEBUG === '1') { console.log(`${message}`) @@ -46,31 +41,30 @@ function getTelemetryConfig () { const userConfig = new Conf({ projectName: 'keystonejs', projectSuffix: '', - projectVersion: '2.0.0', + projectVersion: '3.0.0', migrations: { - '^2.0.0': (store: Conf) => { - const existing = store.get('telemetry') as unknown as TelemetryVersion1 - if (!existing) return + '^2.0.0': (store) => { + const existing = store.get('telemetry') as TelemetryVersion1 + if (!existing) return // skip non-configured or known opt-outs - const replacement: Telemetry = { - // every informedAt was a copy of device.informedAt, it was copied everywhere - informedAt: existing.device.informedAt, + const replacement: TelemetryVersion2and3 = { + informedAt: null, // re-inform device: { lastSentDate: existing.device.lastSentDate ?? null, }, - projects: {}, // manually copying this below + projects: {}, // see below } // copy existing project lastSentDate's for (const [projectPath, project] of Object.entries(existing.projects)) { - if (projectPath === 'default') continue // informedAt moved to root + if (projectPath === 'default') continue // informedAt moved to device.lastSentDate // dont copy garbage if (typeof project !== 'object') continue if (typeof project.lastSentDate !== 'string') continue if (new Date(project.lastSentDate).toString() === 'Invalid Date') continue - // only lastSentDate is retained + // retain lastSentDate replacement.projects[projectPath] = { lastSentDate: project.lastSentDate, } @@ -78,6 +72,15 @@ function getTelemetryConfig () { store.set('telemetry', replacement) }, + '^3.0.0': (store) => { + const existing = store.get('telemetry') as TelemetryVersion2and3 + if (!existing) return // skip non-configured or known opt-outs + + store.set('telemetry', { + ...existing, + informedAt: null, // re-inform + } satisfies TelemetryVersion2and3) + }, }, }) @@ -97,8 +100,8 @@ function getDefaultedTelemetryConfig () { device: { lastSentDate: null, }, - projects: {} as Telemetry['projects'], // help Typescript infer the type - }, + projects: {}, + } as TelemetryVersion2and3, // help Typescript infer the type userConfig, } } @@ -147,74 +150,52 @@ function collectPackageVersions () { } function printAbout () { - console.log( - `${chalk.yellow('Keystone collects anonymous data when you run')} ${chalk.green( - '"keystone dev"' - )}` - ) + console.log(`${y`Keystone collects anonymous data when you run`} ${g`"keystone dev"`}`) console.log() - console.log( - `For more information, including how to opt-out see https://keystonejs.com/telemetry` - ) + console.log(`For more information, including how to opt-out see https://keystonejs.com/telemetry`) } export function printTelemetryStatus () { const { telemetry } = getTelemetryConfig() if (telemetry === undefined) { - console.log(`Keystone telemetry has been reset to ${chalk.yellow('uninitialized')}`) - console.log() - console.log( - `Telemetry will be sent the next time you run ${chalk.green( - '"keystone dev"' - )}, unless you opt-out` - ) - } else if (telemetry === false) { - console.log(`Keystone telemetry is ${chalk.red('disabled')}`) + console.log(`Keystone telemetry has been reset to ${y`uninitialized`}`) console.log() - console.log(`Telemetry will ${chalk.red('not')} be sent by this system user`) - } else if (typeof telemetry === 'object') { - console.log(`Keystone telemetry is ${chalk.green('enabled')}`) + console.log(`Telemetry will be sent the next time you run ${g`"keystone dev"`}, unless you opt-out`) + return + } + + if (telemetry === false) { + console.log(`Keystone telemetry is ${r`disabled`}`) console.log() + console.log(`Telemetry will ${r`not`} be sent by this system user`) + return + } - console.log(` Device telemetry was last sent on ${telemetry.device.lastSentDate}`) - for (const [projectPath, project] of Object.entries(telemetry.projects)) { - console.log( - ` Project telemetry for "${chalk.yellow(projectPath)}" was last sent on ${ - project?.lastSentDate - }` - ) - } + console.log(`Keystone telemetry is ${g`enabled`}`) + console.log() - console.log() - console.log( - `Telemetry will be sent the next time you run ${chalk.green( - '"keystone dev"' - )}, unless you opt-out` - ) + console.log(` Device telemetry was last sent on ${telemetry.device.lastSentDate}`) + for (const [projectPath, project] of Object.entries(telemetry.projects)) { + console.log(` Project telemetry for "${y(projectPath)}" was last sent on ${project?.lastSentDate}`) } + + console.log() + console.log(`Telemetry will be sent the next time you run ${g`"keystone dev"`}, unless you opt-out`) } function inform () { const { telemetry, userConfig } = getDefaultedTelemetryConfig() - // no telemetry? somehow our earlier checks missed an opt out, do nothing - if (telemetry === false) return + // no telemetry? somehow we missed something, do nothing + if (!telemetry) return console.log() // gap to help visiblity - console.log(`${chalk.bold('Keystone Telemetry')}`) + console.log(`${bold('Keystone Telemetry')}`) printAbout() - console.log( - `You can use ${chalk.green( - '"keystone telemetry --help"' - )} to update your preferences at any time` - ) + console.log(`You can use ${g`"keystone telemetry --help"`} to update your preferences at any time`) console.log() - console.log( - `No telemetry data has been sent yet, but telemetry will be sent the next time you run ${chalk.green( - '"keystone dev"' - )}, unless you opt-out` - ) + console.log(`No telemetry data has been sent, but telemetry will be sent the next time you run ${g`"keystone dev"`}, unless you opt-out`) console.log() // gap to help visiblity // update the informedAt @@ -222,9 +203,11 @@ function inform () { userConfig.set('telemetry', telemetry) } +async function sendEvent (eventType: 'project', eventData: Project): Promise +async function sendEvent (eventType: 'device', eventData: Device): Promise async function sendEvent (eventType: 'project' | 'device', eventData: Project | Device) { const endpoint = process.env.KEYSTONE_TELEMETRY_ENDPOINT || defaultTelemetryEndpoint - const req = https.request(`${endpoint}/v1/event/${eventType}`, { + const req = https.request(`${endpoint}/2/event/${eventType}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -242,8 +225,8 @@ async function sendProjectTelemetryEvent ( ) { const { telemetry, userConfig } = getDefaultedTelemetryConfig() - // no telemetry? somehow our earlier checks missed an opt out, do nothing - if (telemetry === false) return + // no telemetry? somehow we missed something, do nothing + if (!telemetry) return const project = telemetry.projects[cwd] ?? { lastSentDate: null } const { lastSentDate } = project @@ -268,8 +251,8 @@ async function sendProjectTelemetryEvent ( async function sendDeviceTelemetryEvent () { const { telemetry, userConfig } = getDefaultedTelemetryConfig() - // no telemetry? somehow our earlier checks missed an opt out, do nothing - if (telemetry === false) return + // no telemetry? somehow we missed something, do nothing + if (!telemetry) return const { lastSentDate } = telemetry.device if (lastSentDate && lastSentDate >= todaysDate) { @@ -305,7 +288,8 @@ export async function runTelemetry ( const { telemetry } = getDefaultedTelemetryConfig() // don't run if the user has opted out - if (telemetry === false) return + // or if somehow our defaults are problematic, do nothing + if (!telemetry) return // don't send telemetry before we inform the user, allowing opt-out if (!telemetry.informedAt) return inform() diff --git a/packages/core/src/scripts/cli.ts b/packages/core/src/scripts/cli.ts index 45920da7a6b..5af75a4e39c 100644 --- a/packages/core/src/scripts/cli.ts +++ b/packages/core/src/scripts/cli.ts @@ -110,7 +110,7 @@ export async function cli (cwd: string, argv: string[]) { return prisma(cwd, argv.slice(1), Boolean(flags.frozen)) } - if (command === 'telemetry') { + if (command.startsWith('telemetry')) { return telemetry(cwd, argv[1]) } diff --git a/packages/core/src/scripts/telemetry.ts b/packages/core/src/scripts/telemetry.ts index d4b8b337e9f..71576636f43 100644 --- a/packages/core/src/scripts/telemetry.ts +++ b/packages/core/src/scripts/telemetry.ts @@ -11,7 +11,7 @@ export async function telemetry (cwd: string, command?: string) { Usage $ keystone telemetry [command] Commands - disable opt-out of telemetry, disabled for this system user + disable opt-out of telemetry, disabling telemetry for this system user enable opt-in to telemetry reset resets your telemetry configuration (if any) status show if telemetry is enabled, disabled or uninitialised diff --git a/packages/core/src/types/telemetry.ts b/packages/core/src/types/telemetry.ts index ce845955fa2..334b852fe60 100644 --- a/packages/core/src/types/telemetry.ts +++ b/packages/core/src/types/telemetry.ts @@ -1,19 +1,33 @@ import type { DatabaseProvider } from './core' -export type Telemetry = { - informedAt: string | null - device: { - lastSentDate: string | null - } - projects: Partial<{ - [projectPath: string]: { - lastSentDate: string +export type TelemetryVersion1 = + | undefined + | false + | { + device: { lastSentDate?: string, informedAt: string } + projects: { + default: { lastSentDate?: string, informedAt: string } + [projectPath: string]: { lastSentDate?: string, informedAt: string } + } } - }> -} + +export type TelemetryVersion2and3 = + | undefined + | false + | { + informedAt: string | null + device: { + lastSentDate: string | null + } + projects: Partial<{ + [projectPath: string]: { + lastSentDate: string + } + }> + } export type Configuration = { - telemetry?: undefined | false | Telemetry + telemetry?: undefined | false | TelemetryVersion2and3 } export type Device = { diff --git a/packages/core/tests/telemetry.test.ts b/packages/core/tests/telemetry.test.ts index ab1c22905d5..429e005cd59 100644 --- a/packages/core/tests/telemetry.test.ts +++ b/packages/core/tests/telemetry.test.ts @@ -129,7 +129,7 @@ describe('Telemetry tests', () => { } function expectDidSend (lastSentDate: string | null) { - expect(https.request).toHaveBeenCalledWith(`https://telemetry.keystonejs.com/v1/event/project`, { + expect(https.request).toHaveBeenCalledWith(`https://telemetry.keystonejs.com/2/event/project`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -148,7 +148,7 @@ describe('Telemetry tests', () => { }) ) - expect(https.request).toHaveBeenCalledWith(`https://telemetry.keystonejs.com/v1/event/device`, { + expect(https.request).toHaveBeenCalledWith(`https://telemetry.keystonejs.com/2/event/device`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -173,12 +173,12 @@ describe('Telemetry tests', () => { expect(Object.keys(mockTelemetryConfig?.projects).length).toBe(0) }) - test('Telemetry is sent on second run', async () => { + test('Telemetry is sent after inform', async () => { await runTelemetry(mockProjectDir, lists, 'sqlite') // inform await runTelemetry(mockProjectDir, lists, 'sqlite') // send expectDidSend(null) - expect(https.request).toHaveBeenCalledTimes(2) + expect(https.request).toHaveBeenCalledTimes(2) // would be 4 if sent twice expect(mockTelemetryConfig).toBeDefined() expect(mockTelemetryConfig?.device.lastSentDate).toBe(today) expect(mockTelemetryConfig?.projects).toBeDefined() @@ -215,7 +215,7 @@ describe('Telemetry tests', () => { expect(mockTelemetryConfig).toBe(false) }) - test(`Telemetry is not sent if telemetry is disabled`, async () => { + test(`Telemetry is not sent if telemetry configuration is disabled`, async () => { mockTelemetryConfig = false await runTelemetry(mockProjectDir, lists, 'sqlite') // inform