From 0c1f0f81df726b5cf56ae33ba67af649e1f94b8f Mon Sep 17 00:00:00 2001 From: Djordje Lukic <djordje.lukic@docker.com> Date: Mon, 22 Jun 2020 09:55:28 +0200 Subject: [PATCH] Usage metrics Send usage to Docker Desktop --- cli/main.go | 10 +++ metrics/client.go | 65 +++++++++++++++++ metrics/conn_other.go | 29 ++++++++ metrics/conn_windows.go | 35 +++++++++ metrics/metics_test.go | 94 ++++++++++++++++++++++++ metrics/metrics.go | 154 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 387 insertions(+) create mode 100644 metrics/client.go create mode 100644 metrics/conn_other.go create mode 100644 metrics/conn_windows.go create mode 100644 metrics/metics_test.go create mode 100644 metrics/metrics.go diff --git a/cli/main.go b/cli/main.go index e72980419..1ecc18c51 100644 --- a/cli/main.go +++ b/cli/main.go @@ -35,6 +35,7 @@ import ( _ "github.com/docker/api/azure" _ "github.com/docker/api/example" _ "github.com/docker/api/local" + "github.com/docker/api/metrics" "github.com/docker/api/cli/cmd" "github.com/docker/api/cli/cmd/compose" @@ -154,6 +155,15 @@ func main() { if err != nil { fatal(errors.Wrap(err, "unable to create context store")) } + + ctype := store.DefaultContextType + cc, _ := s.Get(currentContext) + if cc != nil { + ctype = cc.Type() + } + + metrics.Track(ctype, os.Args[1:], root.PersistentFlags()) + ctx = apicontext.WithCurrentContext(ctx, currentContext) ctx = store.WithContextStore(ctx, s) diff --git a/metrics/client.go b/metrics/client.go new file mode 100644 index 000000000..fa62070d6 --- /dev/null +++ b/metrics/client.go @@ -0,0 +1,65 @@ +/* + Copyright 2020 Docker, Inc. + + 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 + +import ( + "bytes" + "context" + "encoding/json" + "net" + "net/http" +) + +type client struct { + httpClient *http.Client +} + +// Command is a command +type Command struct { + Command string `json:"command"` + Context string `json:"context"` +} + +// Client sends metrics to Docker Desktopn +type Client interface { + // Send sends the command to Docker Desktop. Note that the function doesn't + // return anything, not even an error, this is because we don't really care + // if the metrics were sent or not. We only fire and forget. + Send(Command) +} + +// NewClient returns a new metrics client +func NewClient() Client { + return &client{ + httpClient: &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return conn() + }, + }, + }, + } +} + +func (c *client) Send(command Command) { + req, err := json.Marshal(command) + if err != nil { + return + } + + _, _ = c.httpClient.Post("http://localhost/usage", "application/json", bytes.NewBuffer(req)) +} diff --git a/metrics/conn_other.go b/metrics/conn_other.go new file mode 100644 index 000000000..eb47f1565 --- /dev/null +++ b/metrics/conn_other.go @@ -0,0 +1,29 @@ +// +build !windows + +/* + Copyright 2020 Docker, Inc. + + 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 + +import "net" + +const ( + socket = "/var/run/docker-cli.sock" +) + +func conn() (net.Conn, error) { + return net.Dial("unix", socket) +} diff --git a/metrics/conn_windows.go b/metrics/conn_windows.go new file mode 100644 index 000000000..5d414f54c --- /dev/null +++ b/metrics/conn_windows.go @@ -0,0 +1,35 @@ +// +build windows + +/* + Copyright 2020 Docker, Inc. + + 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 + +import ( + "net" + "time" + + "github.com/Microsoft/go-winio" +) + +const ( + socket = `\\.\pipe\docker_cli` +) + +func conn() (net.Conn, error) { + timeout := 200 * time.Millisecond + return winio.DialPipe(socket, &timeout) +} diff --git a/metrics/metics_test.go b/metrics/metics_test.go new file mode 100644 index 000000000..26ffb0b4e --- /dev/null +++ b/metrics/metics_test.go @@ -0,0 +1,94 @@ +/* + Copyright 2020 Docker, Inc. + + 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 + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestFlag(t *testing.T) { + root := &cobra.Command{} + root.PersistentFlags().BoolP("debug", "d", false, "debug") + root.PersistentFlags().String("str", "str", "str") + + testCases := []struct { + name string + flags []string + expected string + }{ + { + name: "with long flags", + flags: []string{"--debug", "run"}, + expected: "run", + }, + { + name: "with short flags", + flags: []string{"-d", "run"}, + expected: "run", + }, + { + name: "with flags with value", + flags: []string{"--debug", "--str", "str-value", "run"}, + expected: "run", + }, + { + name: "with --", + flags: []string{"--debug", "--str", "str-value", "--", "run"}, + expected: "", + }, + { + name: "without a command", + flags: []string{"--debug", "--str", "str-value"}, + expected: "", + }, + { + name: "with unknown short flag", + flags: []string{"-f", "run"}, + expected: "", + }, + { + name: "with unknown long flag", + flags: []string{"--unknown-flag", "run"}, + expected: "", + }, + { + name: "management command", + flags: []string{"image", "ls"}, + expected: "image ls", + }, + { + name: "management command with flag", + flags: []string{"image", "--test", "ls"}, + expected: "image", + }, + { + name: "management subcommand with flag", + flags: []string{"image", "ls", "-q"}, + expected: "image ls", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + result := getCommand(testCase.flags, root.PersistentFlags()) + assert.Equal(t, testCase.expected, result) + }) + } +} diff --git a/metrics/metrics.go b/metrics/metrics.go new file mode 100644 index 000000000..46e4a5a45 --- /dev/null +++ b/metrics/metrics.go @@ -0,0 +1,154 @@ +/* + Copyright 2020 Docker, Inc. + + 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 + +import ( + "strings" + + flag "github.com/spf13/pflag" +) + +var managementCommands = []string{ + "app", + "assemble", + "builder", + "buildx", + "cluster", + "compose", + "config", + "container", + "context", + "help", + "image", + "manifest", + "network", + "node", + "plugin", + "registry", + "secret", + "service", + "stack", + "swarm", + "system", + "template", + "trust", + "volume", +} + +// Track sends the tracking analytics to Docker Desktop +func Track(context string, args []string, flags *flag.FlagSet) { + // Fire and forget, we don't want to slow down the user waiting for DD + // metrics endpoint to respond. We could lose some events but that's ok. + go func() { + defer func() { + _ = recover() + }() + command := getCommand(args, flags) + if command != "" { + c := NewClient() + c.Send(Command{ + Command: command, + Context: context, + }) + } + }() +} + +func getCommand(args []string, flags *flag.FlagSet) string { + command := "" + args = stripFlags(args, flags) + + if len(args) != 0 { + command = args[0] + if contains(managementCommands, command) { + if sub := getSubCommand(args[1:]); sub != "" { + return command + " " + sub + } + } + } + + return command +} + +func getSubCommand(args []string) string { + if len(args) != 0 && isArg(args[0]) { + return args[0] + } + return "" +} + +func contains(array []string, needle string) bool { + for _, val := range array { + if val == needle { + return true + } + } + return false +} + +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 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 != "" +}