Merge pull request #741 from gtardif/fix_cli_metrics

Simplify metrics gathering, separate running commands from getting help
This commit is contained in:
Guillaume Tardif 2020-10-08 15:34:09 +02:00 committed by GitHub
commit cda66b5e90
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 372 additions and 218 deletions

View File

@ -198,37 +198,37 @@ func main() {
if err = root.ExecuteContext(ctx); err != nil { if err = root.ExecuteContext(ctx); err != nil {
// if user canceled request, simply exit without any error message // if user canceled request, simply exit without any error message
if errdefs.IsErrCanceled(err) || errors.Is(ctx.Err(), context.Canceled) { if errdefs.IsErrCanceled(err) || errors.Is(ctx.Err(), context.Canceled) {
metrics.Track(ctype, os.Args[1:], root.PersistentFlags(), metrics.CanceledStatus) metrics.Track(ctype, os.Args[1:], metrics.CanceledStatus)
os.Exit(130) os.Exit(130)
} }
if ctype == store.AwsContextType { if ctype == store.AwsContextType {
exit(root, currentContext, errors.Errorf(`%q context type has been renamed. Recreate the context by running: exit(currentContext, errors.Errorf(`%q context type has been renamed. Recreate the context by running:
$ docker context create %s <name>`, cc.Type(), store.EcsContextType), ctype) $ docker context create %s <name>`, cc.Type(), store.EcsContextType), ctype)
} }
// Context should always be handled by new CLI // Context should always be handled by new CLI
requiredCmd, _, _ := root.Find(os.Args[1:]) requiredCmd, _, _ := root.Find(os.Args[1:])
if requiredCmd != nil && isContextAgnosticCommand(requiredCmd) { if requiredCmd != nil && isContextAgnosticCommand(requiredCmd) {
exit(root, currentContext, err, ctype) exit(currentContext, err, ctype)
} }
mobycli.ExecIfDefaultCtxType(ctx, root) mobycli.ExecIfDefaultCtxType(ctx, root)
checkIfUnknownCommandExistInDefaultContext(err, currentContext, root, ctype) checkIfUnknownCommandExistInDefaultContext(err, currentContext, ctype)
exit(root, currentContext, err, ctype) exit(currentContext, err, ctype)
} }
metrics.Track(ctype, os.Args[1:], root.PersistentFlags(), metrics.SuccessStatus) metrics.Track(ctype, os.Args[1:], metrics.SuccessStatus)
} }
func exit(root *cobra.Command, ctx string, err error, ctype string) { func exit(ctx string, err error, ctype string) {
metrics.Track(ctype, os.Args[1:], root.PersistentFlags(), metrics.FailureStatus) metrics.Track(ctype, os.Args[1:], metrics.FailureStatus)
if errors.Is(err, errdefs.ErrLoginRequired) { if errors.Is(err, errdefs.ErrLoginRequired) {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(errdefs.ExitCodeLoginRequired) os.Exit(errdefs.ExitCodeLoginRequired)
} }
if errors.Is(err, errdefs.ErrNotImplemented) { if errors.Is(err, errdefs.ErrNotImplemented) {
name := metrics.GetCommand(os.Args[1:], root.PersistentFlags()) name := metrics.GetCommand(os.Args[1:])
fmt.Fprintf(os.Stderr, "Command %q not available in current context (%s)\n", name, ctx) fmt.Fprintf(os.Stderr, "Command %q not available in current context (%s)\n", name, ctx)
os.Exit(1) os.Exit(1)
@ -242,14 +242,14 @@ func fatal(err error) {
os.Exit(1) os.Exit(1)
} }
func checkIfUnknownCommandExistInDefaultContext(err error, currentContext string, root *cobra.Command, contextType string) { func checkIfUnknownCommandExistInDefaultContext(err error, currentContext string, contextType string) {
submatch := unknownCommandRegexp.FindSubmatch([]byte(err.Error())) submatch := unknownCommandRegexp.FindSubmatch([]byte(err.Error()))
if len(submatch) == 2 { if len(submatch) == 2 {
dockerCommand := string(submatch[1]) dockerCommand := string(submatch[1])
if mobycli.IsDefaultContextCommand(dockerCommand) { if mobycli.IsDefaultContextCommand(dockerCommand) {
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) 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)
metrics.Track(contextType, os.Args[1:], root.PersistentFlags(), metrics.FailureStatus) metrics.Track(contextType, os.Args[1:], metrics.FailureStatus)
os.Exit(1) os.Exit(1)
} }
} }

View File

@ -86,7 +86,7 @@ func Exec(root *cobra.Command) {
err := cmd.Run() err := cmd.Run()
childExit <- true childExit <- true
if err != nil { if err != nil {
metrics.Track(store.DefaultContextType, os.Args[1:], root.PersistentFlags(), metrics.FailureStatus) metrics.Track(store.DefaultContextType, os.Args[1:], metrics.FailureStatus)
if exiterr, ok := err.(*exec.ExitError); ok { if exiterr, ok := err.(*exec.ExitError); ok {
os.Exit(exiterr.ExitCode()) os.Exit(exiterr.ExitCode())
@ -94,7 +94,7 @@ func Exec(root *cobra.Command) {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
metrics.Track(store.DefaultContextType, os.Args[1:], root.PersistentFlags(), metrics.SuccessStatus) metrics.Track(store.DefaultContextType, os.Args[1:], metrics.SuccessStatus)
os.Exit(0) os.Exit(0)
} }

147
metrics/commands.go Normal file
View File

@ -0,0 +1,147 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package metrics
var commandFlags = []string{
//added to catch scan details
"--version", "--login",
}
// Generated with generatecommands/main.go
var managementCommands = []string{
"ecs",
"scan",
"app",
"builder",
"imagetools",
"buildx",
"checkpoint",
"config",
"container",
"context",
"create",
"image",
"manifest",
"network",
"node",
"plugin",
"secret",
"service",
"stack",
"swarm",
"system",
"key",
"signer",
"trust",
"volume",
"login",
"logout",
"compose",
}
var commands = []string{
"bundle",
"completion",
"init",
"inspect",
"install",
"deploy",
"list",
"ls",
"merge",
"pull",
"push",
"render",
"split",
"status",
"uninstall",
"upgrade",
"validate",
"version",
"build",
"prune",
"create",
"bake",
"f",
"b",
"du",
"rm",
"stop",
"use",
"remove",
"attach",
"commit",
"cp",
"diff",
"exec",
"export",
"kill",
"logs",
"ps",
"pause",
"port",
"rename",
"restart",
"run",
"start",
"stats",
"top",
"unpause",
"update",
"wait",
"aci",
"ecs",
"show",
"history",
"import",
"load",
"images",
"rmi",
"save",
"tag",
"annotate",
"connect",
"disconnect",
"demote",
"promote",
"disable",
"enable",
"set",
"rollback",
"scale",
"up",
"down",
"services",
"ca",
"join",
"join-token",
"leave",
"unlock",
"unlock-key",
"df",
"events",
"info",
"generate",
"add",
"revoke",
"sign",
"login",
"azure",
"logout",
"search",
"convert",
}

View File

@ -0,0 +1,106 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"os/exec"
"strings"
"github.com/docker/compose-cli/utils"
)
var managementCommands = []string{"ecs", "scan"}
var commands = []string{}
func main() {
fmt.Println("Walking through docker help to list commands...")
getCommands()
getCommands("compose")
fmt.Printf(`
var managementCommands = []string{
"%s",
}
var commands = []string{
"%s",
}
`, strings.Join(managementCommands, "\", \n\t\""), strings.Join(commands, "\", \n\t\""))
}
const (
mgtCommandsSection = "Management Commands:"
commandsSection = "Commands:"
aliasesSection = "Aliases:"
)
func getCommands(execCommands ...string) {
withHelp := append(execCommands, "--help")
cmd := exec.Command("docker", withHelp...)
output, err := cmd.Output()
if err != nil {
return
}
text := string(output)
lines := strings.Split(text, "\n")
section := ""
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
if strings.HasPrefix(trimmedLine, mgtCommandsSection) {
section = mgtCommandsSection
continue
}
if strings.HasPrefix(trimmedLine, commandsSection) || strings.HasPrefix(trimmedLine, "Available Commands:") {
section = commandsSection
if len(execCommands) > 0 {
command := execCommands[len(execCommands)-1]
managementCommands = append(managementCommands, command)
}
continue
}
if strings.HasPrefix(trimmedLine, aliasesSection) {
section = aliasesSection
continue
}
if trimmedLine == "" {
section = ""
continue
}
tokens := strings.Split(trimmedLine, " ")
command := strings.Replace(tokens[0], "*", "", 1)
switch section {
case mgtCommandsSection:
getCommands(append(execCommands, command)...)
case commandsSection:
if !utils.StringContains(commands, command) {
commands = append(commands, command)
}
getCommands(append(execCommands, command)...)
case aliasesSection:
aliases := strings.Split(trimmedLine, ",")
for _, alias := range aliases {
trimmedAlias := strings.TrimSpace(alias)
if !utils.StringContains(commands, trimmedAlias) {
commands = append(commands, trimmedAlias)
}
}
}
}
}

View File

@ -17,68 +17,12 @@
package metrics package metrics
import ( import (
"strings"
flag "github.com/spf13/pflag"
"github.com/docker/compose-cli/utils" "github.com/docker/compose-cli/utils"
) )
var managementCommands = []string{
"app",
"assemble",
"builder",
"buildx",
"ecs",
"ecs compose",
"cluster",
"compose",
"config",
"container",
"context",
// We add "context create" as a management command to be able to catch
// calls to "context create aci"
"context create",
"help",
"image",
// Adding "login" as a management command so that the system can catch
// commands like `docker login azure`
"login",
"manifest",
"network",
"node",
"plugin",
"registry",
"secret",
"service",
"stack",
"swarm",
"system",
"template",
"trust",
"volume",
}
// managementSubCommands holds a list of allowed subcommands of a management
// command. For example we want to send an event for "docker login azure" but
// we don't wat to send the name of the registry when the user does a
// "docker login my-registry", we only want to send "login"
var managementSubCommands = map[string][]string{
"login": {
"azure",
},
"context create": {
"aci",
},
}
const (
scanCommand = "scan"
)
// Track sends the tracking analytics to Docker Desktop // Track sends the tracking analytics to Docker Desktop
func Track(context string, args []string, flags *flag.FlagSet, status string) { func Track(context string, args []string, status string) {
command := GetCommand(args, flags) command := GetCommand(args)
if command != "" { if command != "" {
c := NewClient() c := NewClient()
c.Send(Command{ c.Send(Command{
@ -90,115 +34,39 @@ func Track(context string, args []string, flags *flag.FlagSet, status string) {
} }
} }
func isCommand(word string) bool {
return utils.StringContains(commands, word) || isManagementCommand(word)
}
func isManagementCommand(word string) bool {
return utils.StringContains(managementCommands, word)
}
func isCommandFlag(word string) bool {
return utils.StringContains(commandFlags, word)
}
// GetCommand get the invoked command // GetCommand get the invoked command
func GetCommand(args []string, flags *flag.FlagSet) string { func GetCommand(args []string) string {
command := "" result := ""
strippedArgs := stripFlags(args, flags) onlyFlags := false
for _, arg := range args {
if len(strippedArgs) != 0 { if arg == "--help" {
command = strippedArgs[0] return ""
if command == scanCommand {
return getScanCommand(args)
} }
if arg == "--" {
for {
if utils.StringContains(managementCommands, command) {
if sub := getSubCommand(command, strippedArgs[1:]); sub != "" {
command += " " + sub
strippedArgs = strippedArgs[1:]
continue
}
}
break break
} }
} if isCommandFlag(arg) || (!onlyFlags && isCommand(arg)) {
if result == "" {
return command result = arg
} } else {
result = result + " " + arg
func getScanCommand(args []string) string { }
command := args[0] if !isManagementCommand(arg) {
onlyFlags = true
if utils.StringContains(args, "--auth") {
return command + " auth"
}
if utils.StringContains(args, "--version") {
return command + " version"
}
return command
}
func getSubCommand(command string, args []string) string {
if len(args) == 0 {
return ""
}
if val, ok := managementSubCommands[command]; ok {
if utils.StringContains(val, args[0]) {
return args[0]
}
return ""
}
if isArg(args[0]) {
return args[0]
}
return ""
}
func stripFlags(args []string, flags *flag.FlagSet) []string {
commands := []string{}
for len(args) > 0 {
s := args[0]
args = args[1:]
if s == "--" {
return commands
}
if flagArg(s, flags) {
if len(args) <= 1 {
return commands
} }
args = args[1:]
}
if isArg(s) {
commands = append(commands, s)
} }
} }
return result
return commands
}
func flagArg(s string, flags *flag.FlagSet) bool {
return strings.HasPrefix(s, "--") && !strings.Contains(s, "=") && !hasNoOptDefVal(s[2:], flags) ||
strings.HasPrefix(s, "-") && !strings.Contains(s, "=") && len(s) == 2 && !shortHasNoOptDefVal(s[1:], flags)
}
func isArg(s string) bool {
return s != "" && !strings.HasPrefix(s, "-")
}
func hasNoOptDefVal(name string, fs *flag.FlagSet) bool {
flag := fs.Lookup(name)
if flag == nil {
return false
}
return flag.NoOptDefVal != ""
}
func shortHasNoOptDefVal(name string, fs *flag.FlagSet) bool {
flag := fs.ShorthandLookup(name[:1])
if flag == nil {
return false
}
return flag.NoOptDefVal != ""
} }

View File

@ -19,15 +19,10 @@ package metrics
import ( import (
"testing" "testing"
"github.com/spf13/cobra"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
) )
func TestFlag(t *testing.T) { func TestGetCommand(t *testing.T) {
root := &cobra.Command{}
root.PersistentFlags().BoolP("debug", "D", false, "debug")
root.PersistentFlags().String("str", "str", "str")
testCases := []struct { testCases := []struct {
name string name string
args []string args []string
@ -58,16 +53,6 @@ func TestFlag(t *testing.T) {
args: []string{"--debug", "--str", "str-value"}, args: []string{"--debug", "--str", "str-value"},
expected: "", expected: "",
}, },
{
name: "with unknown short flag",
args: []string{"-f", "run"},
expected: "",
},
{
name: "with unknown long flag",
args: []string{"--unknown-flag", "run"},
expected: "",
},
{ {
name: "management command", name: "management command",
args: []string{"image", "ls"}, args: []string{"image", "ls"},
@ -76,7 +61,7 @@ func TestFlag(t *testing.T) {
{ {
name: "management command with flag", name: "management command with flag",
args: []string{"image", "--test", "ls"}, args: []string{"image", "--test", "ls"},
expected: "image", expected: "image ls",
}, },
{ {
name: "management subcommand with flag", name: "management subcommand with flag",
@ -88,26 +73,36 @@ func TestFlag(t *testing.T) {
args: []string{"login", "azure"}, args: []string{"login", "azure"},
expected: "login azure", expected: "login azure",
}, },
{
name: "azure logout",
args: []string{"logout", "azure"},
expected: "logout azure",
},
{ {
name: "azure login with flags", name: "azure login with flags",
args: []string{"login", "-u", "test", "azure"}, args: []string{"login", "-u", "test", "azure"},
expected: "login azure", expected: "login azure",
}, },
{ {
name: "azure login with azure user", name: "login to a registry",
args: []string{"login", "-u", "azure"}, args: []string{"login", "myregistry"},
expected: "login", expected: "login",
}, },
{ {
name: "login to a registry", name: "logout from a registry",
args: []string{"login", "registry"}, args: []string{"logout", "myregistry"},
expected: "login", expected: "logout",
}, },
{ {
name: "context create aci", name: "context create aci",
args: []string{"context", "create", "aci"}, args: []string{"context", "create", "aci"},
expected: "context create aci", expected: "context create aci",
}, },
{
name: "context create ecs",
args: []string{"context", "create", "ecs"},
expected: "context create ecs",
},
{ {
name: "create a context from another context", name: "create a context from another context",
args: []string{"context", "create", "test-context", "--from=default"}, args: []string{"context", "create", "test-context", "--from=default"},
@ -119,9 +114,9 @@ func TestFlag(t *testing.T) {
expected: "create", expected: "create",
}, },
{ {
name: "create a container named aci", name: "start a container named aci",
args: []string{"create", "aci"}, args: []string{"start", "aci"},
expected: "create", expected: "start",
}, },
{ {
name: "create a container named test-container", name: "create a container named test-container",
@ -152,15 +147,44 @@ func TestFlag(t *testing.T) {
for _, testCase := range testCases { for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
result := GetCommand(testCase.args, root.PersistentFlags()) result := GetCommand(testCase.args)
assert.Equal(t, testCase.expected, result)
})
}
}
func TestIgnoreHelpCommands(t *testing.T) {
testCases := []struct {
name string
args []string
expected string
}{
{
name: "help",
args: []string{"--help"},
expected: "",
},
{
name: "help on run",
args: []string{"run", "--help"},
expected: "",
},
{
name: "help on compose up",
args: []string{"compose", "up", "--help"},
expected: "",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
result := GetCommand(testCase.args)
assert.Equal(t, testCase.expected, result) assert.Equal(t, testCase.expected, result)
}) })
} }
} }
func TestEcs(t *testing.T) { func TestEcs(t *testing.T) {
root := &cobra.Command{}
testCases := []struct { testCases := []struct {
name string name string
args []string args []string
@ -217,23 +241,21 @@ func TestEcs(t *testing.T) {
expected: "ecs compose logs", expected: "ecs compose logs",
}, },
{ {
name: "setup", name: "ecs",
args: []string{"ecs", "setup"}, args: []string{"ecs", "anything"},
expected: "ecs setup", expected: "ecs",
}, },
} }
for _, testCase := range testCases { for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
result := GetCommand(testCase.args, root.PersistentFlags()) result := GetCommand(testCase.args)
assert.Equal(t, testCase.expected, result) assert.Equal(t, testCase.expected, result)
}) })
} }
} }
func TestScan(t *testing.T) { func TestScan(t *testing.T) {
root := &cobra.Command{}
testCases := []struct { testCases := []struct {
name string name string
args []string args []string
@ -246,34 +268,34 @@ func TestScan(t *testing.T) {
}, },
{ {
name: "scan image with long flags", name: "scan image with long flags",
args: []string{"scan", "--file", "file", "image"}, args: []string{"scan", "--file", "file", "myimage"},
expected: "scan", expected: "scan",
}, },
{ {
name: "scan image with short flags", name: "scan image with short flags",
args: []string{"scan", "-f", "file", "image"}, args: []string{"scan", "-f", "file", "myimage"},
expected: "scan", expected: "scan",
}, },
{ {
name: "scan with long flag", name: "scan with long flag",
args: []string{"scan", "--dependency-tree", "image"}, args: []string{"scan", "--dependency-tree", "myimage"},
expected: "scan", expected: "scan",
}, },
{ {
name: "auth", name: "auth",
args: []string{"scan", "--auth"}, args: []string{"scan", "--login"},
expected: "scan auth", expected: "scan --login",
}, },
{ {
name: "version", name: "version",
args: []string{"scan", "--version"}, args: []string{"scan", "--version"},
expected: "scan version", expected: "scan --version",
}, },
} }
for _, testCase := range testCases { for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
result := GetCommand(testCase.args, root.PersistentFlags()) result := GetCommand(testCase.args)
assert.Equal(t, testCase.expected, result) assert.Equal(t, testCase.expected, result)
}) })
} }

View File

@ -164,6 +164,17 @@ func TestContextMetrics(t *testing.T) {
s.Start() s.Start()
defer s.Stop() defer s.Stop()
t.Run("do not send metrics on help commands", func(t *testing.T) {
s.ResetUsage()
c.RunDockerCmd("--help")
c.RunDockerCmd("ps", "--help")
c.RunDockerCmd("run", "--help")
usage := s.GetUsage()
assert.Equal(t, 0, len(usage))
})
t.Run("metrics on default context", func(t *testing.T) { t.Run("metrics on default context", func(t *testing.T) {
s.ResetUsage() s.ResetUsage()
@ -185,7 +196,7 @@ func TestContextMetrics(t *testing.T) {
c.RunDockerCmd("ps") c.RunDockerCmd("ps")
c.RunDockerCmd("context", "use", "test-example") c.RunDockerCmd("context", "use", "test-example")
c.RunDockerCmd("ps") c.RunDockerCmd("ps")
c.RunDockerOrExitError("error") c.RunDockerOrExitError("stop", "unknown")
c.RunDockerCmd("context", "use", "default") c.RunDockerCmd("context", "use", "default")
c.RunDockerCmd("--context", "test-example", "ps") c.RunDockerCmd("--context", "test-example", "ps")
@ -195,7 +206,7 @@ func TestContextMetrics(t *testing.T) {
assert.Equal(t, `{"command":"ps","context":"moby","source":"cli","status":"success"}`, usage[1]) assert.Equal(t, `{"command":"ps","context":"moby","source":"cli","status":"success"}`, usage[1])
assert.Equal(t, `{"command":"context use","context":"moby","source":"cli","status":"success"}`, usage[2]) assert.Equal(t, `{"command":"context use","context":"moby","source":"cli","status":"success"}`, usage[2])
assert.Equal(t, `{"command":"ps","context":"example","source":"cli","status":"success"}`, usage[3]) assert.Equal(t, `{"command":"ps","context":"example","source":"cli","status":"success"}`, usage[3])
assert.Equal(t, `{"command":"error","context":"example","source":"cli","status":"failure"}`, usage[4]) assert.Equal(t, `{"command":"stop","context":"example","source":"cli","status":"failure"}`, usage[4])
assert.Equal(t, `{"command":"context use","context":"example","source":"cli","status":"success"}`, usage[5]) assert.Equal(t, `{"command":"context use","context":"example","source":"cli","status":"success"}`, usage[5])
assert.Equal(t, `{"command":"ps","context":"example","source":"cli","status":"success"}`, usage[6]) assert.Equal(t, `{"command":"ps","context":"example","source":"cli","status":"success"}`, usage[6])
}) })