Repository: docker/compose-cli
Pull request #2223 (2023-02-06 21:22:02)

metrics: move helper code from Compose
Compose doesn't actually currently use this code, it's only used here. Moving it inline so that we can drop it from Compose in the future or make changes as needed without worrying about this as a dependency.


Patch:
diff --git a/cli/main.go b/cli/main.go
index 03cafd8d..bbd445ab 100644
--- a/cli/main.go
+++ b/cli/main.go
@@ -256,7 +256,7 @@ func main() {
 	if err = root.ExecuteContext(ctx); err != nil {
 		handleError(ctx, err, ctype, currentContext, cc, root)
 	}
-	metricsClient.Track(ctype, os.Args[1:], compose.SuccessStatus)
+	metricsClient.Track(ctype, os.Args[1:], metrics.SuccessStatus)
 }
 
 func customizeCliForACI(command *cobra.Command, proxy *api.ServiceProxy) {
@@ -278,7 +278,7 @@ 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) {
 	// 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)
 		os.Exit(130)
 	}
 	if ctype == store.AwsContextType {
@@ -300,20 +300,21 @@ $ docker context create %s <name>`, cc.Type(), store.EcsContextType), ctype)
 
 func exit(ctx string, err error, ctype string) {
 	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)
 		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)
 
@@ -350,7 +351,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)
 			os.Exit(1)
 		}
 	}
diff --git a/cli/metrics/client.go b/cli/metrics/client.go
index 4b012483..ee7dbaa8 100644
--- a/cli/metrics/client.go
+++ b/cli/metrics/client.go
@@ -57,11 +57,11 @@ 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
+	// 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)
 }
 
@@ -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)
diff --git a/cli/metrics/event.go b/cli/metrics/event.go
new file mode 100644
index 00000000..e5c73d5f
--- /dev/null
+++ b/cli/metrics/event.go
@@ -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}
+	}
+}
diff --git a/cli/metrics/metrics.go b/cli/metrics/metrics.go
index 82edfd2c..f880b7d8 100644
--- a/cli/metrics/metrics.go
+++ b/cli/metrics/metrics.go
@@ -31,7 +31,7 @@ func (c *client) Track(context string, args []string, status string) {
 	}
 	command := GetCommand(args)
 	if command != "" {
-		c.Send(Command{
+		c.SendUsage(Command{
 			Command: command,
 			Context: context,
 			Source:  c.getMetadata(CLISource, args),
diff --git a/cli/mobycli/exec.go b/cli/mobycli/exec.go
index 4b4e9abe..100e39ab 100644
--- a/cli/mobycli/exec.go
+++ b/cli/mobycli/exec.go
@@ -28,7 +28,6 @@ import (
 	"runtime"
 	"strings"
 
-	"github.com/docker/compose/v2/pkg/compose"
 	"github.com/google/shlex"
 	"github.com/spf13/cobra"
 
@@ -72,7 +71,7 @@ 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()
@@ -83,10 +82,14 @@ func Exec(root *cobra.Command) {
 	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,
+			)
 			os.Exit(exitCode)
 		}
-		metricsClient.Track(store.DefaultContextType, os.Args[1:], compose.FailureStatus)
+		metricsClient.Track(store.DefaultContextType, os.Args[1:], metrics.FailureStatus)
 		fmt.Fprintln(os.Stderr, err)
 		os.Exit(1)
 	}
@@ -95,7 +98,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)
 
 	os.Exit(0)
 }
diff --git a/cli/server/metrics.go b/cli/server/metrics.go
index 17881a84..683cfd36 100644
--- a/cli/server/metrics.go
+++ b/cli/server/metrics.go
@@ -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"
@@ -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,
 			})
 		}


Test patch:
diff --git a/cli/server/metrics_test.go b/cli/server/metrics_test.go
index b830e2c0..2c1e44c0 100644
--- a/cli/server/metrics_test.go
+++ b/cli/server/metrics_test.go
@@ -60,7 +60,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)
 
@@ -126,7 +126,7 @@ 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)
 }