Skip to content

Commit ec117e8

Browse files
authored
chore: add CLI invokation telemetry (#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

Lines changed: 10 additions & 0 deletions
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

Lines changed: 36 additions & 4 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 80 additions & 0 deletions
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

Lines changed: 13 additions & 0 deletions
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

Lines changed: 22 additions & 9 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy