Skip to content
This repository was archived by the owner on Nov 27, 2023. It is now read-only.

Commit 7843778

Browse files
committed
metrics: track and report non-aggregated events
Currently, "usage" reports are sent, which are aggregated. Add "event" variant, which won't be aggregated. For compatibility, the "usage" events remain and nothing has changed in terms of how they parse the command args, for example. This will ensure continued functioning of anything that relies on these metrics. For the "event" variants, the CLI parsing is slightly different in an attempt to improve data analysis capabilities while still being respectful of user privacy (i.e. only known values of Docker CLI commands/flags are ever recorded). Additionally, execution duration information is now reported with these events. Signed-off-by: Milas Bowman <[email protected]>
1 parent ce03114 commit 7843778

File tree

10 files changed

+423
-47
lines changed

10 files changed

+423
-47
lines changed

Diff for: cli/main.go

+5-5
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ func main() {
260260
handleError(ctx, err, ctype, currentContext, cc, root, start, duration)
261261
}
262262
metricsClient.Track(
263-
metrics.CmdMeta{
263+
metrics.CmdResult{
264264
ContextType: ctype,
265265
Args: os.Args[1:],
266266
Status: metrics.SuccessStatus,
@@ -298,7 +298,7 @@ func handleError(
298298
// if user canceled request, simply exit without any error message
299299
if api.IsErrCanceled(err) || errors.Is(ctx.Err(), context.Canceled) {
300300
metricsClient.Track(
301-
metrics.CmdMeta{
301+
metrics.CmdResult{
302302
ContextType: ctype,
303303
Args: os.Args[1:],
304304
Status: metrics.CanceledStatus,
@@ -335,7 +335,7 @@ func exit(ctx string, err error, ctype string, start time.Time, duration time.Du
335335
if exit, ok := err.(cli.StatusError); ok {
336336
// TODO(milas): shouldn't this use the exit code to determine status?
337337
metricsClient.Track(
338-
metrics.CmdMeta{
338+
metrics.CmdResult{
339339
ContextType: ctype,
340340
Args: os.Args[1:],
341341
Status: metrics.SuccessStatus,
@@ -358,7 +358,7 @@ func exit(ctx string, err error, ctype string, start time.Time, duration time.Du
358358
exitCode = metrics.CommandSyntaxFailure.ExitCode
359359
}
360360
metricsClient.Track(
361-
metrics.CmdMeta{
361+
metrics.CmdResult{
362362
ContextType: ctype,
363363
Args: os.Args[1:],
364364
Status: metricsStatus,
@@ -400,7 +400,7 @@ func checkIfUnknownCommandExistInDefaultContext(err error, currentContext string
400400

401401
if mobycli.IsDefaultContextCommand(dockerCommand) {
402402
fmt.Fprintf(os.Stderr, "Command %q not available in current context (%s), you can use the \"default\" context to run this command\n", dockerCommand, currentContext)
403-
metricsClient.Track(metrics.CmdMeta{
403+
metricsClient.Track(metrics.CmdResult{
404404
ContextType: contextType,
405405
Args: os.Args[1:],
406406
Status: metrics.FailureStatus,

Diff for: cli/metrics/client.go

+25-12
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,26 @@ import (
2626
// specified file path.
2727
const EnvVarDebugMetricsPath = "DOCKER_METRICS_DEBUG_LOG"
2828

29-
type CmdMeta struct {
29+
// Timeout is the maximum amount of time we'll wait for metrics sending to be
30+
// acknowledged before giving up.
31+
const Timeout = 50 * time.Millisecond
32+
33+
// CmdResult provides details about process execution.
34+
type CmdResult struct {
35+
// ContextType is `moby` for Docker or the name of a cloud provider.
3036
ContextType string
31-
Args []string
32-
Status string
33-
ExitCode int
34-
Start time.Time
35-
Duration time.Duration
37+
// Args minus the process name (argv[0] aka `docker`).
38+
Args []string
39+
// Status based on exit code as a descriptive value.
40+
//
41+
// Deprecated: used for usage, events rely exclusively on exit code.
42+
Status string
43+
// ExitCode is 0 on success; otherwise, failure.
44+
ExitCode int
45+
// Start time of the process (UTC).
46+
Start time.Time
47+
// Duration of process execution.
48+
Duration time.Duration
3649
}
3750

3851
type client struct {
@@ -44,8 +57,8 @@ type cliversion struct {
4457
f func() string
4558
}
4659

47-
// Command is a command
48-
type Command struct {
60+
// CommandUsage reports a CLI invocation for aggregation.
61+
type CommandUsage struct {
4962
Command string `json:"command"`
5063
Context string `json:"context"`
5164
Source string `json:"source"`
@@ -69,9 +82,9 @@ type Client interface {
6982
// SendUsage sends the command to Docker Desktop.
7083
//
7184
// Note that metric collection is best-effort, so any errors are ignored.
72-
SendUsage(Command)
85+
SendUsage(CommandUsage)
7386
// Track creates an event for a command execution and reports it.
74-
Track(cmd CmdMeta)
87+
Track(CmdResult)
7588
}
7689

7790
// NewClient returns a new metrics client that will send metrics using the
@@ -107,7 +120,7 @@ func (c *client) WithCliVersionFunc(f func() string) {
107120
c.cliversion.f = f
108121
}
109122

110-
func (c *client) SendUsage(command Command) {
123+
func (c *client) SendUsage(command CommandUsage) {
111124
result := make(chan bool, 1)
112125
go func() {
113126
c.reporter.Heartbeat(command)
@@ -118,6 +131,6 @@ func (c *client) SendUsage(command Command) {
118131
// Posting metrics without Desktop listening returns in less than a ms, and a handful of ms (often <2ms) when Desktop is listening
119132
select {
120133
case <-result:
121-
case <-time.After(50 * time.Millisecond):
134+
case <-time.After(Timeout):
122135
}
123136
}

Diff for: cli/metrics/docker_command.go

+193
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/*
2+
Copyright 2022 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package metrics
18+
19+
import (
20+
"strings"
21+
"time"
22+
)
23+
24+
// DockerCLIEvent represents an invocation of `docker` from the the CLI.
25+
type DockerCLIEvent struct {
26+
Command string `json:"command,omitempty"`
27+
Subcommand string `json:"subcommand,omitempty"`
28+
Usage bool `json:"usage,omitempty"`
29+
ExitCode int32 `json:"exit_code"`
30+
StartTime time.Time `json:"start_time"`
31+
DurationSecs float64 `json:"duration_secs,omitempty"`
32+
}
33+
34+
// NewDockerCLIEvent inspects the command line string and returns a stripped down
35+
// version suitable for reporting.
36+
//
37+
// The parser will only use known values for command/subcommand from a hardcoded
38+
// built-in set for safety. It also does not attempt to perfectly accurately
39+
// reflect how arg parsing works in a real program, instead favoring a fairly
40+
// simple approach that's still reasonably robust.
41+
//
42+
// If the command does not map to a known Docker (or first-party plugin)
43+
// command, `nil` will be returned. Similarly, if no subcommand for the
44+
// built-in/plugin can be determined, it will be empty.
45+
func NewDockerCLIEvent(cmd CmdResult) *DockerCLIEvent {
46+
if len(cmd.Args) == 0 {
47+
return nil
48+
}
49+
50+
cmdPath := findCommand(append([]string{"docker"}, cmd.Args...))
51+
if cmdPath == nil {
52+
return nil
53+
}
54+
55+
if len(cmdPath) < 2 {
56+
// ignore unknown commands; we can't infer anything from them safely
57+
// N.B. ONLY compose commands are supported by `cmdHierarchy` currently!
58+
return nil
59+
}
60+
61+
// look for a subcommand
62+
var subcommand string
63+
if len(cmdPath) >= 3 {
64+
var subcommandParts []string
65+
for _, c := range cmdPath[2:] {
66+
subcommandParts = append(subcommandParts, c.name)
67+
}
68+
subcommand = strings.Join(subcommandParts, "-")
69+
}
70+
71+
var usage bool
72+
for _, arg := range cmd.Args {
73+
// TODO(milas): also support `docker help build` syntax
74+
if arg == "help" {
75+
return nil
76+
}
77+
78+
if arg == "--help" || arg == "-h" {
79+
usage = true
80+
}
81+
}
82+
83+
event := &DockerCLIEvent{
84+
Command: cmdPath[1].name,
85+
Subcommand: subcommand,
86+
ExitCode: int32(cmd.ExitCode),
87+
Usage: usage,
88+
StartTime: cmd.Start,
89+
DurationSecs: cmd.Duration.Seconds(),
90+
}
91+
92+
return event
93+
}
94+
95+
func findCommand(args []string) []*cmdNode {
96+
if len(args) == 0 {
97+
return nil
98+
}
99+
100+
cmdPath := []*cmdNode{cmdHierarchy}
101+
if len(args) == 1 {
102+
return cmdPath
103+
}
104+
105+
nodePath := []string{args[0]}
106+
for _, v := range args[1:] {
107+
v = strings.TrimSpace(v)
108+
if v == "" || strings.HasPrefix(v, "-") {
109+
continue
110+
}
111+
candidate := append(nodePath, v)
112+
if c := cmdHierarchy.find(candidate); c != nil {
113+
cmdPath = append(cmdPath, c)
114+
nodePath = candidate
115+
}
116+
}
117+
118+
return cmdPath
119+
}
120+
121+
type cmdNode struct {
122+
name string
123+
plugin bool
124+
children []*cmdNode
125+
}
126+
127+
func (c *cmdNode) find(path []string) *cmdNode {
128+
if len(path) == 0 {
129+
return nil
130+
}
131+
132+
if c.name != path[0] {
133+
return nil
134+
}
135+
136+
if len(path) == 1 {
137+
return c
138+
}
139+
140+
remainder := path[1:]
141+
for _, child := range c.children {
142+
if res := child.find(remainder); res != nil {
143+
return res
144+
}
145+
}
146+
147+
return nil
148+
}
149+
150+
var cmdHierarchy = &cmdNode{
151+
name: "docker",
152+
children: []*cmdNode{
153+
{
154+
name: "compose",
155+
plugin: true,
156+
children: []*cmdNode{
157+
{
158+
name: "alpha",
159+
children: []*cmdNode{
160+
{name: "watch"},
161+
{name: "dryrun"},
162+
},
163+
},
164+
{name: "build"},
165+
{name: "config"},
166+
{name: "convert"},
167+
{name: "cp"},
168+
{name: "create"},
169+
{name: "down"},
170+
{name: "events"},
171+
{name: "exec"},
172+
{name: "images"},
173+
{name: "kill"},
174+
{name: "logs"},
175+
{name: "ls"},
176+
{name: "pause"},
177+
{name: "port"},
178+
{name: "ps"},
179+
{name: "pull"},
180+
{name: "push"},
181+
{name: "restart"},
182+
{name: "rm"},
183+
{name: "run"},
184+
{name: "start"},
185+
{name: "stop"},
186+
{name: "top"},
187+
{name: "unpause"},
188+
{name: "up"},
189+
{name: "version"},
190+
},
191+
},
192+
},
193+
}

0 commit comments

Comments
 (0)