-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Add keystone telemetry inform
command and update telemetry policy
#9292
Changes from 7 commits
032cd5c
a42f104
ce0fce1
e3d7e2f
36b9759
7ea48b6
639f619
77e24a6
d442333
fb001c3
ead420b
1e8323a
cccd82c
24cac4a
3145776
cd18302
2884869
61079d9
3dff2b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@keystone-6/core": minor | ||
--- | ||
|
||
Adds `keystone telemetry inform` command to show an informed consent notice |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,17 +5,18 @@ import ci from 'ci-info' | |
import Conf from 'conf' | ||
import { | ||
bold, | ||
blue as b, | ||
yellow as y, | ||
red as r, | ||
green as g | ||
green as g, | ||
grey, | ||
} from 'chalk' | ||
import { | ||
type Configuration, | ||
type Device, | ||
type PackageName, | ||
type Project, | ||
type TelemetryVersion1, | ||
type TelemetryVersion2and3, | ||
type TelemetryVersion2, | ||
} from '../types/telemetry' | ||
import { type DatabaseProvider } from '../types' | ||
import { type InitialisedList } from './core/initialise-lists' | ||
|
@@ -37,8 +38,14 @@ function log (message: unknown) { | |
} | ||
} | ||
|
||
type Telemetry = TelemetryVersion2 | ||
type TelemetryOK = Exclude<Telemetry, false | undefined> | ||
type Configuration = ReturnType<typeof getTelemetryConfig>['userConfig'] | ||
|
||
function getTelemetryConfig () { | ||
const userConfig = new Conf<Configuration>({ | ||
const userConfig = new Conf<{ | ||
telemetry?: TelemetryVersion2 | ||
}>({ | ||
projectName: 'keystonejs', | ||
projectSuffix: '', | ||
projectVersion: '3.0.0', | ||
|
@@ -47,15 +54,15 @@ function getTelemetryConfig () { | |
const existing = store.get('telemetry') as TelemetryVersion1 | ||
if (!existing) return // skip non-configured or known opt-outs | ||
|
||
const replacement: TelemetryVersion2and3 = { | ||
const replacement: TelemetryVersion2 = { | ||
informedAt: null, // re-inform | ||
device: { | ||
lastSentDate: existing.device.lastSentDate ?? null, | ||
}, | ||
projects: {}, // see below | ||
} | ||
|
||
// copy existing project lastSentDate's | ||
// copy existing project.lastSentDate's | ||
for (const [projectPath, project] of Object.entries(existing.projects)) { | ||
if (projectPath === 'default') continue // informedAt moved to device.lastSentDate | ||
|
||
|
@@ -70,16 +77,16 @@ function getTelemetryConfig () { | |
} | ||
} | ||
|
||
store.set('telemetry', replacement) | ||
store.set('telemetry', replacement satisfies TelemetryVersion2) | ||
}, | ||
'^3.0.0': (store) => { | ||
const existing = store.get('telemetry') as TelemetryVersion2and3 | ||
const existing = store.get('telemetry') as TelemetryVersion2 | ||
if (!existing) return // skip non-configured or known opt-outs | ||
|
||
store.set('telemetry', { | ||
...existing, | ||
informedAt: null, // re-inform | ||
} satisfies TelemetryVersion2and3) | ||
} satisfies Telemetry) | ||
}, | ||
}, | ||
}) | ||
|
@@ -90,23 +97,15 @@ function getTelemetryConfig () { | |
} | ||
} | ||
|
||
function getDefaultedTelemetryConfig () { | ||
const { telemetry, userConfig } = getTelemetryConfig() | ||
|
||
if (telemetry === undefined) { | ||
return { | ||
telemetry: { | ||
informedAt: null, | ||
device: { | ||
lastSentDate: null, | ||
}, | ||
projects: {}, | ||
} as TelemetryVersion2and3, // help Typescript infer the type | ||
userConfig, | ||
} | ||
} | ||
|
||
return { telemetry, userConfig } | ||
function getDefault (telemetry: Telemetry) { | ||
if (telemetry) return telemetry | ||
return { | ||
informedAt: null, | ||
device: { | ||
lastSentDate: null, | ||
}, | ||
projects: {}, | ||
} satisfies Telemetry // help Typescript infer the type | ||
} | ||
|
||
const todaysDate = new Date().toISOString().slice(0, 10) | ||
|
@@ -133,42 +132,49 @@ function collectFieldCount (lists: Record<string, InitialisedList>) { | |
} | ||
|
||
function collectPackageVersions () { | ||
const versions: Project['versions'] = { | ||
const packages: Project['packages'] = { | ||
'@keystone-6/core': '0.0.0', // effectively unknown | ||
} | ||
|
||
for (const packageName of packageNames) { | ||
try { | ||
const packageJson = require(`${packageName}/package.json`) | ||
versions[packageName] = packageJson.version | ||
packages[packageName] = packageJson.version | ||
} catch { | ||
// do nothing, most likely because the package is not installed | ||
} | ||
} | ||
|
||
return versions | ||
return packages | ||
} | ||
|
||
function printAbout () { | ||
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`) | ||
} | ||
|
||
export function printTelemetryStatus () { | ||
function printNext (telemetry: Telemetry) { | ||
if (!telemetry) { | ||
console.log(`Telemetry data will ${r`not`} be sent by this system user`) | ||
return | ||
} | ||
console.log(`Telemetry data will be sent the next time you run ${g`"keystone dev"`}`) | ||
dcousens marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
function printTelemetryStatus () { | ||
const { telemetry } = getTelemetryConfig() | ||
|
||
if (telemetry === undefined) { | ||
console.log(`Keystone telemetry has been reset to ${y`uninitialized`}`) | ||
dcousens marked this conversation as resolved.
Show resolved
Hide resolved
|
||
console.log() | ||
console.log(`Telemetry will be sent the next time you run ${g`"keystone dev"`}, unless you opt-out`) | ||
printNext(telemetry) | ||
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`) | ||
printNext(telemetry) | ||
return | ||
} | ||
|
||
|
@@ -181,22 +187,24 @@ export function printTelemetryStatus () { | |
} | ||
|
||
console.log() | ||
console.log(`Telemetry will be sent the next time you run ${g`"keystone dev"`}, unless you opt-out`) | ||
printNext(telemetry) | ||
} | ||
|
||
function inform () { | ||
const { telemetry, userConfig } = getDefaultedTelemetryConfig() | ||
|
||
// no telemetry? somehow we missed something, do nothing | ||
if (!telemetry) return | ||
|
||
function inform ( | ||
telemetry: TelemetryOK, | ||
userConfig: Configuration | ||
) { | ||
console.log() // gap to help visiblity | ||
console.log(`${bold('Keystone Telemetry')}`) | ||
printAbout() | ||
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, but telemetry will be sent the next time you run ${g`"keystone dev"`}, unless you opt-out`) | ||
if (telemetry.informedAt === null) { | ||
console.log(`No telemetry data has been sent as part of this notice`) | ||
} | ||
printNext(telemetry) | ||
console.log() // gap to help visiblity | ||
console.log(`For more information, including how to opt-out see ${grey`https://keystonejs.com/telemetry`} (updated ${b`2024-08-15`})`) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As everyone will be re-informed as of #9290, this date helps to show that the information has been updated (however minor that change is) |
||
|
||
// update the informedAt | ||
telemetry.informedAt = new Date().toJSON() | ||
|
@@ -221,13 +229,10 @@ async function sendEvent (eventType: 'project' | 'device', eventData: Project | | |
async function sendProjectTelemetryEvent ( | ||
cwd: string, | ||
lists: Record<string, InitialisedList>, | ||
dbProviderName: DatabaseProvider | ||
dbProviderName: DatabaseProvider, | ||
telemetry: TelemetryOK, | ||
userConfig: Configuration | ||
) { | ||
const { telemetry, userConfig } = getDefaultedTelemetryConfig() | ||
|
||
// no telemetry? somehow we missed something, do nothing | ||
if (!telemetry) return | ||
|
||
const project = telemetry.projects[cwd] ?? { lastSentDate: null } | ||
const { lastSentDate } = project | ||
if (lastSentDate && lastSentDate >= todaysDate) { | ||
|
@@ -239,7 +244,7 @@ async function sendProjectTelemetryEvent ( | |
previous: lastSentDate, | ||
fields: collectFieldCount(lists), | ||
lists: Object.keys(lists).length, | ||
versions: collectPackageVersions(), | ||
packages: collectPackageVersions(), | ||
database: dbProviderName, | ||
}) | ||
|
||
|
@@ -248,12 +253,10 @@ async function sendProjectTelemetryEvent ( | |
userConfig.set('telemetry', telemetry) | ||
} | ||
|
||
async function sendDeviceTelemetryEvent () { | ||
const { telemetry, userConfig } = getDefaultedTelemetryConfig() | ||
|
||
// no telemetry? somehow we missed something, do nothing | ||
if (!telemetry) return | ||
|
||
async function sendDeviceTelemetryEvent ( | ||
telemetry: TelemetryOK, | ||
userConfig: Configuration | ||
) { | ||
const { lastSentDate } = telemetry.device | ||
if (lastSentDate && lastSentDate >= todaysDate) { | ||
log('device telemetry already sent today') | ||
|
@@ -285,26 +288,36 @@ export async function runTelemetry ( | |
return | ||
} | ||
|
||
const { telemetry } = getDefaultedTelemetryConfig() | ||
const { telemetry, userConfig } = getTelemetryConfig() | ||
|
||
// don't run if the user has opted out | ||
// or if somehow our defaults are problematic, do nothing | ||
if (!telemetry) return | ||
if (telemetry === false) return | ||
|
||
// don't send telemetry before we inform the user, allowing opt-out | ||
if (!telemetry.informedAt) return inform() | ||
const telemetryDefaulted = getDefault(telemetry) | ||
if (!telemetryDefaulted.informedAt) return inform(telemetryDefaulted, userConfig) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is pretty neat, what about showing it is disabled too?
and
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. At some stage I want to switch to declarative wording here rather than procedural, but we won't do that in this pull request Example is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @kennedybaird can we do this in a different pull request? The suggested improvement might require a new set of wiring and functions, depending on how we do this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And, like in #8118 (see #8118 (comment)), the phrasing of this might resurface whether we want to try and manage our dependencies telemetry configuration too There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
||
await sendProjectTelemetryEvent(cwd, lists, dbProviderName) | ||
await sendDeviceTelemetryEvent() | ||
await sendProjectTelemetryEvent(cwd, lists, dbProviderName, telemetryDefaulted, userConfig) | ||
await sendDeviceTelemetryEvent(telemetryDefaulted, userConfig) | ||
} catch (err) { | ||
log(err) | ||
} | ||
} | ||
|
||
export function statusTelemetry () { | ||
printTelemetryStatus() | ||
} | ||
|
||
export function informTelemetry () { | ||
const { userConfig } = getTelemetryConfig() | ||
inform(getDefault(false), userConfig) | ||
} | ||
|
||
export function enableTelemetry () { | ||
const { telemetry, userConfig } = getTelemetryConfig() | ||
if (telemetry === false) { | ||
userConfig.delete('telemetry') | ||
if (!telemetry) { | ||
userConfig.set('telemetry', getDefault(telemetry)) | ||
} | ||
printTelemetryStatus() | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moving forward, we'll use the
/3
version in theendpoint
to differentiate report schema versions in the same way we differentiate report types using/project
and/device
.This makes that abundantly transparent.