Telemetry (contributor guide)
This page covers how telemetry is implemented and how to add it to a command. For what the CLI collects, where it is sent, and how to opt out, see Telemetry in the user guide.
Telemetry is implemented in internal/telemetry/.
Common Properties
The following are attached to every event:
Top-level event fields
These map to Amplitude's built-in fields and power native segmentation (version filters, OS breakdowns, language charts, etc.).
| Field | Source |
|---|---|
user_id |
DataRobot uid from GET /api/v2/account/info/, cached to disk with endpoint + token fingerprint validation; empty if unauthenticated or cache miss — see User ID |
device_id |
OS machine ID (hashed) or persisted UUID — see Device ID |
session_id |
Unix millisecond timestamp generated once per process invocation — Amplitude uses this as the built-in Session ID for session-based analysis |
app_version |
CLI version set at build time via ldflags |
platform |
Always "CLI" |
os_name |
OS name (e.g. "macOS") |
os_version |
OS version (e.g. "15.7.5") |
language |
User locale tag (e.g. "en-US"), via go-locale; Amplitude maps to a display language name |
ip |
Always "$remote" — Amplitude resolves location server-side |
Event properties
| Property | Source |
|---|---|
install_method |
Set at build time via ldflags (release, source, etc.) |
os_arch |
CPU architecture from runtime.GOARCH |
go_version |
Go runtime version (e.g. go1.26.4) from runtime.Version() |
shell |
Detected shell name (e.g. zsh, bash, powershell) from parent process / $SHELL |
datarobot_instance |
Base URL of the configured DataRobot instance |
command_kind |
"core" or "plugin" — automatically set by the root command dispatcher |
organization_id |
DataRobot org ID from GET /api/v2/account/info/, cached to disk; absent if unauthenticated |
tenant_id |
DataRobot tenant ID from GET /api/v2/account/info/, cached to disk; may be empty for legacy/system accounts |
template_name |
Best-effort from .datarobot/answers/ in the current repo |
template_id |
Stable ID of the DataRobot template used to create this project; absent when not inside a template project |
Event Wiring
Telemetry events are wired declaratively at command-construction time using a small API exported by internal/telemetry:
| Helper | Use when… |
|---|---|
telemetry.Track(cmd) |
The command needs no extra event properties beyond the common ones. |
telemetry.TrackWith(cmd, extract) |
The command needs dynamic event properties from flags or args at firing time. |
telemetry.TrackPlugin(cmd, ver) |
The command comes from a plugin. Adds plugin_version and sets command_kind. |
Each helper sets a "telemetry" annotation on the cobra command. After RunE completes, cobra.OnFinalize calls telemetry.EventFor(cmd, args), which returns an Amplitude event with EventType == cmd.CommandPath() and any properties the registered extractor produced.
This approach ensures:
- Local: Wiring lives next to the command it tracks, not in a central map.
- Late-bound: Events fire after RunE, so PropExtractors can read results computed during command execution (see Reading RunE results in a PropExtractor).
- Extensible: Adding a new event requires one call where the command is built.
- Self-documenting: The cobra command itself carries its telemetry intent.
Process exit and telemetry flush
Amplitude events are queued in-process and flushed asynchronously. If a command calls os.Exit directly (plugins, task runner exit-code propagation) or if RunE returns an error that causes cobra to skip PersistentPostRunE, the queue would be silently dropped. Two mechanisms handle this:
cmd.Exit(code int) — for main.go's error path
cmd.Exit lives in cmd/exit.go alongside the telemetryClient package-level variable that PersistentPreRunE sets. Use it only from main.go when ExecuteContext returns an error:
if err := cmd.ExecuteContext(ctx); err != nil {
log.Stop()
cmd.Exit(1) // flushes telemetry then calls os.Exit(1)
}
cmd.Exit is nil-safe: if PersistentPreRunE never ran (e.g. flag parse failure before any command executes) there are no queued events and it falls straight through to os.Exit.
telemetry.ExitWithContext(ctx context.Context, code int) — for cobra sub-commands
Commands that must propagate a subprocess exit code (plugin dispatch, task run --exit-code) cannot use return err because Go errors carry no integer code. They call telemetry.ExitWithContext with the command's cobra context — the client stored there by PersistentPreRunE is flushed before os.Exit:
RunE: func(cmd *cobra.Command, args []string) error {
exitCode := runSubprocess(...)
telemetry.ExitWithContext(cmd.Context(), exitCode) // never returns
return nil // unreachable
},
State ownership
internal/telemetry is stateless — it defines Client, helpers, and ClientContextKey but holds no global variables. The two places that own state are:
- The
cmdpackage (root.go+exit.go) with thetelemetryClientvariable. - The cobra command context, which stores a
*telemetry.Clientundertelemetry.ClientContextKey{}.
Both are set in PersistentPreRunE; the context value is consumed by PersistentPostRunE (normal path) or telemetry.ExitWithContext (exit-code path).
Silent errors and SilenceErrors
cli.ErrSilent (internal/cli/command.go) is a sentinel returned by RunE implementations that have already printed their own user-facing error. Returning it tells cobra "don't add a second Error: … line", but only if the command (or an ancestor) has SilenceErrors: true set.
Rules:
- Any command whose
RunEcan returncli.ErrSilent— either directly or by calling a sub-command'sRunE— must setSilenceErrors: trueon its owncobra.Command. - When composing commands (e.g. calling
compose.Cmd().RunE(nil, nil)inside another command'sRunE), the caller must also carrySilenceErrors: true, because the sentinel propagates up the call stack. main.goalways callscmd.Exit(1)for any non-nil error, so telemetry is recorded even for silent failures.
Example:
func Cmd() *cobra.Command {
return &cobra.Command{
Use: "add [...]",
RunE: RunE,
SilenceErrors: true, // required: RunE can return cli.ErrSilent via compose.Cmd().RunE
}
}
func RunE(_ *cobra.Command, args []string) error {
if err := addComponents(args); err != nil {
return err
}
// compose prints its own error and returns cli.ErrSilent on failure;
// SilenceErrors: true above prevents cobra from echoing "Error: silent error".
return compose.Cmd().RunE(nil, nil)
}
Execution flow
User invokes command
↓
Cobra parses flags
↓
PersistentPreRunE (root.go)
├─ Initialize CommonProperties (session ID, user ID, env, ...)
├─ Stamp props.CommandKind = "core" or "plugin"
│ based on telemetry.IsPluginCommand(cmd)
├─ Stamp props.NonInteractive via telemetry.StampInteractionMode
│ so every event shares the same automation signal
├─ Build telemetry.Client
└─ Register cobra.OnFinalize (closes over cmd, args, client)
↓
RunE / Run executes
↓
cobra.OnFinalize (via cobra's deferred postRun, fires on success and error paths — unlike PersistentPostRunE, see [gotcha](https://www.jvt.me/posts/2024/11/29/gotcha-cobra-persistentpostrune/))
├─ telemetry.EventFor(cmd, args) → if tracked, client.Track(event)
├─ Flush telemetry (3-second timeout)
└─ log.Stop()
Stamping universal interaction flags
telemetry.StampInteractionMode centralizes everything that decides whether the
current invocation is interactive. The helper runs exactly once in
root.PersistentPreRunE, so every event in the session inherits the same
non_interactive property without each command needing to parse flags on its own.
DATAROBOT_CLI_NON_INTERACTIVE=trueforcesnon_interactive=true- Any parsed
--yesflag (including short-y) flips the flag for that session --force-interactiveis not an interaction signal: it only forces the setup wizard to re-run (viastate.HasCompletedDotenvSetup) and does not override--yesor the env var for confirmation prompts- Commands can reuse the same logic directly via
cli.IsNonInteractive(cmd)
To expose another universal flag or environment override, extend
telemetry.StampInteractionMode (and update its tests) rather than touching
individual commands. TODO: when the next global automation flag is added,
document its behavior here and wire it through the helper alongside --yes.
Reading RunE results in a PropExtractor
Because cobra.OnFinalize fires after RunE on both success and error paths (unlike PersistentPostRunE), a PropExtractor can read data that RunE computed. The recommended pattern is to declare closure variables in Cmd() that RunE writes and the PropExtractor reads:
func Cmd() *cobra.Command {
var (
checkResult tools.CheckResult
installSuccess []string
installError string
)
cmd := &cobra.Command{
RunE: func(cmd *cobra.Command, _ []string) error {
checkResult = tools.CheckPrerequisites() // written by RunE
installSuccess, err = dependencies.Install(…)
if err != nil {
installError = err.Error()
return err
}
return nil
},
}
telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any {
return map[string]any{ // read by PropExtractor
"validation_violations": checkResult.ValidationViolations,
"install_success": installSuccess,
"install_error": installError,
}
})
return cmd
}
Both RunE and the PropExtractor close over the same local variables. No context keys or package-level variables are needed.
How to add telemetry to a new command
1. Decide what (if anything) to extract
Inspect the command's flags and args. Decide which (if any) should be exposed as event properties.
2. Wire the command at construction
Find the function (or init) that builds the cobra command and add a telemetry.Track* call before returning.
Simple command, no extra properties:
import "github.com/datarobot/cli/internal/telemetry"
func Cmd() *cobra.Command {
cmd := &cobra.Command{
Use: "foo",
Short: "Do foo",
// ...
}
telemetry.Track(cmd)
return cmd
}
Command that contributes properties from positional args:
telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any {
return map[string]any{
"component_name": telemetry.FirstArg(args),
}
})
Command that contributes a property from a flag:
telemetry.TrackWith(cmd, func(c *cobra.Command, args []string) map[string]any {
ver, _ := c.Flags().GetString("version")
return map[string]any{
"plugin_name": telemetry.FirstArg(args),
"plugin_version": ver,
}
})
3. Add the command's path to the wiring test
IMPORTANT: Edit cmd/telemetry_wiring_test.go and add the new cmd.CommandPath() to expectedTrackedCommands. The test will fail loudly if anyone later removes the wiring.
4. Test it
Run the CLI with telemetry disabled and check the debug log to see your event:
dr foo --debug --disable-telemetry
# .dr-tui-debug.log will include "Telemetry event (dry-run)" entries
Plugin Commands
Plugin commands are discovered at runtime by cmd/plugin/discovery.go::createPluginCommand, which calls telemetry.TrackPlugin(cmd, manifest.Version). This:
- Sets the
"telemetry"annotation soEventForwill fire an event. - Sets the
"telemetry:plugin"annotation soIsPluginCommandreturns true, which causes the root to stampcommand_kind = "plugin"on the common properties. - Registers an extractor that adds
plugin_versionto the event.
The event type is cmd.CommandPath() — for example dr assist. There is no longer a synthetic "dr plugin execute" event.
SDK log routing
The Amplitude SDK emits its own internal logs (HTTP responses, client lifecycle, etc.) via a custom logger adapter in amplitudeLogger. All Amplitude SDK log entries are prefixed with [amplitude] for traceability in debug log files.
The adapter demotes Amplitude's INFO-level logs (e.g. HTTP response code, HTTP response body) to DEBUG when the app's log level is above INFO. This keeps them off stderr by default while still capturing them in the debug log file (see Logging).
| CLI flags | Amplitude INFO appears as | Visible on stderr? |
|---|---|---|
| (default) | DEBUG | No |
--verbose |
INFO | Yes |
--debug |
INFO | Yes |
WARN and ERROR messages from the SDK always pass through at their original level.
Testing
Run the telemetry test suite:
Key tests:
internal/telemetry/wire_test.go— exercisesTrack,TrackWith,TrackPlugin,EventFor,IsPluginCommand,FirstArg.internal/telemetry/properties_test.go— exercises common properties includingcommand_kind.cmd/telemetry_wiring_test.go— verifies that every expected core command path is wired in the static command tree, and separately walks thedr workload,dr artifactanddr pipelinegroups asserting every leaf under them is wired, listed or not.
Maintenance checklist
- Renaming a command? The event type follows
cmd.CommandPath()automatically, but you must updateexpectedTrackedCommandsincmd/telemetry_wiring_test.go. - Removing a command? Remove its
expectedTrackedCommandsentry. - Adding a leaf under
dr workload,dr artifactordr pipeline? Wire it withTrack/TrackWithand add it toexpectedTrackedCommands. The subtree walk fails on an unwired leaf whether or not the list mentions it, which is what a list alone cannot check. - Changing event properties? Update the closure passed to
TrackWith.