Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions internal/telemetry/telemetry.go
Original file line number Diff line number Diff line change
@@ -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
}
158 changes: 158 additions & 0 deletions internal/telemetry/telemetry_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
39 changes: 39 additions & 0 deletions internal/telemetry/validate.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading