Skip to content

Commit 97e6b0c

Browse files
committed
feat(analytics): enhance command tracking with success and failure events
1 parent 2f8e706 commit 97e6b0c

6 files changed

Lines changed: 225 additions & 60 deletions

File tree

internal/analytics/analytics.go

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"sync"
1313
"time"
1414

15-
"github.com/spf13/cobra"
1615
"golang.org/x/text/cases"
1716
"golang.org/x/text/language"
1817

@@ -46,15 +45,20 @@ func NewTracker() *Tracker {
4645

4746
func (t *Tracker) TrackFirstLogin(id string, loginType string) {
4847
eventName := fmt.Sprintf("%s - Auth0 - First Login", eventNamePrefix)
49-
t.track(eventName, id)
48+
t.track(eventName, id, nil)
5049

5150
eventName = fmt.Sprintf("%s - Auth0 - First Login - %s", eventNamePrefix, loginType)
52-
t.track(eventName, id)
51+
t.track(eventName, id, nil)
5352
}
5453

55-
func (t *Tracker) TrackCommandRun(cmd *cobra.Command, id string) {
56-
eventName := generateRunEventName(cmd.CommandPath())
57-
t.track(eventName, id)
54+
func (t *Tracker) TrackCommandSucceeded(commandPath string, id string) {
55+
eventName := generateSucceededEventName(commandPath)
56+
t.track(eventName, id, map[string]string{"success": "true", "error_class": "none"})
57+
}
58+
59+
func (t *Tracker) TrackCommandFailed(commandPath string, id string, properties map[string]string) {
60+
eventName := generateFailedEventName(commandPath)
61+
t.track(eventName, id, properties)
5862
}
5963

6064
func (t *Tracker) Wait(ctx context.Context) {
@@ -73,12 +77,12 @@ func (t *Tracker) Wait(ctx context.Context) {
7377
}
7478
}
7579

76-
func (t *Tracker) track(eventName string, id string) {
80+
func (t *Tracker) track(eventName string, id string, properties map[string]string) {
7781
if !shouldTrack() {
7882
return
7983
}
8084

81-
event := newEvent(eventName, id)
85+
event := newEvent(eventName, id, properties)
8286

8387
t.wg.Add(1)
8488
go t.sendEvent(event)
@@ -111,22 +115,32 @@ func (t *Tracker) sendEvent(event *event) {
111115
}()
112116
}
113117

114-
func newEvent(eventName string, id string) *event {
118+
func newEvent(eventName string, id string, properties map[string]string) *event {
119+
eventProperties := map[string]string{
120+
versionKey: buildinfo.Version,
121+
osKey: runtime.GOOS,
122+
archKey: runtime.GOARCH,
123+
}
124+
125+
for k, v := range properties {
126+
eventProperties[k] = v
127+
}
128+
115129
return &event{
116-
App: appID,
117-
ID: id,
118-
Event: eventName,
119-
Timestamp: timestamp(),
120-
Properties: map[string]string{
121-
versionKey: buildinfo.Version,
122-
osKey: runtime.GOOS,
123-
archKey: runtime.GOARCH,
124-
},
130+
App: appID,
131+
ID: id,
132+
Event: eventName,
133+
Timestamp: timestamp(),
134+
Properties: eventProperties,
125135
}
126136
}
127137

128-
func generateRunEventName(command string) string {
129-
return generateEventName(command, "Run")
138+
func generateSucceededEventName(command string) string {
139+
return generateEventName(command, "Succeeded")
140+
}
141+
142+
func generateFailedEventName(command string) string {
143+
return generateEventName(command, "Failed")
130144
}
131145

132146
func generateEventName(command string, action string) string {

internal/analytics/analytics_test.go

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,35 +34,37 @@ func TestGenerateEventName(t *testing.T) {
3434
})
3535
}
3636

37-
func TestGenerateRunEventName(t *testing.T) {
38-
t.Run("generates from root command run", func(t *testing.T) {
39-
want := "CLI - Auth0 - Run"
40-
got := generateRunEventName("auth0")
37+
func TestGenerateSucceededEventName(t *testing.T) {
38+
t.Run("generates from root command", func(t *testing.T) {
39+
want := "CLI - Auth0 - Succeeded"
40+
got := generateSucceededEventName("auth0")
4141
assert.Equal(t, want, got)
4242
})
4343

44-
t.Run("generates from top-level command run", func(t *testing.T) {
45-
want := "CLI - Auth0 - Apps - Run"
46-
got := generateRunEventName("auth0 apps")
44+
t.Run("generates from top-level command", func(t *testing.T) {
45+
want := "CLI - Auth0 - Apps - Succeeded"
46+
got := generateSucceededEventName("auth0 apps")
4747
assert.Equal(t, want, got)
4848
})
49+
}
4950

50-
t.Run("generates from subcommand run", func(t *testing.T) {
51-
want := "CLI - Apps - List - Run"
52-
got := generateRunEventName("auth0 apps list")
51+
func TestGenerateFailedEventName(t *testing.T) {
52+
t.Run("generates from root command", func(t *testing.T) {
53+
want := "CLI - Auth0 - Failed"
54+
got := generateFailedEventName("auth0")
5355
assert.Equal(t, want, got)
5456
})
5557

56-
t.Run("generates from deep subcommand run", func(t *testing.T) {
57-
want := "CLI - Apis - Scopes List - Run"
58-
got := generateRunEventName("auth0 apis scopes list")
58+
t.Run("generates from subcommand", func(t *testing.T) {
59+
want := "CLI - Apps - List - Failed"
60+
got := generateFailedEventName("auth0 apps list")
5961
assert.Equal(t, want, got)
6062
})
6163
}
6264

6365
func TestNewEvent(t *testing.T) {
6466
t.Run("creates a new event instance", func(t *testing.T) {
65-
event := newEvent("event", "id")
67+
event := newEvent("event", "id", nil)
6668
// Assert that the interval between the event timestamp and now is within 1 second.
6769
assert.WithinDuration(t, time.Now(), time.Unix(0, event.Timestamp*int64(1000000)), 1*time.Second)
6870
assert.Equal(t, event.App, appID)
@@ -71,4 +73,11 @@ func TestNewEvent(t *testing.T) {
7173
assert.Equal(t, event.Properties[osKey], runtime.GOOS)
7274
assert.Equal(t, event.Properties[archKey], runtime.GOARCH)
7375
})
76+
77+
t.Run("merges extra properties", func(t *testing.T) {
78+
event := newEvent("event", "id", map[string]string{"success": "false", "error_class": "auth"})
79+
assert.Equal(t, "false", event.Properties["success"])
80+
assert.Equal(t, "auth", event.Properties["error_class"])
81+
assert.Equal(t, runtime.GOOS, event.Properties[osKey])
82+
})
7483
}

internal/cli/cli.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,15 @@ type cli struct {
4040
tracker *analytics.Tracker
4141

4242
// Set of flags which are user specified.
43-
debug bool
44-
tenant string
45-
json bool
46-
jsonCompact bool
47-
csv bool
48-
force bool
49-
noInput bool
50-
noColor bool
43+
debug bool
44+
tenant string
45+
json bool
46+
jsonCompact bool
47+
csv bool
48+
force bool
49+
noInput bool
50+
noColor bool
51+
executedCommandPath string
5152

5253
Config config.Config
5354
}

internal/cli/login.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,6 @@ func loginCmd(cli *cli) *cobra.Command {
223223
}
224224
}
225225

226-
cli.tracker.TrackCommandRun(cmd, cli.Config.InstallID)
227-
228226
if len(cli.Config.Tenants) > 1 {
229227
cli.renderer.Infof("%s Switch between authenticated tenants with `auth0 tenants use <tenant>`",
230228
ansi.Faint("Hint:"),

internal/cli/root.go

Lines changed: 87 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@ package cli
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"os"
78
"os/signal"
89
"time"
910
"unicode"
1011

12+
"github.com/auth0/go-auth0/management"
1113
"github.com/spf13/cobra"
1214

1315
"github.com/auth0/auth0-cli/internal/analytics"
1416
"github.com/auth0/auth0-cli/internal/ansi"
1517
"github.com/auth0/auth0-cli/internal/buildinfo"
18+
"github.com/auth0/auth0-cli/internal/config"
1619
"github.com/auth0/auth0-cli/internal/display"
1720
"github.com/auth0/auth0-cli/internal/instrumentation"
1821
)
@@ -62,17 +65,19 @@ func Execute() {
6265
ansi.InitConsole()
6366

6467
cancelCtx := contextWithCancel()
65-
if err := rootCmd.ExecuteContext(cancelCtx); err != nil {
68+
err := rootCmd.ExecuteContext(cancelCtx)
69+
trackCommandOutcome(cli, err)
70+
71+
timeoutCtx, cancel := context.WithTimeout(cancelCtx, 3*time.Second)
72+
defer cancel()
73+
cli.tracker.Wait(timeoutCtx) // No event should be tracked after this has run.
74+
75+
if err != nil {
6676
renderErrorMessage(cli.renderer, err.Error())
6777

6878
instrumentation.ReportException(err)
6979
os.Exit(1) // nolint:gocritic
7080
}
71-
72-
timeoutCtx, cancel := context.WithTimeout(cancelCtx, 3*time.Second)
73-
// Defers are executed in LIFO order.
74-
defer cancel()
75-
defer cli.tracker.Wait(timeoutCtx) // No event should be tracked after this has run, or it will panic e.g. in earlier deferred functions.
7681
}
7782

7883
func buildRootCmd(cli *cli) *cobra.Command {
@@ -84,6 +89,8 @@ func buildRootCmd(cli *cli) *cobra.Command {
8489
Long: rootShort + "\n" + getLogin(cli),
8590
Version: buildinfo.GetVersionWithCommit(),
8691
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
92+
cli.executedCommandPath = cmd.CommandPath()
93+
8794
ansi.Initialize(cli.noColor)
8895
prepareInteractivity(cmd)
8996
cli.configureRenderer()
@@ -92,16 +99,6 @@ func buildRootCmd(cli *cli) *cobra.Command {
9299
return nil
93100
}
94101

95-
// We're tracking the login command in its Run method, so
96-
// we'll only add this defer if the command is not login.
97-
defer func() {
98-
if cli.tracker != nil &&
99-
cmd.CommandPath() != "auth0 login" &&
100-
cli.Config.IsLoggedInWithTenant(cli.tenant) {
101-
cli.tracker.TrackCommandRun(cmd, cli.Config.InstallID)
102-
}
103-
}()
104-
105102
if err := cli.setupWithAuthentication(cmd.Context()); err != nil {
106103
return err
107104
}
@@ -225,3 +222,77 @@ func renderErrorMessage(display *display.Renderer, errorMessage string) {
225222
display.Errorf(humanReadableErrorMessage)
226223
display.Newline()
227224
}
225+
226+
func trackCommandOutcome(cli *cli, executionErr error) {
227+
if cli.tracker == nil {
228+
return
229+
}
230+
231+
installID := resolveInstallIDForTracking(cli)
232+
if installID == "" {
233+
return
234+
}
235+
236+
if cli.executedCommandPath == "" {
237+
cli.executedCommandPath = "auth0"
238+
}
239+
240+
if executionErr != nil {
241+
cli.tracker.TrackCommandFailed(cli.executedCommandPath, installID, classifyCommandFailure(executionErr))
242+
return
243+
}
244+
245+
cli.tracker.TrackCommandSucceeded(cli.executedCommandPath, installID)
246+
}
247+
248+
func resolveInstallIDForTracking(cli *cli) string {
249+
if cli.Config.InstallID != "" {
250+
return cli.Config.InstallID
251+
}
252+
253+
if err := cli.Config.Initialize(); err != nil {
254+
if errors.Is(err, config.ErrConfigFileMissing) {
255+
return ""
256+
}
257+
return ""
258+
}
259+
260+
return cli.Config.InstallID
261+
}
262+
263+
func classifyCommandFailure(err error) map[string]string {
264+
properties := map[string]string{
265+
"success": "false",
266+
"error_class": "unknown",
267+
}
268+
269+
if errors.Is(err, config.ErrInvalidToken) || errors.Is(err, config.ErrMalformedToken) {
270+
properties["error_class"] = "auth"
271+
return properties
272+
}
273+
274+
var missingScopesErr config.ErrTokenMissingRequiredScopes
275+
if errors.As(err, &missingScopesErr) {
276+
properties["error_class"] = "auth"
277+
return properties
278+
}
279+
280+
var managementErr management.Error
281+
if errors.As(err, &managementErr) {
282+
status := managementErr.Status()
283+
switch {
284+
case status == 401 || status == 403:
285+
properties["error_class"] = "auth"
286+
case status == 400 || status == 422:
287+
properties["error_class"] = "validation"
288+
case status == 404:
289+
properties["error_class"] = "not_found"
290+
case status == 429:
291+
properties["error_class"] = "rate_limit"
292+
case status >= 500:
293+
properties["error_class"] = "api"
294+
}
295+
}
296+
297+
return properties
298+
}

0 commit comments

Comments
 (0)