From 19c9bc3f3cc8d1a0897f0cd611ef28c46e5c5ce4 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Sat, 24 Oct 2026 12:23:49 +0530 Subject: [PATCH] feat(plugins): add plugin telemetry Plugin runs, installs, updates and uninstalls are counted through the analytics tracker. An event carries the plugin name and version, plus whether a run succeeded and a short reason code. Arguments, flags, tenant names and output are never part of it, and AUTH0_CLI_ANALYTICS=false turns it off. --- README.md | 2 + internal/analytics/analytics.go | 130 +++- internal/analytics/analytics_test.go | 198 ++++++ internal/cli/cli.go | 5 + internal/cli/plugin.go | 22 +- internal/cli/plugin_run.go | 7 + internal/cli/plugin_telemetry.go | 71 +++ internal/cli/plugin_telemetry_test.go | 845 ++++++++++++++++++++++++++ internal/cli/root.go | 7 + 9 files changed, 1285 insertions(+), 2 deletions(-) create mode 100644 internal/cli/plugin_telemetry.go create mode 100644 internal/cli/plugin_telemetry_test.go diff --git a/README.md b/README.md index 5b3d85775..11c64625e 100644 --- a/README.md +++ b/README.md @@ -476,6 +476,8 @@ Usage data points are collected during use of this CLI. This includes the CLI ve Auth0 uses this data to understand how the tool is used, to prioritize the features, enhancements and fixes that matter most to our users, and to attribute usage to a tenant for support and product purposes. +The events that count plugin runs, installs, updates and uninstalls carry only the plugin name and version, plus whether a run succeeded and a short reason code. Plugin arguments, flags, tenant names and output are never part of those events. + To **opt-out** of this collection, set the `AUTH0_CLI_ANALYTICS` environment variable to `false`. ## Feedback diff --git a/internal/analytics/analytics.go b/internal/analytics/analytics.go index 21c10b350..483c6d9dc 100644 --- a/internal/analytics/analytics.go +++ b/internal/analytics/analytics.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "runtime" + "strconv" "strings" "sync" "time" @@ -25,10 +26,18 @@ const ( versionKey = "version" osKey = "os" archKey = "arch" + pluginKey = "plugin" + pluginVersionKey = "plugin_version" + successKey = "success" + errorReasonKey = "error_reason" ) type Tracker struct { wg sync.WaitGroup + + // The client that sends the events. When it is nil, a default client is used. Tests + // replace it, so that no test reaches the analytics endpoint. + client *http.Client } type event struct { @@ -43,6 +52,13 @@ func NewTracker() *Tracker { return &Tracker{} } +// NewTrackerWithClient returns a tracker that sends its events with the given +// client. It exists so that tests can see what is sent without any request +// reaching the analytics endpoint. +func NewTrackerWithClient(client *http.Client) *Tracker { + return &Tracker{client: client} +} + func (t *Tracker) TrackFirstLogin(id string, loginType string, properties map[string]string) { eventName := fmt.Sprintf("%s - Auth0 - First Login", eventNamePrefix) t.track(eventName, id, properties) @@ -56,6 +72,32 @@ func (t *Tracker) TrackCommandRun(commandPath string, id string, properties map[ t.track(eventName, id, properties) } +// TrackPlugin counts one event about a plugin. The event name comes from the +// closed set of actions, and the properties are built here from the named fields +// of the event, never from a map that the caller fills, so nothing the user typed +// or received can be added to it. An action that is not one of the constants +// sends nothing. +func (t *Tracker) TrackPlugin(action PluginAction, pluginEvent PluginEvent, id string) { + if !action.valid() { + return + } + + properties := map[string]string{ + pluginKey: pluginEvent.Plugin, + pluginVersionKey: pluginEvent.Version, + } + + if action == PluginActionRun { + properties[successKey] = strconv.FormatBool(pluginEvent.Success) + + if !pluginEvent.Success { + properties[errorReasonKey] = pluginErrorReason(pluginEvent.ErrorReason) + } + } + + t.track(fmt.Sprintf("%s - Plugin - %s", eventNamePrefix, action), id, properties) +} + func (t *Tracker) Wait(ctx context.Context) { ch := make(chan struct{}) @@ -96,7 +138,11 @@ func (t *Tracker) sendEvent(event *event) { req.Header.Set("Content-Type", "application/json") - client := &http.Client{} + client := t.client + if client == nil { + client = &http.Client{} + } + resp, err := client.Do(req) if err != nil { println(err.Error()) @@ -166,3 +212,85 @@ func timestamp() int64 { ms := int64(t.Nanosecond()) / 1e6 return s + ms } + +// PluginAction is what happened to a plugin. It is a closed set: the name of a +// plugin event is made from it, so a name chosen by someone else never becomes +// part of an event name. +type PluginAction string + +const ( + // PluginActionRun is a run of a command of an installed plugin. + PluginActionRun PluginAction = "Run" + + // PluginActionInstall is an install that put a plugin version on this machine. + PluginActionInstall PluginAction = "Install" + + // PluginActionUpdate is an update that moved a plugin to another version. + PluginActionUpdate PluginAction = "Update" + + // PluginActionUninstall is the removal of an installed plugin. + PluginActionUninstall PluginAction = "Uninstall" +) + +// valid reports whether the action is one of the constants. +func (a PluginAction) valid() bool { + switch a { + case PluginActionRun, PluginActionInstall, PluginActionUpdate, PluginActionUninstall: + return true + default: + return false + } +} + +// PluginEvent is everything a plugin event can carry. There is no field for +// arguments, flags, a tenant, an exit code or output, so none can be sent. +type PluginEvent struct { + // Plugin is the name of the plugin without its registry, such as checkmate. + Plugin string + + // Version is the version of the plugin the event is about. + Version string + + // Success and ErrorReason describe the outcome of a run, and are ignored for + // every other action. ErrorReason is the reason code of the CLI for a failed + // run, and is sent as it is only when it is one of the known plugin codes. + Success bool + ErrorReason string +} + +// otherReason is what a reason that is not on the allowlist is sent as. +const otherReason = "other" + +// pluginReasons are the reason codes of the CLI that a failed plugin run can +// have. A reason that is not in the set is sent as "other", so free text can +// never reach an event. +var pluginReasons = map[string]bool{ + "plugin_approval_check": true, + "plugin_approvals_damaged": true, + "plugin_changed": true, + "plugin_command_missing": true, + "plugin_exit": true, + "plugin_interactive": true, + "plugin_interrupted": true, + "plugin_no_tenant": true, + "plugin_not_approved": true, + "plugin_output_unsupported": true, + "plugin_registry_unavailable": true, + "plugin_revoked": true, + "plugin_session_unavailable": true, + "plugin_start_failed": true, + "plugin_tenant_mismatch": true, + "plugin_timeout": true, + "plugin_timeout_invalid": true, + "plugin_verification_failed": true, +} + +// pluginErrorReason returns the reason if it is a known plugin reason code, and +// "other" otherwise. +func pluginErrorReason(reason string) string { + if pluginReasons[reason] { + return reason + } + + return otherReason +} diff --git a/internal/analytics/analytics_test.go b/internal/analytics/analytics_test.go index 48c4c2668..fd9103c83 100644 --- a/internal/analytics/analytics_test.go +++ b/internal/analytics/analytics_test.go @@ -1,11 +1,19 @@ package analytics import ( + "context" + "encoding/json" + "io" + "net/http" "runtime" + "sync" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/buildinfo" ) func TestGenerateEventName(t *testing.T) { @@ -73,3 +81,193 @@ func TestNewEvent(t *testing.T) { assert.Equal(t, runtime.GOOS, event.Properties[osKey]) }) } + +// recordingTransport keeps the events that the tracker sends instead of sending +// them, so that no test reaches the analytics endpoint. +type recordingTransport struct { + mu sync.Mutex + events []event + urls []string +} + +func (r *recordingTransport) RoundTrip(request *http.Request) (*http.Response, error) { + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + + var sent event + if err := json.Unmarshal(body, &sent); err != nil { + return nil, err + } + + r.mu.Lock() + r.events = append(r.events, sent) + r.urls = append(r.urls, request.URL.String()) + r.mu.Unlock() + + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(http.NoBody), Request: request}, nil +} + +// newRecordedTracker returns a tracker that records what it sends, for a build +// with a version, so that tracking is on unless the test turns it off. +func newRecordedTracker(t *testing.T) (*Tracker, *recordingTransport) { + t.Helper() + + previous := buildinfo.Version + buildinfo.Version = "1.2.3" + + t.Cleanup(func() { buildinfo.Version = previous }) + t.Setenv("AUTH0_CLI_ANALYTICS", "") + + transport := &recordingTransport{} + + return NewTrackerWithClient(&http.Client{Transport: transport}), transport +} + +// sent waits for the events in flight and returns what was sent. +func (r *recordingTransport) sent(t *testing.T, tracker *Tracker) []event { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + tracker.Wait(ctx) + require.NoError(t, ctx.Err()) + + r.mu.Lock() + defer r.mu.Unlock() + + return append([]event{}, r.events...) +} + +func TestTrackPlugin(t *testing.T) { + base := func(extra map[string]string) map[string]string { + properties := map[string]string{ + versionKey: "1.2.3", + osKey: runtime.GOOS, + archKey: runtime.GOARCH, + } + + for k, v := range extra { + properties[k] = v + } + + return properties + } + + tests := []struct { + name string + action PluginAction + event PluginEvent + wantName string + wantProps map[string]string + wantNoSend bool + }{ + { + name: "a successful run", + action: PluginActionRun, + event: PluginEvent{Plugin: "checkmate", Version: "1.0.0", Success: true}, + wantName: "CLI - Plugin - Run", + wantProps: base(map[string]string{"plugin": "checkmate", "plugin_version": "1.0.0", "success": "true"}), + }, + { + name: "a failed run with a known reason", + action: PluginActionRun, + event: PluginEvent{Plugin: "checkmate", Version: "1.0.0", ErrorReason: "plugin_exit"}, + wantName: "CLI - Plugin - Run", + wantProps: base(map[string]string{"plugin": "checkmate", "plugin_version": "1.0.0", "success": "false", "error_reason": "plugin_exit"}), + }, + { + name: "a failed run with free text as the reason", + action: PluginActionRun, + event: PluginEvent{Plugin: "checkmate", Version: "1.0.0", ErrorReason: "open /Users/jane/acme.us.auth0.com: denied"}, + wantName: "CLI - Plugin - Run", + wantProps: base(map[string]string{"plugin": "checkmate", "plugin_version": "1.0.0", "success": "false", "error_reason": "other"}), + }, + { + name: "a failed run without a reason", + action: PluginActionRun, + event: PluginEvent{Plugin: "checkmate", Version: "1.0.0"}, + wantName: "CLI - Plugin - Run", + wantProps: base(map[string]string{"plugin": "checkmate", "plugin_version": "1.0.0", "success": "false", "error_reason": "other"}), + }, + { + name: "an install ignores the outcome fields", + action: PluginActionInstall, + event: PluginEvent{Plugin: "checkmate", Version: "1.0.0", Success: true, ErrorReason: "plugin_exit"}, + wantName: "CLI - Plugin - Install", + wantProps: base(map[string]string{"plugin": "checkmate", "plugin_version": "1.0.0"}), + }, + { + name: "an update", + action: PluginActionUpdate, + event: PluginEvent{Plugin: "checkmate", Version: "2.0.0"}, + wantName: "CLI - Plugin - Update", + wantProps: base(map[string]string{"plugin": "checkmate", "plugin_version": "2.0.0"}), + }, + { + name: "an uninstall", + action: PluginActionUninstall, + event: PluginEvent{Plugin: "checkmate", Version: "2.0.0"}, + wantName: "CLI - Plugin - Uninstall", + wantProps: base(map[string]string{"plugin": "checkmate", "plugin_version": "2.0.0"}), + }, + { + name: "an action that is not one of the constants", + action: PluginAction("Run - Everything"), + event: PluginEvent{Plugin: "checkmate", Version: "1.0.0"}, + wantNoSend: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tracker, transport := newRecordedTracker(t) + + tracker.TrackPlugin(test.action, test.event, "install-id") + + sent := transport.sent(t, tracker) + + if test.wantNoSend { + assert.Empty(t, sent) + return + } + + require.Len(t, sent, 1) + assert.Equal(t, test.wantName, sent[0].Event) + assert.Equal(t, "install-id", sent[0].ID) + assert.Equal(t, appID, sent[0].App) + assert.Equal(t, test.wantProps, sent[0].Properties) + assert.Equal(t, []string{analyticsEndpoint}, transport.urls) + }) + } +} + +func TestTrackPlugin_OptOut(t *testing.T) { + t.Run("AUTH0_CLI_ANALYTICS=false sends nothing", func(t *testing.T) { + tracker, transport := newRecordedTracker(t) + t.Setenv("AUTH0_CLI_ANALYTICS", "false") + + for _, action := range []PluginAction{PluginActionRun, PluginActionInstall, PluginActionUpdate, PluginActionUninstall} { + tracker.TrackPlugin(action, PluginEvent{Plugin: "checkmate", Version: "1.0.0", Success: true}, "install-id") + } + + assert.Empty(t, transport.sent(t, tracker)) + }) + + t.Run("a build without a version sends nothing", func(t *testing.T) { + tracker, transport := newRecordedTracker(t) + buildinfo.Version = "" + + tracker.TrackPlugin(PluginActionRun, PluginEvent{Plugin: "checkmate", Version: "1.0.0", Success: true}, "install-id") + + assert.Empty(t, transport.sent(t, tracker)) + }) +} + +func TestPluginErrorReason(t *testing.T) { + for _, reason := range []string{"", "other", "none", "unclassified", "auth_failed", "PLUGIN_EXIT", "plugin_exit ", "plugin_unknown", "exit status 3"} { + assert.Equal(t, "other", pluginErrorReason(reason), reason) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 849091364..3ba7230bf 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -73,6 +73,11 @@ type cli struct { // plugin ID, see registerPluginCommands. shadowedPlugins map[string]string + // The plugin command that is running, which its pre-run hook sets, and the + // version of the plugin once its files were verified. It is what the telemetry + // of a plugin run reads, see trackPluginRun. It is nil for every other command. + pluginRun *pluginRunIdentity + Config config.Config } diff --git a/internal/cli/plugin.go b/internal/cli/plugin.go index aed627d01..08b1700ae 100644 --- a/internal/cli/plugin.go +++ b/internal/cli/plugin.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" + "github.com/auth0/auth0-cli/internal/analytics" "github.com/auth0/auth0-cli/internal/buildinfo" "github.com/auth0/auth0-cli/internal/config" "github.com/auth0/auth0-cli/internal/display" @@ -430,6 +431,8 @@ func installPluginCmd(cli *cli) *cobra.Command { return err } + cli.trackPluginChange(analytics.PluginActionInstall, result) + op.say(result, view, false) cli.renderer.PluginChange(*view) @@ -533,10 +536,15 @@ func uninstallPluginCmd(cli *cli) *cobra.Command { return err } - if _, err := requireInstalled(svc.store, ref); err != nil { + installed, err := requireInstalled(svc.store, ref) + if err != nil { return err } + // The version that is active now is the one that goes away, so it is read + // before the plugin is removed. + removed := installed.Active + if !cli.force && cli.agentMode { return errDestructiveNoConfirm } @@ -553,6 +561,8 @@ func uninstallPluginCmd(cli *cli) *cobra.Command { return err } + cli.trackPlugin(analytics.PluginActionUninstall, ref.ID, analytics.PluginEvent{Version: removed}) + cli.renderer.PluginUninstalled(ref.ID, freed) return nil @@ -1105,6 +1115,8 @@ func (o *pluginOperation) updateOne(ctx context.Context, ref pluginRef, opts sto return err } + o.cli.trackPluginChange(analytics.PluginActionUpdate, result) + o.say(result, view, true) o.cli.renderer.PluginChange(*view) @@ -1130,6 +1142,14 @@ func (o *pluginOperation) updateAll(ctx context.Context, opts store.Options) err recordErr := o.consenter.Record(ctx, done...) + // Only the plugins that were updated are counted, and only when their approval + // was saved. + if recordErr == nil { + for _, result := range done { + o.cli.trackPluginChange(analytics.PluginActionUpdate, result) + } + } + var ( views []display.PluginChangeResult failed []string diff --git a/internal/cli/plugin_run.go b/internal/cli/plugin_run.go index 9aa1e6ed8..4c88b5ca4 100644 --- a/internal/cli/plugin_run.go +++ b/internal/cli/plugin_run.go @@ -566,6 +566,10 @@ func ownedFlags(root *cobra.Command) []runner.OwnedFlag { // them as cobra would have, and then runs the hook of the root, which does not // log in for these commands. func (p *pluginCommand) preRun(cmd *cobra.Command, args []string) error { + // From here on the command is a plugin run, even when it fails before the + // plugin is known, so that it never sends the event of a core command. + p.cli.pluginRun = &pluginRunIdentity{id: p.id} + scanned, err := runner.Scan(args, ownedFlags(cmd.Root())) if err != nil { return usageError{err: err} @@ -688,6 +692,9 @@ func (p *pluginCommand) run(cmd *cobra.Command, spec plugins.Command, lead []str return err } + // The plugin and its version are known now. A run that got this far is counted. + c.pluginRun = &pluginRunIdentity{id: p.id, version: verified.Version} + command, ok := commandOf(verified.Manifest, spec.Name) if !ok { return validationError{ diff --git a/internal/cli/plugin_telemetry.go b/internal/cli/plugin_telemetry.go new file mode 100644 index 000000000..70b780f55 --- /dev/null +++ b/internal/cli/plugin_telemetry.go @@ -0,0 +1,71 @@ +package cli + +import ( + "strings" + + "github.com/auth0/auth0-cli/internal/analytics" + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/registry" + "github.com/auth0/auth0-cli/internal/plugins/store" +) + +// pluginRunIdentity is the plugin command that is running. The version is empty +// until the installed files of the plugin were verified, and a run that stops +// before that is not counted. +type pluginRunIdentity struct { + id string + version string +} + +// trackablePlugin reports whether events may name the plugin. Only the plugins of +// the built-in registry do: the names of any other registry, and of a plugin that +// a developer linked, are not ours to collect. +func trackablePlugin(id string) bool { + return strings.HasPrefix(id, registry.DefaultRegistryName+"/") +} + +// trackPluginRun sends the event of a plugin run: the plugin, its version and how +// the run ended. Nothing else about the run is sent. A run that stopped before the +// plugin was verified sends nothing. +func trackPluginRun(c *cli, executionErr error) { + run := c.pluginRun + if run == nil || run.version == "" { + return + } + + event := analytics.PluginEvent{Version: run.version, Success: executionErr == nil} + + if executionErr != nil { + event.ErrorReason = errorReason(executionErr) + } + + c.trackPlugin(analytics.PluginActionRun, run.id, event) +} + +// trackPluginChange sends the event of an install or an update, when it changed +// what is installed. A plugin that was already at that version sends nothing. +func (c *cli) trackPluginChange(action analytics.PluginAction, result store.Result) { + if result.Action == store.ActionUnchanged { + return + } + + c.trackPlugin(action, result.ID, analytics.PluginEvent{Version: result.Version}) +} + +// trackPlugin sends one plugin event with the install ID, as the other events do. +// It sends nothing for a plugin that is not from the built-in registry, without a +// version, or when there is no install ID, which is the case in env auth mode. +func (c *cli) trackPlugin(action analytics.PluginAction, id string, event analytics.PluginEvent) { + if c.tracker == nil || !trackablePlugin(id) || event.Version == "" { + return + } + + installID := resolveInstallIDForTracking(c) + if installID == "" { + return + } + + event.Plugin = plugins.NameOf(id) + + c.tracker.TrackPlugin(action, event, installID) +} diff --git a/internal/cli/plugin_telemetry_test.go b/internal/cli/plugin_telemetry_test.go new file mode 100644 index 000000000..a1b967649 --- /dev/null +++ b/internal/cli/plugin_telemetry_test.go @@ -0,0 +1,845 @@ +package cli + +import ( + "context" + "encoding/json" + "io" + "maps" + "net/http" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/analytics" + "github.com/auth0/auth0-cli/internal/buildinfo" + "github.com/auth0/auth0-cli/internal/config" + "github.com/auth0/auth0-cli/internal/plugins" + "github.com/auth0/auth0-cli/internal/plugins/approvals" + "github.com/auth0/auth0-cli/internal/plugins/store" +) + +// telemetryVersion is the version the tests give the CLI. Tracking is off for a +// build without a version, and a prerelease keeps the plugin feature on. +const telemetryVersion = "1.39.0-beta.1" + +// sentEvent is an event as it went over the wire. +type sentEvent struct { + Event string `json:"event"` + Identity string `json:"identity"` + Properties map[string]string `json:"properties"` +} + +// eventRecorder is the transport of the tracker in these tests. It keeps what +// the tracker sends and answers for the analytics endpoint, so no request ever +// leaves the test. +type eventRecorder struct { + mu sync.Mutex + bodies []string +} + +func (r *eventRecorder) RoundTrip(request *http.Request) (*http.Response, error) { + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + + r.mu.Lock() + r.bodies = append(r.bodies, string(body)) + r.mu.Unlock() + + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(http.NoBody), Request: request}, nil +} + +// newEventRecorder gives the CLI a tracker that sends to the recorder, with a +// build version and an install ID, as a release build has. A command that reads +// a config file with an install ID in it replaces the ID, so the tests compare +// with the install ID of the CLI after the run. +func newEventRecorder(t *testing.T, c *cli) *eventRecorder { + t.Helper() + + previous := buildinfo.Version + buildinfo.Version = telemetryVersion + + t.Cleanup(func() { buildinfo.Version = previous }) + t.Setenv("AUTH0_CLI_ANALYTICS", "") + + recorder := &eventRecorder{} + + c.Config.InstallID = "test-install-id" + c.tracker = analytics.NewTrackerWithClient(&http.Client{Transport: recorder}) + + return recorder +} + +// events waits for the events in flight and returns the ones that were sent +// since the last call, in the order they were sent. +func (r *eventRecorder) events(t *testing.T, c *cli) []sentEvent { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + c.tracker.Wait(ctx) + require.NoError(t, ctx.Err()) + + r.mu.Lock() + defer r.mu.Unlock() + + var sent []sentEvent + + for _, body := range r.bodies { + var event sentEvent + require.NoError(t, json.Unmarshal([]byte(body), &event), body) + + sent = append(sent, event) + } + + return sent +} + +// raw returns the JSON of every event sent so far. +func (r *eventRecorder) raw() string { + r.mu.Lock() + defer r.mu.Unlock() + + return strings.Join(r.bodies, "\n") +} + +// reset forgets the events sent so far. +func (r *eventRecorder) reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.bodies = nil +} + +// wireJSON returns the JSON of one event as it went over the wire. +func wireJSON(t *testing.T, event sentEvent) string { + t.Helper() + + body, err := json.Marshal(event) + require.NoError(t, err) + + return string(body) +} + +// eventNames returns the names of the events, sorted. +func eventNames(events []sentEvent) []string { + names := make([]string, 0, len(events)) + for _, event := range events { + names = append(names, event.Event) + } + + slices.Sort(names) + + return names +} + +// only returns the event with the name, and fails when there is not exactly one. +func only(t *testing.T, events []sentEvent, name string) sentEvent { + t.Helper() + + var found []sentEvent + + for _, event := range events { + if event.Event == name { + found = append(found, event) + } + } + + require.Len(t, found, 1, "events named %q in %v", name, eventNames(events)) + + return found[0] +} + +// pluginProperties is the full set of properties of a plugin event: the ones every +// event has, and the given ones. +func pluginProperties(extra map[string]string) map[string]string { + properties := map[string]string{ + "version": telemetryVersion, + "os": runtime.GOOS, + "arch": runtime.GOARCH, + } + + for name, value := range extra { + properties[name] = value + } + + return properties +} + +// commandEventKeys are the properties of the event of a core command. +var commandEventKeys = []string{ + "version", "os", "arch", "interactive", "ci", "agent_client", "no_input", + "output_format", "forced", "is_api", "tenant", "success", "error_class", "error_reason", +} + +// newTestRoot builds the command tree the way Execute does, for the lookup of the +// command a command line is for. +func newTestRoot(c *cli) *cobra.Command { + root := buildRootCmd(c) + addPersistentFlags(root, c) + addSubCommands(root, c) + enforceUnknownSubcommand(root) + + return root +} + +// tracked runs a command line of a plugin, then reports the outcome the way +// Execute does, and returns the events that were sent. +func (r *runEnv) tracked(t *testing.T, recorder *eventRecorder, args ...string) ([]sentEvent, error) { + t.Helper() + + recorder.reset() + + r.cli.pluginRun, r.cli.executedCommandPath = nil, "" + + // The tree binds the flags to the CLI, so it is built before the run, which + // parses them. + newTestRoot(r.cli) + + err := r.exec(args...) + + trackCommandOutcome(r.cli, err) + + return recorder.events(t, r.cli), err +} + +// tracked is the same for a command of the plugin group. +func (e *pluginEnv) tracked(t *testing.T, recorder *eventRecorder, args ...string) ([]sentEvent, error) { + t.Helper() + + recorder.reset() + + e.cli.pluginRun, e.cli.executedCommandPath = nil, "" + + // The tree binds the flags to the CLI, so it is built before the run, which + // parses them. + newTestRoot(e.cli) + + err := e.run(args...) + + trackCommandOutcome(e.cli, err) + + return recorder.events(t, e.cli), err +} + +func TestPluginRunTelemetry(t *testing.T) { + tests := []struct { + name string + commands func() []plugins.Command + unapproved bool + setup func(t *testing.T, r *runEnv) + args []string + want map[string]string + }{ + { + name: "a run that succeeds", + args: []string{"checkmate"}, + want: map[string]string{"success": "true"}, + }, + { + name: "a plugin that exits with a code", + args: []string{"checkmate", "--mode=exit", "--code=7"}, + want: map[string]string{"success": "false", "error_reason": "plugin_exit"}, + }, + { + name: "a plugin that runs out of time", + commands: func() []plugins.Command { + commands := fixtureCommands() + commands[1].Timeout = "1s" + + return commands + }, + args: []string{"checkmate", "scan", "--mode=sleep", "--marker={marker}"}, + want: map[string]string{"success": "false", "error_reason": "plugin_timeout"}, + }, + { + name: "not approved", + unapproved: true, + args: []string{"checkmate"}, + want: map[string]string{"success": "false", "error_reason": "plugin_not_approved"}, + }, + { + name: "revoked", + setup: func(_ *testing.T, r *runEnv) { + r.republish(r.fixtureIndex(r.record).Revoke(pluginTestID, "1.0.0", "it leaks tokens.")) + }, + args: []string{"checkmate"}, + want: map[string]string{"success": "false", "error_reason": "plugin_revoked"}, + }, + { + name: "the session is for another tenant", + setup: func(_ *testing.T, r *runEnv) { r.Domain = "other.us.auth0.com" }, + args: []string{"checkmate"}, + want: map[string]string{"success": "false", "error_reason": "plugin_tenant_mismatch"}, + }, + { + name: "an output the command does not support", + args: []string{"checkmate", "scan", "--json"}, + want: map[string]string{"success": "false", "error_reason": "plugin_output_unsupported"}, + }, + { + name: "a failure that is not one of the reasons of the plugin", + setup: func(_ *testing.T, r *runEnv) { + r.loginErr = authError{err: assert.AnError, reason: "session_expired"} + }, + args: []string{"checkmate"}, + want: map[string]string{"success": "false", "error_reason": "other"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + commands := fixtureCommands + if test.commands != nil { + commands = test.commands + } + + r := newRunEnv(t, commands()) + recorder := newEventRecorder(t, r.cli) + + if !test.unapproved { + r.approve() + } + + if test.setup != nil { + test.setup(t, r) + } + + marker := filepath.Join(t.TempDir(), "pid") + + args := slices.Clone(test.args) + for n := range args { + args[n] = strings.ReplaceAll(args[n], "{marker}", marker) + } + + events, _ := r.tracked(t, recorder, args...) + + require.Len(t, events, 1, "one event for one run, and no event of a core command: %v", eventNames(events)) + assert.Equal(t, "CLI - Plugin - Run", events[0].Event) + assert.NotEmpty(t, r.cli.Config.InstallID) + assert.Equal(t, r.cli.Config.InstallID, events[0].Identity) + assert.Equal(t, pluginProperties(mergeProperties(map[string]string{"plugin": "checkmate", "plugin_version": "1.0.0"}, test.want)), events[0].Properties) + }) + } +} + +func TestPluginRunTelemetry_NothingBeforeTheFilesAreVerified(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, r *runEnv) + args []string + }{ + { + name: "plugins are turned off", + setup: func(t *testing.T, _ *runEnv) { t.Setenv(pluginsDisabledEnv, "true") }, + args: []string{"checkmate"}, + }, + { + name: "a tenant flag that conflicts with the environment", + setup: func(t *testing.T, _ *runEnv) { + t.Setenv(authModeEnvVar, authModeEnv) + t.Setenv(envDomain, "fixed.us.auth0.com") + t.Setenv(envAPIToken, "env-token") + }, + args: []string{"checkmate", "--tenant", pluginTestTenant}, + }, + { + name: "files that changed", + setup: func(t *testing.T, r *runEnv) { + verified, err := r.svc.store.Verify(pluginTestID) + require.NoError(t, err) + + require.NoError(t, appendToFile(verified.Bin, "tampered")) + }, + args: []string{"checkmate"}, + }, + { + name: "a flag the CLI cannot read", + args: []string{"checkmate", "--tenant"}, + }, + { + name: "the help of the plugin", + args: []string{"checkmate", "--help"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + recorder := newEventRecorder(t, r.cli) + r.approve() + + if test.setup != nil { + test.setup(t, r) + } + + events, _ := r.tracked(t, recorder, test.args...) + + assert.Empty(t, events, "neither the event of the plugin nor the event of a core command") + }) + } +} + +func TestPluginRunTelemetry_NothingTheUserTyped(t *testing.T) { + secrets := []string{pluginTestTenant, "customer-xyz.eu.auth0.com", "sk_live_SECRETVALUE", "hunter2-password", "validators", "--mode", "secret.txt"} + + for _, test := range []struct { + name string + mode string + want map[string]string + }{ + {"a run that succeeds", "", map[string]string{"success": "true"}}, + {"a run that fails", "--mode=exit", map[string]string{"success": "false", "error_reason": "plugin_exit"}}, + } { + t.Run(test.name, func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + recorder := newEventRecorder(t, r.cli) + r.approve() + + args := []string{"checkmate", "--tenant", pluginTestTenant, "--validators=sk_live_SECRETVALUE", "customer-xyz.eu.auth0.com", "hunter2-password", "--json", "--csv", "secret.txt"} + if test.mode != "" { + args = append(args, test.mode, "--code=7") + } + + events, _ := r.tracked(t, recorder, args...) + + require.Len(t, events, 1, eventNames(events)) + assert.Equal(t, "CLI - Plugin - Run", events[0].Event) + + wire := recorder.raw() + for _, secret := range secrets { + assert.NotContains(t, wire, secret) + } + + assert.Equal(t, pluginProperties(mergeProperties(map[string]string{"plugin": "checkmate", "plugin_version": "1.0.0"}, test.want)), events[0].Properties) + }) + } +} + +func TestPluginRunTelemetry_EnvAuth(t *testing.T) { + t.Run("env auth mode has no install ID", func(t *testing.T) { + r := newRunEnv(t, fixtureCommands()) + recorder := newEventRecorder(t, r.cli) + r.approve() + + t.Setenv(authModeEnvVar, authModeEnv) + t.Setenv(envDomain, pluginTestTenant) + t.Setenv(envAPIToken, "env-token") + + r.cli.Config.InstallID = "" + + events, err := r.tracked(t, recorder, "checkmate") + + require.NoError(t, err) + assert.True(t, r.started()) + assert.Empty(t, events) + }) +} + +func TestTrackPluginGate(t *testing.T) { + tests := []struct { + name string + pluginID string + version string + want bool + }{ + {"a plugin of the built-in registry", "auth0/checkmate", "1.0.0", true}, + {"a plugin of a registry the user added", "acme/checkmate", "1.0.0", false}, + {"a plugin that only starts like the registry", "auth0x/checkmate", "1.0.0", false}, + {"a plugin that was linked for development", "dev/checkmate", "1.0.0", false}, + {"a plugin without a version", "auth0/checkmate", "", false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c := &cli{Config: config.Config{InstallID: "test-install-id"}} + recorder := newEventRecorder(t, c) + + // A run, whatever the plugin is, never sends the event of a core command. + c.pluginRun = &pluginRunIdentity{id: test.pluginID, version: test.version} + c.executedCommandPath = "auth0 " + plugins.NameOf(test.pluginID) + + trackCommandOutcome(c, nil) + c.trackPluginChange(analytics.PluginActionInstall, store.Result{ID: test.pluginID, Version: test.version, Action: store.ActionInstalled}) + c.trackPlugin(analytics.PluginActionUninstall, test.pluginID, analytics.PluginEvent{Version: test.version}) + + events := recorder.events(t, c) + + if !test.want { + assert.Empty(t, events) + return + } + + assert.Equal(t, []string{"CLI - Plugin - Install", "CLI - Plugin - Run", "CLI - Plugin - Uninstall"}, eventNames(events)) + assert.Equal(t, "test-install-id", events[0].Identity) + }) + } +} + +func TestPluginChangeTelemetry_Install(t *testing.T) { + t.Run("an install sends its event and the command event with the tenant flag", func(t *testing.T) { + e, _ := newInstallEnv(t) + recorder := newEventRecorder(t, e.cli) + e.answer("y\n") + + events, err := e.tracked(t, recorder, "plugin", "install", "checkmate", "--tenant", pluginTestTenant) + + require.NoError(t, err) + require.Equal(t, []string{"CLI - Plugin - Install", "CLI - Plugin - Install - Run"}, eventNames(events)) + + install := only(t, events, "CLI - Plugin - Install") + assert.NotEmpty(t, e.cli.Config.InstallID) + assert.Equal(t, e.cli.Config.InstallID, install.Identity) + assert.Equal(t, pluginProperties(map[string]string{"plugin": "checkmate", "plugin_version": "1.0.0"}), install.Properties) + assert.NotContains(t, wireJSON(t, install), pluginTestTenant) + + command := only(t, events, "CLI - Plugin - Install - Run") + assert.ElementsMatch(t, commandEventKeys, slices.Collect(maps.Keys(command.Properties))) + assert.Equal(t, pluginTestTenant, command.Properties["tenant"]) + }) + + t.Run("an install without the tenant flag sends an empty tenant, as any command does", func(t *testing.T) { + e, _ := newInstallEnv(t) + recorder := newEventRecorder(t, e.cli) + e.answer("y\n") + + events, err := e.tracked(t, recorder, "plugin", "install", "checkmate") + + require.NoError(t, err) + + command := only(t, events, "CLI - Plugin - Install - Run") + assert.ElementsMatch(t, commandEventKeys, slices.Collect(maps.Keys(command.Properties))) + assert.Empty(t, command.Properties["tenant"]) + assert.NotContains(t, wireJSON(t, only(t, events, "CLI - Plugin - Install")), "tenant") + }) + + t.Run("an install that changed nothing sends no plugin event", func(t *testing.T) { + e, _ := newInstallEnv(t) + recorder := newEventRecorder(t, e.cli) + e.install("1.0.0") + + events, err := e.tracked(t, recorder, "plugin", "install", "checkmate") + + require.NoError(t, err) + assert.Contains(t, e.stderr.String(), "already installed") + assert.Equal(t, []string{"CLI - Plugin - Install - Run"}, eventNames(events)) + }) + + t.Run("a person who declines sends no plugin event", func(t *testing.T) { + e, _ := newInstallEnv(t) + recorder := newEventRecorder(t, e.cli) + e.answer("n\n") + + events, err := e.tracked(t, recorder, "plugin", "install", "checkmate", "--tenant", pluginTestTenant) + + require.NoError(t, err) + assert.Empty(t, e.installedIDs()) + assert.Equal(t, []string{"CLI - Plugin - Install - Run"}, eventNames(events)) + }) + + t.Run("an install that fails sends no plugin event", func(t *testing.T) { + e, _ := newInstallEnv(t) + recorder := newEventRecorder(t, e.cli) + + events, err := e.tracked(t, recorder, "plugin", "install", "checkmate@3.0.0") + + require.Error(t, err) + assert.Equal(t, []string{"CLI - Plugin - Install - Run"}, eventNames(events)) + assert.Equal(t, "false", only(t, events, "CLI - Plugin - Install - Run").Properties["success"]) + }) + + t.Run("a version that was installed before and is switched to", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"), e.release("2.0.0"))) + recorder := newEventRecorder(t, e.cli) + e.install("2.0.0") + + events, err := e.tracked(t, recorder, "plugin", "install", "checkmate@1.0.0") + + require.NoError(t, err) + + install := only(t, events, "CLI - Plugin - Install") + assert.Equal(t, pluginProperties(map[string]string{"plugin": "checkmate", "plugin_version": "1.0.0"}), install.Properties) + }) +} + +func TestPluginChangeTelemetry_Update(t *testing.T) { + t.Run("an update sends the version it moved to", func(t *testing.T) { + e, _, _ := newUpdateEnv(t, withAsks, withAsks) + recorder := newEventRecorder(t, e.cli) + + events, err := e.tracked(t, recorder, "plugin", "update", "checkmate", "--tenant", pluginTestTenant) + + require.NoError(t, err) + assert.Equal(t, []string{"CLI - Plugin - Update", "CLI - Plugin - Update - Run"}, eventNames(events)) + update := only(t, events, "CLI - Plugin - Update") + assert.Equal(t, pluginProperties(map[string]string{"plugin": "checkmate", "plugin_version": "2.0.0"}), update.Properties) + assert.NotContains(t, wireJSON(t, update), pluginTestTenant) + + command := only(t, events, "CLI - Plugin - Update - Run") + assert.ElementsMatch(t, commandEventKeys, slices.Collect(maps.Keys(command.Properties))) + assert.Equal(t, pluginTestTenant, command.Properties["tenant"]) + }) + + t.Run("an update to the version that is installed sends no plugin event", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"))) + e.installPlugin(pluginTestID, "") + recorder := newEventRecorder(t, e.cli) + + events, err := e.tracked(t, recorder, "plugin", "update", "checkmate") + + require.NoError(t, err) + assert.Contains(t, e.stderr.String(), "is up to date") + assert.Equal(t, []string{"CLI - Plugin - Update - Run"}, eventNames(events)) + assert.Empty(t, only(t, events, "CLI - Plugin - Update - Run").Properties["tenant"]) + }) + + t.Run("an update that is declined sends no plugin event", func(t *testing.T) { + e, _, _ := newUpdateEnv(t, withScopes("read:clients"), withScopes("read:clients", "read:logs")) + recorder := newEventRecorder(t, e.cli) + e.answer("n\n") + + events, err := e.tracked(t, recorder, "plugin", "update", "checkmate", "--tenant", pluginTestTenant) + + require.NoError(t, err) + assert.Equal(t, "1.0.0", e.activeVersion(pluginTestID)) + assert.Equal(t, []string{"CLI - Plugin - Update - Run"}, eventNames(events)) + }) + + t.Run("an update that fails sends no plugin event", func(t *testing.T) { + e, _, _ := newUpdateEnv(t, withAsks, withAsks) + recorder := newEventRecorder(t, e.cli) + e.server.Fail(pluginArtifactPath("checkmate", "2.0.0"), 503) + + events, err := e.tracked(t, recorder, "plugin", "update", "checkmate", "--tenant", pluginTestTenant) + + require.Error(t, err) + assert.Equal(t, []string{"CLI - Plugin - Update - Run"}, eventNames(events)) + }) +} + +func TestPluginChangeTelemetry_UpdateAll(t *testing.T) { + t.Run("one event for each plugin that was updated, none for a pinned one", func(t *testing.T) { + e, _, _ := newPairEnv(t, true, withAsks, withAsks) + recorder := newEventRecorder(t, e.cli) + + events, err := e.tracked(t, recorder, "plugin", "update", "--all", "--tenant", pluginTestTenant) + + require.NoError(t, err) + assert.Equal(t, []string{"CLI - Plugin - Update", "CLI - Plugin - Update - Run"}, eventNames(events)) + + update := only(t, events, "CLI - Plugin - Update") + assert.Equal(t, pluginProperties(map[string]string{"plugin": "other", "plugin_version": "2.0.0"}), update.Properties) + assert.NotContains(t, wireJSON(t, update), pluginTestTenant) + assert.Equal(t, pluginTestTenant, only(t, events, "CLI - Plugin - Update - Run").Properties["tenant"]) + }) + + t.Run("an update that failed or was declined is left out of a mixed result", func(t *testing.T) { + tests := []struct { + name string + setup func(e *pluginEnv) + want []string + }{ + { + name: "the second download fails", + setup: func(e *pluginEnv) { e.server.Fail(pluginArtifactPath("other", "2.0.0"), 503) }, + want: []string{"checkmate"}, + }, + { + name: "the person declines the first plugin", + setup: func(e *pluginEnv) { e.answer("n\ny\n") }, + want: []string{"other"}, + }, + { + name: "every plugin was updated", + setup: func(e *pluginEnv) {}, + want: []string{"checkmate", "other"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e, _, _ := newPairEnv(t, false, withScopes("read:clients"), withScopes("read:clients", "read:logs")) + recorder := newEventRecorder(t, e.cli) + + e.answer("y\ny\n") + test.setup(e) + + events, _ := e.tracked(t, recorder, "plugin", "update", "--all", "--tenant", pluginTestTenant) + + var updated []string + + for _, event := range events { + if event.Event == "CLI - Plugin - Update" { + assert.Equal(t, "2.0.0", event.Properties["plugin_version"]) + assert.NotContains(t, event.Properties, "success") + + updated = append(updated, event.Properties["plugin"]) + } + } + + slices.Sort(updated) + + assert.Equal(t, test.want, updated) + }) + } + }) + + t.Run("updates whose approval could not be saved send no plugin event", func(t *testing.T) { + e, _, _ := newPairEnv(t, false, withScopes("read:clients"), withScopes("read:clients", "read:logs")) + recorder := newEventRecorder(t, e.cli) + e.answer("y\ny\n") + + // The approvals of the operation go to a directory that can be read and not + // written, so asking for consent works and recording the approvals fails + // after the plugins were updated. + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("a directory cannot be made read-only for this user") + } + + readOnly := t.TempDir() + require.NoError(t, os.Chmod(readOnly, 0o500)) + t.Cleanup(func() { _ = os.Chmod(readOnly, 0o700) }) + + e.svc.approvals = &approvals.Approvals{Dir: readOnly} + + events, err := e.tracked(t, recorder, "plugin", "update", "--all", "--tenant", pluginTestTenant) + + require.Error(t, err) + assert.Equal(t, "2.0.0", e.activeVersion(pluginTestID)) + assert.Equal(t, []string{"CLI - Plugin - Update - Run"}, eventNames(events)) + }) + + t.Run("a plugin that is already current sends no plugin event", func(t *testing.T) { + e := newPluginEnv(t) + e.publish(e.index(e.release("1.0.0"))) + e.installPlugin(pluginTestID, "") + recorder := newEventRecorder(t, e.cli) + + events, err := e.tracked(t, recorder, "plugin", "update", "--all") + + require.NoError(t, err) + assert.Equal(t, []string{"CLI - Plugin - Update - Run"}, eventNames(events)) + }) +} + +func TestPluginChangeTelemetry_Uninstall(t *testing.T) { + newEnv := func(t *testing.T) *pluginEnv { + t.Helper() + + e, _, _ := newUpdateEnv(t, withAsks, withAsks) + e.installPlugin(pluginTestID, "2.0.0") + + return e + } + + t.Run("sends the version that was active before the plugin was removed", func(t *testing.T) { + e := newEnv(t) + recorder := newEventRecorder(t, e.cli) + + events, err := e.tracked(t, recorder, "plugin", "uninstall", "checkmate", "--force", "--tenant", pluginTestTenant) + + require.NoError(t, err) + assert.Empty(t, e.installedIDs()) + assert.Equal(t, []string{"CLI - Plugin - Uninstall", "CLI - Plugin - Uninstall - Run"}, eventNames(events)) + + uninstall := only(t, events, "CLI - Plugin - Uninstall") + assert.Equal(t, pluginProperties(map[string]string{"plugin": "checkmate", "plugin_version": "2.0.0"}), uninstall.Properties) + assert.NotContains(t, wireJSON(t, uninstall), pluginTestTenant) + assert.Equal(t, pluginTestTenant, only(t, events, "CLI - Plugin - Uninstall - Run").Properties["tenant"]) + }) + + t.Run("a person who does not confirm sends no plugin event", func(t *testing.T) { + e := newEnv(t) + recorder := newEventRecorder(t, e.cli) + e.Terminal = true + + events, err := e.tracked(t, recorder, "plugin", "uninstall", "checkmate") + + require.NoError(t, err) + assert.Equal(t, []string{pluginTestID}, e.installedIDs()) + assert.Equal(t, []string{"CLI - Plugin - Uninstall - Run"}, eventNames(events)) + assert.Empty(t, only(t, events, "CLI - Plugin - Uninstall - Run").Properties["tenant"]) + }) + + t.Run("a plugin that is not installed sends no plugin event", func(t *testing.T) { + e := newEnv(t) + recorder := newEventRecorder(t, e.cli) + + events, err := e.tracked(t, recorder, "plugin", "uninstall", "nothing", "--force") + + require.Error(t, err) + assert.Equal(t, []string{"CLI - Plugin - Uninstall - Run"}, eventNames(events)) + }) + + t.Run("env auth mode has no install ID", func(t *testing.T) { + e := newEnv(t) + recorder := newEventRecorder(t, e.cli) + + t.Setenv(authModeEnvVar, authModeEnv) + t.Setenv(envDomain, pluginTestTenant) + t.Setenv(envAPIToken, "env-token") + + e.cli.Config.InstallID = "" + + events, err := e.tracked(t, recorder, "plugin", "uninstall", "checkmate", "--force") + + require.NoError(t, err) + assert.Empty(t, e.installedIDs()) + assert.Empty(t, events) + }) +} + +func TestPluginGroupFollowsTheTenantPatternOfOtherCommands(t *testing.T) { + const leak = "leak.us.auth0.com" + + t.Run("a plugin command that does not log in sends the tenant flag", func(t *testing.T) { + e, _ := newInstallEnv(t) + recorder := newEventRecorder(t, e.cli) + + events, err := e.tracked(t, recorder, "plugin", "install", "checkmate@3.0.0", "--tenant", leak) + + require.Error(t, err) + require.Equal(t, []string{"CLI - Plugin - Install - Run"}, eventNames(events)) + assert.Equal(t, leak, events[0].Properties["tenant"]) + }) + + t.Run("a core command sends the tenant flag as before", func(t *testing.T) { + e := newPluginEnv(t) + recorder := newEventRecorder(t, e.cli) + + events, _ := e.tracked(t, recorder, "apps", "show", "a", "b", "--tenant", leak) + + require.Len(t, events, 1, eventNames(events)) + assert.Equal(t, leak, events[0].Properties["tenant"]) + }) +} + +// appendToFile adds text to the end of a file, which makes a plugin fail its +// verification. +func appendToFile(path, text string) error { + file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + return err + } + + if _, err := file.WriteString(text); err != nil { + _ = file.Close() + + return err + } + + return file.Close() +} diff --git a/internal/cli/root.go b/internal/cli/root.go index efe2da54f..889a7216f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -758,6 +758,13 @@ func trackCommandOutcome(cli *cli, executionErr error) { return } + // A plugin run sends the event of the plugin, and never the event of a command, + // which carries the tenant and the output format. + if cli.pluginRun != nil { + trackPluginRun(cli, executionErr) + return + } + installID := resolveInstallIDForTracking(cli) if installID == "" { return