github.1git.de/docker/cli@v26.1.3+incompatible/cli/command/telemetry_utils.go (about)

     1  package command
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"strings"
     7  	"time"
     8  
     9  	"github.com/docker/cli/cli/version"
    10  	"github.com/moby/term"
    11  	"github.com/pkg/errors"
    12  	"github.com/spf13/cobra"
    13  	"go.opentelemetry.io/otel/attribute"
    14  	"go.opentelemetry.io/otel/metric"
    15  )
    16  
    17  // BaseCommandAttributes returns an attribute.Set containing attributes to attach to metrics/traces
    18  func BaseCommandAttributes(cmd *cobra.Command, streams Streams) []attribute.KeyValue {
    19  	return append([]attribute.KeyValue{
    20  		attribute.String("command.name", getCommandName(cmd)),
    21  	}, stdioAttributes(streams)...)
    22  }
    23  
    24  // InstrumentCobraCommands wraps all cobra commands' RunE funcs to set a command duration metric using otel.
    25  //
    26  // Note: this should be the last func to wrap/modify the PersistentRunE/RunE funcs before command execution.
    27  //
    28  // can also be used for spans!
    29  func (cli *DockerCli) InstrumentCobraCommands(ctx context.Context, cmd *cobra.Command) {
    30  	// If PersistentPreRunE is nil, make it execute PersistentPreRun and return nil by default
    31  	ogPersistentPreRunE := cmd.PersistentPreRunE
    32  	if ogPersistentPreRunE == nil {
    33  		ogPersistentPreRun := cmd.PersistentPreRun
    34  		//nolint:unparam // necessary because error will always be nil here
    35  		ogPersistentPreRunE = func(cmd *cobra.Command, args []string) error {
    36  			ogPersistentPreRun(cmd, args)
    37  			return nil
    38  		}
    39  		cmd.PersistentPreRun = nil
    40  	}
    41  
    42  	// wrap RunE in PersistentPreRunE so that this operation gets executed on all children commands
    43  	cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
    44  		// If RunE is nil, make it execute Run and return nil by default
    45  		ogRunE := cmd.RunE
    46  		if ogRunE == nil {
    47  			ogRun := cmd.Run
    48  			//nolint:unparam // necessary because error will always be nil here
    49  			ogRunE = func(cmd *cobra.Command, args []string) error {
    50  				ogRun(cmd, args)
    51  				return nil
    52  			}
    53  			cmd.Run = nil
    54  		}
    55  		cmd.RunE = func(cmd *cobra.Command, args []string) error {
    56  			// start the timer as the first step of every cobra command
    57  			stopInstrumentation := cli.StartInstrumentation(cmd)
    58  			cmdErr := ogRunE(cmd, args)
    59  			stopInstrumentation(cmdErr)
    60  			return cmdErr
    61  		}
    62  
    63  		return ogPersistentPreRunE(cmd, args)
    64  	}
    65  }
    66  
    67  // StartInstrumentation instruments CLI commands with the individual metrics and spans configured.
    68  // It's the main command OTel utility, and new command-related metrics should be added to it.
    69  // It should be called immediately before command execution, and returns a stopInstrumentation function
    70  // that must be called with the error resulting from the command execution.
    71  func (cli *DockerCli) StartInstrumentation(cmd *cobra.Command) (stopInstrumentation func(error)) {
    72  	baseAttrs := BaseCommandAttributes(cmd, cli)
    73  	return startCobraCommandTimer(cli.MeterProvider(), baseAttrs)
    74  }
    75  
    76  func startCobraCommandTimer(mp metric.MeterProvider, attrs []attribute.KeyValue) func(err error) {
    77  	meter := getDefaultMeter(mp)
    78  	durationCounter, _ := meter.Float64Counter(
    79  		"command.time",
    80  		metric.WithDescription("Measures the duration of the cobra command"),
    81  		metric.WithUnit("ms"),
    82  	)
    83  	start := time.Now()
    84  
    85  	return func(err error) {
    86  		// Use a new context for the export so that the command being cancelled
    87  		// doesn't affect the metrics, and we get metrics for cancelled commands.
    88  		ctx, cancel := context.WithTimeout(context.Background(), exportTimeout)
    89  		defer cancel()
    90  
    91  		duration := float64(time.Since(start)) / float64(time.Millisecond)
    92  		cmdStatusAttrs := attributesFromError(err)
    93  		durationCounter.Add(ctx, duration,
    94  			metric.WithAttributes(attrs...),
    95  			metric.WithAttributes(cmdStatusAttrs...),
    96  		)
    97  		if mp, ok := mp.(MeterProvider); ok {
    98  			mp.ForceFlush(ctx)
    99  		}
   100  	}
   101  }
   102  
   103  func stdioAttributes(streams Streams) []attribute.KeyValue {
   104  	// we don't wrap stderr, but we do wrap in/out
   105  	_, stderrTty := term.GetFdInfo(streams.Err())
   106  	return []attribute.KeyValue{
   107  		attribute.Bool("command.stdin.isatty", streams.In().IsTerminal()),
   108  		attribute.Bool("command.stdout.isatty", streams.Out().IsTerminal()),
   109  		attribute.Bool("command.stderr.isatty", stderrTty),
   110  	}
   111  }
   112  
   113  func attributesFromError(err error) []attribute.KeyValue {
   114  	attrs := []attribute.KeyValue{}
   115  	exitCode := 0
   116  	if err != nil {
   117  		exitCode = 1
   118  		if stderr, ok := err.(statusError); ok {
   119  			// StatusError should only be used for errors, and all errors should
   120  			// have a non-zero exit status, so only set this here if this value isn't 0
   121  			if stderr.StatusCode != 0 {
   122  				exitCode = stderr.StatusCode
   123  			}
   124  		}
   125  		attrs = append(attrs, attribute.String("command.error.type", otelErrorType(err)))
   126  	}
   127  	attrs = append(attrs, attribute.Int("command.status.code", exitCode))
   128  
   129  	return attrs
   130  }
   131  
   132  // otelErrorType returns an attribute for the error type based on the error category.
   133  func otelErrorType(err error) string {
   134  	name := "generic"
   135  	if errors.Is(err, context.Canceled) {
   136  		name = "canceled"
   137  	}
   138  	return name
   139  }
   140  
   141  // statusError reports an unsuccessful exit by a command.
   142  type statusError struct {
   143  	Status     string
   144  	StatusCode int
   145  }
   146  
   147  func (e statusError) Error() string {
   148  	return fmt.Sprintf("Status: %s, Code: %d", e.Status, e.StatusCode)
   149  }
   150  
   151  // getCommandName gets the cobra command name in the format
   152  // `... parentCommandName commandName` by traversing it's parent commands recursively.
   153  // until the root command is reached.
   154  //
   155  // Note: The root command's name is excluded. If cmd is the root cmd, return ""
   156  func getCommandName(cmd *cobra.Command) string {
   157  	fullCmdName := getFullCommandName(cmd)
   158  	i := strings.Index(fullCmdName, " ")
   159  	if i == -1 {
   160  		return ""
   161  	}
   162  	return fullCmdName[i+1:]
   163  }
   164  
   165  // getFullCommandName gets the full cobra command name in the format
   166  // `... parentCommandName commandName` by traversing it's parent commands recursively
   167  // until the root command is reached.
   168  func getFullCommandName(cmd *cobra.Command) string {
   169  	if cmd.HasParent() {
   170  		return fmt.Sprintf("%s %s", getFullCommandName(cmd.Parent()), cmd.Name())
   171  	}
   172  	return cmd.Name()
   173  }
   174  
   175  // getDefaultMeter gets the default metric.Meter for the application
   176  // using the given metric.MeterProvider
   177  func getDefaultMeter(mp metric.MeterProvider) metric.Meter {
   178  	return mp.Meter(
   179  		"github.com/docker/cli",
   180  		metric.WithInstrumentationVersion(version.Version),
   181  	)
   182  }