Skip to content

Commit 12e947e

Browse files
committed
refactor/telemetry: minimize recorder API
The telemetry package has no production callers yet, so exporting event models, source configuration, options, and test-only controls commits src-cli to an API before its integration requirements are known. Keep only the recorder construction and recording operations public, fix the client identity internally, and pass event values directly. ## Test Plan - go test ./...
1 parent 3b460b9 commit 12e947e

2 files changed

Lines changed: 51 additions & 104 deletions

File tree

internal/telemetry/telemetry.go

Lines changed: 27 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ import (
1212
"bytes"
1313
"context"
1414
"encoding/json"
15-
"fmt"
16-
"io"
1715
"net/http"
1816
"sort"
1917
"time"
@@ -24,8 +22,8 @@ import (
2422
)
2523

2624
const (
27-
// ClientName identifies src-cli as the source of telemetry events.
28-
ClientName = "SRC_CLI"
25+
// clientName identifies src-cli as the source of telemetry events.
26+
clientName = "SRC_CLI"
2927

3028
// eventParametersVersion is the schema version of the metadata we attach to
3129
// each event. Bump it when the shape of the metadata changes.
@@ -48,82 +46,34 @@ const recordEventsMutation = `mutation RecordTelemetryEvents($events: [Telemetry
4846
}
4947
}`
5048

51-
// Source identifies the client emitting events. It is constant for the lifetime
52-
// of a process.
53-
type Source struct {
54-
// Client is the source client name, e.g. ClientName.
55-
Client string
56-
// ClientVersion is the src-cli version, e.g. "6.1.0" or "dev".
57-
ClientVersion string
58-
}
59-
60-
// Event is a single telemetry event.
61-
//
62-
// Feature and Action carry the event's identity and are always exported by
63-
// Sourcegraph, so command identity lives here (e.g. Feature "srcCli.search",
64-
// Action "succeeded"). Metadata values are numeric-only and are also always
65-
// exported; they must never contain user content. See .context/TELEMETRY.md.
66-
type Event struct {
67-
// Feature is a noun describing what the event is about, e.g. "srcCli.search".
68-
Feature string
69-
// Action is a verb describing what happened, e.g. "succeeded" or "failed".
70-
Action string
71-
// Metadata holds numeric-only, PII-free facts about the event.
72-
Metadata map[string]float64
73-
}
74-
75-
// Recorder records events for a single Source through an api.Client.
49+
// Recorder records events through an api.Client.
7650
type Recorder struct {
77-
client api.Client
78-
source Source
79-
timeout time.Duration
80-
debug io.Writer
51+
client api.Client
52+
clientVersion string
53+
timeout time.Duration
8154
}
8255

83-
// Option customizes a Recorder.
84-
type Option func(*Recorder)
85-
86-
// WithTimeout overrides the default per-Record timeout.
87-
func WithTimeout(d time.Duration) Option {
88-
return func(r *Recorder) {
89-
if d > 0 {
90-
r.timeout = d
91-
}
56+
// NewRecorder returns a Recorder for the given src-cli version.
57+
func NewRecorder(client api.Client, clientVersion string) *Recorder {
58+
return &Recorder{
59+
client: client,
60+
clientVersion: clientVersion,
61+
timeout: defaultTimeout,
9262
}
9363
}
9464

95-
// WithDebug sets a writer that receives a diagnostic line whenever an event is
96-
// dropped. Intended to be wired to verbose (-v) output; leave unset for silence.
97-
func WithDebug(w io.Writer) Option {
98-
return func(r *Recorder) { r.debug = w }
99-
}
100-
101-
// NewRecorder returns a Recorder that records events for source through client.
102-
func NewRecorder(client api.Client, source Source, opts ...Option) *Recorder {
103-
r := &Recorder{
104-
client: client,
105-
source: source,
106-
timeout: defaultTimeout,
107-
}
108-
for _, opt := range opts {
109-
opt(r)
110-
}
111-
return r
112-
}
113-
114-
// Record sends event on a best-effort basis. It never returns an error and
115-
// never panics: network, GraphQL, timeout, and old-instance failures are all
116-
// silently dropped (written to the debug writer if one was set via WithDebug).
117-
// It applies its own timeout, so the caller's context need not carry a deadline.
118-
func (r *Recorder) Record(ctx context.Context, event Event) {
119-
if err := r.record(ctx, event); err != nil && r.debug != nil {
120-
fmt.Fprintf(r.debug, "telemetry: dropping event %q/%q: %v\n", event.Feature, event.Action, err)
121-
}
65+
// Record sends an event on a best-effort basis. Feature and action identify the
66+
// event (for example, "srcCli.search" and "succeeded"). Metadata must contain
67+
// only numeric, PII-free facts. Record never returns an error or panics: network,
68+
// GraphQL, timeout, and old-instance failures are silently dropped. It applies
69+
// its own timeout, so the caller's context need not carry a deadline.
70+
func (r *Recorder) Record(ctx context.Context, feature, action string, metadata map[string]float64) {
71+
_ = r.record(ctx, feature, action, metadata)
12272
}
12373

12474
// record does the work behind Record and returns any error, so it can be tested
12575
// directly. Callers outside tests should use Record.
126-
func (r *Recorder) record(ctx context.Context, event Event) error {
76+
func (r *Recorder) record(ctx context.Context, feature, action string, metadata map[string]float64) error {
12777
if r.client == nil {
12878
return errors.New("nil api client")
12979
}
@@ -132,7 +82,7 @@ func (r *Recorder) record(ctx context.Context, event Event) error {
13282
defer cancel()
13383

13484
vars := map[string]any{
135-
"events": []any{buildEventInput(r.source, event)},
85+
"events": []any{buildEventInput(r.clientVersion, feature, action, metadata)},
13686
}
13787

13888
payload, err := json.Marshal(map[string]any{
@@ -170,17 +120,17 @@ func (r *Recorder) record(ctx context.Context, event Event) error {
170120
}
171121

172122
// buildEventInput builds a single TelemetryEventInput as a JSON-serializable map.
173-
func buildEventInput(source Source, event Event) map[string]any {
123+
func buildEventInput(clientVersion, feature, action string, metadata map[string]float64) map[string]any {
174124
return map[string]any{
175-
"feature": event.Feature,
176-
"action": event.Action,
125+
"feature": feature,
126+
"action": action,
177127
"source": map[string]any{
178-
"client": source.Client,
179-
"clientVersion": source.ClientVersion,
128+
"client": clientName,
129+
"clientVersion": clientVersion,
180130
},
181131
"parameters": map[string]any{
182132
"version": eventParametersVersion,
183-
"metadata": buildMetadata(event.Metadata),
133+
"metadata": buildMetadata(metadata),
184134
},
185135
}
186136
}

internal/telemetry/telemetry_test.go

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,7 @@ import (
2323
"github.com/stretchr/testify/mock"
2424
)
2525

26-
func testSource() Source {
27-
return Source{Client: ClientName, ClientVersion: "6.1.0"}
28-
}
26+
const testClientVersion = "6.1.0"
2927

3028
func response(statusCode int, body string) *http.Response {
3129
return &http.Response{
@@ -72,11 +70,10 @@ func TestRecord_SendsWellFormedMutation(t *testing.T) {
7270
Return(req, nil)
7371
client.On("Do", req).Return(response(http.StatusOK, "{}"), nil)
7472

75-
rec := NewRecorder(client, testSource())
76-
rec.Record(context.Background(), Event{
77-
Feature: "srcCli.search",
78-
Action: "succeeded",
79-
Metadata: map[string]float64{"durationMs": 12, "exitCode": 0},
73+
rec := NewRecorder(client, testClientVersion)
74+
rec.Record(context.Background(), "srcCli.search", "succeeded", map[string]float64{
75+
"durationMs": 12,
76+
"exitCode": 0,
8077
})
8178

8279
assert.Equal(t, recordEventsMutation, gotPayload.Query)
@@ -90,8 +87,8 @@ func TestRecord_SendsWellFormedMutation(t *testing.T) {
9087
assert.Equal(t, "succeeded", event["action"])
9188

9289
source := event["source"].(map[string]any)
93-
assert.Equal(t, ClientName, source["client"])
94-
assert.Equal(t, "6.1.0", source["clientVersion"])
90+
assert.Equal(t, clientName, source["client"])
91+
assert.Equal(t, testClientVersion, source["clientVersion"])
9592

9693
params := event["parameters"].(map[string]any)
9794
assert.Equal(t, float64(eventParametersVersion), params["version"])
@@ -122,8 +119,8 @@ func TestRecord_EmptyMetadataSendsEmptyList(t *testing.T) {
122119
Return(req, nil)
123120
client.On("Do", req).Return(response(http.StatusOK, "{}"), nil)
124121

125-
rec := NewRecorder(client, testSource())
126-
rec.Record(context.Background(), Event{Feature: "srcCli.version", Action: "succeeded"})
122+
rec := NewRecorder(client, testClientVersion)
123+
rec.Record(context.Background(), "srcCli.version", "succeeded", nil)
127124

128125
event := gotPayload.Variables["events"].([]any)[0].(map[string]any)
129126
params := event["parameters"].(map[string]any)
@@ -136,17 +133,15 @@ func TestRecord_NetworkErrorSwallowed(t *testing.T) {
136133
client.On("NewHTTPRequest", mock.Anything, http.MethodPost, ".api/graphql", mock.Anything).Return(req, nil)
137134
client.On("Do", req).Return(nil, errors.New("connection refused"))
138135

139-
var debug bytes.Buffer
140-
rec := NewRecorder(client, testSource(), WithDebug(&debug))
136+
rec := NewRecorder(client, testClientVersion)
141137

142138
// Must not panic and must not surface the error.
143139
assert.NotPanics(t, func() {
144-
rec.Record(context.Background(), Event{Feature: "srcCli.search", Action: "failed"})
140+
rec.Record(context.Background(), "srcCli.search", "failed", nil)
145141
})
146-
assert.Contains(t, debug.String(), "connection refused")
147142

148143
// record itself reports the error for callers that want it.
149-
err := rec.record(context.Background(), Event{Feature: "srcCli.search", Action: "failed"})
144+
err := rec.record(context.Background(), "srcCli.search", "failed", nil)
150145
assert.Error(t, err)
151146
}
152147

@@ -158,16 +153,16 @@ func TestRecord_GraphQLErrorSwallowed(t *testing.T) {
158153
client.On("NewHTTPRequest", mock.Anything, http.MethodPost, ".api/graphql", mock.Anything).Return(req, nil)
159154
client.On("Do", req).Return(response(http.StatusOK, "{\"errors\":[{\"message\":\"unknown field telemetry\"}]}"), nil)
160155

161-
rec := NewRecorder(client, testSource())
156+
rec := NewRecorder(client, testClientVersion)
162157
assert.NotPanics(t, func() {
163-
rec.Record(context.Background(), Event{Feature: "srcCli.search", Action: "succeeded"})
158+
rec.Record(context.Background(), "srcCli.search", "succeeded", nil)
164159
})
165160
}
166161

167162
func TestRecord_NilClientDoesNotPanic(t *testing.T) {
168-
rec := NewRecorder(nil, testSource())
163+
rec := NewRecorder(nil, testClientVersion)
169164
assert.NotPanics(t, func() {
170-
rec.Record(context.Background(), Event{Feature: "srcCli.search", Action: "succeeded"})
165+
rec.Record(context.Background(), "srcCli.search", "succeeded", nil)
171166
})
172167
}
173168

@@ -203,8 +198,8 @@ func TestRecord_OAuthUnauthorizedDoesNotWriteToStdout(t *testing.T) {
203198
os.Stdout = stdoutWriter
204199
t.Cleanup(func() { os.Stdout = oldStdout })
205200

206-
rec := NewRecorder(client, testSource())
207-
rec.Record(context.Background(), Event{Feature: "srcCli.search", Action: "succeeded"})
201+
rec := NewRecorder(client, testClientVersion)
202+
rec.Record(context.Background(), "srcCli.search", "succeeded", nil)
208203

209204
if err := stdoutWriter.Close(); err != nil {
210205
t.Fatal(err)
@@ -235,22 +230,24 @@ func TestRecord_AppliesTimeout(t *testing.T) {
235230
Return(req, nil)
236231
client.On("Do", req).Return(response(http.StatusOK, "{}"), nil)
237232

238-
rec := NewRecorder(client, testSource(), WithTimeout(50*time.Millisecond))
239-
rec.Record(context.Background(), Event{Feature: "srcCli.search", Action: "succeeded"})
233+
rec := NewRecorder(client, testClientVersion)
234+
rec.timeout = 50 * time.Millisecond
235+
rec.Record(context.Background(), "srcCli.search", "succeeded", nil)
240236

241237
assert.True(t, hadDeadline, "expected Record to apply a context deadline")
242238
}
243239

244240
func TestRecord_TimeoutCancelsHTTPRequest(t *testing.T) {
245241
requestCanceled := make(chan struct{})
246242
client := &cancellationClient{canceled: requestCanceled, release: make(chan struct{})}
247-
rec := NewRecorder(client, testSource(), WithTimeout(20*time.Millisecond))
243+
rec := NewRecorder(client, testClientVersion)
244+
rec.timeout = 20 * time.Millisecond
248245

249246
ctx, cancel := context.WithCancel(context.Background())
250247
defer cancel()
251248
done := make(chan struct{})
252249
go func() {
253-
rec.Record(ctx, Event{Feature: "srcCli.search", Action: "succeeded"})
250+
rec.Record(ctx, "srcCli.search", "succeeded", nil)
254251
close(done)
255252
}()
256253

0 commit comments

Comments
 (0)