sourcegraph/dev/sg/analytics.go
Noah S-C e669330215
feat(sg): sqlite-backed local store for sg analytics (#63578)
Removes existing `sg analytics` command and replaces it with a
one-per-invocation sqlite backed approach. This is a local storage for
invocation events before theyre pushed to bigquery

## Test plan

```
sqlite> select * from analytics;
0190792e-af38-751a-b93e-8481290a18b6|1|{"args":[],"command":"sg help","flags":{"help":null,"sg":null},"nargs":0,"end_time":"2024-07-03T15:20:21.069837706Z","success":true}
0190792f-4e2b-7c35-98d6-ad73cab82391|1|{"args":["dotcom"],"command":"sg live","flags":{"live":null,"sg":null},"nargs":1,"end_time":"2024-07-03T15:21:04.563232429Z","success":true}
```

## Changelog

<!-- OPTIONAL; info at
https://www.notion.so/sourcegraph/Writing-a-changelog-entry-dd997f411d524caabf0d8d38a24a878c
-->

---------

Co-authored-by: William Bezuidenhout <william.bezuidenhout@sourcegraph.com>
2024-07-09 12:47:49 +02:00

136 lines
3.7 KiB
Go

package main
import (
"fmt"
"runtime"
"strings"
"github.com/urfave/cli/v2"
"github.com/sourcegraph/sourcegraph/dev/sg/internal/analytics"
"github.com/sourcegraph/sourcegraph/dev/sg/internal/std"
"github.com/sourcegraph/sourcegraph/dev/sg/interrupt"
)
// addAnalyticsHooks wraps command actions with analytics hooks. We reconstruct commandPath
// ourselves because the library's state (and hence .FullName()) seems to get a bit funky.
//
// It also handles watching for panics and formatting them in a useful manner.
func addAnalyticsHooks(commandPath []string, commands []*cli.Command) {
for _, command := range commands {
fullCommandPath := append(commandPath, command.Name)
if len(command.Subcommands) > 0 {
addAnalyticsHooks(fullCommandPath, command.Subcommands)
}
// No action to perform analytics on
if command.Action == nil {
continue
}
// Set up analytics hook for command
fullCommand := strings.Join(fullCommandPath, " ")
// Wrap action with analytics
wrappedAction := command.Action
command.Action = func(cmd *cli.Context) (actionErr error) {
cmdFlags := make(map[string][]string)
for _, parent := range cmd.Lineage() {
if parent.Command == nil {
continue
}
cmdFlags[parent.Command.Name] = parent.LocalFlagNames()
}
cmdCtx, err := analytics.NewInvocation(cmd.Context, cmd.App.Version, map[string]any{
"command": fullCommand,
"flags": cmdFlags,
"args": cmd.Args().Slice(),
"nargs": cmd.NArg(),
})
if err != nil {
std.Out.WriteWarningf("Failed to create analytics event: %s", err)
return
}
cmd.Context = cmdCtx
// Make sure analytics are persisted before exit (interrupts or panics)
defer func() {
if p := recover(); p != nil {
// Render a more elegant message
std.Out.WriteWarningf("Encountered panic - please open an issue with the command output:\n\t%s", sgBugReportTemplate)
message := fmt.Sprintf("%v:\n%s", p, getRelevantStack("addAnalyticsHooks"))
actionErr = cli.Exit(message, 1)
// Log event
err := analytics.InvocationPanicked(cmd.Context, p)
maybeLog("failed to persist analytics panic event: %s", err)
}
}()
interrupt.Register(func() {
err := analytics.InvocationCancelled(cmd.Context)
maybeLog("failed to persist analytics cancel event: %s", err)
})
// Call the underlying action
actionErr = wrappedAction(cmd)
// Capture analytics post-run
if actionErr != nil {
err := analytics.InvocationFailed(cmd.Context, actionErr)
maybeLog("failed to persist analytics cancel event: %s", err)
} else {
err := analytics.InvocationSucceeded(cmd.Context)
maybeLog("failed to persist analytics success event: %s", err)
}
return actionErr
}
}
}
func maybeLog(fmt string, err error) { //nolint:unparam
if err == nil {
return
}
std.Out.WriteWarningf(fmt, err)
}
// getRelevantStack generates a stacktrace that encapsulates the relevant parts of a
// stacktrace for user-friendly reading.
func getRelevantStack(excludeFunctions ...string) string {
callers := make([]uintptr, 32)
n := runtime.Callers(3, callers) // recover -> getRelevantStack -> runtime.Callers
frames := runtime.CallersFrames(callers[:n])
var stack strings.Builder
for {
frame, next := frames.Next()
var excludedFunction bool
for _, e := range excludeFunctions {
if strings.Contains(frame.Function, e) {
excludedFunction = true
break
}
}
// Only include frames from sg and things that are not excluded.
if !strings.Contains(frame.File, "dev/sg/") || excludedFunction {
if !next {
break
}
continue
}
stack.WriteString(frame.Function)
stack.WriteByte('\n')
stack.WriteString(fmt.Sprintf("\t%s:%d\n", frame.File, frame.Line))
if !next {
break
}
}
return stack.String()
}