Ver Fonte

otel: refactor root command span reporting

* Move all the initialization code out of `main.go`
* Ensure spans are reported when there's an error with the
  command
* Attach the Compose version & active Docker context to the
  resource instead of the span
* Name the root CLI span `cli/<cmd>` for clarity and grab
  the full subcommand path (e.g. `alpha-viz` instead of just
  `viz`)

Signed-off-by: Milas Bowman <[email protected]>
Milas Bowman há 2 anos atrás
pai
commit
e1f8603a62
3 ficheiros alterados com 141 adições e 49 exclusões
  1. 131 0
      cmd/cmdtrace/cmd_span.go
  2. 5 49
      cmd/main.go
  3. 5 0
      internal/tracing/tracing.go

+ 131 - 0
cmd/cmdtrace/cmd_span.go

@@ -0,0 +1,131 @@
+/*
+   Copyright 2023 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 cmdtrace
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"sort"
+	"strings"
+	"time"
+
+	dockercli "github.com/docker/cli/cli"
+	"github.com/docker/cli/cli/command"
+	commands "github.com/docker/compose/v2/cmd/compose"
+	"github.com/docker/compose/v2/internal/tracing"
+	"github.com/spf13/cobra"
+	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/codes"
+	"go.opentelemetry.io/otel/trace"
+)
+
+// Setup should be called as part of the command's PersistentPreRunE
+// as soon as possible after initializing the dockerCli.
+//
+// It initializes the tracer for the CLI using both auto-detection
+// from the Docker context metadata as well as standard OTEL_ env
+// vars, creates a root span for the command, and wraps the actual
+// command invocation to ensure the span is properly finalized and
+// exported before exit.
+func Setup(cmd *cobra.Command, dockerCli command.Cli) error {
+	tracingShutdown, err := tracing.InitTracing(dockerCli)
+	if err != nil {
+		return fmt.Errorf("initializing tracing: %w", err)
+	}
+
+	ctx := cmd.Context()
+	ctx, cmdSpan := tracing.Tracer.Start(
+		ctx,
+		"cli/"+strings.Join(commandName(cmd), "-"),
+	)
+	cmd.SetContext(ctx)
+	wrapRunE(cmd, cmdSpan, tracingShutdown)
+	return nil
+}
+
+// wrapRunE injects a wrapper function around the command's actual RunE (or Run)
+// method. This is necessary to capture the command result for reporting as well
+// as flushing any spans before exit.
+//
+// Unfortunately, PersistentPostRun(E) can't be used for this purpose because it
+// only runs if RunE does _not_ return an error, but this should run unconditionally.
+func wrapRunE(c *cobra.Command, cmdSpan trace.Span, tracingShutdown tracing.ShutdownFunc) {
+	origRunE := c.RunE
+	if origRunE == nil {
+		origRun := c.Run
+		//nolint:unparam // wrapper function for RunE, always returns nil by design
+		origRunE = func(cmd *cobra.Command, args []string) error {
+			origRun(cmd, args)
+			return nil
+		}
+		c.Run = nil
+	}
+
+	c.RunE = func(cmd *cobra.Command, args []string) error {
+		cmdErr := origRunE(cmd, args)
+		if cmdSpan != nil {
+			if cmdErr != nil && !errors.Is(cmdErr, context.Canceled) {
+				// default exit code is 1 if a more descriptive error
+				// wasn't returned
+				exitCode := 1
+				var statusErr dockercli.StatusError
+				if errors.As(cmdErr, &statusErr) {
+					exitCode = statusErr.StatusCode
+				}
+				cmdSpan.SetStatus(codes.Error, "CLI command returned error")
+				cmdSpan.RecordError(cmdErr, trace.WithAttributes(
+					attribute.Int("exit_code", exitCode),
+				))
+
+			} else {
+				cmdSpan.SetStatus(codes.Ok, "")
+			}
+			cmdSpan.End()
+		}
+		if tracingShutdown != nil {
+			// use background for root context because the cmd's context might have
+			// been canceled already
+			ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+			defer cancel()
+			// TODO(milas): add an env var to enable logging from the
+			// OTel components for debugging purposes
+			_ = tracingShutdown(ctx)
+		}
+		return cmdErr
+	}
+}
+
+// commandName returns the path components for a given command.
+//
+// The root Compose command and anything before (i.e. "docker")
+// are not included.
+//
+// For example:
+//   - docker compose alpha watch -> [alpha, watch]
+//   - docker-compose up -> [up]
+func commandName(cmd *cobra.Command) []string {
+	var name []string
+	for c := cmd; c != nil; c = c.Parent() {
+		if c.Name() == commands.PluginName {
+			break
+		}
+		name = append(name, c.Name())
+	}
+	sort.Sort(sort.Reverse(sort.StringSlice(name)))
+	return name
+}

+ 5 - 49
cmd/main.go

@@ -17,55 +17,35 @@
 package main
 
 import (
-	"context"
 	"os"
-	"time"
 
 	dockercli "github.com/docker/cli/cli"
 	"github.com/docker/cli/cli-plugins/manager"
 	"github.com/docker/cli/cli-plugins/plugin"
 	"github.com/docker/cli/cli/command"
-	"github.com/pkg/errors"
+	"github.com/docker/compose/v2/cmd/cmdtrace"
 	"github.com/spf13/cobra"
-	"go.opentelemetry.io/otel/attribute"
-	"go.opentelemetry.io/otel/codes"
-	"go.opentelemetry.io/otel/trace"
 
 	"github.com/docker/compose/v2/cmd/compatibility"
 	commands "github.com/docker/compose/v2/cmd/compose"
 	"github.com/docker/compose/v2/internal"
-	"github.com/docker/compose/v2/internal/tracing"
 	"github.com/docker/compose/v2/pkg/api"
 	"github.com/docker/compose/v2/pkg/compose"
 )
 
 func pluginMain() {
 	plugin.Run(func(dockerCli command.Cli) *cobra.Command {
-		var tracingShutdown tracing.ShutdownFunc
-		var cmdSpan trace.Span
-
 		serviceProxy := api.NewServiceProxy().WithService(compose.NewComposeService(dockerCli))
 		cmd := commands.RootCommand(dockerCli, serviceProxy)
 		originalPreRun := cmd.PersistentPreRunE
 		cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
+			// initialize the dockerCli instance
 			if err := plugin.PersistentPreRunE(cmd, args); err != nil {
 				return err
 			}
-			// the call to plugin.PersistentPreRunE is what actually
-			// initializes the command.Cli instance, so this is the earliest
-			// that tracing can be practically initialized (in the future,
-			// this could ideally happen in coordination with docker/cli)
-			tracingShutdown, _ = tracing.InitTracing(dockerCli)
-
-			ctx := cmd.Context()
-			ctx, cmdSpan = tracing.Tracer.Start(
-				ctx, "cli/"+cmd.Name(),
-				trace.WithAttributes(
-					attribute.String("compose.version", internal.Version),
-					attribute.String("docker.context", dockerCli.CurrentContext()),
-				),
-			)
-			cmd.SetContext(ctx)
+			// TODO(milas): add an env var to enable logging from the
+			// OTel components for debugging purposes
+			_ = cmdtrace.Setup(cmd, dockerCli)
 
 			if originalPreRun != nil {
 				return originalPreRun(cmd, args)
@@ -73,30 +53,6 @@ func pluginMain() {
 			return nil
 		}
 
-		// manually wrap RunE instead of using PersistentPostRunE because the
-		// latter only runs when RunE does _not_ return an error, but the
-		// tracing clean-up logic should always be invoked
-		originalPersistentPostRunE := cmd.PersistentPostRunE
-		cmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) (err error) {
-			defer func() {
-				if cmdSpan != nil {
-					if err != nil && !errors.Is(err, context.Canceled) {
-						cmdSpan.SetStatus(codes.Error, "CLI command returned error")
-						cmdSpan.RecordError(err)
-					}
-					cmdSpan.End()
-				}
-				if tracingShutdown != nil {
-					ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
-					defer cancel()
-					_ = tracingShutdown(ctx)
-				}
-			}()
-			if originalPersistentPostRunE != nil {
-				return originalPersistentPostRunE(cmd, args)
-			}
-			return nil
-		}
 		cmd.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
 			return dockercli.StatusError{
 				StatusCode: compose.CommandSyntaxFailure.ExitCode,

+ 5 - 0
internal/tracing/tracing.go

@@ -24,6 +24,9 @@ import (
 	"strconv"
 	"strings"
 
+	"github.com/docker/compose/v2/internal"
+	"go.opentelemetry.io/otel/attribute"
+
 	"github.com/docker/cli/cli/command"
 	"github.com/moby/buildkit/util/tracing/detect"
 	_ "github.com/moby/buildkit/util/tracing/detect/delegated" //nolint:blank-imports
@@ -103,6 +106,8 @@ func InitProvider(dockerCli command.Cli) (ShutdownFunc, error) {
 		ctx,
 		resource.WithAttributes(
 			semconv.ServiceName("compose"),
+			semconv.ServiceVersion(internal.Version),
+			attribute.String("docker.context", dockerCli.CurrentContext()),
 		),
 	)
 	if err != nil {