diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 0000000000..74d372b2a3 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,186 @@ +// Package telemetry records src-cli usage events to the Sourcegraph instance +// the CLI is authenticated to, using the Telemetry V2 `recordEvents` GraphQL +// mutation. +// +// Recording is strictly best-effort: a failure to record — whether a network +// error, a GraphQL error, a timeout, or an instance too old to support the +// mutation — must never affect the command the user actually ran. See +// .context/TELEMETRY.md for the design and event schema. +package telemetry + +import ( + "context" + "fmt" + "io" + "sort" + "time" + + "github.com/sourcegraph/src-cli/internal/api" + + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +const ( + // ClientName identifies src-cli as the source of telemetry events. + ClientName = "SRC_CLI" + + // eventParametersVersion is the schema version of the metadata we attach to + // each event. Bump it when the shape of the metadata changes. + eventParametersVersion = 1 + + // defaultTimeout bounds how long a single Record call may spend recording. + // It is deliberately short: telemetry is sent synchronously right before the + // process exits, so it must not add meaningful latency. + defaultTimeout = 2 * time.Second +) + +// recordEventsMutation mirrors the mutation used by Sourcegraph's own clients. +// The `telemetry` mutation only exists on Sourcegraph 5.2+, so on older +// instances this returns GraphQL errors, which Record silently drops. +const recordEventsMutation = `mutation RecordTelemetryEvents($events: [TelemetryEventInput!]!) { + telemetry { + recordEvents(events: $events) { + alwaysNil + } + } +}` + +// Source identifies the client emitting events. It is constant for the lifetime +// of a process. +type Source struct { + // Client is the source client name, e.g. ClientName. + Client string + // ClientVersion is the src-cli version, e.g. "6.1.0" or "dev". + ClientVersion string +} + +// Event is a single telemetry event. +// +// Feature and Action carry the event's identity and are always exported by +// Sourcegraph, so command identity lives here (e.g. Feature "srcCli.search", +// Action "succeeded"). Metadata values are numeric-only and are also always +// exported; they must never contain user content. See .context/TELEMETRY.md. +type Event struct { + // Feature is a noun describing what the event is about, e.g. "srcCli.search". + Feature string + // Action is a verb describing what happened, e.g. "succeeded" or "failed". + Action string + // Metadata holds numeric-only, PII-free facts about the event. + Metadata map[string]float64 +} + +// Recorder records events for a single Source through an api.Client. +type Recorder struct { + client api.Client + source Source + timeout time.Duration + debug io.Writer +} + +// Option customizes a Recorder. +type Option func(*Recorder) + +// WithTimeout overrides the default per-Record timeout. +func WithTimeout(d time.Duration) Option { + return func(r *Recorder) { + if d > 0 { + r.timeout = d + } + } +} + +// WithDebug sets a writer that receives a diagnostic line whenever an event is +// dropped. Intended to be wired to verbose (-v) output; leave unset for silence. +func WithDebug(w io.Writer) Option { + return func(r *Recorder) { r.debug = w } +} + +// NewRecorder returns a Recorder that records events for source through client. +func NewRecorder(client api.Client, source Source, opts ...Option) *Recorder { + r := &Recorder{ + client: client, + source: source, + timeout: defaultTimeout, + } + for _, opt := range opts { + opt(r) + } + return r +} + +// Record sends event on a best-effort basis. It never returns an error and +// never panics: validation, network, GraphQL, timeout, and old-instance +// failures are all silently dropped (written to the debug writer if one was set +// via WithDebug). It applies its own timeout, so the caller's context need not +// carry a deadline. +func (r *Recorder) Record(ctx context.Context, event Event) { + if err := r.record(ctx, event); err != nil && r.debug != nil { + fmt.Fprintf(r.debug, "telemetry: dropping event %q/%q: %v\n", event.Feature, event.Action, err) + } +} + +// record does the work behind Record and returns any error, so it can be tested +// directly. Callers outside tests should use Record. +func (r *Recorder) record(ctx context.Context, event Event) error { + if r.client == nil { + return errors.New("nil api client") + } + if err := Validate(event.Feature, event.Action); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + + vars := map[string]any{ + "events": []any{buildEventInput(r.source, event)}, + } + + // The recordEvents payload has no fields we care about; we only need to + // know whether the request succeeded. + var result struct { + Telemetry struct { + RecordEvents struct { + AlwaysNil *string + } + } + } + if _, err := r.client.NewRequest(recordEventsMutation, vars).Do(ctx, &result); err != nil { + return err + } + return nil +} + +// buildEventInput builds a single TelemetryEventInput as a JSON-serializable map. +func buildEventInput(source Source, event Event) map[string]any { + return map[string]any{ + "feature": event.Feature, + "action": event.Action, + "source": map[string]any{ + "client": source.Client, + "clientVersion": source.ClientVersion, + }, + "parameters": map[string]any{ + "version": eventParametersVersion, + "metadata": buildMetadata(event.Metadata), + }, + } +} + +// buildMetadata converts numeric metadata into the list of {key, value} inputs +// the API expects, sorted by key for deterministic output. +func buildMetadata(metadata map[string]float64) []any { + out := make([]any, 0, len(metadata)) + if len(metadata) == 0 { + return out + } + keys := make([]string, 0, len(metadata)) + for k := range metadata { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + out = append(out, map[string]any{"key": k, "value": metadata[k]}) + } + return out +} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go new file mode 100644 index 0000000000..c17e603594 --- /dev/null +++ b/internal/telemetry/telemetry_test.go @@ -0,0 +1,158 @@ +package telemetry + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/sourcegraph/src-cli/internal/api" + apimock "github.com/sourcegraph/src-cli/internal/api/mock" + + "github.com/sourcegraph/sourcegraph/lib/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func testSource() Source { + return Source{Client: ClientName, ClientVersion: "6.1.0"} +} + +func TestRecord_SendsWellFormedMutation(t *testing.T) { + client := &apimock.Client{} + req := &apimock.Request{} + + var gotQuery string + var gotVars map[string]any + client.On("NewRequest", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + gotQuery = args.Get(0).(string) + gotVars = args.Get(1).(map[string]any) + }). + Return(req) + req.On("Do", mock.Anything, mock.Anything).Return(true, nil) + + rec := NewRecorder(client, testSource()) + rec.Record(context.Background(), Event{ + Feature: "srcCli.search", + Action: "succeeded", + Metadata: map[string]float64{"durationMs": 12, "exitCode": 0}, + }) + + assert.Equal(t, recordEventsMutation, gotQuery) + + events, ok := gotVars["events"].([]any) + if !ok || len(events) != 1 { + t.Fatalf("expected 1 event, got %#v", gotVars["events"]) + } + event := events[0].(map[string]any) + assert.Equal(t, "srcCli.search", event["feature"]) + assert.Equal(t, "succeeded", event["action"]) + + source := event["source"].(map[string]any) + assert.Equal(t, ClientName, source["client"]) + assert.Equal(t, "6.1.0", source["clientVersion"]) + + params := event["parameters"].(map[string]any) + assert.Equal(t, eventParametersVersion, params["version"]) + + metadata := params["metadata"].([]any) + // sorted by key: durationMs, exitCode + assert.Equal(t, []any{ + map[string]any{"key": "durationMs", "value": float64(12)}, + map[string]any{"key": "exitCode", "value": float64(0)}, + }, metadata) + + client.AssertExpectations(t) + req.AssertExpectations(t) +} + +func TestRecord_EmptyMetadataSendsEmptyList(t *testing.T) { + client := &apimock.Client{} + req := &apimock.Request{} + + var gotVars map[string]any + client.On("NewRequest", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { gotVars = args.Get(1).(map[string]any) }). + Return(req) + req.On("Do", mock.Anything, mock.Anything).Return(true, nil) + + rec := NewRecorder(client, testSource()) + rec.Record(context.Background(), Event{Feature: "srcCli.version", Action: "succeeded"}) + + event := gotVars["events"].([]any)[0].(map[string]any) + params := event["parameters"].(map[string]any) + assert.Equal(t, []any{}, params["metadata"]) +} + +func TestRecord_ValidationFailsBeforeSending(t *testing.T) { + client := &apimock.Client{} + // No expectations set: NewRequest must never be called. + + rec := NewRecorder(client, testSource()) + err := rec.record(context.Background(), Event{Feature: "Bad_Feature", Action: "succeeded"}) + + assert.Error(t, err) + client.AssertNotCalled(t, "NewRequest", mock.Anything, mock.Anything) +} + +func TestRecord_NetworkErrorSwallowed(t *testing.T) { + client := &apimock.Client{} + req := &apimock.Request{} + client.On("NewRequest", mock.Anything, mock.Anything).Return(req) + req.On("Do", mock.Anything, mock.Anything).Return(false, errors.New("connection refused")) + + var debug bytes.Buffer + rec := NewRecorder(client, testSource(), WithDebug(&debug)) + + // Must not panic and must not surface the error. + assert.NotPanics(t, func() { + rec.Record(context.Background(), Event{Feature: "srcCli.search", Action: "failed"}) + }) + assert.Contains(t, debug.String(), "connection refused") + + // record itself reports the error for callers that want it. + err := rec.record(context.Background(), Event{Feature: "srcCli.search", Action: "failed"}) + assert.Error(t, err) +} + +func TestRecord_GraphQLErrorSwallowed(t *testing.T) { + // Simulates an instance too old to have the telemetry mutation: the server + // returns GraphQL errors, which must be dropped silently. + client := &apimock.Client{} + req := &apimock.Request{} + client.On("NewRequest", mock.Anything, mock.Anything).Return(req) + req.On("Do", mock.Anything, mock.Anything). + Return(false, api.GraphQlErrors{}) + + rec := NewRecorder(client, testSource()) + assert.NotPanics(t, func() { + rec.Record(context.Background(), Event{Feature: "srcCli.search", Action: "succeeded"}) + }) +} + +func TestRecord_NilClientDoesNotPanic(t *testing.T) { + rec := NewRecorder(nil, testSource()) + assert.NotPanics(t, func() { + rec.Record(context.Background(), Event{Feature: "srcCli.search", Action: "succeeded"}) + }) +} + +func TestRecord_AppliesTimeout(t *testing.T) { + client := &apimock.Client{} + req := &apimock.Request{} + + var hadDeadline bool + client.On("NewRequest", mock.Anything, mock.Anything).Return(req) + req.On("Do", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + ctx := args.Get(0).(context.Context) + _, hadDeadline = ctx.Deadline() + }). + Return(true, nil) + + rec := NewRecorder(client, testSource(), WithTimeout(50*time.Millisecond)) + rec.Record(context.Background(), Event{Feature: "srcCli.search", Action: "succeeded"}) + + assert.True(t, hadDeadline, "expected Record to apply a context deadline") +} diff --git a/internal/telemetry/validate.go b/internal/telemetry/validate.go new file mode 100644 index 0000000000..b71f3acf9e --- /dev/null +++ b/internal/telemetry/validate.go @@ -0,0 +1,39 @@ +package telemetry + +import ( + "github.com/sourcegraph/src-cli/internal/lazyregexp" + + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +// maxNameLength is the maximum length Sourcegraph accepts for a feature or +// action name. +const maxNameLength = 64 + +// featureActionRegex matches the names Sourcegraph accepts for feature and +// action: they must start with a lowercase letter and contain only letters, +// dashes, and dots (no digits, underscores, or whitespace). It mirrors the +// server-side validation in the Sourcegraph monorepo. +var featureActionRegex = lazyregexp.New(`^[a-z][a-zA-Z\-.]+$`) + +// Validate reports whether feature and action satisfy Sourcegraph's naming +// rules. Events that fail validation are rejected before any request is made. +func Validate(feature, action string) error { + if err := validateName("feature", feature); err != nil { + return err + } + return validateName("action", action) +} + +func validateName(kind, name string) error { + if name == "" { + return errors.Newf("telemetry %s must not be empty", kind) + } + if len(name) > maxNameLength { + return errors.Newf("telemetry %s %q exceeds %d characters", kind, name, maxNameLength) + } + if !featureActionRegex.MatchString(name) { + return errors.Newf("telemetry %s %q must match %s", kind, name, featureActionRegex.Re().String()) + } + return nil +} diff --git a/internal/telemetry/validate_test.go b/internal/telemetry/validate_test.go new file mode 100644 index 0000000000..bf73648dba --- /dev/null +++ b/internal/telemetry/validate_test.go @@ -0,0 +1,44 @@ +package telemetry + +import "testing" + +func TestValidate(t *testing.T) { + tests := []struct { + name string + feature string + action string + wantErr bool + }{ + {name: "valid simple", feature: "srcCli", action: "succeeded"}, + {name: "valid dotted feature", feature: "srcCli.batch.apply", action: "failed"}, + {name: "valid dashed feature", feature: "srcCli.code-intel", action: "succeeded"}, + {name: "empty feature", feature: "", action: "succeeded", wantErr: true}, + {name: "empty action", feature: "srcCli", action: "", wantErr: true}, + {name: "digit in feature", feature: "srcCli2", action: "succeeded", wantErr: true}, + {name: "underscore in action", feature: "srcCli", action: "did_it", wantErr: true}, + {name: "leading uppercase", feature: "SrcCli", action: "succeeded", wantErr: true}, + {name: "whitespace", feature: "srcCli search", action: "succeeded", wantErr: true}, + {name: "single char feature (too short for +)", feature: "s", action: "succeeded", wantErr: true}, + {name: "too long", feature: longName(70), action: "ok", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate(tt.feature, tt.action) + if tt.wantErr && err == nil { + t.Fatalf("Validate(%q, %q) = nil, want error", tt.feature, tt.action) + } + if !tt.wantErr && err != nil { + t.Fatalf("Validate(%q, %q) = %v, want nil", tt.feature, tt.action, err) + } + }) + } +} + +func longName(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = 'a' + } + return string(b) +}