返回 DeepSeek-Reasonix
client_test.go
根目录 / internal / telemetry / client_test.go
1 package telemetry
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "net/http"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15 )
16
17 type roundTripFunc func(*http.Request) (*http.Response, error)
18
19 func TestMain(m *testing.M) {
20 endpoint = "http://127.0.0.1:0/v1"
21 os.Exit(m.Run())
22 }
23
24 func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
25
26 func telemetryResponse(status int) *http.Response {
27 return &http.Response{
28 StatusCode: status,
29 Body: io.NopCloser(strings.NewReader("")),
30 Header: make(http.Header),
31 }
32 }
33
34 func testClient(home string, transport http.RoundTripper) *Client {
35 return &Client{
36 home: home,
37 version: "v1.20.0",
38 installID: strings.Repeat("a", 32),
39 http: &http.Client{Transport: transport},
40 }
41 }
42
43 func TestInstallIDRepairsMalformedOwnedFile(t *testing.T) {
44 home := t.TempDir()
45 path := filepath.Join(home, "cli-telemetry-install-id")
46 if err := os.WriteFile(path, []byte("truncated\n"), 0o600); err != nil {
47 t.Fatal(err)
48 }
49
50 id, err := installID(home)
51 if err != nil {
52 t.Fatalf("installID: %v", err)
53 }
54 if !validInstallID(id) {
55 t.Fatalf("repaired install id = %q", id)
56 }
57 b, err := os.ReadFile(path)
58 if err != nil {
59 t.Fatal(err)
60 }
61 if got := strings.TrimSpace(string(b)); got != id {
62 t.Fatalf("persisted install id = %q, want %q", got, id)
63 }
64 }
65
66 func TestDailyPingSendsOnceWithCLISurface(t *testing.T) {
67 home := t.TempDir()
68 var mu sync.Mutex
69 var payloads []pingPayload
70 client := testClient(home, roundTripFunc(func(req *http.Request) (*http.Response, error) {
71 if req.URL.String() != endpoint+"/ping" {
72 t.Fatalf("request URL = %q", req.URL)
73 }
74 var payload pingPayload
75 if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
76 t.Fatal(err)
77 }
78 mu.Lock()
79 payloads = append(payloads, payload)
80 mu.Unlock()
81 return telemetryResponse(http.StatusAccepted), nil
82 }))
83
84 if err := client.sendDailyPing(context.Background()); err != nil {
85 t.Fatal(err)
86 }
87 if err := client.sendDailyPing(context.Background()); err != nil {
88 t.Fatal(err)
89 }
90 mu.Lock()
91 defer mu.Unlock()
92 if len(payloads) != 1 {
93 t.Fatalf("ping requests = %d, want 1", len(payloads))
94 }
95 if payloads[0].Surface != "cli" || payloads[0].InstallID != client.installID {
96 t.Fatalf("ping payload = %+v", payloads[0])
97 }
98 }
99
100 func TestFailedDailyPingRemovesClaimAndRetries(t *testing.T) {
101 home := t.TempDir()
102 calls := 0
103 client := testClient(home, roundTripFunc(func(*http.Request) (*http.Response, error) {
104 calls++
105 if calls == 1 {
106 return nil, errors.New("offline")
107 }
108 return telemetryResponse(http.StatusAccepted), nil
109 }))
110
111 if err := client.sendDailyPing(context.Background()); err == nil {
112 t.Fatal("first ping unexpectedly succeeded")
113 }
114 claim := filepath.Join(home, "cli-telemetry-ping-"+time.Now().UTC().Format("2006-01-02"))
115 if _, err := os.Stat(claim); !errors.Is(err, os.ErrNotExist) {
116 t.Fatalf("failed ping claim remains: %v", err)
117 }
118 if err := client.sendDailyPing(context.Background()); err != nil {
119 t.Fatalf("retry ping: %v", err)
120 }
121 if calls != 2 {
122 t.Fatalf("ping calls = %d, want 2", calls)
123 }
124 }
125
126 func TestFlushPendingAggregatesAndDeletesOnlyAfterSuccess(t *testing.T) {
127 home := t.TempDir()
128 for _, counters := range [][]Counter{
129 {{Signal: "turns", Bucket: "count", Count: 2}},
130 {{Signal: "turns", Bucket: "count", Count: 3}, {Signal: "cli_exit", Bucket: "success", Count: 1}},
131 } {
132 if err := appendPending(home, pendingPayload{Version: "v1.20.0", OS: "android", Counters: counters}); err != nil {
133 t.Fatal(err)
134 }
135 }
136 requests := 0
137 client := testClient(home, roundTripFunc(func(req *http.Request) (*http.Response, error) {
138 requests++
139 var payload metricsPayload
140 if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
141 t.Fatal(err)
142 }
143 if payload.Surface != "cli" || payload.OS != "android" {
144 t.Fatalf("metrics payload = %+v", payload)
145 }
146 got := map[string]int{}
147 for _, counter := range payload.Counters {
148 got[counter.Signal+"/"+counter.Bucket] = counter.Count
149 }
150 if got["turns/count"] != 5 || got["cli_exit/success"] != 1 {
151 t.Fatalf("aggregated counters = %#v", got)
152 }
153 return telemetryResponse(http.StatusAccepted), nil
154 }))
155
156 if err := client.flushPending(context.Background()); err != nil {
157 t.Fatal(err)
158 }
159 if requests != 1 {
160 t.Fatalf("metrics requests = %d, want 1", requests)
161 }
162 entries, err := os.ReadDir(filepath.Join(home, pendingDirName))
163 if err != nil || len(entries) != 0 {
164 t.Fatalf("pending entries after success = %d, err = %v", len(entries), err)
165 }
166 }
167
168 func TestFlushPendingUploadsCompletionMetricsWithoutContent(t *testing.T) {
169 home := t.TempDir()
170 const secret = "PRIVATE_TASK_ANSWER_REASON_PATH_MODEL"
171 want := map[string]string{
172 "completion_validation_outcome": "enforce_continue",
173 "completion_validation_latency": "s_5_15",
174 "completion_validation_error": "timeout",
175 "completion_validation_attempt": "repair",
176 "completion_evaluator_finish_reason": "stop",
177 "completion_evaluator_cache_hit": "90_100",
178 }
179 counters := make([]Counter, 0, len(want)+1)
180 for signal, bucket := range want {
181 counters = append(counters, Counter{Signal: signal, Bucket: bucket, Count: 1})
182 }
183 // Even a syntactically safe bucket must be discarded when its signal could
184 // carry user content.
185 counters = append(counters, Counter{Signal: "task_text", Bucket: strings.ToLower(secret), Count: 1})
186 if err := appendPending(home, pendingPayload{Version: "v1.34.0", OS: "linux", Counters: counters}); err != nil {
187 t.Fatal(err)
188 }
189
190 client := testClient(home, roundTripFunc(func(req *http.Request) (*http.Response, error) {
191 body, err := io.ReadAll(req.Body)
192 if err != nil {
193 t.Fatal(err)
194 }
195 if strings.Contains(strings.ToUpper(string(body)), secret) {
196 t.Fatalf("completion metrics upload leaked private content: %s", body)
197 }
198 var payload metricsPayload
199 if err := json.Unmarshal(body, &payload); err != nil {
200 t.Fatal(err)
201 }
202 got := map[string]string{}
203 for _, counter := range payload.Counters {
204 got[counter.Signal] = counter.Bucket
205 }
206 if len(got) != len(want) {
207 t.Fatalf("uploaded completion signals = %#v, want %#v", got, want)
208 }
209 for signal, bucket := range want {
210 if got[signal] != bucket {
211 t.Errorf("%s bucket = %q, want %q", signal, got[signal], bucket)
212 }
213 }
214 return telemetryResponse(http.StatusAccepted), nil
215 }))
216 client.version = "v1.34.0"
217 if err := client.flushPending(context.Background()); err != nil {
218 t.Fatal(err)
219 }
220 }
221
222 func TestFailedFlushRestoresClaimsForRetry(t *testing.T) {
223 home := t.TempDir()
224 if err := appendPending(home, pendingPayload{
225 Version: "v1.20.0", OS: "linux", Counters: []Counter{{Signal: "turns", Bucket: "count", Count: 1}},
226 }); err != nil {
227 t.Fatal(err)
228 }
229 calls := 0
230 client := testClient(home, roundTripFunc(func(*http.Request) (*http.Response, error) {
231 calls++
232 if calls == 1 {
233 return telemetryResponse(http.StatusServiceUnavailable), nil
234 }
235 return telemetryResponse(http.StatusAccepted), nil
236 }))
237
238 if err := client.flushPending(context.Background()); err == nil {
239 t.Fatal("first flush unexpectedly succeeded")
240 }
241 entries, err := os.ReadDir(filepath.Join(home, pendingDirName))
242 if err != nil || len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".json") {
243 t.Fatalf("failed flush entries = %v, err = %v", entries, err)
244 }
245 if err := client.flushPending(context.Background()); err != nil {
246 t.Fatalf("retry flush: %v", err)
247 }
248 entries, err = os.ReadDir(filepath.Join(home, pendingDirName))
249 if err != nil || len(entries) != 0 {
250 t.Fatalf("pending entries after retry = %d, err = %v", len(entries), err)
251 }
252 }
253
254 func TestPendingClaimsAreExclusiveAcrossFlushers(t *testing.T) {
255 home := t.TempDir()
256 if err := appendPending(home, pendingPayload{
257 Version: "v1.20.0", OS: "linux", Counters: []Counter{{Signal: "turns", Bucket: "count", Count: 1}},
258 }); err != nil {
259 t.Fatal(err)
260 }
261 dir := filepath.Join(home, pendingDirName)
262 first, err := claimPendingFiles(dir, time.Now())
263 if err != nil {
264 t.Fatal(err)
265 }
266 second, err := claimPendingFiles(dir, time.Now())
267 if err != nil {
268 t.Fatal(err)
269 }
270 if len(first) != 1 || len(second) != 0 {
271 t.Fatalf("claims: first=%v second=%v", first, second)
272 }
273 }
274
275 func TestPendingValidationAcceptsAndroid(t *testing.T) {
276 if !validPendingPayload(pendingPayload{
277 Version: "v1.20.0", OS: "android", Counters: []Counter{{Signal: "turns", Bucket: "count", Count: 1}},
278 }) {
279 t.Fatal("Android CLI payload was rejected")
280 }
281 }
282
283 func TestPendingQueueCountsActiveAndRecoversStaleClaims(t *testing.T) {
284 dir := filepath.Join(t.TempDir(), pendingDirName)
285 if err := os.MkdirAll(dir, 0o700); err != nil {
286 t.Fatal(err)
287 }
288 for i := range maxPending {
289 path := filepath.Join(dir, strings.Repeat("a", 16)+"-"+time.Unix(int64(i), 0).Format("150405")+".json.uploading")
290 if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
291 t.Fatal(err)
292 }
293 }
294 if err := appendPending(filepath.Dir(dir), pendingPayload{
295 Version: "v1.20.0", OS: "linux", Counters: []Counter{{Signal: "turns", Bucket: "count", Count: 1}},
296 }); err != nil {
297 t.Fatal(err)
298 }
299 entries, err := os.ReadDir(dir)
300 if err != nil || len(entries) != maxPending {
301 t.Fatalf("bounded queue entries = %d, err = %v", len(entries), err)
302 }
303
304 staleDir := filepath.Join(t.TempDir(), pendingDirName)
305 if err := os.MkdirAll(staleDir, 0o700); err != nil {
306 t.Fatal(err)
307 }
308 staleClaim := filepath.Join(staleDir, "sample.json.uploading")
309 if err := os.WriteFile(staleClaim, []byte("{}"), 0o600); err != nil {
310 t.Fatal(err)
311 }
312 stale := time.Now().Add(-3 * time.Minute)
313 if err := os.Chtimes(staleClaim, stale, stale); err != nil {
314 t.Fatal(err)
315 }
316 if !prunePending(staleDir, time.Now()) {
317 t.Fatal("stale claim recovery did not make a queue slot")
318 }
319 if _, err := os.Stat(strings.TrimSuffix(staleClaim, ".uploading")); err != nil {
320 t.Fatalf("stale claim was not recovered: %v", err)
321 }
322 }
323
323 lines GO