Skip to content

Commit 400a8bb

Browse files
authored
Merge pull request #4940 from krissetto/otel-init
Initial otel implementation
2 parents b8d5454 + efd82e1 commit 400a8bb

File tree

4 files changed

+278
-6
lines changed

4 files changed

+278
-6
lines changed

cli/command/telemetry_docker.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"net/url"
7+
"os"
78
"path"
89

910
"github.com/pkg/errors"
@@ -14,7 +15,11 @@ import (
1415
sdktrace "go.opentelemetry.io/otel/sdk/trace"
1516
)
1617

17-
const otelContextFieldName = "otel"
18+
const (
19+
otelContextFieldName string = "otel"
20+
otelExporterOTLPEndpoint string = "OTEL_EXPORTER_OTLP_ENDPOINT"
21+
debugEnvVarPrefix string = "DOCKER_CLI_"
22+
)
1823

1924
// dockerExporterOTLPEndpoint retrieves the OTLP endpoint used for the docker reporter
2025
// from the current context.
@@ -49,8 +54,15 @@ func dockerExporterOTLPEndpoint(cli Cli) (endpoint string, secure bool) {
4954
}
5055

5156
// keys from https://opentelemetry.io/docs/concepts/sdk-configuration/otlp-exporter-configuration/
52-
endpoint, ok = otelMap["OTEL_EXPORTER_OTLP_ENDPOINT"].(string)
53-
if !ok {
57+
endpoint, _ = otelMap[otelExporterOTLPEndpoint].(string)
58+
59+
// Override with env var value if it exists AND IS SET
60+
// (ignore otel defaults for this override when the key exists but is empty)
61+
if override := os.Getenv(debugEnvVarPrefix + otelExporterOTLPEndpoint); override != "" {
62+
endpoint = override
63+
}
64+
65+
if endpoint == "" {
5466
return "", false
5567
}
5668

cli/command/telemetry_utils.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package command
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strconv"
7+
"strings"
8+
"time"
9+
10+
"github.com/docker/cli/cli/version"
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+
// BaseMetricAttributes returns an attribute.Set containing attributes to attach to metrics/traces
18+
func BaseMetricAttributes(cmd *cobra.Command) attribute.Set {
19+
attrList := []attribute.KeyValue{
20+
attribute.String("command.name", getCommandName(cmd)),
21+
}
22+
return attribute.NewSet(attrList...)
23+
}
24+
25+
// InstrumentCobraCommands wraps all cobra commands' RunE funcs to set a command duration metric using otel.
26+
//
27+
// Note: this should be the last func to wrap/modify the PersistentRunE/RunE funcs before command execution.
28+
//
29+
// can also be used for spans!
30+
func InstrumentCobraCommands(cmd *cobra.Command, mp metric.MeterProvider) {
31+
meter := getDefaultMeter(mp)
32+
// If PersistentPreRunE is nil, make it execute PersistentPreRun and return nil by default
33+
ogPersistentPreRunE := cmd.PersistentPreRunE
34+
if ogPersistentPreRunE == nil {
35+
ogPersistentPreRun := cmd.PersistentPreRun
36+
//nolint:unparam // necessary because error will always be nil here
37+
ogPersistentPreRunE = func(cmd *cobra.Command, args []string) error {
38+
ogPersistentPreRun(cmd, args)
39+
return nil
40+
}
41+
cmd.PersistentPreRun = nil
42+
}
43+
44+
// wrap RunE in PersistentPreRunE so that this operation gets executed on all children commands
45+
cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
46+
// If RunE is nil, make it execute Run and return nil by default
47+
ogRunE := cmd.RunE
48+
if ogRunE == nil {
49+
ogRun := cmd.Run
50+
//nolint:unparam // necessary because error will always be nil here
51+
ogRunE = func(cmd *cobra.Command, args []string) error {
52+
ogRun(cmd, args)
53+
return nil
54+
}
55+
cmd.Run = nil
56+
}
57+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
58+
// start the timer as the first step of every cobra command
59+
stopCobraCmdTimer := startCobraCommandTimer(cmd, meter)
60+
cmdErr := ogRunE(cmd, args)
61+
stopCobraCmdTimer(cmdErr)
62+
return cmdErr
63+
}
64+
65+
return ogPersistentPreRunE(cmd, args)
66+
}
67+
}
68+
69+
func startCobraCommandTimer(cmd *cobra.Command, meter metric.Meter) func(err error) {
70+
ctx := cmd.Context()
71+
baseAttrs := BaseMetricAttributes(cmd)
72+
durationCounter, _ := meter.Float64Counter(
73+
"command.time",
74+
metric.WithDescription("Measures the duration of the cobra command"),
75+
metric.WithUnit("ms"),
76+
)
77+
start := time.Now()
78+
79+
return func(err error) {
80+
duration := float64(time.Since(start)) / float64(time.Millisecond)
81+
cmdStatusAttrs := attributesFromError(err)
82+
durationCounter.Add(ctx, duration,
83+
metric.WithAttributeSet(baseAttrs),
84+
metric.WithAttributeSet(attribute.NewSet(cmdStatusAttrs...)),
85+
)
86+
}
87+
}
88+
89+
func attributesFromError(err error) []attribute.KeyValue {
90+
attrs := []attribute.KeyValue{}
91+
exitCode := 0
92+
if err != nil {
93+
exitCode = 1
94+
if stderr, ok := err.(statusError); ok {
95+
// StatusError should only be used for errors, and all errors should
96+
// have a non-zero exit status, so only set this here if this value isn't 0
97+
if stderr.StatusCode != 0 {
98+
exitCode = stderr.StatusCode
99+
}
100+
}
101+
attrs = append(attrs, attribute.String("command.error.type", otelErrorType(err)))
102+
}
103+
attrs = append(attrs, attribute.String("command.status.code", strconv.Itoa(exitCode)))
104+
105+
return attrs
106+
}
107+
108+
// otelErrorType returns an attribute for the error type based on the error category.
109+
func otelErrorType(err error) string {
110+
name := "generic"
111+
if errors.Is(err, context.Canceled) {
112+
name = "canceled"
113+
}
114+
return name
115+
}
116+
117+
// statusError reports an unsuccessful exit by a command.
118+
type statusError struct {
119+
Status string
120+
StatusCode int
121+
}
122+
123+
func (e statusError) Error() string {
124+
return fmt.Sprintf("Status: %s, Code: %d", e.Status, e.StatusCode)
125+
}
126+
127+
// getCommandName gets the cobra command name in the format
128+
// `... parentCommandName commandName` by traversing it's parent commands recursively.
129+
// until the root command is reached.
130+
//
131+
// Note: The root command's name is excluded. If cmd is the root cmd, return ""
132+
func getCommandName(cmd *cobra.Command) string {
133+
fullCmdName := getFullCommandName(cmd)
134+
i := strings.Index(fullCmdName, " ")
135+
if i == -1 {
136+
return ""
137+
}
138+
return fullCmdName[i+1:]
139+
}
140+
141+
// getFullCommandName gets the full cobra command name in the format
142+
// `... parentCommandName commandName` by traversing it's parent commands recursively
143+
// until the root command is reached.
144+
func getFullCommandName(cmd *cobra.Command) string {
145+
if cmd.HasParent() {
146+
return fmt.Sprintf("%s %s", getFullCommandName(cmd.Parent()), cmd.Name())
147+
}
148+
return cmd.Name()
149+
}
150+
151+
// getDefaultMeter gets the default metric.Meter for the application
152+
// using the given metric.MeterProvider
153+
func getDefaultMeter(mp metric.MeterProvider) metric.Meter {
154+
return mp.Meter(
155+
"github.com/docker/cli",
156+
metric.WithInstrumentationVersion(version.Version),
157+
)
158+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package command
2+
3+
import (
4+
"testing"
5+
6+
"github.com/spf13/cobra"
7+
"gotest.tools/v3/assert"
8+
)
9+
10+
func setupCobraCommands() (*cobra.Command, *cobra.Command, *cobra.Command) {
11+
rootCmd := &cobra.Command{
12+
Use: "root [OPTIONS] COMMAND [ARG...]",
13+
}
14+
childCmd := &cobra.Command{
15+
Use: "child [OPTIONS] COMMAND [ARG...]",
16+
}
17+
grandchildCmd := &cobra.Command{
18+
Use: "grandchild [OPTIONS] COMMAND [ARG...]",
19+
}
20+
childCmd.AddCommand(grandchildCmd)
21+
rootCmd.AddCommand(childCmd)
22+
23+
return rootCmd, childCmd, grandchildCmd
24+
}
25+
26+
func TestGetFullCommandName(t *testing.T) {
27+
rootCmd, childCmd, grandchildCmd := setupCobraCommands()
28+
29+
t.Parallel()
30+
31+
for _, tc := range []struct {
32+
testName string
33+
cmd *cobra.Command
34+
expected string
35+
}{
36+
{
37+
testName: "rootCmd",
38+
cmd: rootCmd,
39+
expected: "root",
40+
},
41+
{
42+
testName: "childCmd",
43+
cmd: childCmd,
44+
expected: "root child",
45+
},
46+
{
47+
testName: "grandChild",
48+
cmd: grandchildCmd,
49+
expected: "root child grandchild",
50+
},
51+
} {
52+
tc := tc
53+
t.Run(tc.testName, func(t *testing.T) {
54+
t.Parallel()
55+
actual := getFullCommandName(tc.cmd)
56+
assert.Equal(t, actual, tc.expected)
57+
})
58+
}
59+
}
60+
61+
func TestGetCommandName(t *testing.T) {
62+
rootCmd, childCmd, grandchildCmd := setupCobraCommands()
63+
64+
t.Parallel()
65+
66+
for _, tc := range []struct {
67+
testName string
68+
cmd *cobra.Command
69+
expected string
70+
}{
71+
{
72+
testName: "rootCmd",
73+
cmd: rootCmd,
74+
expected: "",
75+
},
76+
{
77+
testName: "childCmd",
78+
cmd: childCmd,
79+
expected: "child",
80+
},
81+
{
82+
testName: "grandchildCmd",
83+
cmd: grandchildCmd,
84+
expected: "child grandchild",
85+
},
86+
} {
87+
tc := tc
88+
t.Run(tc.testName, func(t *testing.T) {
89+
t.Parallel()
90+
actual := getCommandName(tc.cmd)
91+
assert.Equal(t, actual, tc.expected)
92+
})
93+
}
94+
}

cmd/docker/docker.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"context"
45
"fmt"
56
"os"
67
"os/exec"
@@ -21,17 +22,19 @@ import (
2122
"github.com/sirupsen/logrus"
2223
"github.com/spf13/cobra"
2324
"github.com/spf13/pflag"
25+
"go.opentelemetry.io/otel"
2426
)
2527

2628
func main() {
27-
dockerCli, err := command.NewDockerCli()
29+
ctx := context.Background()
30+
dockerCli, err := command.NewDockerCli(command.WithBaseContext(ctx))
2831
if err != nil {
2932
fmt.Fprintln(os.Stderr, err)
3033
os.Exit(1)
3134
}
3235
logrus.SetOutput(dockerCli.Err())
3336

34-
if err := runDocker(dockerCli); err != nil {
37+
if err := runDocker(ctx, dockerCli); err != nil {
3538
if sterr, ok := err.(cli.StatusError); ok {
3639
if sterr.Status != "" {
3740
fmt.Fprintln(dockerCli.Err(), sterr.Status)
@@ -286,7 +289,7 @@ func tryPluginRun(dockerCli command.Cli, cmd *cobra.Command, subcommand string,
286289
}
287290

288291
//nolint:gocyclo
289-
func runDocker(dockerCli *command.DockerCli) error {
292+
func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
290293
tcmd := newDockerCommand(dockerCli)
291294

292295
cmd, args, err := tcmd.HandleGlobalFlags()
@@ -298,6 +301,11 @@ func runDocker(dockerCli *command.DockerCli) error {
298301
return err
299302
}
300303

304+
mp := dockerCli.MeterProvider(ctx)
305+
defer mp.Shutdown(ctx)
306+
otel.SetMeterProvider(mp)
307+
command.InstrumentCobraCommands(cmd, mp)
308+
301309
var envs []string
302310
args, os.Args, envs, err = processAliases(dockerCli, cmd, args, os.Args)
303311
if err != nil {

0 commit comments

Comments
 (0)