Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix telemetry disable command #9290

Merged
merged 7 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-telemetry-disable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@keystone-6/core": patch
---

Fixes the `keystone telemetry disable` command for opting out of telemetry
138 changes: 60 additions & 78 deletions packages/core/src/lib/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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}`)
Expand All @@ -46,38 +41,46 @@ function getTelemetryConfig () {
const userConfig = new Conf<Configuration>({
projectName: 'keystonejs',
projectSuffix: '',
projectVersion: '2.0.0',
projectVersion: '3.0.0',
migrations: {
'^2.0.0': (store: Conf<Configuration>) => {
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
Copy link
Member

@dcousens dcousens Aug 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone has unknowingly missed their opt-out failing, this will re-inform them

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,
}
}

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
Copy link
Member

@dcousens dcousens Aug 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone has unknowingly missed their opt-out failing, this will re-inform them

} satisfies TelemetryVersion2and3)
},
},
})

Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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 our earlier 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
Expand All @@ -224,7 +205,7 @@ function inform () {

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',
Expand All @@ -242,8 +223,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 our earlier missed something, do nothing
if (!telemetry) return

const project = telemetry.projects[cwd] ?? { lastSentDate: null }
const { lastSentDate } = project
Expand All @@ -268,8 +249,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 our earlier missed something, do nothing
if (!telemetry) return

const { lastSentDate } = telemetry.device
if (lastSentDate && lastSentDate >= todaysDate) {
Expand Down Expand Up @@ -305,7 +286,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()
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/scripts/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 25 additions & 11 deletions packages/core/src/types/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
10 changes: 5 additions & 5 deletions packages/core/tests/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading