packages/cli/src/util/telemetry/README.md
The Vercel CLI uses telemetry to track invocations of commands, subcommands, arguments (but typically not their values), options and flags (a.k.a. boolean options).
This structure is heavily cribbed from two other Vercel projects that are already tracking metrics: the next and turbo CLIs. Specifically we drew inspiration from the turbo-telemetry package.
However, the Vercel CLI's code structure differs from these two project that necessitates slightly different code organization.
The telemetry system has two main components: clients and eventStores.
Clients are responsible for calling methods that push tracking events into an event store. The abstract class of all clients is TelemetryClient.
This provides the interface for tracking:
trackCliCommand()trackCliSubcommand()trackCliArgument()trackCliOption()trackCliFlag()and a number of other event types that can occur anywhere in the CLI (errors, help calls, etc).
The track{*} methods are all protected and cannot be invoked directly on subclass instances of TelemetryClient.
Instead, each subclass is expected to implement specific tracking methods that call to the appropriate protected method. This acts as an implicit allow-list of what can be tracked at each layer (root → command → subcommand) of the CLI.
Each layer of the CLI invocation may have its own telemetry client subclass.
root has a instance of RootTelementryClient from src/util/telemetry/root.tscommand would have an instance of {CommandName}TelemetryClient at src/util/telemetry/commands/{command-name}/index.tssubcommand would have an instance of {CommandName}{SubcommandName}TelemetryClient at src/util/telemetry/commands/{command-name}/{subcommand-name}.tsMethods within these classes are intended to be called directly during the CLI's execution. The naming convention for these methods is as follows:
trackCliCommand{commandName}()trackCliSubcommand{subcommandName}()trackCliArgument{argumentName}()trackCliOption{optionName}()trackCliFlag{flagName}()A command like vercel joke list [humor-level] --random [randomness seed] --kid-safe would result in methods and client subclasses like:
RootTelementryClient.trackCliCommandJoke() called in src/index.tsJokeTelemtryClient.trackCliSubcommandList() called in src/commands/joke/index.tsJokeListTelemtryClient.trackCliArgumentHumorLevel() called in src/commands/joke/list.tsJokeListTelemtryClient.trackCliOptionRandom() called in src/commands/joke/list.tsJokeListTelemtryClient.trackCliFlagKidSafe() called in src/commands/joke/list.tsAlthough the structure is quite verbose, it is the pattern established earlier by other teams and the methodology approved by the Security team.
We want to track usage of every:
For arguments to commands, subcommands, and options we track any data that is:
Typically that is data is finite and/or represented by constants in code.
So, the following types of data would not be tracked:
But we would track:
"dpl_" or "https://" as values)"CUSTOM")"preview") or the name of an integration ("redis")A single instance of a TelemetryEventStore is created and stored on the CLI client object passed to every command and subcommand. When initializing a new telemetry client pass this object in:
const myTelemetryClient = new TelemetryClientSubClass({
opts: {
store: client.telemetryEventStore
}
})
This instance is the central object containing all events tracked during a CLI invocation. At the end of the invocation client.telemetryEventStore.save() is called to persist the metrics data.
For every datum tracked, please provide unit tests. For the example vercel joke list [humor-level] --random [randomness seed] --kid-safe,
this would have tests in test/unit/commands/joke/list.test.ts that invoke the vercel joke list command in various ways that exercise every argument, option, and flag.
The mock client instance used in unit tests has a matching mocked telemetryEventStore that can be inspected
after invoking the CLI. Vitest has been extended with a test helper toHaveTelemetryEvents() to ease verifying that the store is populated with the desired values. See our other unit tests for examples
but the rough pattern is:
import joke from '../../../../src/commands/joke';
it('tracks humor level', async () => {
client.setArgv('joke', 'list', '10'); // build up the simulated command line segments
const exitCode = await joke(client); // call the command function
expect(exitCode, 'exit code for "joke"').toEqual(0); // ensure the command reaches completion with success
// ensure the store has the items you expect
expect(client.telemetryEventStore).toHaveTelemetryEvents([
{
key: `subcommand:list`,
value: 'ls',
},
{
key: `argument:joke-level`,
value: '10',
},
]);
});