Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit ec117e8

Browse files
authored
chore: add CLI invokation telemetry (coder#7589)
1 parent b6604e8 commit ec117e8

File tree

7 files changed

+163
-14
lines changed

7 files changed

+163
-14
lines changed

cli/clibase/cmd.go

+10
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@ func (c *Cmd) FullUsage() string {
145145
return strings.Join(uses, " ")
146146
}
147147

148+
// FullOptions returns the options of the command and its parents.
149+
func (c *Cmd) FullOptions() OptionSet {
150+
var opts OptionSet
151+
if c.Parent != nil {
152+
opts = append(opts, c.Parent.FullOptions()...)
153+
}
154+
opts = append(opts, c.Options...)
155+
return opts
156+
}
157+
148158
// Invoke creates a new invocation of the command, with
149159
// stdio discarded.
150160
//

cli/root.go

+36-4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package cli
22

33
import (
44
"context"
5+
"encoding/base64"
6+
"encoding/json"
57
"errors"
68
"flag"
79
"fmt"
@@ -33,6 +35,7 @@ import (
3335
"github.com/coder/coder/cli/config"
3436
"github.com/coder/coder/coderd"
3537
"github.com/coder/coder/coderd/gitauth"
38+
"github.com/coder/coder/coderd/telemetry"
3639
"github.com/coder/coder/codersdk"
3740
"github.com/coder/coder/codersdk/agentsdk"
3841
)
@@ -425,6 +428,24 @@ type RootCmd struct {
425428
noFeatureWarning bool
426429
}
427430

431+
func telemetryInvocation(i *clibase.Invocation) telemetry.CLIInvocation {
432+
var topts []telemetry.CLIOption
433+
for _, opt := range i.Command.FullOptions() {
434+
if opt.ValueSource == clibase.ValueSourceNone || opt.ValueSource == clibase.ValueSourceDefault {
435+
continue
436+
}
437+
topts = append(topts, telemetry.CLIOption{
438+
Name: opt.Name,
439+
ValueSource: string(opt.ValueSource),
440+
})
441+
}
442+
return telemetry.CLIInvocation{
443+
Command: i.Command.FullName(),
444+
Options: topts,
445+
InvokedAt: time.Now(),
446+
}
447+
}
448+
428449
// InitClient sets client to a new client.
429450
// It reads from global configuration files if flags are not set.
430451
func (r *RootCmd) InitClient(client *codersdk.Client) clibase.MiddlewareFunc {
@@ -465,7 +486,18 @@ func (r *RootCmd) InitClient(client *codersdk.Client) clibase.MiddlewareFunc {
465486
}
466487
}
467488

468-
err = r.setClient(client, r.clientURL)
489+
telemInv := telemetryInvocation(i)
490+
byt, err := json.Marshal(telemInv)
491+
if err != nil {
492+
// Should be impossible
493+
panic(err)
494+
}
495+
err = r.setClient(
496+
client, r.clientURL,
497+
append(r.header, codersdk.CLITelemetryHeader+"="+
498+
base64.StdEncoding.EncodeToString(byt),
499+
),
500+
)
469501
if err != nil {
470502
return err
471503
}
@@ -512,12 +544,12 @@ func (r *RootCmd) InitClient(client *codersdk.Client) clibase.MiddlewareFunc {
512544
}
513545
}
514546

515-
func (r *RootCmd) setClient(client *codersdk.Client, serverURL *url.URL) error {
547+
func (*RootCmd) setClient(client *codersdk.Client, serverURL *url.URL, headers []string) error {
516548
transport := &headerTransport{
517549
transport: http.DefaultTransport,
518550
header: http.Header{},
519551
}
520-
for _, header := range r.header {
552+
for _, header := range headers {
521553
parts := strings.SplitN(header, "=", 2)
522554
if len(parts) < 2 {
523555
return xerrors.Errorf("split header %q had less than two parts", header)
@@ -533,7 +565,7 @@ func (r *RootCmd) setClient(client *codersdk.Client, serverURL *url.URL) error {
533565

534566
func (r *RootCmd) createUnauthenticatedClient(serverURL *url.URL) (*codersdk.Client, error) {
535567
var client codersdk.Client
536-
err := r.setClient(&client, serverURL)
568+
err := r.setClient(&client, serverURL, r.header)
537569
return &client, err
538570
}
539571

cli/vscodessh.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func (r *RootCmd) vscodeSSH() *clibase.Cmd {
8383
client.SetSessionToken(string(sessionToken))
8484

8585
// This adds custom headers to the request!
86-
err = r.setClient(client, serverURL)
86+
err = r.setClient(client, serverURL, r.header)
8787
if err != nil {
8888
return xerrors.Errorf("set client: %w", err)
8989
}

coderd/coderd.go

+1
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ func New(options *Options) *API {
465465
// Specific routes can specify different limits, but every rate
466466
// limit must be configurable by the admin.
467467
apiRateLimiter,
468+
httpmw.ReportCLITelemetry(api.Logger, options.Telemetry),
468469
)
469470
r.Get("/", apiRoot)
470471
// All CSP errors will be logged

coderd/httpmw/clitelemetry.go

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package httpmw
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/json"
6+
"net/http"
7+
"sync"
8+
"time"
9+
10+
"tailscale.com/tstime/rate"
11+
12+
"cdr.dev/slog"
13+
"github.com/coder/coder/coderd/telemetry"
14+
"github.com/coder/coder/codersdk"
15+
)
16+
17+
func ReportCLITelemetry(log slog.Logger, rep telemetry.Reporter) func(http.Handler) http.Handler {
18+
var (
19+
mu sync.Mutex
20+
21+
// We send telemetry at most once per minute.
22+
limiter = rate.NewLimiter(rate.Every(time.Minute), 1)
23+
queue []telemetry.CLIInvocation
24+
)
25+
26+
log = log.Named("cli-telemetry")
27+
28+
return func(next http.Handler) http.Handler {
29+
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
30+
// No matter what, we proceed with the request.
31+
defer next.ServeHTTP(rw, r)
32+
33+
payload := r.Header.Get(codersdk.CLITelemetryHeader)
34+
if payload == "" {
35+
return
36+
}
37+
38+
byt, err := base64.StdEncoding.DecodeString(payload)
39+
if err != nil {
40+
log.Error(
41+
r.Context(),
42+
"base64 decode",
43+
slog.F("error", err),
44+
)
45+
return
46+
}
47+
48+
var inv telemetry.CLIInvocation
49+
err = json.Unmarshal(byt, &inv)
50+
if err != nil {
51+
log.Error(
52+
r.Context(),
53+
"unmarshal header",
54+
slog.Error(err),
55+
)
56+
return
57+
}
58+
59+
// We do expensive work in a goroutine so we don't block the
60+
// request.
61+
go func() {
62+
mu.Lock()
63+
defer mu.Unlock()
64+
65+
queue = append(queue, inv)
66+
if !limiter.Allow() && len(queue) < 1024 {
67+
return
68+
}
69+
rep.Report(&telemetry.Snapshot{
70+
CLIInvocations: queue,
71+
})
72+
log.Debug(
73+
r.Context(),
74+
"report sent", slog.F("count", len(queue)),
75+
)
76+
queue = queue[:0]
77+
}()
78+
})
79+
}
80+
}

coderd/telemetry/telemetry.go

+13
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,7 @@ type Snapshot struct {
701701
WorkspaceBuilds []WorkspaceBuild `json:"workspace_build"`
702702
WorkspaceResources []WorkspaceResource `json:"workspace_resources"`
703703
WorkspaceResourceMetadata []WorkspaceResourceMetadata `json:"workspace_resource_metadata"`
704+
CLIInvocations []CLIInvocation `json:"cli_invocations"`
704705
}
705706

706707
// Deployment contains information about the host running Coder.
@@ -876,6 +877,18 @@ type License struct {
876877
UUID uuid.UUID `json:"uuid"`
877878
}
878879

880+
type CLIOption struct {
881+
Name string `json:"name"`
882+
ValueSource string `json:"value_source"`
883+
}
884+
885+
type CLIInvocation struct {
886+
Command string `json:"command"`
887+
Options []CLIOption `json:"options"`
888+
// InvokedAt is provided for deduplication purposes.
889+
InvokedAt time.Time `json:"invoked_at"`
890+
}
891+
879892
type noopReporter struct{}
880893

881894
func (*noopReporter) Report(_ *Snapshot) {}

codersdk/client.go

+22-9
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ const (
6161
// Only owners can bypass rate limits. This is typically used for scale testing.
6262
// nolint: gosec
6363
BypassRatelimitHeader = "X-Coder-Bypass-Ratelimit"
64+
65+
// Note: the use of X- prefix is deprecated, and we should eventually remove
66+
// it from BypassRatelimitHeader.
67+
//
68+
// See: https://datatracker.ietf.org/doc/html/rfc6648.
69+
70+
// CLITelemetryHeader contains a base64-encoded representation of the CLI
71+
// command that was invoked to produce the request. It is for internal use
72+
// only.
73+
CLITelemetryHeader = "Coder-CLI-Telemetry"
6474
)
6575

6676
// loggableMimeTypes is a list of MIME types that are safe to log
@@ -179,15 +189,6 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac
179189
return nil, xerrors.Errorf("create request: %w", err)
180190
}
181191

182-
if c.PlainLogger != nil {
183-
out, err := httputil.DumpRequest(req, c.LogBodies)
184-
if err != nil {
185-
return nil, xerrors.Errorf("dump request: %w", err)
186-
}
187-
out = prefixLines([]byte("http --> "), out)
188-
_, _ = c.PlainLogger.Write(out)
189-
}
190-
191192
tokenHeader := c.SessionTokenHeader
192193
if tokenHeader == "" {
193194
tokenHeader = SessionTokenHeader
@@ -221,6 +222,18 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac
221222
})
222223

223224
resp, err := c.HTTPClient.Do(req)
225+
226+
// We log after sending the request because the HTTP Transport may modify
227+
// the request within Do, e.g. by adding headers.
228+
if resp != nil && c.PlainLogger != nil {
229+
out, err := httputil.DumpRequest(resp.Request, c.LogBodies)
230+
if err != nil {
231+
return nil, xerrors.Errorf("dump request: %w", err)
232+
}
233+
out = prefixLines([]byte("http --> "), out)
234+
_, _ = c.PlainLogger.Write(out)
235+
}
236+
224237
if err != nil {
225238
return nil, err
226239
}

0 commit comments

Comments
 (0)