diff --git a/cmd/cmdtrace/cmd_span.go b/cmd/cmdtrace/cmd_span.go new file mode 100644 index 000000000..f3d5f35be --- /dev/null +++ b/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 +} diff --git a/cmd/main.go b/cmd/main.go index aae236037..e7934566f 100644 --- a/cmd/main.go +++ b/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, diff --git a/internal/tracing/tracing.go b/internal/tracing/tracing.go index c656ba49c..afa534d57 100644 --- a/internal/tracing/tracing.go +++ b/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 {