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

feat(cli) add telemetry scaffolding #5321

Merged
merged 7 commits into from
Dec 18, 2023
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
1 change: 1 addition & 0 deletions packages/@sanity/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
},
"dependencies": {
"@babel/traverse": "^7.23.5",
"@sanity/telemetry": "^0.7.0",
"chalk": "^4.1.2",
"esbuild": "^0.19.8",
"esbuild-register": "^3.4.1",
Expand Down
15 changes: 15 additions & 0 deletions packages/@sanity/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {CliConfigResult, getCliConfig} from './util/getCliConfig'
import {getInstallCommand} from './packageManager'
import {CommandRunnerOptions} from './types'
import {debug} from './debug'
import {createTelemetryStore} from './util/createTelemetryStore'
import {getClientWrapper} from './util/clientWrapper'

const sanityEnv = process.env.SANITY_INTERNAL_ENV || 'production' // eslint-disable-line no-process-env
const knownEnvs = ['development', 'staging', 'production']
Expand All @@ -25,6 +27,9 @@ export async function runCli(cliRoot: string, {cliVersion}: {cliVersion: string}
installUnhandledRejectionsHandler()

const pkg = {name: '@sanity/cli', version: cliVersion}

const {logger: telemetry, flush: flushTelemetry} = createTelemetryStore({env: process.env})

const args = parseArguments()
const isInit = args.groupOrCommand === 'init' && args.argsWithoutOptions[0] !== 'plugin'
const cwd = getCurrentWorkingDirectory()
Expand All @@ -48,12 +53,17 @@ export async function runCli(cliRoot: string, {cliVersion}: {cliVersion: string}
if (!cliConfig) {
debug('No CLI config found')
}
const apiClient = getClientWrapper(
cliConfig?.config?.api || null,
cliConfig?.path || (cliConfig?.version === 2 ? 'sanity.json' : 'sanity.cli.js'),
)

const options: CommandRunnerOptions = {
cliRoot: cliRoot,
workDir: workDir,
corePath: await getCoreModulePath(workDir, cliConfig),
cliConfig,
telemetry,
}

warnOnNonProductionEnvironment()
Expand All @@ -76,6 +86,11 @@ export async function runCli(cliRoot: string, {cliVersion}: {cliVersion: string}
args.groupOrCommand = 'help'
}

if (args.groupOrCommand === 'logout') {
// flush telemetry events before logging out
await flushTelemetry()
Copy link
Contributor

Choose a reason for hiding this comment

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

why is this here instead of in the logoutCommand code?

Copy link
Member Author

@bjoerge bjoerge Dec 18, 2023

Choose a reason for hiding this comment

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

trying to keep the interface exposed to subcommands minimal, so wanted to avoid passing the flush method to subcommands at this point. We might change this later if we see that it's something we want subcommands to be able to do, but for now it made sense to special case this here.

}

const cliRunner = getCliRunner(commands)
cliRunner.runCommand(args.groupOrCommand, args, options).catch((err) => {
const error = typeof err.details === 'string' ? err.details : err
Expand Down
4 changes: 4 additions & 0 deletions packages/@sanity/cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {Ora} from 'ora'
import type {SanityClient} from '@sanity/client'
import type {Separator, DistinctQuestion, Answers, ChoiceCollection} from 'inquirer'
import type {InlineConfig, ConfigEnv} from 'vite'
import {TelemetryLogger, TelemetryStore} from '@sanity/telemetry'
import type {ClientRequirements} from './util/clientWrapper'
import type {CliConfigResult} from './util/getCliConfig'
import type {CliPackageManager} from './packageManager'
Expand Down Expand Up @@ -95,12 +96,14 @@ export interface CliV2CommandContext extends CliBaseCommandContext {
sanityMajorVersion: 2
cliConfig?: SanityJson
cliPackageManager?: CliPackageManager
telemetry: TelemetryLogger<unknown>
}

export interface CliV3CommandContext extends CliBaseCommandContext {
sanityMajorVersion: 3
cliConfig?: CliConfig
cliPackageManager: CliPackageManager
telemetry: TelemetryLogger<unknown>
}

export interface CliCommandRunner {
Expand Down Expand Up @@ -132,6 +135,7 @@ export interface CommandRunnerOptions {
cliRoot: string
workDir: string
corePath: string | undefined
telemetry: TelemetryLogger<unknown>
}

export interface CliOutputter {
Expand Down
110 changes: 110 additions & 0 deletions packages/@sanity/cli/src/util/createTelemetryStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {SanityClient} from '@sanity/client'
import {ConsentStatus, createBatchedStore, createSessionId, TelemetryEvent} from '@sanity/telemetry'
import {debug as baseDebug} from '../debug'
import {getClientWrapper, getCliToken} from './clientWrapper'
import {isTrueish} from './isTrueish'
import {isCi} from './isCi'

const debug = baseDebug.extend('telemetry')

const VALID_STATUSES: ConsentStatus[] = ['granted', 'denied', 'unset']
function parseConsent(value: unknown): ConsentStatus {
if (typeof value === 'string' && VALID_STATUSES.includes(value.toLowerCase() as any)) {
return value as ConsentStatus
}
throw new Error(`Invalid consent status. Must be one of: ${VALID_STATUSES.join(', ')}`)
}

function createTelemetryClient(token: string) {
const getClient = getClientWrapper(null, 'sanity.cli.js')
return getClient({requireUser: false, requireProject: false}).config({
apiVersion: '2023-12-18',
token,
useCdn: false,
useProjectHostname: false,
})
}

let _client: SanityClient | null = null
function getCachedClient(token: string) {
if (!_client) {
_client = createTelemetryClient(token)
}
return _client
}

export function createTelemetryStore(options: {env: {[key: string]: string | undefined}}) {
debug('Initializing telemetry')
const {env} = options

function fetchConsent(client: SanityClient) {
return client.request({uri: '/intake/telemetry-status'})
}

function resolveConsent(): Promise<{status: ConsentStatus}> {
debug('Resolving consent…')
if (isCi) {
debug('CI environment detected, treating telemetry consent as denied')
return Promise.resolve({status: 'denied'})
}
if (isTrueish(env.DO_NOT_TRACK)) {
debug('DO_NOT_TRACK is set, consent is denied')
return Promise.resolve({status: 'denied'})
}
const token = getCliToken()
if (!token) {
debug('User is not logged in, consent is undetermined')
return Promise.resolve({status: 'undetermined'})
}
const client = getCachedClient(token)
return fetchConsent(client)
.then((response) => {
debug('User consent status is %s', response.status)
return {status: parseConsent(response.status)}
})
.catch((err) => {
debug('Failed to fetch user consent status, treating it as "undetermined": %s', err.stack)
return {status: 'undetermined'}
})
}

// Note: if this function throws/rejects the events will be put back on the buffer
async function sendEvents(batch: TelemetryEvent[]) {
const token = getCliToken()
if (!token) {
// Note: since the telemetry store checks for consent before sending events, and this token
// check is also done during consent checking, this would normally never happen
debug('No user token found. Something is not quite right')
return Promise.reject(new Error('User is not logged in'))
}
const client = getCachedClient(token)
debug('Submitting %s telemetry events', batch.length)
try {
return await client.request({
uri: '/intake/batch',
method: 'POST',
json: true,
body: batch,
})
} catch (err) {
const statusCode = err.response && err.response.statusCode
debug(
'Failed to send telemetry events%s: %s',
statusCode ? ` (HTTP ${statusCode})` : '',
err.stack,
)
// note: we want to throw - the telemetry store implements error handling already
throw err
}
}

const sessionId = createSessionId()
debug('session id: %s', sessionId)

const store = createBatchedStore(sessionId, {
resolveConsent,
sendEvents,
})
process.once('beforeExit', () => store.flush())
return store
}
7 changes: 7 additions & 0 deletions packages/@sanity/cli/src/util/isCi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* eslint-disable no-process-env */
import {isTrueish} from './isTrueish'

export const isCi =
isTrueish(process.env.CI) || // Travis CI, CircleCI, Gitlab CI, Appveyor, CodeShip
isTrueish(process.env.CONTINUOUS_INTEGRATION) || // Travis CI
process.env.BUILD_NUMBER // Jenkins, TeamCity
8 changes: 8 additions & 0 deletions packages/@sanity/cli/src/util/isTrueish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function isTrueish(value: string | undefined) {
if (value === undefined) return false
if (value.toLowerCase() === 'true') return true
if (value.toLowerCase() === 'false') return false
const number = parseInt(value, 10)
if (isNaN(number)) return false
return number > 0
}
5 changes: 2 additions & 3 deletions packages/@sanity/cli/src/util/updateNotifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@ import type {PackageJson} from '../types'
import {getCliUpgradeCommand} from '../packageManager'
import {debug} from '../debug'
import {getUserConfig} from './getUserConfig'
import {isCi} from './isCi'

const MAX_BLOCKING_TIME = 300
const TWELVE_HOURS = 1000 * 60 * 60 * 12
const isDisabled =
process.env.CI || // Travis CI, CircleCI, Gitlab CI, Appveyor, CodeShip
process.env.CONTINUOUS_INTEGRATION || // Travis CI
process.env.BUILD_NUMBER || // Jenkins, TeamCity
isCi || // Running in CI environment
process.env.NO_UPDATE_NOTIFIER // Explicitly disabled

interface UpdateCheckOptions {
Expand Down
23 changes: 23 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3758,6 +3758,17 @@
dependencies:
"@sanity/uuid" "3.0.2"

"@sanity/telemetry@^0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@sanity/telemetry/-/telemetry-0.7.0.tgz#12df09a6a1b8ae5037685755257c5f7f52c3b276"
integrity sha512-XyNN+bysHRVRxWXRrVkJFrcz7ZBd2jioy8L8xHm4rp5UxX2kv57LQT9JGwt3+TX37yjm/Dz8Z6LgsPi/7bN+aA==
dependencies:
lodash "^4.17.21"
react "^18.2.0"
react-dom "^18.2.0"
rxjs "^7.8.1"
typeid-js "^0.3.0"

"@sanity/[email protected]":
version "0.0.1-alpha.1"
resolved "https://registry.yarnpkg.com/@sanity/test/-/test-0.0.1-alpha.1.tgz#35b0847b7b10a547f281ffe4615f53a8296e0bee"
Expand Down Expand Up @@ -15663,6 +15674,13 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==

typeid-js@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/typeid-js/-/typeid-js-0.3.0.tgz#704dedde2382fcb4e5c15c54d285f0209d37f9cf"
integrity sha512-A1EmvIWG6xwYRfHuYUjPltHqteZ1EiDG+HOmbIYXeHUVztmnGrPIfU9KIK1QC30x59ko0r4JsMlwzsALCyiB3Q==
dependencies:
uuidv7 "^0.4.4"

typescript@^5.1.6, typescript@^5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78"
Expand Down Expand Up @@ -15946,6 +15964,11 @@ uuid@^9.0.1:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==

uuidv7@^0.4.4:
version "0.4.4"
resolved "https://registry.yarnpkg.com/uuidv7/-/uuidv7-0.4.4.tgz#e7ffd7981f590c478fb8868eff4bb3bc55fa90e6"
integrity sha512-jjRGChg03uGp9f6wQYSO8qXkweJwRbA5WRuEQE8xLIiehIzIIi23qZSzsyvZPCPoFqkeLtZuz7Plt1LGukAInA==

v8-compile-cache-lib@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
Expand Down
Loading