Skip to content

Commit 9545bdc

Browse files
authored
feat(telemetry): send command opts and args (#7251)
* build: print `callCli` output when using DEBUG_TESTS I don't know why we weren't doing this before. This is so helpful. * feat(telemetry): send command opts and args It's impossible for us currently to identify the potential impact of option deprecation. This won't address that retroactively for past CLI versions, but at least it will give us data going forward. Attaching the `source` is important, otherwise this would include applied default values and we wouldn't be able to distinguish this from actual user-provided values. Unfortunately there doesn't appear to be a commander API to get named positional arguments, so `args` is just an array. Seems ok. * test: undo rejection check This is different on Windows... bleh.
1 parent 795a451 commit 9545bdc

File tree

5 files changed

+75
-4
lines changed

5 files changed

+75
-4
lines changed

src/commands/base-command.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { DefaultLogger, Project } from '@netlify/build-info'
77
import { NodeFS, NoopLogger } from '@netlify/build-info/node'
88
import { resolveConfig } from '@netlify/config'
99
import { isCI } from 'ci-info'
10-
import { Command, Help, Option } from 'commander'
10+
import { Command, Help, Option, type OptionValues } from 'commander'
1111
import debug from 'debug'
1212
import { findUp } from 'find-up'
1313
import inquirer from 'inquirer'
@@ -75,6 +75,23 @@ const COMMANDS_WITHOUT_WORKSPACE_OPTIONS = new Set(['api', 'recipes', 'completio
7575
*/
7676
const COMMANDS_WITH_FEATURE_FLAGS = new Set(['build', 'dev', 'deploy'])
7777

78+
/**
79+
* Names of options whose values should be scrubbed
80+
*/
81+
const SCRUBBED_OPTIONS = new Set(['auth'])
82+
83+
const getScrubbedOptions = (command: BaseCommand): Record<string, { source: OptionValues['source']; value: unknown }> =>
84+
Object.entries(command.optsWithGlobals()).reduce(
85+
(acc: Record<string, { source: OptionValues['source']; value: unknown }>, [key, value]) => ({
86+
...acc,
87+
[key]: {
88+
source: command.getOptionValueSourceWithGlobals(key),
89+
value: SCRUBBED_OPTIONS.has(key) ? '********' : value,
90+
},
91+
}),
92+
{},
93+
)
94+
7895
/** Formats a help list correctly with the correct indent */
7996
const formatHelpList = (textArray: string[]) => textArray.join('\n').replace(/^/gm, ' '.repeat(HELP_INDENT_WIDTH))
8097

@@ -626,6 +643,8 @@ export default class BaseCommand extends Command {
626643
monorepo: Boolean(this.project.workspace),
627644
packageManager: this.project.packageManager?.name,
628645
buildSystem: this.project.buildSystems.map(({ id }) => id),
646+
opts: getScrubbedOptions(actionCommand),
647+
args: actionCommand.args,
629648
})
630649

631650
// set the project and the netlify api object on the command,

src/utils/telemetry/request.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// This file is being called by `src/utils/telemetry/telemetry.js` as a child process
2-
// to run a s a detached process
2+
// to run as a detached process
33
import process from 'process'
44

55
import fetch from 'node-fetch'

src/utils/telemetry/telemetry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import isValidEventName from './validation.js'
1212

1313
const dirPath = dirname(fileURLToPath(import.meta.url))
1414

15-
function send(type: 'track' | 'identify', payload: object) {
15+
function send(type: 'track' | 'identify', payload: Record<string, unknown>) {
1616
const requestFile = join(dirPath, 'request.js')
1717
const options = JSON.stringify({
1818
data: payload,

tests/integration/telemetry.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ await withMockApi(routes, () => {
7474
didEnableCompileCache: expect.any(Boolean),
7575
monorepo: false,
7676
nodejsVersion,
77+
opts: expect.any(Object),
78+
args: expect.any(Array),
7779
// TODO: this should be NPM however some CI tests are using pnpm which changes the value
7880
packageManager: expect.stringMatching(/npm|pnpm/),
7981
})
@@ -96,6 +98,8 @@ await withMockApi(routes, () => {
9698
didEnableCompileCache: expect.any(Boolean),
9799
monorepo: false,
98100
nodejsVersion,
101+
opts: expect.any(Object),
102+
args: expect.any(Array),
99103
// TODO: this should be NPM however some CI tests are using pnpm which changes the value
100104
packageManager: expect.stringMatching(/npm|pnpm/),
101105
})
@@ -124,4 +128,47 @@ await withMockApi(routes, () => {
124128
)
125129
})
126130
})
131+
132+
test<MockApiTestContext>('should attach user options as `opts` and positional args as `args`', async ({
133+
apiUrl,
134+
requests,
135+
}) => {
136+
await expect(
137+
callCli(['blobs:get', '--filter', 'web', '-O', './output_dir', 'my-store', 'my-key'], getCLIOptions(apiUrl)),
138+
).rejects.toThrowError(/You don't appear to be in a folder that is linked to a site/)
139+
const request = requests.find(({ path }) => path === '/api/v1/track')
140+
expect(request).toBeDefined()
141+
142+
expect(request!.body).toHaveProperty('event', 'cli:command')
143+
expect(request!.body).toHaveProperty(
144+
'properties',
145+
expect.objectContaining({
146+
command: 'blobs:get',
147+
opts: {
148+
filter: { source: 'cli', value: 'web' },
149+
output: { source: 'cli', value: './output_dir' },
150+
},
151+
args: ['my-store', 'my-key'],
152+
}),
153+
)
154+
})
155+
156+
test<MockApiTestContext>('should scrub values of sensitive `opts`', async ({ apiUrl, requests }) => {
157+
await callCli(['api', 'listSites', '--auth', 'my-sensitive-token'], getCLIOptions(apiUrl))
158+
const request = requests.find(({ path }) => path === '/api/v1/track')
159+
expect(request).toBeDefined()
160+
161+
expect(request!.body).toHaveProperty('event', 'cli:command')
162+
expect(request!.body).toHaveProperty(
163+
'properties',
164+
expect.objectContaining({
165+
command: 'api',
166+
opts: {
167+
auth: { source: 'cli', value: '********' },
168+
list: { source: 'default', value: false },
169+
},
170+
args: ['listSites'],
171+
}),
172+
)
173+
})
127174
})

tests/integration/utils/call-cli.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@ export const callCli = async function (
1717
parseJson = false,
1818
): // eslint-disable-next-line @typescript-eslint/no-explicit-any
1919
Promise<any> {
20-
const { stdout } = await execa.node(cliPath, args, {
20+
const { stdout, stderr } = await execa.node(cliPath, args, {
2121
timeout: CLI_TIMEOUT,
2222
nodeOptions: [],
2323
...execOptions,
2424
})
25+
if (process.env.DEBUG_TESTS) {
26+
process.stdout.write(stdout)
27+
process.stderr.write(stderr)
28+
}
29+
2530
if (parseJson) {
2631
return JSON.parse(stdout)
2732
}

0 commit comments

Comments
 (0)