Skip to content

Commit

Permalink
Instrument plugin command executions
Browse files Browse the repository at this point in the history
Signed-off-by: Christopher Petito <chrisjpetito@gmail.com>
  • Loading branch information
krissetto committed May 16, 2024
1 parent 28c5652 commit d57c8fa
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 18 deletions.
11 changes: 9 additions & 2 deletions cli-plugins/manager/cobra.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,18 @@ func AddPluginCommandStubs(dockerCli command.Cli, rootCmd *cobra.Command) (err e
cargs = append(cargs, args...)
cargs = append(cargs, toComplete)
os.Args = cargs
runCommand, runErr := PluginRunCommand(dockerCli, p.Name, cmd)
pluginRunCommand, runErr := PluginRunCommand(dockerCli, p.Name, cmd)
if runErr != nil {
return nil, cobra.ShellCompDirectiveError
}
runErr = runCommand.Run()
if dockerCli, ok := dockerCli.(*command.DockerCli); !ok {
runCommand := dockerCli.InstrumentPluginCommand(pluginRunCommand)
runErr = runCommand.TimedRun(cmd.Context())
} else {
// This should not happen. continue without instrumenting the cmd if it does
fmt.Fprint(dockerCli.Err(), "Warning: Unexpected error, the plugin command will not have OTEL metrics")
runErr = pluginRunCommand.Run()
}
if runErr == nil {
os.Exit(0) // plugin already rendered complete data
}
Expand Down
98 changes: 91 additions & 7 deletions cli/command/telemetry_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package command
import (
"context"
"fmt"
"os/exec"
"strings"
"time"

Expand All @@ -14,8 +15,8 @@ import (
"go.opentelemetry.io/otel/metric"
)

// BaseCommandAttributes returns an attribute.Set containing attributes to attach to metrics/traces
func BaseCommandAttributes(cmd *cobra.Command, streams Streams) []attribute.KeyValue {
// baseCommandAttributes returns an attribute.Set containing attributes to attach to metrics/traces
func baseCommandAttributes(cmd *cobra.Command, streams Streams) []attribute.KeyValue {
return append([]attribute.KeyValue{
attribute.String("command.name", getCommandName(cmd)),
}, stdioAttributes(streams)...)
Expand Down Expand Up @@ -69,7 +70,7 @@ func (cli *DockerCli) InstrumentCobraCommands(ctx context.Context, cmd *cobra.Co
// It should be called immediately before command execution, and returns a stopInstrumentation function
// that must be called with the error resulting from the command execution.
func (cli *DockerCli) StartInstrumentation(cmd *cobra.Command) (stopInstrumentation func(error)) {
baseAttrs := BaseCommandAttributes(cmd, cli)
baseAttrs := baseCommandAttributes(cmd, cli)
return startCobraCommandTimer(cli.MeterProvider(), baseAttrs)
}

Expand All @@ -89,7 +90,7 @@ func startCobraCommandTimer(mp metric.MeterProvider, attrs []attribute.KeyValue)
defer cancel()

duration := float64(time.Since(start)) / float64(time.Millisecond)
cmdStatusAttrs := attributesFromError(err)
cmdStatusAttrs := attributesFromCommandError(err)
durationCounter.Add(ctx, duration,
metric.WithAttributes(attrs...),
metric.WithAttributes(cmdStatusAttrs...),
Expand All @@ -100,6 +101,66 @@ func startCobraCommandTimer(mp metric.MeterProvider, attrs []attribute.KeyValue)
}
}

// basePluginCommandAttributes returns a slice of attribute.KeyValue to attach to metrics/traces
func basePluginCommandAttributes(plugincmd *exec.Cmd, streams Streams) []attribute.KeyValue {
pluginPath := strings.Split(plugincmd.Path, "-")
pluginName := pluginPath[len(pluginPath)-1]
return append([]attribute.KeyValue{
attribute.String("plugin.name", pluginName),
}, stdioAttributes(streams)...)
}

// wrappedCmd is used to wrap an exec.Cmd in order to instrument the
// command with otel by using the TimedRun() func
type wrappedCmd struct {
*exec.Cmd

baseAttrs []attribute.KeyValue
cli *DockerCli
}

// TimedRun measures the duration of the command execution using an otel meter
func (c *wrappedCmd) TimedRun(ctx context.Context) error {
stopPluginCommandTimer := c.cli.startPluginCommandTimer(c.cli.MeterProvider(), c.baseAttrs)
err := c.Cmd.Run()
stopPluginCommandTimer(err)
return err
}

// InstrumentPluginCommand instruments the plugin's exec.Cmd to measure its execution time
// Execute the returned command with TimedRun() to record the execution time.
func (cli *DockerCli) InstrumentPluginCommand(plugincmd *exec.Cmd) *wrappedCmd {
baseAttrs := basePluginCommandAttributes(plugincmd, cli)
newCmd := &wrappedCmd{Cmd: plugincmd, baseAttrs: baseAttrs, cli: cli}
return newCmd
}

func (cli *DockerCli) startPluginCommandTimer(mp metric.MeterProvider, attrs []attribute.KeyValue) func(err error) {
durationCounter, _ := getDefaultMeter(mp).Float64Counter(
"plugin.command.time",
metric.WithDescription("Measures the duration of the plugin execution"),
metric.WithUnit("ms"),
)
start := time.Now()

return func(err error) {
// Use a new context for the export so that the command being cancelled
// doesn't affect the metrics, and we get metrics for cancelled commands.
ctx, cancel := context.WithTimeout(context.Background(), exportTimeout)
defer cancel()

duration := float64(time.Since(start)) / float64(time.Millisecond)
pluginStatusAttrs := attributesFromPluginError(err)
durationCounter.Add(ctx, duration,
metric.WithAttributes(attrs...),
metric.WithAttributes(pluginStatusAttrs...),
)
if mp, ok := mp.(MeterProvider); ok {
mp.ForceFlush(ctx)
}
}
}

func stdioAttributes(streams Streams) []attribute.KeyValue {
// we don't wrap stderr, but we do wrap in/out
_, stderrTty := term.GetFdInfo(streams.Err())
Expand All @@ -110,7 +171,9 @@ func stdioAttributes(streams Streams) []attribute.KeyValue {
}
}

func attributesFromError(err error) []attribute.KeyValue {
// Used to create attributes from an error.
// The error is expected to be returned from the execution of a cobra command
func attributesFromCommandError(err error) []attribute.KeyValue {
attrs := []attribute.KeyValue{}
exitCode := 0
if err != nil {
Expand All @@ -129,6 +192,27 @@ func attributesFromError(err error) []attribute.KeyValue {
return attrs
}

// Used to create attributes from an error.
// The error is expected to be returned from the execution of a plugin
func attributesFromPluginError(err error) []attribute.KeyValue {
attrs := []attribute.KeyValue{}
exitCode := 0
if err != nil {
exitCode = 1
if stderr, ok := err.(statusError); ok {
// StatusError should only be used for errors, and all errors should
// have a non-zero exit status, so only set this here if this value isn't 0
if stderr.StatusCode != 0 {
exitCode = stderr.StatusCode
}
}
attrs = append(attrs, attribute.String("plugin.error.type", otelErrorType(err)))
}
attrs = append(attrs, attribute.Int("plugin.status.code", exitCode))

return attrs
}

// otelErrorType returns an attribute for the error type based on the error category.
func otelErrorType(err error) string {
name := "generic"
Expand All @@ -149,7 +233,7 @@ func (e statusError) Error() string {
}

// getCommandName gets the cobra command name in the format
// `... parentCommandName commandName` by traversing it's parent commands recursively.
// `... parentCommandName commandName` by traversing its parent commands recursively.
// until the root command is reached.
//
// Note: The root command's name is excluded. If cmd is the root cmd, return ""
Expand All @@ -163,7 +247,7 @@ func getCommandName(cmd *cobra.Command) string {
}

// getFullCommandName gets the full cobra command name in the format
// `... parentCommandName commandName` by traversing it's parent commands recursively
// `... parentCommandName commandName` by traversing its parent commands recursively
// until the root command is reached.
func getFullCommandName(cmd *cobra.Command) string {
if cmd.HasParent() {
Expand Down
2 changes: 1 addition & 1 deletion cli/command/telemetry_utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ func TestAttributesFromError(t *testing.T) {
tc := tc
t.Run(tc.testName, func(t *testing.T) {
t.Parallel()
actual := attributesFromError(tc.err)
actual := attributesFromCommandError(tc.err)
assert.Check(t, reflect.DeepEqual(actual, tc.expected))
})
}
Expand Down
32 changes: 24 additions & 8 deletions cmd/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,15 @@ func setupHelpCommand(dockerCli command.Cli, rootCmd, helpCmd *cobra.Command) {
helpCmd.Run = nil
helpCmd.RunE = func(c *cobra.Command, args []string) error {
if len(args) > 0 {
helpcmd, err := pluginmanager.PluginRunCommand(dockerCli, args[0], rootCmd)
helpRunCmd, err := pluginmanager.PluginRunCommand(dockerCli, args[0], rootCmd)
if err == nil {
return helpcmd.Run()
if dockerCli, ok := dockerCli.(*command.DockerCli); ok {
helpcmd := dockerCli.InstrumentPluginCommand(helpRunCmd)
return helpcmd.TimedRun(c.Context())
}
// This should not happen. continue without instrumenting the cmd if it does
fmt.Fprint(dockerCli.Err(), "Warning: Unexpected error, the help command will not have OTEL metrics")
return helpRunCmd.Run()
}
if !pluginmanager.IsNotFound(err) {
return errors.Errorf("unknown help topic: %v", strings.Join(args, " "))
Expand All @@ -159,11 +165,17 @@ func tryRunPluginHelp(dockerCli command.Cli, ccmd *cobra.Command, cargs []string
if err != nil {
return err
}
helpcmd, err := pluginmanager.PluginRunCommand(dockerCli, cmd.Name(), root)
helpRunCmd, err := pluginmanager.PluginRunCommand(dockerCli, cmd.Name(), root)
if err != nil {
return err
}
return helpcmd.Run()
if dockerCli, ok := dockerCli.(*command.DockerCli); ok {
helpcmd := dockerCli.InstrumentPluginCommand(helpRunCmd)
return helpcmd.TimedRun(ccmd.Context())
}
// This should not happen. continue without instrumenting the cmd if it does
fmt.Fprint(dockerCli.Err(), "Warning: Unexpected error, the plugin help command will not have OTEL metrics")
return helpRunCmd.Run()
}

func setHelpFunc(dockerCli command.Cli, cmd *cobra.Command) {
Expand Down Expand Up @@ -224,12 +236,14 @@ func setValidateArgs(dockerCli command.Cli, cmd *cobra.Command) {
})
}

func tryPluginRun(dockerCli command.Cli, cmd *cobra.Command, subcommand string, envs []string) error {
plugincmd, err := pluginmanager.PluginRunCommand(dockerCli, subcommand, cmd)
func tryPluginRun(dockerCli *command.DockerCli, cmd *cobra.Command, subcommand string, envs []string) error {
pluginRunCmd, err := pluginmanager.PluginRunCommand(dockerCli, subcommand, cmd)
if err != nil {
return err
}

plugincmd := dockerCli.InstrumentPluginCommand(pluginRunCmd)

// Establish the plugin socket, adding it to the environment under a
// well-known key if successful.
srv, err := socket.NewPluginServer(nil)
Expand Down Expand Up @@ -279,7 +293,7 @@ func tryPluginRun(dockerCli command.Cli, cmd *cobra.Command, subcommand string,
}
}()

if err := plugincmd.Run(); err != nil {
if err := plugincmd.TimedRun(cmd.Context()); err != nil {
statusCode := 1
exitErr, ok := err.(*exec.ExitError)
if !ok {
Expand All @@ -304,6 +318,8 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
return err
}

cmd.SetContext(ctx)

if err := tcmd.Initialize(); err != nil {
return err
}
Expand Down Expand Up @@ -357,7 +373,7 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
// We've parsed global args already, so reset args to those
// which remain.
cmd.SetArgs(args)
err = cmd.ExecuteContext(ctx)
err = cmd.Execute()

// If the command is being executed in an interactive terminal
// and hook are enabled, run the plugin hooks.
Expand Down

0 comments on commit d57c8fa

Please sign in to comment.