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

Commit 731d87b

Browse files
authored
Merge pull request #2226 from milas/metrics-event-reporter
metrics: track and report non-aggregated events
2 parents ce03114 + 7843778 commit 731d87b

10 files changed

+423
-47
lines changed

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,

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
}

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)