Skip to content

Commit 57f59c8

Browse files
committed
Instrument plugin command executions
Signed-off-by: Christopher Petito <[email protected]>
1 parent 28c5652 commit 57f59c8

File tree

5 files changed

+124
-18
lines changed

5 files changed

+124
-18
lines changed

cli-plugins/manager/cobra.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,18 @@ func AddPluginCommandStubs(dockerCli command.Cli, rootCmd *cobra.Command) (err e
9090
cargs = append(cargs, args...)
9191
cargs = append(cargs, toComplete)
9292
os.Args = cargs
93-
runCommand, runErr := PluginRunCommand(dockerCli, p.Name, cmd)
93+
pluginRunCommand, runErr := PluginRunCommand(dockerCli, p.Name, cmd)
9494
if runErr != nil {
9595
return nil, cobra.ShellCompDirectiveError
9696
}
97-
runErr = runCommand.Run()
97+
if dockerCli, ok := dockerCli.(*command.DockerCli); !ok {
98+
runCommand := dockerCli.InstrumentPluginCommand(pluginRunCommand)
99+
runErr = runCommand.TimedRun(cmd.Context())
100+
} else {
101+
// This should not happen. continue without instrumenting the cmd if it does
102+
fmt.Fprint(dockerCli.Err(), "Warning: Unexpected error, the plugin command will not have OTEL metrics")
103+
runErr = pluginRunCommand.Run()
104+
}
98105
if runErr == nil {
99106
os.Exit(0) // plugin already rendered complete data
100107
}

cli/command/telemetry_utils.go

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package command
33
import (
44
"context"
55
"fmt"
6+
"os/exec"
67
"strings"
78
"time"
89

@@ -14,8 +15,8 @@ import (
1415
"go.opentelemetry.io/otel/metric"
1516
)
1617

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

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

9192
duration := float64(time.Since(start)) / float64(time.Millisecond)
92-
cmdStatusAttrs := attributesFromError(err)
93+
cmdStatusAttrs := attributesFromCommandError(err)
9394
durationCounter.Add(ctx, duration,
9495
metric.WithAttributes(attrs...),
9596
metric.WithAttributes(cmdStatusAttrs...),
@@ -100,6 +101,63 @@ func startCobraCommandTimer(mp metric.MeterProvider, attrs []attribute.KeyValue)
100101
}
101102
}
102103

104+
// basePluginCommandAttributes returns a slice of attribute.KeyValue to attach to metrics/traces
105+
func basePluginCommandAttributes(plugincmd *exec.Cmd, streams Streams) []attribute.KeyValue {
106+
pluginPath := strings.Split(plugincmd.Path, "-")
107+
pluginName := pluginPath[len(pluginPath)-1]
108+
return append([]attribute.KeyValue{
109+
attribute.String("plugin.name", pluginName),
110+
}, stdioAttributes(streams)...)
111+
}
112+
113+
// wrappedCmd is used to wrap an exec.Cmd in order to instrument the
114+
// command with otel by using the TimedRun() func
115+
type wrappedCmd struct {
116+
*exec.Cmd
117+
118+
baseAttrs []attribute.KeyValue
119+
cli *DockerCli
120+
}
121+
122+
// TimedRun measures the duration of the command execution using and otel meter
123+
func (c *wrappedCmd) TimedRun(ctx context.Context) error {
124+
stopPluginCommandTimer := c.cli.startPluginCommandTimer(c.cli.MeterProvider(), c.baseAttrs)
125+
err := c.Cmd.Run()
126+
stopPluginCommandTimer(err)
127+
return err
128+
}
129+
130+
// InstrumentPluginCommand instruments the plugin's exec.Cmd to measure it's execution time
131+
// Execute the returned command with TimedRun() to record the execution time.
132+
func (cli *DockerCli) InstrumentPluginCommand(plugincmd *exec.Cmd) *wrappedCmd {
133+
baseAttrs := basePluginCommandAttributes(plugincmd, cli)
134+
newCmd := &wrappedCmd{Cmd: plugincmd, baseAttrs: baseAttrs, cli: cli}
135+
return newCmd
136+
}
137+
138+
func (cli *DockerCli) startPluginCommandTimer(mp metric.MeterProvider, attrs []attribute.KeyValue) func(err error) {
139+
durationCounter, _ := getDefaultMeter(mp).Float64Counter(
140+
"plugin.command.time",
141+
metric.WithDescription("Measures the duration of the plugin execution"),
142+
metric.WithUnit("ms"),
143+
)
144+
start := time.Now()
145+
146+
return func(err error) {
147+
// Use a new context for the export so that the command being cancelled
148+
// doesn't affect the metrics, and we get metrics for cancelled commands.
149+
ctx, cancel := context.WithTimeout(context.Background(), exportTimeout)
150+
defer cancel()
151+
152+
duration := float64(time.Since(start)) / float64(time.Millisecond)
153+
pluginStatusAttrs := attributesFromPluginError(err)
154+
durationCounter.Add(ctx, duration,
155+
metric.WithAttributes(attrs...),
156+
metric.WithAttributes(pluginStatusAttrs...),
157+
)
158+
}
159+
}
160+
103161
func stdioAttributes(streams Streams) []attribute.KeyValue {
104162
// we don't wrap stderr, but we do wrap in/out
105163
_, stderrTty := term.GetFdInfo(streams.Err())
@@ -110,7 +168,9 @@ func stdioAttributes(streams Streams) []attribute.KeyValue {
110168
}
111169
}
112170

113-
func attributesFromError(err error) []attribute.KeyValue {
171+
// Used to create attributes from an error.
172+
// The error is expected to be returned from the execution of a cobra command
173+
func attributesFromCommandError(err error) []attribute.KeyValue {
114174
attrs := []attribute.KeyValue{}
115175
exitCode := 0
116176
if err != nil {
@@ -129,6 +189,27 @@ func attributesFromError(err error) []attribute.KeyValue {
129189
return attrs
130190
}
131191

192+
// Used to create attributes from an error.
193+
// The error is expected to be returned from the execution of a plugin
194+
func attributesFromPluginError(err error) []attribute.KeyValue {
195+
attrs := []attribute.KeyValue{}
196+
exitCode := 0
197+
if err != nil {
198+
exitCode = 1
199+
if stderr, ok := err.(statusError); ok {
200+
// StatusError should only be used for errors, and all errors should
201+
// have a non-zero exit status, so only set this here if this value isn't 0
202+
if stderr.StatusCode != 0 {
203+
exitCode = stderr.StatusCode
204+
}
205+
}
206+
attrs = append(attrs, attribute.String("plugin.error.type", otelErrorType(err)))
207+
}
208+
attrs = append(attrs, attribute.Int("plugin.status.code", exitCode))
209+
210+
return attrs
211+
}
212+
132213
// otelErrorType returns an attribute for the error type based on the error category.
133214
func otelErrorType(err error) string {
134215
name := "generic"

cli/command/telemetry_utils_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ func TestAttributesFromError(t *testing.T) {
182182
tc := tc
183183
t.Run(tc.testName, func(t *testing.T) {
184184
t.Parallel()
185-
actual := attributesFromError(tc.err)
185+
actual := attributesFromCommandError(tc.err)
186186
assert.Check(t, reflect.DeepEqual(actual, tc.expected))
187187
})
188188
}

cmd/docker/docker.go

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,16 @@ func setupHelpCommand(dockerCli command.Cli, rootCmd, helpCmd *cobra.Command) {
136136
helpCmd.Run = nil
137137
helpCmd.RunE = func(c *cobra.Command, args []string) error {
138138
if len(args) > 0 {
139-
helpcmd, err := pluginmanager.PluginRunCommand(dockerCli, args[0], rootCmd)
139+
helpRunCmd, err := pluginmanager.PluginRunCommand(dockerCli, args[0], rootCmd)
140140
if err == nil {
141-
return helpcmd.Run()
141+
if dockerCli, ok := dockerCli.(*command.DockerCli); !ok {
142+
helpcmd := dockerCli.InstrumentPluginCommand(helpRunCmd)
143+
return helpcmd.TimedRun(c.Context())
144+
} else {
145+
// This should not happen. continue without instrumenting the cmd if it does
146+
fmt.Fprint(dockerCli.Err(), "Warning: Unexpected error, the help command will not have OTEL metrics")
147+
return helpRunCmd.Run()
148+
}
142149
}
143150
if !pluginmanager.IsNotFound(err) {
144151
return errors.Errorf("unknown help topic: %v", strings.Join(args, " "))
@@ -159,11 +166,18 @@ func tryRunPluginHelp(dockerCli command.Cli, ccmd *cobra.Command, cargs []string
159166
if err != nil {
160167
return err
161168
}
162-
helpcmd, err := pluginmanager.PluginRunCommand(dockerCli, cmd.Name(), root)
169+
helpRunCmd, err := pluginmanager.PluginRunCommand(dockerCli, cmd.Name(), root)
163170
if err != nil {
164171
return err
165172
}
166-
return helpcmd.Run()
173+
if dockerCli, ok := dockerCli.(*command.DockerCli); !ok {
174+
helpcmd := dockerCli.InstrumentPluginCommand(helpRunCmd)
175+
return helpcmd.TimedRun(ccmd.Context())
176+
} else {
177+
// This should not happen. continue without instrumenting the cmd if it does
178+
fmt.Fprint(dockerCli.Err(), "Warning: Unexpected error, the plugin help command will not have OTEL metrics")
179+
return helpRunCmd.Run()
180+
}
167181
}
168182

169183
func setHelpFunc(dockerCli command.Cli, cmd *cobra.Command) {
@@ -224,12 +238,14 @@ func setValidateArgs(dockerCli command.Cli, cmd *cobra.Command) {
224238
})
225239
}
226240

227-
func tryPluginRun(dockerCli command.Cli, cmd *cobra.Command, subcommand string, envs []string) error {
228-
plugincmd, err := pluginmanager.PluginRunCommand(dockerCli, subcommand, cmd)
241+
func tryPluginRun(dockerCli *command.DockerCli, cmd *cobra.Command, subcommand string, envs []string) error {
242+
pluginRunCmd, err := pluginmanager.PluginRunCommand(dockerCli, subcommand, cmd)
229243
if err != nil {
230244
return err
231245
}
232246

247+
plugincmd := dockerCli.InstrumentPluginCommand(pluginRunCmd)
248+
233249
// Establish the plugin socket, adding it to the environment under a
234250
// well-known key if successful.
235251
srv, err := socket.NewPluginServer(nil)
@@ -279,7 +295,7 @@ func tryPluginRun(dockerCli command.Cli, cmd *cobra.Command, subcommand string,
279295
}
280296
}()
281297

282-
if err := plugincmd.Run(); err != nil {
298+
if err := plugincmd.TimedRun(cmd.Context()); err != nil {
283299
statusCode := 1
284300
exitErr, ok := err.(*exec.ExitError)
285301
if !ok {
@@ -304,6 +320,8 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
304320
return err
305321
}
306322

323+
cmd.SetContext(ctx)
324+
307325
if err := tcmd.Initialize(); err != nil {
308326
return err
309327
}
@@ -357,7 +375,7 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
357375
// We've parsed global args already, so reset args to those
358376
// which remain.
359377
cmd.SetArgs(args)
360-
err = cmd.ExecuteContext(ctx)
378+
err = cmd.Execute()
361379

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

e2e/cli-plugins/run_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ func TestHelpGood(t *testing.T) {
138138
res := icmd.RunCmd(run("-l", "info", "help", "helloworld"))
139139
res.Assert(t, icmd.Expected{
140140
ExitCode: 0,
141-
Err: icmd.None,
141+
Err: "Warning: Unexpected error, the help command will not have OTEL metrics",
142142
})
143143
golden.Assert(t, res.Stdout(), "docker-help-helloworld.golden")
144144
}
@@ -191,7 +191,7 @@ func TestHelpGoodSubcommand(t *testing.T) {
191191
res := icmd.RunCmd(run("-l", "info", "help", "helloworld", "goodbye"))
192192
res.Assert(t, icmd.Expected{
193193
ExitCode: 0,
194-
Err: icmd.None,
194+
Err: "Warning: Unexpected error, the help command will not have OTEL metrics",
195195
})
196196
golden.Assert(t, res.Stdout(), "docker-help-helloworld-goodbye.golden")
197197
}

0 commit comments

Comments
 (0)