Skip to content
Open
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
9 changes: 9 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ using pre-built templates. Get from idea to production in minutes, not hours.
// Store telemetry client in context for use by commands
cmd.SetContext(context.WithValue(cmd.Context(), telemetryClientKey{}, client))

config.SetAPIConsumerTrace(config.CommandPathToTrace(cmd.CommandPath()))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice, I like that cmd.CommandPath() is a thing.


return nil
},
PersistentPostRunE: func(cmd *cobra.Command, _ []string) error {
Expand Down Expand Up @@ -211,6 +213,13 @@ func initializeConfig(cmd *cobra.Command) error {

_ = viper.BindEnv("external-editor", "VISUAL", "EDITOR")

// API consumer tracking is enabled by default.
// Set DATAROBOT_API_CONSUMER_TRACKING_ENABLED=false to opt out,
// matching the Python SDK convention.
viper.SetDefault(config.APIConsumerTrackingEnabled, true)

_ = viper.BindEnv(config.APIConsumerTrackingEnabled, "DATAROBOT_API_CONSUMER_TRACKING_ENABLED")

// If DATAROBOT_CLI_CONFIG is set and no explicit --config flag was provided,
// use the environment variable value
if configFilePath == "" {
Expand Down
7 changes: 7 additions & 0 deletions docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ export EDITOR=nano

# Force setup wizard to run even if already completed
export DATAROBOT_CLI_FORCE_INTERACTIVE=true

# API consumer tracking (default: true)
# Set to false to disable the X-DataRobot-Api-Consumer-Trace header on API requests.
# Matches the Python SDK's DATAROBOT_API_CONSUMER_TRACKING_ENABLED behavior.
# When enabled, the header value identifies the command being run using dot-notation:
# datarobot.cli.<command>.<subcommand> (e.g. datarobot.cli.templates.setup)
export DATAROBOT_API_CONSUMER_TRACKING_ENABLED=false
Comment on lines +138 to +144
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Once we add in the rest of telemetry we should probably explain this further.

```

### Advanced flags
Expand Down
40 changes: 40 additions & 0 deletions internal/config/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,46 @@ func GetUserAgentHeader() string {
return version.GetAppNameVersionText()
}

// apiConsumerTrace holds the dot-notation command path set at startup.
// Example: "datarobot.cli.templates.setup"
var apiConsumerTrace string

// SetAPIConsumerTrace stores the dot-notation trace value for the running command.
// Called once during PersistentPreRunE with the result of CommandPathToTrace.
func SetAPIConsumerTrace(trace string) {
apiConsumerTrace = trace
}

// GetAPIConsumerTrace returns the dot-notation trace value for the running command.
// Falls back to "datarobot.cli" if SetAPIConsumerTrace has not been called.
func GetAPIConsumerTrace() string {
if apiConsumerTrace == "" {
return "datarobot.cli"
}

return apiConsumerTrace
}

// CommandPathToTrace converts a cobra command path (e.g. "dr templates setup")
// to the canonical dot-notation trace format (e.g. "datarobot.cli.templates.setup").
func CommandPathToTrace(commandPath string) string {
parts := strings.Fields(commandPath)
if len(parts) == 0 {
return "datarobot.cli"
}

parts[0] = "datarobot.cli"

return strings.Join(parts, ".")
}
Comment on lines +92 to +103
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Checking a few things here -- Cobra doesn't appear to allow dots or periods in command names, which tracks.

Also, I wonder how this works with command aliases, like dr r for dr run, as well with shell aliases, like datarobot start for dr start. I assume that CommandPath() provides a canonical name, based off of:

// CommandPath returns the full path to this command.
func (c *Command) CommandPath() string {
	if c.HasParent() {
		return c.Parent().CommandPath() + " " + c.Name()
	}
	return c.DisplayName()
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

hmm, not sure what to say here ... need to check that.. let's do that on next steps


// IsAPIConsumerTrackingEnabled returns true if the X-DataRobot-Api-Consumer-Trace
// header should be sent with API requests. Controlled by the
// DATAROBOT_API_CONSUMER_TRACKING_ENABLED environment variable (default: true).
func IsAPIConsumerTrackingEnabled() bool {
return viper.GetBool(APIConsumerTrackingEnabled)
}

func RedactedReqInfo(req *http.Request) string {
// Dump the request to a byte slice after cloning and removing Auth header
dumpReq := req.Clone(req.Context())
Expand Down
111 changes: 111 additions & 0 deletions internal/config/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"path/filepath"
"testing"

"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/stretchr/testify/suite"
)
Expand Down Expand Up @@ -107,3 +108,113 @@ func (suite *APITestSuite) TestSetURLToConfigDoesNotWriteFile() {
configFile := filepath.Join(suite.tempDir, ".config/datarobot/drconfig.yaml")
suite.NoFileExists(configFile, "SetURLToConfig must not write the config file to disk")
}

func (suite *APITestSuite) TestCommandPathToTrace() {
tests := []struct {
name string
input string
expected string
}{
{
name: "empty string",
input: "",
expected: "datarobot.cli",
},
{
name: "root command only",
input: "dr",
expected: "datarobot.cli",
},
{
name: "single subcommand",
input: "dr start",
expected: "datarobot.cli.start",
},
{
name: "nested subcommand",
input: "dr templates setup",
expected: "datarobot.cli.templates.setup",
},
{
name: "deeply nested subcommand",
input: "dr self plugin add",
expected: "datarobot.cli.self.plugin.add",
},
}

for _, tc := range tests {
suite.Run(tc.name, func() {
suite.Equal(tc.expected, CommandPathToTrace(tc.input))
})
}
}

func (suite *APITestSuite) TestGetSetAPIConsumerTrace() {
SetAPIConsumerTrace("")

suite.Equal("datarobot.cli", GetAPIConsumerTrace(), "should fall back to datarobot.cli when unset")

SetAPIConsumerTrace("datarobot.cli.templates.list")
suite.Equal("datarobot.cli.templates.list", GetAPIConsumerTrace())

SetAPIConsumerTrace("datarobot.cli.start")
suite.Equal("datarobot.cli.start", GetAPIConsumerTrace())
}

func (suite *APITestSuite) TestIsAPIConsumerTrackingEnabled() {
suite.False(IsAPIConsumerTrackingEnabled(), "should be false when viper has no config set")

viper.Set(APIConsumerTrackingEnabled, true)
suite.True(IsAPIConsumerTrackingEnabled())

viper.Set(APIConsumerTrackingEnabled, false)
suite.False(IsAPIConsumerTrackingEnabled())
}

// TestCommandPathToTraceWithAliases verifies that cobra always resolves
// command aliases to their canonical Use name before CommandPath() is called.
// This means CommandPathToTrace never receives an alias string — cobra
// normalises it first — so the trace always uses the canonical command name.
func (suite *APITestSuite) TestCommandPathToTraceWithAliases() {
// Build a small cobra command tree that mirrors real CLI aliases:
// root (Use: "dr")
// └─ run (Use: "run", Aliases: ["r"]) → like dr run / dr r
// └─ start (Use: "start", Aliases: ["quickstart"]) → like dr start / dr quickstart
// └─ templates (Use: "templates", Aliases: ["template"])
// └─ list (Use: "list")
root := &cobra.Command{Use: "dr"}

run := &cobra.Command{Use: "run [tasks]", Aliases: []string{"r"}}
start := &cobra.Command{Use: "start", Aliases: []string{"quickstart"}}
templates := &cobra.Command{Use: "templates", Aliases: []string{"template"}}
list := &cobra.Command{Use: "list"}

templates.AddCommand(list)
root.AddCommand(run, start, templates)

tests := []struct {
name string
args []string
expectedTrace string
}{
// Canonical invocations
{"canonical: dr run", []string{"run"}, "datarobot.cli.run"},
{"canonical: dr start", []string{"start"}, "datarobot.cli.start"},
{"canonical: dr templates list", []string{"templates", "list"}, "datarobot.cli.templates.list"},
// Cobra command aliases — CommandPath() must return the canonical name
{"alias: dr r → dr run", []string{"r"}, "datarobot.cli.run"},
{"alias: dr quickstart → dr start", []string{"quickstart"}, "datarobot.cli.start"},
{"alias: dr template list → dr templates list", []string{"template", "list"}, "datarobot.cli.templates.list"},
}

for _, tc := range tests {
suite.Run(tc.name, func() {
cmd, _, err := root.Find(tc.args)
suite.Require().NoError(err)
suite.Require().NotNil(cmd)

trace := CommandPathToTrace(cmd.CommandPath())
suite.Equal(tc.expectedTrace, trace)
})
}
}
4 changes: 4 additions & 0 deletions internal/config/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ func VerifyToken(ctx context.Context, datarobotEndpoint, token string) error {
req.Header.Add("Authorization", bearer)
req.Header.Add("User-Agent", getUserAgent(ctx))

if IsAPIConsumerTrackingEnabled() {
req.Header.Add("X-DataRobot-Api-Consumer-Trace", "datarobot.cli.auth.verify")
}

log.Debug("Request Info: \n" + RedactedReqInfo(req))

client := &http.Client{
Expand Down
2 changes: 2 additions & 0 deletions internal/config/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ package config
const (
DataRobotURL = "endpoint"
DataRobotAPIKey = "token"

APIConsumerTrackingEnabled = "api-consumer-tracking-enabled"
)
6 changes: 5 additions & 1 deletion internal/drapi/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ import (
"net/http"
"time"

"github.com/charmbracelet/log"
"github.com/datarobot/cli/internal/config"
"github.com/datarobot/cli/internal/log"
)

var token string
Expand All @@ -46,6 +46,10 @@ func Get(url, info string) (*http.Response, error) {
req.Header.Add("Authorization", "Bearer "+token)
req.Header.Add("User-Agent", config.GetUserAgentHeader())

if config.IsAPIConsumerTrackingEnabled() {
req.Header.Add("X-DataRobot-Api-Consumer-Trace", config.GetAPIConsumerTrace())
}

if info != "" {
log.Infof("Fetching %s from: %s", info, url)
}
Expand Down
Loading