Skip to content

Commit ee1b283

Browse files
committed
otel: capture whether process was invoked from a terminal
This commit adds a "terminal" attribute to `BaseMetricAttributes` that allows us to discern whether an invocation was from an interactive terminal or not. Signed-off-by: Laura Brehm <[email protected]>
1 parent 155dc5e commit ee1b283

4 files changed

Lines changed: 119 additions & 14 deletions

File tree

cli/command/telemetry.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ func (r *telemetryResource) init() {
110110
return
111111
}
112112

113-
opts := append(r.defaultOptions(), r.opts...)
113+
opts := append(defaultResourceOptions(), r.opts...)
114114
res, err := resource.New(context.Background(), opts...)
115115
if err != nil {
116116
otel.Handle(err)
@@ -122,7 +122,7 @@ func (r *telemetryResource) init() {
122122
r.opts = nil
123123
}
124124

125-
func (r *telemetryResource) defaultOptions() []resource.Option {
125+
func defaultResourceOptions() []resource.Option {
126126
return []resource.Option{
127127
resource.WithDetectors(serviceNameDetector{}),
128128
resource.WithAttributes(

cli/command/telemetry_utils.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,26 @@ import (
88
"time"
99

1010
"github.com/docker/cli/cli/version"
11+
"github.com/moby/term"
1112
"github.com/pkg/errors"
1213
"github.com/spf13/cobra"
1314
"go.opentelemetry.io/otel/attribute"
1415
"go.opentelemetry.io/otel/metric"
1516
)
1617

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{
18+
// BaseCommandAttributes returns an attribute.Set containing attributes to attach to metrics/traces
19+
func BaseCommandAttributes(cmd *cobra.Command, streams Streams) []attribute.KeyValue {
20+
return append([]attribute.KeyValue{
2021
attribute.String("command.name", getCommandName(cmd)),
21-
}
22-
return attribute.NewSet(attrList...)
22+
}, stdioAttributes(streams)...)
2323
}
2424

2525
// InstrumentCobraCommands wraps all cobra commands' RunE funcs to set a command duration metric using otel.
2626
//
2727
// Note: this should be the last func to wrap/modify the PersistentRunE/RunE funcs before command execution.
2828
//
2929
// can also be used for spans!
30-
func InstrumentCobraCommands(cmd *cobra.Command, mp metric.MeterProvider) {
30+
func (cli *DockerCli) InstrumentCobraCommands(cmd *cobra.Command, mp metric.MeterProvider) {
3131
meter := getDefaultMeter(mp)
3232
// If PersistentPreRunE is nil, make it execute PersistentPreRun and return nil by default
3333
ogPersistentPreRunE := cmd.PersistentPreRunE
@@ -56,7 +56,8 @@ func InstrumentCobraCommands(cmd *cobra.Command, mp metric.MeterProvider) {
5656
}
5757
cmd.RunE = func(cmd *cobra.Command, args []string) error {
5858
// start the timer as the first step of every cobra command
59-
stopCobraCmdTimer := startCobraCommandTimer(cmd, meter)
59+
baseAttrs := BaseCommandAttributes(cmd, cli)
60+
stopCobraCmdTimer := startCobraCommandTimer(cmd, meter, baseAttrs)
6061
cmdErr := ogRunE(cmd, args)
6162
stopCobraCmdTimer(cmdErr)
6263
return cmdErr
@@ -66,9 +67,8 @@ func InstrumentCobraCommands(cmd *cobra.Command, mp metric.MeterProvider) {
6667
}
6768
}
6869

69-
func startCobraCommandTimer(cmd *cobra.Command, meter metric.Meter) func(err error) {
70+
func startCobraCommandTimer(cmd *cobra.Command, meter metric.Meter, attrs []attribute.KeyValue) func(err error) {
7071
ctx := cmd.Context()
71-
baseAttrs := BaseMetricAttributes(cmd)
7272
durationCounter, _ := meter.Float64Counter(
7373
"command.time",
7474
metric.WithDescription("Measures the duration of the cobra command"),
@@ -80,12 +80,22 @@ func startCobraCommandTimer(cmd *cobra.Command, meter metric.Meter) func(err err
8080
duration := float64(time.Since(start)) / float64(time.Millisecond)
8181
cmdStatusAttrs := attributesFromError(err)
8282
durationCounter.Add(ctx, duration,
83-
metric.WithAttributeSet(baseAttrs),
84-
metric.WithAttributeSet(attribute.NewSet(cmdStatusAttrs...)),
83+
metric.WithAttributes(attrs...),
84+
metric.WithAttributes(cmdStatusAttrs...),
8585
)
8686
}
8787
}
8888

89+
func stdioAttributes(streams Streams) []attribute.KeyValue {
90+
// we don't wrap stderr, but we do wrap in/out
91+
_, stderrTty := term.GetFdInfo(streams.Err())
92+
return []attribute.KeyValue{
93+
attribute.Bool("command.stdin.isatty", streams.In().IsTerminal()),
94+
attribute.Bool("command.stdout.isatty", streams.Out().IsTerminal()),
95+
attribute.Bool("command.stderr.isatty", stderrTty),
96+
}
97+
}
98+
8999
func attributesFromError(err error) []attribute.KeyValue {
90100
attrs := []attribute.KeyValue{}
91101
exitCode := 0

cli/command/telemetry_utils_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
package command
22

33
import (
4+
"bytes"
5+
"context"
6+
"io"
7+
"reflect"
8+
"strings"
49
"testing"
510

11+
"github.com/docker/cli/cli/streams"
612
"github.com/spf13/cobra"
13+
"go.opentelemetry.io/otel/attribute"
714
"gotest.tools/v3/assert"
815
)
916

@@ -92,3 +99,91 @@ func TestGetCommandName(t *testing.T) {
9299
})
93100
}
94101
}
102+
103+
func TestStdioAttributes(t *testing.T) {
104+
outBuffer := new(bytes.Buffer)
105+
errBuffer := new(bytes.Buffer)
106+
t.Parallel()
107+
for _, tc := range []struct {
108+
test string
109+
stdinTty bool
110+
stdoutTty bool
111+
// TODO(laurazard): test stderr
112+
expected []attribute.KeyValue
113+
}{
114+
{
115+
test: "",
116+
expected: []attribute.KeyValue{
117+
attribute.Bool("command.stdin.isatty", false),
118+
attribute.Bool("command.stdout.isatty", false),
119+
attribute.Bool("command.stderr.isatty", false),
120+
},
121+
},
122+
{
123+
test: "",
124+
stdinTty: true,
125+
stdoutTty: true,
126+
expected: []attribute.KeyValue{
127+
attribute.Bool("command.stdin.isatty", true),
128+
attribute.Bool("command.stdout.isatty", true),
129+
attribute.Bool("command.stderr.isatty", false),
130+
},
131+
},
132+
} {
133+
tc := tc
134+
t.Run(tc.test, func(t *testing.T) {
135+
t.Parallel()
136+
cli := &DockerCli{
137+
in: streams.NewIn(io.NopCloser(strings.NewReader(""))),
138+
out: streams.NewOut(outBuffer),
139+
err: errBuffer,
140+
}
141+
cli.In().SetIsTerminal(tc.stdinTty)
142+
cli.Out().SetIsTerminal(tc.stdoutTty)
143+
actual := stdioAttributes(cli)
144+
145+
assert.Check(t, reflect.DeepEqual(actual, tc.expected))
146+
})
147+
}
148+
}
149+
150+
func TestAttributesFromError(t *testing.T) {
151+
t.Parallel()
152+
153+
for _, tc := range []struct {
154+
testName string
155+
err error
156+
expected []attribute.KeyValue
157+
}{
158+
{
159+
testName: "no error",
160+
err: nil,
161+
expected: []attribute.KeyValue{
162+
attribute.String("command.status.code", "0"),
163+
},
164+
},
165+
{
166+
testName: "non-0 exit code",
167+
err: statusError{StatusCode: 127},
168+
expected: []attribute.KeyValue{
169+
attribute.String("command.error.type", "generic"),
170+
attribute.String("command.status.code", "127"),
171+
},
172+
},
173+
{
174+
testName: "canceled",
175+
err: context.Canceled,
176+
expected: []attribute.KeyValue{
177+
attribute.String("command.error.type", "canceled"),
178+
attribute.String("command.status.code", "1"),
179+
},
180+
},
181+
} {
182+
tc := tc
183+
t.Run(tc.testName, func(t *testing.T) {
184+
t.Parallel()
185+
actual := attributesFromError(tc.err)
186+
assert.Check(t, reflect.DeepEqual(actual, tc.expected))
187+
})
188+
}
189+
}

cmd/docker/docker.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
304304
mp := dockerCli.MeterProvider(ctx)
305305
defer mp.Shutdown(ctx)
306306
otel.SetMeterProvider(mp)
307-
command.InstrumentCobraCommands(cmd, mp)
307+
dockerCli.InstrumentCobraCommands(cmd, mp)
308308

309309
var envs []string
310310
args, os.Args, envs, err = processAliases(dockerCli, cmd, args, os.Args)

0 commit comments

Comments
 (0)