Merge pull request #630 from docker/cli_metrics_failures

Cli metrics failures
This commit is contained in:
Guillaume Tardif 2020-09-18 18:26:54 +02:00 committed by GitHub
commit 01ea2488a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 135 additions and 52 deletions

View File

@ -28,6 +28,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/docker/compose-cli/context/store" "github.com/docker/compose-cli/context/store"
"github.com/docker/compose-cli/errdefs"
"github.com/docker/compose-cli/prompt" "github.com/docker/compose-cli/prompt"
) )
@ -40,7 +41,7 @@ type ContextParams struct {
} }
// ErrSubscriptionNotFound is returned when a required subscription is not found // ErrSubscriptionNotFound is returned when a required subscription is not found
var ErrSubscriptionNotFound = errors.New("subscription not found") var ErrSubscriptionNotFound = errors.Wrapf(errdefs.ErrNotFound, "subscription")
// IsSubscriptionNotFoundError returns true if the unwrapped error is IsSubscriptionNotFoundError // IsSubscriptionNotFoundError returns true if the unwrapped error is IsSubscriptionNotFoundError
func IsSubscriptionNotFoundError(err error) bool { func IsSubscriptionNotFoundError(err error) bool {
@ -138,7 +139,7 @@ func (helper contextCreateACIHelper) chooseGroup(ctx context.Context, subscripti
group, err := helper.selector.Select("Select a resource group", groupNames) group, err := helper.selector.Select("Select a resource group", groupNames)
if err != nil { if err != nil {
if err == terminal.InterruptErr { if err == terminal.InterruptErr {
os.Exit(0) return resources.Group{}, errdefs.ErrCanceled
} }
return resources.Group{}, err return resources.Group{}, err

View File

@ -70,7 +70,7 @@ $ docker context create my-context --description "some description" --docker "ho
Use: "create CONTEXT", Use: "create CONTEXT",
Short: "Create new context", Short: "Create new context",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
mobycli.Exec() mobycli.Exec(cmd.Root())
return nil return nil
}, },
Long: longHelp, Long: longHelp,

View File

@ -27,7 +27,7 @@ func inspectCommand() *cobra.Command {
Use: "inspect", Use: "inspect",
Short: "Display detailed information on one or more contexts", Short: "Display detailed information on one or more contexts",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
mobycli.Exec() mobycli.Exec(cmd.Root())
return nil return nil
}, },
} }

View File

@ -69,7 +69,7 @@ func runList(cmd *cobra.Command, opts lsOpts) error {
return err return err
} }
if opts.format != "" { if opts.format != "" {
mobycli.Exec() mobycli.Exec(cmd.Root())
return nil return nil
} }

View File

@ -56,7 +56,7 @@ func runLogin(cmd *cobra.Command, args []string) error {
backend := args[0] backend := args[0]
return errors.New("unknown backend type for cloud login: " + backend) return errors.New("unknown backend type for cloud login: " + backend)
} }
mobycli.Exec() mobycli.Exec(cmd.Root())
return nil return nil
} }

View File

@ -37,6 +37,6 @@ func Command() *cobra.Command {
} }
func runLogout(cmd *cobra.Command, args []string) error { func runLogout(cmd *cobra.Command, args []string) error {
mobycli.Exec() mobycli.Exec(cmd.Root())
return nil return nil
} }

View File

@ -51,7 +51,7 @@ func runVersion(cmd *cobra.Command, version string) error {
// we don't want to fail on error, there is an error if the engine is not available but it displays client version info // we don't want to fail on error, there is an error if the engine is not available but it displays client version info
// Still, technically the [] byte versionResult could be nil, just let the original command display what it has to display // Still, technically the [] byte versionResult could be nil, just let the original command display what it has to display
if versionResult == nil { if versionResult == nil {
mobycli.Exec() mobycli.Exec(cmd.Root())
return nil return nil
} }
var s string = string(versionResult) var s string = string(versionResult)

View File

@ -102,7 +102,7 @@ func main() {
SilenceUsage: true, SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error { PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if !isContextAgnosticCommand(cmd) { if !isContextAgnosticCommand(cmd) {
mobycli.ExecIfDefaultCtxType(cmd.Context()) mobycli.ExecIfDefaultCtxType(cmd.Context(), cmd.Root())
} }
return nil return nil
}, },
@ -136,7 +136,7 @@ func main() {
helpFunc := root.HelpFunc() helpFunc := root.HelpFunc()
root.SetHelpFunc(func(cmd *cobra.Command, args []string) { root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if !isContextAgnosticCommand(cmd) { if !isContextAgnosticCommand(cmd) {
mobycli.ExecIfDefaultCtxType(cmd.Context()) mobycli.ExecIfDefaultCtxType(cmd.Context(), cmd.Root())
} }
helpFunc(cmd, args) helpFunc(cmd, args)
}) })
@ -158,7 +158,7 @@ func main() {
// --host and --version should immediately be forwarded to the original cli // --host and --version should immediately be forwarded to the original cli
if opts.Host != "" || opts.Version { if opts.Host != "" || opts.Version {
mobycli.Exec() mobycli.Exec(root)
} }
if opts.Config == "" { if opts.Config == "" {
@ -171,7 +171,7 @@ func main() {
s, err := store.New(configDir) s, err := store.New(configDir)
if err != nil { if err != nil {
mobycli.Exec() mobycli.Exec(root)
} }
ctype := store.DefaultContextType ctype := store.DefaultContextType
@ -185,41 +185,43 @@ func main() {
root.AddCommand(volume.ACICommand()) root.AddCommand(volume.ACICommand())
} }
metrics.Track(ctype, os.Args[1:], root.PersistentFlags())
ctx = apicontext.WithCurrentContext(ctx, currentContext) ctx = apicontext.WithCurrentContext(ctx, currentContext)
ctx = store.WithContextStore(ctx, s) ctx = store.WithContextStore(ctx, s)
if err = root.ExecuteContext(ctx); err != nil { if err = root.ExecuteContext(ctx); err != nil {
// if user canceled request, simply exit without any error message // if user canceled request, simply exit without any error message
if errors.Is(ctx.Err(), context.Canceled) { if errdefs.IsErrCanceled(err) || errors.Is(ctx.Err(), context.Canceled) {
metrics.Track(ctype, os.Args[1:], root.PersistentFlags(), metrics.CanceledStatus)
os.Exit(130) os.Exit(130)
} }
if ctype == store.AwsContextType { if ctype == store.AwsContextType {
exit(root, currentContext, errors.Errorf(`%q context type has been renamed. Recreate the context by running: exit(root, currentContext, errors.Errorf(`%q context type has been renamed. Recreate the context by running:
$ docker context create %s <name>`, cc.Type(), store.EcsContextType)) $ docker context create %s <name>`, cc.Type(), store.EcsContextType), ctype)
} }
// Context should always be handled by new CLI // Context should always be handled by new CLI
requiredCmd, _, _ := root.Find(os.Args[1:]) requiredCmd, _, _ := root.Find(os.Args[1:])
if requiredCmd != nil && isContextAgnosticCommand(requiredCmd) { if requiredCmd != nil && isContextAgnosticCommand(requiredCmd) {
exit(root, currentContext, err) exit(root, currentContext, err, ctype)
} }
mobycli.ExecIfDefaultCtxType(ctx) mobycli.ExecIfDefaultCtxType(ctx, root)
checkIfUnknownCommandExistInDefaultContext(err, currentContext) checkIfUnknownCommandExistInDefaultContext(err, currentContext, root)
exit(root, currentContext, err) exit(root, currentContext, err, ctype)
} }
metrics.Track(ctype, os.Args[1:], root.PersistentFlags(), metrics.SuccessStatus)
} }
func exit(cmd *cobra.Command, ctx string, err error) { func exit(root *cobra.Command, ctx string, err error, ctype string) {
metrics.Track(ctype, os.Args[1:], root.PersistentFlags(), metrics.FailureStatus)
if errors.Is(err, errdefs.ErrLoginRequired) { if errors.Is(err, errdefs.ErrLoginRequired) {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(errdefs.ExitCodeLoginRequired) os.Exit(errdefs.ExitCodeLoginRequired)
} }
if errors.Is(err, errdefs.ErrNotImplemented) { if errors.Is(err, errdefs.ErrNotImplemented) {
cmd, _, _ := cmd.Traverse(os.Args[1:]) cmd, _, _ := root.Traverse(os.Args[1:])
name := cmd.Name() name := cmd.Name()
parent := cmd.Parent() parent := cmd.Parent()
if parent != nil && parent.Parent() != nil { if parent != nil && parent.Parent() != nil {
@ -237,13 +239,14 @@ func fatal(err error) {
os.Exit(1) os.Exit(1)
} }
func checkIfUnknownCommandExistInDefaultContext(err error, currentContext string) { func checkIfUnknownCommandExistInDefaultContext(err error, currentContext string, root *cobra.Command) {
submatch := unknownCommandRegexp.FindSubmatch([]byte(err.Error())) submatch := unknownCommandRegexp.FindSubmatch([]byte(err.Error()))
if len(submatch) == 2 { if len(submatch) == 2 {
dockerCommand := string(submatch[1]) dockerCommand := string(submatch[1])
if mobycli.IsDefaultContextCommand(dockerCommand) { if mobycli.IsDefaultContextCommand(dockerCommand) {
fmt.Fprintf(os.Stderr, "Command %q not available in current context (%s), you can use the \"default\" context to run this command\n", dockerCommand, currentContext) fmt.Fprintf(os.Stderr, "Command %q not available in current context (%s), you can use the \"default\" context to run this command\n", dockerCommand, currentContext)
metrics.Track(currentContext, os.Args[1:], root.PersistentFlags(), metrics.FailureStatus)
os.Exit(1) os.Exit(1)
} }
} }

View File

@ -24,8 +24,11 @@ import (
"os/signal" "os/signal"
"strings" "strings"
"github.com/spf13/cobra"
apicontext "github.com/docker/compose-cli/context" apicontext "github.com/docker/compose-cli/context"
"github.com/docker/compose-cli/context/store" "github.com/docker/compose-cli/context/store"
"github.com/docker/compose-cli/metrics"
) )
var delegatedContextTypes = []string{store.DefaultContextType} var delegatedContextTypes = []string{store.DefaultContextType}
@ -33,8 +36,8 @@ var delegatedContextTypes = []string{store.DefaultContextType}
// ComDockerCli name of the classic cli binary // ComDockerCli name of the classic cli binary
const ComDockerCli = "com.docker.cli" const ComDockerCli = "com.docker.cli"
// ExecIfDefaultCtxType delegates to com.docker.cli if on moby or AWS context (until there is an AWS backend) // ExecIfDefaultCtxType delegates to com.docker.cli if on moby context
func ExecIfDefaultCtxType(ctx context.Context) { func ExecIfDefaultCtxType(ctx context.Context, root *cobra.Command) {
currentContext := apicontext.CurrentContext(ctx) currentContext := apicontext.CurrentContext(ctx)
s := store.ContextStore(ctx) s := store.ContextStore(ctx)
@ -42,7 +45,7 @@ func ExecIfDefaultCtxType(ctx context.Context) {
currentCtx, err := s.Get(currentContext) currentCtx, err := s.Get(currentContext)
// Only run original docker command if the current context is not ours. // Only run original docker command if the current context is not ours.
if err != nil || mustDelegateToMoby(currentCtx.Type()) { if err != nil || mustDelegateToMoby(currentCtx.Type()) {
Exec() Exec(root)
} }
} }
@ -56,7 +59,7 @@ func mustDelegateToMoby(ctxType string) bool {
} }
// Exec delegates to com.docker.cli if on moby context // Exec delegates to com.docker.cli if on moby context
func Exec() { func Exec(root *cobra.Command) {
cmd := exec.Command(ComDockerCli, os.Args[1:]...) cmd := exec.Command(ComDockerCli, os.Args[1:]...)
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
@ -83,12 +86,16 @@ func Exec() {
err := cmd.Run() err := cmd.Run()
childExit <- true childExit <- true
if err != nil { if err != nil {
metrics.Track(store.DefaultContextName, os.Args[1:], root.PersistentFlags(), metrics.FailureStatus)
if exiterr, ok := err.(*exec.ExitError); ok { if exiterr, ok := err.(*exec.ExitError); ok {
os.Exit(exiterr.ExitCode()) os.Exit(exiterr.ExitCode())
} }
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
metrics.Track(store.DefaultContextName, os.Args[1:], root.PersistentFlags(), metrics.SuccessStatus)
os.Exit(0) os.Exit(0)
} }

View File

@ -30,6 +30,7 @@ import (
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
"github.com/docker/compose-cli/context/store" "github.com/docker/compose-cli/context/store"
"github.com/docker/compose-cli/errdefs"
"github.com/docker/compose-cli/prompt" "github.com/docker/compose-cli/prompt"
) )
@ -155,7 +156,7 @@ func (h contextCreateAWSHelper) chooseProfile(section map[string]ini.Section) (s
selected, err := h.user.Select("Select AWS Profile", profiles) selected, err := h.user.Select("Select AWS Profile", profiles)
if err != nil { if err != nil {
if err == terminal.InterruptErr { if err == terminal.InterruptErr {
os.Exit(-1) return "", errdefs.ErrCanceled
} }
return "", err return "", err
} }

View File

@ -42,6 +42,8 @@ var (
// ErrNotImplemented is returned when a backend doesn't implement // ErrNotImplemented is returned when a backend doesn't implement
// an action // an action
ErrNotImplemented = errors.New("not implemented") ErrNotImplemented = errors.New("not implemented")
// ErrCanceled is returned when the command was canceled by user
ErrCanceled = errors.New("canceled")
// ErrParsingFailed is returned when a string cannot be parsed // ErrParsingFailed is returned when a string cannot be parsed
ErrParsingFailed = errors.New("parsing failed") ErrParsingFailed = errors.New("parsing failed")
// ErrWrongContextType is returned when the caller tries to get a context // ErrWrongContextType is returned when the caller tries to get a context
@ -78,3 +80,8 @@ func IsErrNotImplemented(err error) bool {
func IsErrParsingFailed(err error) bool { func IsErrParsingFailed(err error) bool {
return errors.Is(err, ErrParsingFailed) return errors.Is(err, ErrParsingFailed)
} }
// IsErrCanceled returns true if the unwrapped error is ErrCanceled
func IsErrCanceled(err error) bool {
return errors.Is(err, ErrCanceled)
}

View File

@ -22,6 +22,7 @@ import (
"encoding/json" "encoding/json"
"net" "net"
"net/http" "net/http"
"time"
) )
type client struct { type client struct {
@ -33,6 +34,7 @@ type Command struct {
Command string `json:"command"` Command string `json:"command"`
Context string `json:"context"` Context string `json:"context"`
Source string `json:"source"` Source string `json:"source"`
Status string `json:"status"`
} }
const ( const (
@ -40,6 +42,12 @@ const (
CLISource = "cli" CLISource = "cli"
// APISource is sent for API metrics // APISource is sent for API metrics
APISource = "api" APISource = "api"
// SuccessStatus is sent for API metrics
SuccessStatus = "success"
// FailureStatus is sent for API metrics
FailureStatus = "failure"
// CanceledStatus is sent for API metrics
CanceledStatus = "canceled"
) )
// Client sends metrics to Docker Desktopn // Client sends metrics to Docker Desktopn
@ -64,24 +72,23 @@ func NewClient() Client {
} }
func (c *client) Send(command Command) { func (c *client) Send(command Command) {
wasIn := make(chan bool) result := make(chan bool, 1)
// Fire and forget, we don't want to slow down the user waiting for DD
// metrics endpoint to respond. We could lose some events but that's ok.
go func() { go func() {
defer func() { postMetrics(command, c)
_ = recover() result <- true
}()
wasIn <- true
req, err := json.Marshal(command)
if err != nil {
return
}
_, _ = c.httpClient.Post("http://localhost/usage", "application/json", bytes.NewBuffer(req))
}() }()
<-wasIn
// wait for the post finished, or timeout in case anything freezes.
// Posting metrics without Desktop listening returns in less than a ms, and a handful of ms (often <2ms) when Desktop is listening
select {
case <-result:
case <-time.After(50 * time.Millisecond):
}
}
func postMetrics(command Command, c *client) {
req, err := json.Marshal(command)
if err == nil {
_, _ = c.httpClient.Post("http://localhost/usage", "application/json", bytes.NewBuffer(req))
}
} }

View File

@ -77,7 +77,7 @@ const (
) )
// Track sends the tracking analytics to Docker Desktop // Track sends the tracking analytics to Docker Desktop
func Track(context string, args []string, flags *flag.FlagSet) { func Track(context string, args []string, flags *flag.FlagSet, status string) {
command := getCommand(args, flags) command := getCommand(args, flags)
if command != "" { if command != "" {
c := NewClient() c := NewClient()
@ -85,6 +85,7 @@ func Track(context string, args []string, flags *flag.FlagSet) {
Command: command, Command: command,
Context: context, Context: context,
Source: CLISource, Source: CLISource,
Status: status,
}) })
} }
} }

View File

@ -41,9 +41,7 @@ var (
} }
) )
func metricsServerInterceptor(clictx context.Context) grpc.UnaryServerInterceptor { func metricsServerInterceptor(clictx context.Context, client metrics.Client) grpc.UnaryServerInterceptor {
client := metrics.NewClient()
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
currentContext, err := getIncomingContext(ctx) currentContext, err := getIncomingContext(ctx)
if err != nil { if err != nil {
@ -53,15 +51,21 @@ func metricsServerInterceptor(clictx context.Context) grpc.UnaryServerIntercepto
} }
} }
data, err := handler(ctx, req)
status := metrics.SuccessStatus
if err != nil {
status = metrics.FailureStatus
}
command := methodMapping[info.FullMethod] command := methodMapping[info.FullMethod]
if command != "" { if command != "" {
client.Send(metrics.Command{ client.Send(metrics.Command{
Command: command, Command: command,
Context: currentContext, Context: currentContext,
Source: metrics.APISource, Source: metrics.APISource,
Status: status,
}) })
} }
return data, err
return handler(ctx, req)
} }
} }

View File

@ -21,9 +21,13 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
"github.com/docker/compose-cli/errdefs"
"github.com/docker/compose-cli/metrics"
containersv1 "github.com/docker/compose-cli/protos/containers/v1" containersv1 "github.com/docker/compose-cli/protos/containers/v1"
contextsv1 "github.com/docker/compose-cli/protos/contexts/v1" contextsv1 "github.com/docker/compose-cli/protos/contexts/v1"
streamsv1 "github.com/docker/compose-cli/protos/streams/v1" streamsv1 "github.com/docker/compose-cli/protos/streams/v1"
@ -48,6 +52,44 @@ func TestAllMethodsHaveCorrespondingCliCommand(t *testing.T) {
} }
} }
func TestTrackSuccess(t *testing.T) {
var mockMetrics = &mockMetricsClient{}
mockMetrics.On("Send", metrics.Command{Command: "ps", Context: "aci", Status: "success", Source: "api"}).Return()
interceptor := metricsServerInterceptor(context.TODO(), mockMetrics)
_, err := interceptor(incomingContext("aci"), nil, containerMethodRoute("List"), mockHandler(nil))
assert.NilError(t, err)
}
func TestTrackSFailures(t *testing.T) {
var mockMetrics = &mockMetricsClient{}
mockMetrics.On("Send", metrics.Command{Command: "ps", Context: "default", Status: "failure", Source: "api"}).Return()
interceptor := metricsServerInterceptor(context.TODO(), mockMetrics)
_, err := interceptor(incomingContext("default"), nil, containerMethodRoute("Create"), mockHandler(errdefs.ErrLoginRequired))
assert.Assert(t, err == errdefs.ErrLoginRequired)
}
func containerMethodRoute(action string) *grpc.UnaryServerInfo {
var info = &grpc.UnaryServerInfo{
FullMethod: "/com.docker.api.protos.containers.v1.Containers/" + action,
}
return info
}
func mockHandler(err error) func(ctx context.Context, req interface{}) (interface{}, error) {
return func(ctx context.Context, req interface{}) (interface{}, error) {
return nil, err
}
}
func incomingContext(status string) context.Context {
ctx := metadata.NewIncomingContext(context.TODO(), metadata.MD{
(key): []string{status},
})
return ctx
}
func setupServer() *grpc.Server { func setupServer() *grpc.Server {
ctx := context.TODO() ctx := context.TODO()
s := New(ctx) s := New(ctx)
@ -57,3 +99,11 @@ func setupServer() *grpc.Server {
contextsv1.RegisterContextsServer(s, p.ContextsProxy()) contextsv1.RegisterContextsServer(s, p.ContextsProxy())
return s return s
} }
type mockMetricsClient struct {
mock.Mock
}
func (s *mockMetricsClient) Send(command metrics.Command) {
s.Called(command)
}

View File

@ -24,6 +24,8 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/health" "google.golang.org/grpc/health"
"google.golang.org/grpc/health/grpc_health_v1" "google.golang.org/grpc/health/grpc_health_v1"
"github.com/docker/compose-cli/metrics"
) )
// New returns a new GRPC server. // New returns a new GRPC server.
@ -31,7 +33,7 @@ func New(ctx context.Context) *grpc.Server {
s := grpc.NewServer( s := grpc.NewServer(
grpc.ChainUnaryInterceptor( grpc.ChainUnaryInterceptor(
unaryServerInterceptor(ctx), unaryServerInterceptor(ctx),
metricsServerInterceptor(ctx), metricsServerInterceptor(ctx, metrics.NewClient()),
), ),
grpc.StreamInterceptor(streamServerInterceptor(ctx)), grpc.StreamInterceptor(streamServerInterceptor(ctx)),
) )