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 != ""
+}