Skip to content

Commit d57c8fa

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

File tree

4 files changed

+125
-18
lines changed

4 files changed

+125
-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: 91 additions & 7 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,66 @@ 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 an 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 its 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+
if mp, ok := mp.(MeterProvider); ok {
159+
mp.ForceFlush(ctx)
160+
}
161+
}
162+
}
163+
103164
func stdioAttributes(streams Streams) []attribute.KeyValue {
104165
// we don't wrap stderr, but we do wrap in/out
105166
_, stderrTty := term.GetFdInfo(streams.Err())
@@ -110,7 +171,9 @@ func stdioAttributes(streams Streams) []attribute.KeyValue {
110171
}
111172
}
112173

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

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

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

165249
// getFullCommandName gets the full cobra command name in the format
166-
// `... parentCommandName commandName` by traversing it's parent commands recursively
250+
// `... parentCommandName commandName` by traversing its parent commands recursively
167251
// until the root command is reached.
168252
func getFullCommandName(cmd *cobra.Command) string {
169253
if cmd.HasParent() {

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: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,15 @@ 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+
}
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()
142148
}
143149
if !pluginmanager.IsNotFound(err) {
144150
return errors.Errorf("unknown help topic: %v", strings.Join(args, " "))
@@ -159,11 +165,17 @@ func tryRunPluginHelp(dockerCli command.Cli, ccmd *cobra.Command, cargs []string
159165
if err != nil {
160166
return err
161167
}
162-
helpcmd, err := pluginmanager.PluginRunCommand(dockerCli, cmd.Name(), root)
168+
helpRunCmd, err := pluginmanager.PluginRunCommand(dockerCli, cmd.Name(), root)
163169
if err != nil {
164170
return err
165171
}
166-
return helpcmd.Run()
172+
if dockerCli, ok := dockerCli.(*command.DockerCli); ok {
173+
helpcmd := dockerCli.InstrumentPluginCommand(helpRunCmd)
174+
return helpcmd.TimedRun(ccmd.Context())
175+
}
176+
// This should not happen. continue without instrumenting the cmd if it does
177+
fmt.Fprint(dockerCli.Err(), "Warning: Unexpected error, the plugin help command will not have OTEL metrics")
178+
return helpRunCmd.Run()
167179
}
168180

169181
func setHelpFunc(dockerCli command.Cli, cmd *cobra.Command) {
@@ -224,12 +236,14 @@ func setValidateArgs(dockerCli command.Cli, cmd *cobra.Command) {
224236
})
225237
}
226238

227-
func tryPluginRun(dockerCli command.Cli, cmd *cobra.Command, subcommand string, envs []string) error {
228-
plugincmd, err := pluginmanager.PluginRunCommand(dockerCli, subcommand, cmd)
239+
func tryPluginRun(dockerCli *command.DockerCli, cmd *cobra.Command, subcommand string, envs []string) error {
240+
pluginRunCmd, err := pluginmanager.PluginRunCommand(dockerCli, subcommand, cmd)
229241
if err != nil {
230242
return err
231243
}
232244

245+
plugincmd := dockerCli.InstrumentPluginCommand(pluginRunCmd)
246+
233247
// Establish the plugin socket, adding it to the environment under a
234248
// well-known key if successful.
235249
srv, err := socket.NewPluginServer(nil)
@@ -279,7 +293,7 @@ func tryPluginRun(dockerCli command.Cli, cmd *cobra.Command, subcommand string,
279293
}
280294
}()
281295

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

321+
cmd.SetContext(ctx)
322+
307323
if err := tcmd.Initialize(); err != nil {
308324
return err
309325
}
@@ -357,7 +373,7 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
357373
// We've parsed global args already, so reset args to those
358374
// which remain.
359375
cmd.SetArgs(args)
360-
err = cmd.ExecuteContext(ctx)
376+
err = cmd.Execute()
361377

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

0 commit comments

Comments
 (0)