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

[WIP] Telemetry #465

Draft
wants to merge 22 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 9 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
3 changes: 0 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,6 @@ jobs:
- name: Install yarn dependencies
run: git config --global url."https://".insteadOf ssh:// && yarn install
if: steps.cache_node.outputs.cache-hit != 'true'
- name: Run yarn postinstall if cache hitted
run: yarn run postinstall
if: steps.cache_node.outputs.cache-hit == 'true'
- name: Build packages
run: yarn build
- name: Check licenses
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"check-licenses": "yarn licenses list --prod | grep '\\(─ GPL\\|─ (GPL-[1-9]\\.[0-9]\\+ OR GPL-[1-9]\\.[0-9]\\+)\\)' && echo 'Found GPL license(s). Use 'yarn licenses list --prod' to look up the offending package' || echo 'No GPL licenses found'",
"report-coverage": "yarn workspaces foreach -piv --all run test-coverage",
"test:watch": "node node_modules/jest/bin/jest.js --watch",
"postinstall": "husky install && yarn workspaces foreach -piv --all run postinstall",
"postinstall": "husky install",
"release": "yarn clean && yarn build && yarn workspace @celo/celocli run prepack && yarn cs publish",
"version-and-reinstall": "yarn changeset version && yarn install --no-immutable",
"celocli": "yarn workspace @celo/celocli run --silent celocli"
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@celo/celocli",
"description": "CLI Tool for transacting with the Celo protocol",
"version": "6.0.0-beta.6",
"version": "telemetry-test",
"author": "Celo",
"license": "Apache-2.0",
"repository": "celo-org/developer-tooling",
Expand Down Expand Up @@ -34,7 +34,8 @@
"test-ganache": "RUN_GANACHE_TESTS=true RUN_ANVIL_TESTS=false TZ=UTC NODE_OPTIONS='--experimental-vm-modules' yarn jest --runInBand --forceExit",
"test-ci": "TZ=UTC NODE_OPTIONS='--experimental-vm-modules' yarn jest --runInBand --workerIdleMemoryLimit=0.1 --forceExit",
"test-ci-anvil": "yarn test-anvil --workerIdleMemoryLimit=0.1",
"test-ci-ganache": "yarn test-ganache --workerIdleMemoryLimit=0.1"
"test-ci-ganache": "yarn test-ganache --workerIdleMemoryLimit=0.1",
"postinstall": "./scripts/telemetry-notice.js"
},
"dependencies": {
"@celo/abis": "11.0.0",
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/scripts/telemetry-notice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env node

const { ux } = require('@oclif/core')
const chalk = require('chalk')

// TODO add a reference to actual implementation so users can see what data is being sent
ux.info(
chalk.green(
`\ncelocli is now gathering anonymous usage statistics.

None of the data being collected is personally identifiable and no flags or arguments are being stored nor transmitted.

Data being reported is:
- command (for example ${chalk.bold('network:info')})
- celocli version (for example ${chalk.bold('5.2.3')})
- success status (0/1)

If you would like to opt out of this data collection, you can do so by running:

${chalk.bold('celocli config:set --telemetry 0')}
`
)
)
5 changes: 5 additions & 0 deletions packages/cli/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { CustomFlags } from './utils/command'
import { getNodeUrl } from './utils/config'
import { getFeeCurrencyContractWrapper } from './utils/fee-currency'
import { requireNodeIsSynced } from './utils/helpers'
import { reportUsageStatisticsIfTelemetryEnabled } from './utils/telemetry'

export abstract class BaseCommand extends Command {
static flags: FlagInput = {
Expand Down Expand Up @@ -246,6 +247,8 @@ export abstract class BaseCommand extends Command {
async finally(arg: Error | undefined): Promise<any> {
try {
if (arg) {
reportUsageStatisticsIfTelemetryEnabled(this.config.configDir, false, this.id)

if (!(arg instanceof CLIError)) {
console.error(
`
Expand All @@ -257,6 +260,8 @@ https://github.com/celo-org/developer-tooling/issues/new?assignees=&labels=bug+r
arg
)
}
} else {
reportUsageStatisticsIfTelemetryEnabled(this.config.configDir, true, this.id)
}

if (this._kit !== null) {
Expand Down
13 changes: 12 additions & 1 deletion packages/cli/src/commands/config/set.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ux } from '@oclif/core'
import { Flags, ux } from '@oclif/core'
import chalk from 'chalk'
import { BaseCommand } from '../../base'
import { CeloConfig, readConfig, writeConfig } from '../../utils/config'
Expand All @@ -12,6 +12,11 @@ export default class Set extends BaseCommand {
...BaseCommand.flags.node,
hidden: false,
},
telemetry: Flags.string({
options: ['1', '0'],
description: 'Whether to enable or disable telemetry',
required: false,
}),
}

static examples = [
Expand All @@ -23,6 +28,8 @@ export default class Set extends BaseCommand {
'set --node local # alias for http://localhost:8545',
'set --node ws://localhost:2500',
'set --node <geth-location>/geth.ipc',
'set --telemetry 0 # disable telemetry',
'set --telemetry 1 # enable telemetry',
]

requireSynced = false
Expand All @@ -31,6 +38,7 @@ export default class Set extends BaseCommand {
const res = await this.parse(Set)
const curr = readConfig(this.config.configDir)
const node = res.flags.node ?? curr.node
const telemetry = res.flags.telemetry === '0' ? false : true
const gasCurrency = res.flags.gasCurrency

if (gasCurrency) {
Expand All @@ -41,8 +49,11 @@ export default class Set extends BaseCommand {
)
}

// TODO caveat: won't give us visibility into how many people opted-out from telemetry
// and that's something to consider
await writeConfig(this.config.configDir, {
node,
telemetry,
} as CeloConfig)
}
}
3 changes: 3 additions & 0 deletions packages/cli/src/scripts/postinstall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { printTelemetryInformation } from '../utils/telemetry'

printTelemetryInformation()
2 changes: 1 addition & 1 deletion packages/cli/src/utils/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('writeConfig', () => {
})
it('accepts node', async () => {
const [dir] = getPaths()
await writeConfig(dir, { node: 'SOME_URL' })
await writeConfig(dir, { node: 'SOME_URL', telemetry: true })
expect(spy.mock.calls[0][1]).toMatchInlineSnapshot(`
{
"node": "SOME_URL",
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from 'path'

export interface CeloConfig {
node: string
telemetry: boolean
}

// NOTE: this mapping should stay updated as CeloConfig evolves
Expand All @@ -13,6 +14,7 @@ const LEGACY_MAPPING: Record<string, keyof CeloConfig | undefined> = {

export const defaultConfig: CeloConfig = {
node: 'http://localhost:8545',
telemetry: true,
}

const configFile = 'config.json'
Expand Down
41 changes: 41 additions & 0 deletions packages/cli/src/utils/telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { execSync } from 'child_process'
import { printTelemetryInformation } from './telemetry'

describe('telemetry', () => {
it('prints the information with no error', () => {
const originalExecSync = execSync
const execSyncSpy = jest.spyOn(require('child_process'), 'execSync')

let actualExecSyncOutput

execSyncSpy.mockImplementation((...args: any) => {
const command = args[0] as string

actualExecSyncOutput = originalExecSync(command, {
stdio: 'pipe',
}).toString()

return actualExecSyncOutput
})

printTelemetryInformation()

expect(actualExecSyncOutput).toMatchInlineSnapshot(`
"
celocli is now gathering anonymous usage statistics.

None of the data being collected is personally identifiable and no flags or arguments are being stored nor transmitted.

Data being reported is:
- command (for example network:info)
- celocli version (for example 5.2.3)
- success status (0/1)

If you would like to opt out of this data collection, you can do so by running:

celocli config:set --telemetry 0

"
`)
})
})
99 changes: 99 additions & 0 deletions packages/cli/src/utils/telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'
import packageJson from '../../package.json'
import { readConfig } from './config'

const debug = require('debug')('celocli:telemetry')

export type TelemetryOptions = {
command: string
success: boolean
version: string
}

const getTelemetryOptions = (command: string, success: boolean): TelemetryOptions => {
return {
command,
success,
version: packageJson.version,
}
}

const TELEMETRY_PRINTED_FILE = '.telemetry'

const telemetryInformationAlreadyPrinted = (configDir: string) => {
return fs.existsSync(path.join(configDir, TELEMETRY_PRINTED_FILE))
}

const markTelemetryInformationAsPrinted = (configDir: string) => {
fs.writeFileSync(path.join(configDir, TELEMETRY_PRINTED_FILE), '')
}

export const reportUsageStatisticsIfTelemetryEnabled = (
shazarre marked this conversation as resolved.
Show resolved Hide resolved
configDir: string,
success: boolean,
command: string = '_unknown',
fetchHandler: typeof fetch = fetch
) => {
const config = readConfig(configDir)

if (config.telemetry === true) {
const telemetry = getTelemetryOptions(command, success)

// Only show the information upon first usage
if (!telemetryInformationAlreadyPrinted(configDir)) {
printTelemetryInformation()
markTelemetryInformationAsPrinted(configDir)
}

// TODO alfajores needs to be hardcoded for now
const telemetryData = `test_pag_celocli{success="${
telemetry.success ? 'true' : 'false'
}", version="${telemetry.version}", command="${telemetry.command}", network="alfajores"} 1`

debug(`Sending telemetry data: ${telemetryData}`)

const controller = new AbortController()
const timeout = setTimeout(() => {
controller.abort()
}, 1000)

fetchHandler(process.env.TELEMETRY_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
},
signal: controller.signal,
body: `
${telemetryData}
`,
})
.then((response) => {
clearTimeout(timeout)

if (!response.ok && response.body) {
debug(`Failed to send telemetry data: ${response.statusText}`)

return
}

debug(`Telemetry data sent successfuly`)
})
.catch((err) => {
debug(`Failed to send telemetry data: ${err}`)
})
}
}

export const printTelemetryInformation = () => {
try {
// This approach makes sure that we don't have redundant printing logic
// with an extra benefit of this output not being captured by tests
execSync(path.join(__dirname, '../../scripts/telemetry-notice.js'), {
Fixed Show fixed Hide fixed
stdio: 'inherit',
})
} catch (_) {
// Ignore errors
}
}
Loading