diff --git a/.changeset/add-telemetry-inform.md b/.changeset/add-telemetry-inform.md new file mode 100644 index 00000000000..08dd3fce189 --- /dev/null +++ b/.changeset/add-telemetry-inform.md @@ -0,0 +1,5 @@ +--- +"@keystone-6/core": minor +--- + +Adds `keystone telemetry inform` command to show an informed consent notice diff --git a/.changeset/fix-telemetry-policy.md b/.changeset/fix-telemetry-policy.md new file mode 100644 index 00000000000..462775143b0 --- /dev/null +++ b/.changeset/fix-telemetry-policy.md @@ -0,0 +1,5 @@ +--- +"@keystone-6/core": patch +--- + +Update https://keystonejs.com/docs/reference/telemetry to show that `database` type is collected as part of telemetry diff --git a/docs/content/docs/reference/telemetry.md b/docs/content/docs/reference/telemetry.md index 7a401fe1636..c779ea1e6f2 100644 --- a/docs/content/docs/reference/telemetry.md +++ b/docs/content/docs/reference/telemetry.md @@ -63,6 +63,7 @@ Keystone collects telemetry information in the form of two different types of da We refer to these two different reports, as “device telemetry” and “project telemetry” respectively. These reports are forwarded to [https://telemetry.keystonejs.com/](https://telemetry.keystonejs.com/), and are reported separately to minimize any correlation between them insofar as the timing and grouping of that data, that an otherwise combined report may have. We are collecting these two reports for different reasons, and thus have no need to associate them. +We differentiate and record the type and version of reports from the URL used by Keystone. We additionally record a timestamp of the time that the report is received by the server at [https://telemetry.keystonejs.com](https://telemetry.keystonejs.com/). @@ -78,9 +79,9 @@ A device telemetry report is formatted as JSON and currently looks like: ```json { - "previous": "2022-11-23", + "lastSentDate": "2024-11-23", "os": "darwin", - "node": "18" + "node": "20" } ``` @@ -89,7 +90,8 @@ A device telemetry report is formatted as JSON and currently looks like: The type of information contained within a project telemetry report is currently: - The last date you used `keystone dev` for this project, and -- The resolved versions of any `@keystone-6` packages used by this project, and +- The resolved package versions of any `@keystone-6` packages used by this project, and +- The database type used by the project, - The number of lists for this project, and - The name and number of field types that you are using @@ -97,19 +99,20 @@ A project telemetry report is formatted as JSON and currently looks like: ```json { - "previous": "2022-11-23", - "versions": { - "@keystone-6/auth": "5.0.1", - "@keystone-6/core": "3.1.2", + "lastSentDate": "2024-11-23", + "packages": { + "@keystone-6/auth": "8.0.1", + "@keystone-6/core": "6.1.0", "@keystone-6/document-renderer": "1.1.2", "@keystone-6/fields-document": "5.0.2" }, + "database": "postgresql", "lists": 3, "fields": { "unknown": 1, "@keystone-6/text": 5, - "@keystone-6/image": 1, - "@keystone-6/file": 1 + "@keystone-6/timestamp": 2, + "@keystone-6/checkbox": 1 } } ``` @@ -185,8 +188,8 @@ If you wish to see how telemetry is currently configured for your device or proj ## What if I have a complaint or question -If you have any questions or concerns about the information that is gathered please contact us by logging a GitHub Issue [https://github.com/keystonejs/keystone](https://github.com/keystonejs/keystone). +If you have any questions or concerns about the information that is gathered please contact us by logging a GitHub Issue [https://github.com/keystonejs/keystone](https://github.com/keystonejs/keystone). -Alternatively please contact our Privacy Officer by email to [privacy@keystonejs.com](mailto:privacy@keystonejs.com), or by mail to Level 10, 191 Clarence Street, Sydney NSW 2000. +Alternatively please contact our Privacy Officer by email to [privacy@keystonejs.com](mailto:privacy@keystonejs.com), or by mail to Level 10, 191 Clarence Street, Sydney NSW 2000. For further information about Keystone’s security policy please see [https://github.com/keystonejs/keystone/security/policy](https://github.com/keystonejs/keystone/security/policy) diff --git a/examples/custom-output-paths/keystone.ts b/examples/custom-output-paths/keystone.ts index 5e59148bcb4..e9783b26d2d 100644 --- a/examples/custom-output-paths/keystone.ts +++ b/examples/custom-output-paths/keystone.ts @@ -8,7 +8,7 @@ export default config({ // when working in a monorepo environment you may want to output the prisma client elsewhere // you can use .db.prismaClientPath to configure where that is - prismaClientPath: 'node_modules/.myprisma/client', + prismaClientPath: 'node_modules/myprisma', prismaSchemaPath: 'my-prisma.prisma', }, lists, diff --git a/examples/custom-output-paths/my-prisma.prisma b/examples/custom-output-paths/my-prisma.prisma index 29f51889e92..ceb462537f5 100644 --- a/examples/custom-output-paths/my-prisma.prisma +++ b/examples/custom-output-paths/my-prisma.prisma @@ -9,7 +9,7 @@ datasource sqlite { generator client { provider = "prisma-client-js" - output = "node_modules/.myprisma/client" + output = "node_modules/myprisma" } model Post { diff --git a/examples/custom-output-paths/my-types.ts b/examples/custom-output-paths/my-types.ts index ec283d8f8fe..63f1b32244e 100644 --- a/examples/custom-output-paths/my-types.ts +++ b/examples/custom-output-paths/my-types.ts @@ -123,22 +123,22 @@ export type KeystoneAdminUISortDirection = | 'DESC' type ResolvedPostCreateInput = { - id?: import('./node_modules/.myprisma/client').Prisma.PostCreateInput['id'] - title?: import('./node_modules/.myprisma/client').Prisma.PostCreateInput['title'] - content?: import('./node_modules/.myprisma/client').Prisma.PostCreateInput['content'] - publishDate?: import('./node_modules/.myprisma/client').Prisma.PostCreateInput['publishDate'] + id?: import('./node_modules/myprisma').Prisma.PostCreateInput['id'] + title?: import('./node_modules/myprisma').Prisma.PostCreateInput['title'] + content?: import('./node_modules/myprisma').Prisma.PostCreateInput['content'] + publishDate?: import('./node_modules/myprisma').Prisma.PostCreateInput['publishDate'] } type ResolvedPostUpdateInput = { id?: undefined - title?: import('./node_modules/.myprisma/client').Prisma.PostUpdateInput['title'] - content?: import('./node_modules/.myprisma/client').Prisma.PostUpdateInput['content'] - publishDate?: import('./node_modules/.myprisma/client').Prisma.PostUpdateInput['publishDate'] + title?: import('./node_modules/myprisma').Prisma.PostUpdateInput['title'] + content?: import('./node_modules/myprisma').Prisma.PostUpdateInput['content'] + publishDate?: import('./node_modules/myprisma').Prisma.PostUpdateInput['publishDate'] } export declare namespace Lists { export type Post = import('@keystone-6/core').ListConfig> namespace Post { - export type Item = import('./node_modules/.myprisma/client').Post + export type Item = import('./node_modules/myprisma').Post export type TypeInfo = { key: 'Post' isSingleton: false @@ -166,8 +166,8 @@ export type TypeInfo = { lists: { readonly Post: Lists.Post.TypeInfo } - prisma: import('./node_modules/.myprisma/client').PrismaClient - prismaTypes: import('./node_modules/.myprisma/client').Prisma + prisma: import('./node_modules/myprisma').PrismaClient + prismaTypes: import('./node_modules/myprisma').Prisma session: Session } diff --git a/packages/core/src/lib/telemetry.ts b/packages/core/src/lib/telemetry.ts index 475a24c278f..7b8848ae70f 100644 --- a/packages/core/src/lib/telemetry.ts +++ b/packages/core/src/lib/telemetry.ts @@ -5,31 +5,22 @@ 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' -const defaultTelemetryEndpoint = 'https://telemetry.keystonejs.com' - -const packageNames: PackageName[] = [ - '@keystone-6/core', - '@keystone-6/auth', - '@keystone-6/fields-document', - '@keystone-6/cloudinary', - '@keystone-6/session-store-redis', - '@opensaas/keystone-nextjs-auth', -] +const defaultTelemetryEndpoint = 'https://telemetry.keystonejs.com/3/' function log (message: unknown) { if (process.env.KEYSTONE_TELEMETRY_DEBUG === '1') { @@ -37,8 +28,14 @@ function log (message: unknown) { } } +type Telemetry = TelemetryVersion2 +type TelemetryOK = Exclude +type Configuration = ReturnType['userConfig'] + function getTelemetryConfig () { - const userConfig = new Conf({ + const userConfig = new Conf<{ + telemetry?: TelemetryVersion2 + }>({ projectName: 'keystonejs', projectSuffix: '', projectVersion: '3.0.0', @@ -47,7 +44,7 @@ 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, @@ -55,7 +52,7 @@ function getTelemetryConfig () { 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 +67,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 +87,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) @@ -132,47 +121,57 @@ function collectFieldCount (lists: Record) { return fields } -function collectPackageVersions () { - const versions: Project['versions'] = { - '@keystone-6/core': '0.0.0', // effectively unknown +async function collectPackageVersions () { + const packages: Project['packages'] = { + '@keystone-6/core': '0.0.0', // "unknown" } - for (const packageName of packageNames) { + for (const packageName of [ + '@keystone-6/core', + '@keystone-6/auth', + '@keystone-6/fields-document', + '@keystone-6/cloudinary', + '@keystone-6/session-store-redis', + '@opensaas/keystone-nextjs-auth', + ]) { try { const packageJson = require(`${packageName}/package.json`) - versions[packageName] = packageJson.version - } catch { - // do nothing, most likely because the package is not installed + // const packageJson = await import(`${packageName}/package.json`, { assert: { type: 'json' } }) // TODO: broken in jest + packages[packageName] = packageJson.version + } catch (err) { + // do nothing, the package is probably 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`) +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"`}`) } -export function printTelemetryStatus () { - const { telemetry } = getTelemetryConfig() +function printTelemetryStatus (telemetry: Telemetry, updated = false) { + const auxverb = updated ? 'has been' : 'is' if (telemetry === undefined) { - console.log(`Keystone telemetry has been reset to ${y`uninitialized`}`) + console.log(`Keystone telemetry ${auxverb} ${y`uninitialized`}`) 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(`Keystone telemetry ${auxverb} ${r`disabled`}`) console.log() - console.log(`Telemetry will ${r`not`} be sent by this system user`) + printNext(telemetry) return } - console.log(`Keystone telemetry is ${g`enabled`}`) + console.log(`Keystone telemetry ${auxverb} ${g`enabled`}`) console.log() console.log(` Device telemetry was last sent on ${telemetry.device.lastSentDate}`) @@ -181,22 +180,25 @@ 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(`${y`Keystone collects anonymous data when you run`} ${g`"keystone dev"`}`) console.log(`You can use ${g`"keystone telemetry --help"`} to update your preferences at any time`) + if (telemetry.informedAt === null) { + console.log() + console.log(`No telemetry data has been sent as part of this notice`) + } 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`) + 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-20`})`) // update the informedAt telemetry.informedAt = new Date().toJSON() @@ -207,40 +209,44 @@ async function sendEvent (eventType: 'project', eventData: Project): Promise async function sendEvent (eventType: 'project' | 'device', eventData: Project | Device) { const endpoint = process.env.KEYSTONE_TELEMETRY_ENDPOINT || defaultTelemetryEndpoint - const req = https.request(`${endpoint}/2/event/${eventType}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + await new Promise((resolve) => { + const req = https.request(`${endpoint}${eventType}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, () => resolve()) + + req.once('error', (err) => { + log(err?.message ?? err) + resolve() + }) + req.end(JSON.stringify(eventData)) }) - req.end(JSON.stringify(eventData)) log(`sent ${eventType} report`) } async function sendProjectTelemetryEvent ( cwd: string, lists: Record, - 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) { + if (lastSentDate && lastSentDate === todaysDate) { log('project telemetry already sent today') return } await sendEvent('project', { - previous: lastSentDate, - fields: collectFieldCount(lists), - lists: Object.keys(lists).length, - versions: collectPackageVersions(), + lastSentDate, + packages: await collectPackageVersions(), database: dbProviderName, + lists: Object.keys(lists).length, + fields: collectFieldCount(lists), }) // update the project lastSentDate @@ -248,20 +254,18 @@ 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) { + if (lastSentDate && lastSentDate === todaysDate) { log('device telemetry already sent today') return } await sendEvent('device', { - previous: lastSentDate, + lastSentDate, os: platform(), node: process.versions.node.split('.')[0], }) @@ -285,38 +289,49 @@ 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) - await sendProjectTelemetryEvent(cwd, lists, dbProviderName) - await sendDeviceTelemetryEvent() - } catch (err) { - log(err) + await sendProjectTelemetryEvent(cwd, lists, dbProviderName, telemetryDefaulted, userConfig) + await sendDeviceTelemetryEvent(telemetryDefaulted, userConfig) + } catch (err: any) { + log(err?.message ?? err) } } +export function statusTelemetry (updated = false) { + const { telemetry } = getTelemetryConfig() + printTelemetryStatus(telemetry, updated) +} + +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() + statusTelemetry(true) } export function disableTelemetry () { const { userConfig } = getTelemetryConfig() userConfig.set('telemetry', false) - printTelemetryStatus() + statusTelemetry(true) } export function resetTelemetry () { const { userConfig } = getTelemetryConfig() userConfig.delete('telemetry') - printTelemetryStatus() + statusTelemetry(true) } diff --git a/packages/core/src/scripts/telemetry.ts b/packages/core/src/scripts/telemetry.ts index 71576636f43..c0922e6355b 100644 --- a/packages/core/src/scripts/telemetry.ts +++ b/packages/core/src/scripts/telemetry.ts @@ -1,9 +1,10 @@ -import chalk from 'chalk' +import { bold } from 'chalk' import { - printTelemetryStatus, - enableTelemetry, disableTelemetry, + enableTelemetry, resetTelemetry, + statusTelemetry, + informTelemetry, } from '../lib/telemetry' export async function telemetry (cwd: string, command?: string) { @@ -15,6 +16,7 @@ export async function telemetry (cwd: string, command?: string) { enable opt-in to telemetry reset resets your telemetry configuration (if any) status show if telemetry is enabled, disabled or uninitialised + inform show an informed consent notice For more details visit: https://keystonejs.com/telemetry ` @@ -22,9 +24,10 @@ For more details visit: https://keystonejs.com/telemetry if (command === 'disable') return disableTelemetry() if (command === 'enable') return enableTelemetry() if (command === 'reset') return resetTelemetry() - if (command === 'status') return printTelemetryStatus() + if (command === 'status') return statusTelemetry() + if (command === 'inform') return informTelemetry() if (command === '--help') { - console.log(`${chalk.bold('Keystone Telemetry')}`) + console.log(`${bold('Keystone Telemetry')}`) console.log(usageText) return } diff --git a/packages/core/src/types/telemetry.ts b/packages/core/src/types/telemetry.ts index 334b852fe60..a3de0bd7cb6 100644 --- a/packages/core/src/types/telemetry.ts +++ b/packages/core/src/types/telemetry.ts @@ -11,7 +11,7 @@ export type TelemetryVersion1 = } } -export type TelemetryVersion2and3 = +export type TelemetryVersion2 = | undefined | false | { @@ -26,26 +26,14 @@ export type TelemetryVersion2and3 = }> } -export type Configuration = { - telemetry?: undefined | false | TelemetryVersion2and3 -} - export type Device = { - previous: string | null // new Date().toISOString().slice(0, 10) + lastSentDate: string | null // new Date().toISOString().slice(0, 10) os: string // `linux` | `darwin` | `windows` | ... // os.platform() node: string // `14` | ... | `18` // process.version.split('.').shift().slice(1) } -export type PackageName = - | '@keystone-6/core' - | '@keystone-6/auth' - | '@keystone-6/fields-document' - | '@keystone-6/cloudinary' - | '@keystone-6/session-store-redis' - | '@opensaas/keystone-nextjs-auth' - export type Project = { - previous: string | null // new Date().toISOString().slice(0, 10) + lastSentDate: string | null // new Date().toISOString().slice(0, 10) // omitted uuid for > - lists: number + packages: Partial> database: DatabaseProvider - // uses a new `field.__ksTelemetryFieldTypeName` for the key, defaults to `unknown` + lists: number fields: { + // uses `field.__ksTelemetryFieldTypeName`, default is `unknown` [key: string]: number } } diff --git a/packages/core/tests/telemetry.test.ts b/packages/core/tests/telemetry.test.ts index 429e005cd59..8b6e7f6dd98 100644 --- a/packages/core/tests/telemetry.test.ts +++ b/packages/core/tests/telemetry.test.ts @@ -1,4 +1,5 @@ import https from 'node:https' +import Conf from 'conf' import path from 'path' import type { InitialisedList } from '../src/lib/core/initialise-lists' @@ -7,10 +8,10 @@ import { runTelemetry, disableTelemetry } from '../src/lib/telemetry' const mockProjectRoot = path.resolve(__dirname, '..', '..', '..') const mockProjectDir = path.join(mockProjectRoot, './tests/test-projects/basic') const mockPackageVersions = { - '@keystone-6/core': '3.1.0', - '@keystone-6/auth': '5.0.1', - '@keystone-6/fields-document': '5.0.2', - '@keystone-6/cloudinary': '5.0.1', + '@keystone-6/core': '14.1.0', + '@keystone-6/auth': '9.0.1', + '@keystone-6/fields-document': '18.0.2', + '@keystone-6/cloudinary': '0.0.1', } jest.mock( @@ -43,11 +44,18 @@ jest.mock( ) let mockTelemetryConfig: any = undefined + jest.mock('conf', () => { + const getMockTelemetryConfig = jest.fn(() => { + if (mockTelemetryConfig === 'THROW') throw new Error('JSON.parse error') + return mockTelemetryConfig + }) + return function Conf () { return { - get: () => mockTelemetryConfig, - set: (_name: string, newState: any) => { + get: getMockTelemetryConfig, + set: (key: string, newState: any) => { + if (key !== 'telemetry') throw new Error(`Unexpected conf key ${key}`) mockTelemetryConfig = newState }, delete: () => { @@ -58,12 +66,16 @@ jest.mock('conf', () => { }) jest.mock('node:https', () => { + const once = jest.fn() const end = jest.fn() - const request = jest.fn().mockImplementation(() => ({ end })) as any - request.end = end - return { - request - } + const request = jest.fn().mockImplementation((_, __, f) => { + setTimeout(() => f(), 100) + return { once, end } + }) + // added for reach by toHaveBeenCalledWith + ;(request as any).once = once + ;(request as any).end = end + return { request } }) jest.mock('node:os', () => { @@ -78,7 +90,6 @@ jest.mock('ci-info', () => { return { isCI: false } }) -/////////////////////// const lists: Record = { Thing: { fields: { @@ -129,34 +140,34 @@ describe('Telemetry tests', () => { } function expectDidSend (lastSentDate: string | null) { - expect(https.request).toHaveBeenCalledWith(`https://telemetry.keystonejs.com/2/event/project`, { + expect(https.request).toHaveBeenCalledWith(`https://telemetry.keystonejs.com/3/project`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - }) + }, expect.any(Function)) expect((https.request as any).end).toHaveBeenCalledWith( JSON.stringify({ - previous: lastSentDate, + lastSentDate, + packages: mockPackageVersions, + database: 'sqlite', + lists: 2, fields: { unknown: 0, id: 5, }, - lists: 2, - versions: mockPackageVersions, - database: 'sqlite', }) ) - expect(https.request).toHaveBeenCalledWith(`https://telemetry.keystonejs.com/2/event/device`, { + expect(https.request).toHaveBeenCalledWith(`https://telemetry.keystonejs.com/3/device`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - }) + }, expect.any(Function)) expect((https.request as any).end).toHaveBeenCalledWith( JSON.stringify({ - previous: lastSentDate, + lastSentDate, os: 'keystone-os', node: process.versions.node.split('.')[0], }) @@ -166,11 +177,13 @@ describe('Telemetry tests', () => { test('Telemetry writes out an empty configuration, and sends nothing on first run', async () => { await runTelemetry(mockProjectDir, lists, 'sqlite') // inform + expect(new Conf().get).toHaveBeenCalledTimes(1) expect(https.request).toHaveBeenCalledTimes(0) - expect(mockTelemetryConfig).toBeDefined() - expect(mockTelemetryConfig?.device.lastSentDate).toBe(null) - expect(mockTelemetryConfig?.projects).toBeDefined() - expect(Object.keys(mockTelemetryConfig?.projects).length).toBe(0) + expect(mockTelemetryConfig).toStrictEqual({ + informedAt: expect.stringMatching(new RegExp(`^${today}`)), + device: { lastSentDate: null }, + projects: {} + }) }) test('Telemetry is sent after inform', async () => { @@ -178,12 +191,15 @@ describe('Telemetry tests', () => { await runTelemetry(mockProjectDir, lists, 'sqlite') // send expectDidSend(null) + expect(new Conf().get).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() - expect(mockTelemetryConfig?.projects[mockProjectDir]).toBeDefined() - expect(mockTelemetryConfig?.projects[mockProjectDir].lastSentDate).toBe(today) + expect(mockTelemetryConfig).toStrictEqual({ + informedAt: expect.stringMatching(new RegExp(`^${today}`)), + device: { lastSentDate: today }, + projects: { + [mockProjectDir]: { lastSentDate: today } + } + }) }) test('Telemetry is not sent twice in one day', async () => { @@ -192,21 +208,25 @@ describe('Telemetry tests', () => { await runTelemetry(mockProjectDir, lists, 'sqlite') // send, same day expectDidSend(null) + expect(new Conf().get).toHaveBeenCalledTimes(3) expect(https.request).toHaveBeenCalledTimes(2) // would be 4 if sent twice }) - test('Telemetry sends a lastSentDate on the third run, second day', async () => { + test('Telemetry sends a lastSentDate on the next run, a different day', async () => { mockTelemetryConfig = mockTelemetryConfigInitialised await runTelemetry(mockProjectDir, lists, 'sqlite') // send, different day expectDidSend(mockYesterday) + expect(new Conf().get).toHaveBeenCalledTimes(1) expect(https.request).toHaveBeenCalledTimes(2) - expect(mockTelemetryConfig).toBeDefined() - expect(mockTelemetryConfig?.device.lastSentDate).toBe(today) - expect(mockTelemetryConfig?.projects).toBeDefined() - expect(mockTelemetryConfig?.projects[mockProjectDir]).toBeDefined() - expect(mockTelemetryConfig?.projects[mockProjectDir].lastSentDate).toBe(today) + expect(mockTelemetryConfig).toStrictEqual({ + informedAt: expect.stringMatching(new RegExp(`^${mockYesterday}`)), + device: { lastSentDate: today }, + projects: { + [mockProjectDir]: { lastSentDate: today } + } + }) }) test(`Telemetry is reset when using "keystone telemetry disable"`, () => { @@ -222,10 +242,22 @@ describe('Telemetry tests', () => { await runTelemetry(mockProjectDir, lists, 'sqlite') // send await runTelemetry(mockProjectDir, lists, 'sqlite') // send, same day + expect(new Conf().get).toHaveBeenCalledTimes(3) expect(https.request).toHaveBeenCalledTimes(0) expect(mockTelemetryConfig).toBe(false) }) + test(`Telemetry is unchanged if configuration is malformed`, async () => { + mockTelemetryConfig = 'THROW' + + await runTelemetry(mockProjectDir, lists, 'sqlite') // inform + await runTelemetry(mockProjectDir, lists, 'sqlite') // send + + expect(new Conf().get).toHaveBeenCalledTimes(2) + expect(https.request).toHaveBeenCalledTimes(0) + expect(mockTelemetryConfig).toStrictEqual('THROW') // nothing changes + }) + // easy opt-out tests for (const [key, value] of Object.entries({ NODE_ENV: 'production', @@ -242,24 +274,25 @@ describe('Telemetry tests', () => { process.env[key] = envBefore }) - test(`when initialised, nothing is sent`, async () => { + test(`when telemetry initialised, we do nothing`, async () => { mockTelemetryConfig = mockTelemetryConfigInitialised await runTelemetry(mockProjectDir, lists, 'sqlite') // try send again + expect(new Conf().get).toHaveBeenCalledTimes(0) expect(https.request).toHaveBeenCalledTimes(0) expect(mockTelemetryConfig).toBe(mockTelemetryConfigInitialised) // unchanged }) - test(`if not initialised, we do nothing`, async () => { + test(`when telemetry uninitialised, we do nothing`, async () => { expect(mockTelemetryConfig).toBe(undefined) - expect(https.request).toHaveBeenCalledTimes(0) await runTelemetry(mockProjectDir, lists, 'sqlite') // try inform await runTelemetry(mockProjectDir, lists, 'sqlite') // try send + expect(new Conf().get).toHaveBeenCalledTimes(0) expect(https.request).toHaveBeenCalledTimes(0) - expect(mockTelemetryConfig).toBe(undefined) // nothing changed + expect(mockTelemetryConfig).toBe(undefined) // unchanged }) }) } @@ -267,6 +300,7 @@ describe('Telemetry tests', () => { describe('when something throws internally', () => { let runTelemetryThrows: any beforeEach(() => { + // this is a nightmare, don't touch it jest.resetAllMocks() jest.resetModules() jest.mock('node-fetch', () => { @@ -282,6 +316,7 @@ describe('Telemetry tests', () => { await runTelemetryThrows(mockProjectDir, lists, 'sqlite') // send + // expect(new Conf().get).toHaveBeenCalledTimes(1) // nightmare expect(https.request).toHaveBeenCalledTimes(0) expect(mockTelemetryConfig).toBe(mockTelemetryConfigInitialised) // unchanged }) @@ -304,17 +339,18 @@ describe('Telemetry tests', () => { await runTelemetryCI(mockProjectDir, lists, 'sqlite') // try send again + expect(new Conf().get).toHaveBeenCalledTimes(0) expect(https.request).toHaveBeenCalledTimes(0) expect(mockTelemetryConfig).toBe(mockTelemetryConfigInitialised) // unchanged }) test(`if not initialised, we do nothing`, async () => { - expect(mockTelemetryConfig).toBe(undefined) - expect(https.request).toHaveBeenCalledTimes(0) + mockTelemetryConfig = undefined await runTelemetryCI(mockProjectDir, lists, 'sqlite') // try inform await runTelemetryCI(mockProjectDir, lists, 'sqlite') // try send + expect(new Conf().get).toHaveBeenCalledTimes(0) expect(https.request).toHaveBeenCalledTimes(0) expect(mockTelemetryConfig).toBe(undefined) // nothing changed })