Skip to content
This repository has been archived by the owner on Nov 27, 2023. It is now read-only.

metrics: initial logic for command execution duration #2224

Closed
wants to merge 2 commits into from
Closed
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
42 changes: 27 additions & 15 deletions cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,10 +253,13 @@ func main() {

root.AddCommand(command)

if err = root.ExecuteContext(ctx); err != nil {
handleError(ctx, err, ctype, currentContext, cc, root)
start := time.Now()
err = root.ExecuteContext(ctx)
duration := time.Since(start)
if err != nil {
handleError(ctx, err, ctype, currentContext, cc, root, duration)
}
metricsClient.Track(ctype, os.Args[1:], compose.SuccessStatus)
metricsClient.Track(ctype, os.Args[1:], metrics.SuccessStatus, duration)
}

func customizeCliForACI(command *cobra.Command, proxy *api.ServiceProxy) {
Expand All @@ -275,47 +278,56 @@ func customizeCliForACI(command *cobra.Command, proxy *api.ServiceProxy) {
}
}

func handleError(ctx context.Context, err error, ctype string, currentContext string, cc *store.DockerContext, root *cobra.Command) {
func handleError(
ctx context.Context,
err error,
ctype string,
currentContext string,
cc *store.DockerContext,
root *cobra.Command,
duration time.Duration,
) {
// if user canceled request, simply exit without any error message
if api.IsErrCanceled(err) || errors.Is(ctx.Err(), context.Canceled) {
metricsClient.Track(ctype, os.Args[1:], compose.CanceledStatus)
metricsClient.Track(ctype, os.Args[1:], metrics.CanceledStatus, duration)
os.Exit(130)
}
if ctype == store.AwsContextType {
exit(currentContext, errors.Errorf(`%q context type has been renamed. Recreate the context by running:
$ docker context create %s <name>`, cc.Type(), store.EcsContextType), ctype)
$ docker context create %s <name>`, cc.Type(), store.EcsContextType), ctype, duration)
}

// Context should always be handled by new CLI
requiredCmd, _, _ := root.Find(os.Args[1:])
if requiredCmd != nil && isContextAgnosticCommand(requiredCmd) {
exit(currentContext, err, ctype)
exit(currentContext, err, ctype, duration)
}
mobycli.ExecIfDefaultCtxType(ctx, root)

checkIfUnknownCommandExistInDefaultContext(err, currentContext, ctype)

exit(currentContext, err, ctype)
exit(currentContext, err, ctype, duration)
}

func exit(ctx string, err error, ctype string) {
func exit(ctx string, err error, ctype string, duration time.Duration) {
if exit, ok := err.(cli.StatusError); ok {
metricsClient.Track(ctype, os.Args[1:], compose.SuccessStatus)
// TODO(milas): shouldn't this use the exit code to determine status?
metricsClient.Track(ctype, os.Args[1:], metrics.SuccessStatus, duration)
os.Exit(exit.StatusCode)
}

var composeErr compose.Error
metricsStatus := compose.FailureStatus
metricsStatus := metrics.FailureStatus
exitCode := 1
if errors.As(err, &composeErr) {
metricsStatus = composeErr.GetMetricsFailureCategory().MetricsStatus
exitCode = composeErr.GetMetricsFailureCategory().ExitCode
}
if strings.HasPrefix(err.Error(), "unknown shorthand flag:") || strings.HasPrefix(err.Error(), "unknown flag:") || strings.HasPrefix(err.Error(), "unknown docker command:") {
metricsStatus = compose.CommandSyntaxFailure.MetricsStatus
exitCode = compose.CommandSyntaxFailure.ExitCode
metricsStatus = metrics.CommandSyntaxFailure.MetricsStatus
exitCode = metrics.CommandSyntaxFailure.ExitCode
}
metricsClient.Track(ctype, os.Args[1:], metricsStatus)
metricsClient.Track(ctype, os.Args[1:], metricsStatus, duration)

if errors.Is(err, api.ErrLoginRequired) {
fmt.Fprintln(os.Stderr, err)
Expand Down Expand Up @@ -350,7 +362,7 @@ func checkIfUnknownCommandExistInDefaultContext(err error, currentContext string

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)
metricsClient.Track(contextType, os.Args[1:], compose.FailureStatus)
metricsClient.Track(contextType, os.Args[1:], metrics.FailureStatus, 0)
os.Exit(1)
}
}
Expand Down
14 changes: 7 additions & 7 deletions cli/metrics/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,12 @@ type Client interface {
// WithCliVersionFunc sets the docker cli version func
// that returns the docker cli version (com.docker.cli)
WithCliVersionFunc(f func() string)
// Send sends the command to Docker Desktop. Note that the function doesn't
// return anything, not even an error, this is because we don't really care
// if the metrics were sent or not. We only fire and forget.
Send(Command)
// Track sends the tracking analytics to Docker Desktop
Track(context string, args []string, status string)
// SendUsage sends the command to Docker Desktop.
//
// Note that metric collection is best-effort, so any errors are ignored.
SendUsage(Command)
// Track creates an event for a command execution and reports it.
Track(context string, args []string, status string, duration time.Duration)
}

// NewClient returns a new metrics client that will send metrics using the
Expand Down Expand Up @@ -98,7 +98,7 @@ func (c *client) WithCliVersionFunc(f func() string) {
c.cliversion.f = f
}

func (c *client) Send(command Command) {
func (c *client) SendUsage(command Command) {
result := make(chan bool, 1)
go func() {
c.reporter.Heartbeat(command)
Expand Down
79 changes: 79 additions & 0 deletions cli/metrics/event.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
Copyright 2020 Docker Compose CLI authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package metrics

// FailureCategory struct regrouping metrics failure status and specific exit code
type FailureCategory struct {
MetricsStatus string
ExitCode int
}

const (
// APISource is sent for API metrics
APISource = "api"
// SuccessStatus command success
SuccessStatus = "success"
// FailureStatus command failure
FailureStatus = "failure"
// ComposeParseFailureStatus failure while parsing compose file
ComposeParseFailureStatus = "failure-compose-parse"
// FileNotFoundFailureStatus failure getting compose file
FileNotFoundFailureStatus = "failure-file-not-found"
// CommandSyntaxFailureStatus failure reading command
CommandSyntaxFailureStatus = "failure-cmd-syntax"
// BuildFailureStatus failure building imge
BuildFailureStatus = "failure-build"
// PullFailureStatus failure pulling imge
PullFailureStatus = "failure-pull"
// CanceledStatus command canceled
CanceledStatus = "canceled"
)

var (
// FileNotFoundFailure failure for compose file not found
FileNotFoundFailure = FailureCategory{MetricsStatus: FileNotFoundFailureStatus, ExitCode: 14}
// ComposeParseFailure failure for composefile parse error
ComposeParseFailure = FailureCategory{MetricsStatus: ComposeParseFailureStatus, ExitCode: 15}
// CommandSyntaxFailure failure for command line syntax
CommandSyntaxFailure = FailureCategory{MetricsStatus: CommandSyntaxFailureStatus, ExitCode: 16}
// BuildFailure failure while building images.
BuildFailure = FailureCategory{MetricsStatus: BuildFailureStatus, ExitCode: 17}
// PullFailure failure while pulling image
PullFailure = FailureCategory{MetricsStatus: PullFailureStatus, ExitCode: 18}
)

// FailureCategoryFromExitCode retrieve FailureCategory based on command exit code
func FailureCategoryFromExitCode(exitCode int) FailureCategory {
switch exitCode {
case 0:
return FailureCategory{MetricsStatus: SuccessStatus, ExitCode: 0}
case 14:
return FileNotFoundFailure
case 15:
return ComposeParseFailure
case 16:
return CommandSyntaxFailure
case 17:
return BuildFailure
case 18:
return PullFailure
case 130:
return FailureCategory{MetricsStatus: CanceledStatus, ExitCode: exitCode}
default:
return FailureCategory{MetricsStatus: FailureStatus, ExitCode: exitCode}
}
}
5 changes: 3 additions & 2 deletions cli/metrics/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,20 @@ package metrics
import (
"os"
"strings"
"time"

"github.com/docker/compose/v2/pkg/utils"

"github.com/docker/compose-cli/cli/metrics/metadata"
)

func (c *client) Track(context string, args []string, status string) {
func (c *client) Track(context string, args []string, status string, duration time.Duration) {
if isInvokedAsCliBackend() {
return
}
command := GetCommand(args)
if command != "" {
c.Send(Command{
c.SendUsage(Command{
Command: command,
Context: context,
Source: c.getMetadata(CLISource, args),
Expand Down
17 changes: 12 additions & 5 deletions cli/mobycli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ import (
"regexp"
"runtime"
"strings"
"time"

"github.com/docker/compose/v2/pkg/compose"
"github.com/google/shlex"
"github.com/spf13/cobra"

Expand Down Expand Up @@ -72,21 +72,28 @@ func mustDelegateToMoby(ctxType string) bool {
}

// Exec delegates to com.docker.cli if on moby context
func Exec(root *cobra.Command) {
func Exec(_ *cobra.Command) {
metricsClient := metrics.NewDefaultClient()
metricsClient.WithCliVersionFunc(func() string {
return CliVersion()
})
t := time.Now()
childExit := make(chan bool)
err := RunDocker(childExit, os.Args[1:]...)
childExit <- true
duration := time.Since(t)
if err != nil {
if exiterr, ok := err.(*exec.ExitError); ok {
exitCode := exiterr.ExitCode()
metricsClient.Track(store.DefaultContextType, os.Args[1:], compose.ByExitCode(exitCode).MetricsStatus)
metricsClient.Track(
store.DefaultContextType,
os.Args[1:],
metrics.FailureCategoryFromExitCode(exitCode).MetricsStatus,
duration,
)
os.Exit(exitCode)
}
metricsClient.Track(store.DefaultContextType, os.Args[1:], compose.FailureStatus)
metricsClient.Track(store.DefaultContextType, os.Args[1:], metrics.FailureStatus, duration)
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
Expand All @@ -95,7 +102,7 @@ func Exec(root *cobra.Command) {
if command == "login" && !metrics.HasQuietFlag(commandArgs) {
displayPATSuggestMsg(commandArgs)
}
metricsClient.Track(store.DefaultContextType, os.Args[1:], compose.SuccessStatus)
metricsClient.Track(store.DefaultContextType, os.Args[1:], metrics.SuccessStatus, duration)

os.Exit(0)
}
Expand Down
9 changes: 4 additions & 5 deletions cli/server/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ package server
import (
"context"

"github.com/docker/compose/v2/pkg/compose"
"google.golang.org/grpc"

"github.com/docker/compose-cli/cli/metrics"
Expand Down Expand Up @@ -61,16 +60,16 @@ func metricsServerInterceptor(client metrics.Client) grpc.UnaryServerInterceptor

data, err := handler(ctx, req)

status := compose.SuccessStatus
status := metrics.SuccessStatus
if err != nil {
status = compose.FailureStatus
status = metrics.FailureStatus
}
command := methodMapping[info.FullMethod]
if command != "" {
client.Send(metrics.Command{
client.SendUsage(metrics.Command{
Command: command,
Context: contextType,
Source: compose.APISource,
Source: metrics.APISource,
Status: status,
})
}
Expand Down
9 changes: 5 additions & 4 deletions cli/server/metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"strings"
"testing"
"time"

"github.com/docker/compose/v2/pkg/api"
"github.com/stretchr/testify/mock"
Expand Down Expand Up @@ -60,7 +61,7 @@ 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()
mockMetrics.On("SendUsage", metrics.Command{Command: "ps", Context: "aci", Status: "success", Source: "api"}).Return()
newClient := client.NewClient("aci", noopService{})
interceptor := metricsServerInterceptor(mockMetrics)

Expand Down Expand Up @@ -126,10 +127,10 @@ func (s *mockMetricsClient) WithCliVersionFunc(f func() string) {
s.Called(f)
}

func (s *mockMetricsClient) Send(command metrics.Command) {
func (s *mockMetricsClient) SendUsage(command metrics.Command) {
s.Called(command)
}

func (s *mockMetricsClient) Track(context string, args []string, status string) {
s.Called(context, args, status)
func (s *mockMetricsClient) Track(context string, args []string, status string, duration time.Duration) {
s.Called(context, args, status, duration)
}