返回 DeepSeek-Reasonix
client.go
根目录 / internal / telemetry / client.go
1 package telemetry
2
3 import (
4 "bytes"
5 "context"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "path/filepath"
13 "runtime"
14 "sort"
15 "strings"
16 "time"
17
18 "reasonix/internal/fileutil"
19 "reasonix/internal/netclient"
20 )
21
22 const endpoint = "https://crash.reasonix.io/v1"
23
24 var uploadSignals = map[string]bool{
25 "finish_reason": true, "empty_final": true, "provider_error": true,
26 "cache_hit": true, "tool_error": true, "compaction": true, "turns": true,
27 "recovery_failure": true, "recovery_rule_continue": true,
28 "recovery_review_continue": true, "recovery_human_prompt": true,
29 "recovery_human_continue": true, "recovery_human_revise": true,
30 "recovery_review_error": true, "recovery_repeat_prompt": true,
31 "recovery_review_latency": true, "client_surface": true,
32 "client_version": true, "settings_language": true, "cli_mode": true,
33 "cli_profile": true, "cli_permission_mode": true, "cli_session_mode": true,
34 "cli_turn_latency": true, "cli_exit": true,
35 }
36
37 type Client struct {
38 home string
39 version string
40 installID string
41 http *http.Client
42 }
43
44 func newClient(home, version string, proxy netclient.ProxySpec) (*Client, error) {
45 if strings.TrimSpace(home) == "" {
46 return nil, fmt.Errorf("telemetry: empty home")
47 }
48 client, err := netclient.NewHTTPClient(proxy, netclient.TransportOptions{
49 DialTimeout: 500 * time.Millisecond,
50 TLSHandshakeTimeout: 500 * time.Millisecond,
51 ResponseHeaderTimeout: 750 * time.Millisecond,
52 })
53 if err != nil {
54 return nil, err
55 }
56 client.Timeout = time.Second
57 id, err := installID(home)
58 if err != nil {
59 return nil, err
60 }
61 return &Client{home: home, version: version, installID: id, http: client}, nil
62 }
63
64 func installID(home string) (string, error) {
65 if err := os.MkdirAll(home, 0o700); err != nil {
66 return "", err
67 }
68 path := filepath.Join(home, "cli-telemetry-install-id")
69 if b, err := os.ReadFile(path); err == nil {
70 id := strings.TrimSpace(string(b))
71 if validInstallID(id) {
72 return id, nil
73 }
74 replacement, err := randomHex(16)
75 if err != nil {
76 return "", err
77 }
78 if err := fileutil.AtomicWriteFile(path, []byte(replacement+"\n"), 0o600); err != nil {
79 return "", err
80 }
81 return replacement, nil
82 } else if !os.IsNotExist(err) {
83 return "", err
84 }
85 id, err := randomHex(16)
86 if err != nil {
87 return "", err
88 }
89 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
90 if err != nil {
91 if b, readErr := os.ReadFile(path); readErr == nil && validInstallID(strings.TrimSpace(string(b))) {
92 return strings.TrimSpace(string(b)), nil
93 }
94 return "", err
95 }
96 if _, err = io.WriteString(f, id+"\n"); err != nil {
97 _ = f.Close()
98 _ = os.Remove(path)
99 return "", err
100 }
101 if err := f.Close(); err != nil {
102 return "", err
103 }
104 return id, nil
105 }
106
107 func validInstallID(id string) bool {
108 if len(id) != 32 {
109 return false
110 }
111 _, err := hex.DecodeString(id)
112 return err == nil
113 }
114
115 func (c *Client) backgroundFlush() {
116 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
117 defer cancel()
118 _ = c.sendDailyPing(ctx)
119 _ = c.flushPending(ctx)
120 }
121
122 func (c *Client) sendDailyPing(ctx context.Context) error {
123 day := time.Now().UTC().Format("2006-01-02")
124 claim := filepath.Join(c.home, "cli-telemetry-ping-"+day)
125 f, err := os.OpenFile(claim, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
126 if err != nil {
127 if os.IsExist(err) {
128 state, readErr := os.ReadFile(claim)
129 if readErr == nil && strings.TrimSpace(string(state)) == "sent" {
130 return nil
131 }
132 if info, statErr := os.Stat(claim); statErr == nil && time.Since(info.ModTime()) < 2*time.Minute {
133 return nil
134 }
135 if os.Remove(claim) == nil {
136 return c.sendDailyPing(ctx)
137 }
138 }
139 return err
140 }
141 if _, err := io.WriteString(f, "sending\n"); err != nil {
142 _ = f.Close()
143 _ = os.Remove(claim)
144 return err
145 }
146 if err := f.Close(); err != nil {
147 _ = os.Remove(claim)
148 return err
149 }
150 err = c.post(ctx, "/ping", pingPayload{
151 InstallID: c.installID, Version: c.version, OS: runtime.GOOS, Arch: runtime.GOARCH, Surface: "cli",
152 })
153 if err != nil {
154 _ = os.Remove(claim)
155 } else if writeErr := os.WriteFile(claim, []byte("sent\n"), 0o600); writeErr != nil {
156 _ = os.Remove(claim)
157 err = writeErr
158 }
159 c.prunePingClaims(day)
160 return err
161 }
162
163 func (c *Client) prunePingClaims(current string) {
164 entries, _ := os.ReadDir(c.home)
165 for _, entry := range entries {
166 if strings.HasPrefix(entry.Name(), "cli-telemetry-ping-") && entry.Name() != "cli-telemetry-ping-"+current {
167 _ = os.Remove(filepath.Join(c.home, entry.Name()))
168 }
169 }
170 }
171
172 func (c *Client) flushPending(ctx context.Context) error {
173 dir := filepath.Join(c.home, pendingDirName)
174 paths, err := claimPendingFiles(dir, time.Now())
175 if err != nil {
176 if os.IsNotExist(err) {
177 return nil
178 }
179 return err
180 }
181 claimed := make(map[string]bool, len(paths))
182 for _, path := range paths {
183 claimed[path] = true
184 }
185 defer func() {
186 for path, active := range claimed {
187 if active {
188 _ = os.Rename(path, strings.TrimSuffix(path, ".uploading"))
189 }
190 }
191 }()
192 type group struct {
193 payload pendingPayload
194 paths []string
195 counts map[string]int
196 }
197 groups := map[string]*group{}
198 for _, path := range paths {
199 b, err := os.ReadFile(path)
200 if err != nil {
201 continue
202 }
203 var p pendingPayload
204 if json.Unmarshal(b, &p) != nil || !validPendingPayload(p) {
205 removeClaim(path, claimed)
206 continue
207 }
208 key := p.Version + "\x00" + p.OS
209 g := groups[key]
210 if g == nil {
211 g = &group{payload: pendingPayload{Version: p.Version, OS: p.OS}, counts: map[string]int{}}
212 groups[key] = g
213 }
214 g.paths = append(g.paths, path)
215 for _, counter := range p.Counters {
216 if validCounter(counter) {
217 g.counts[counter.Signal+"\x00"+counter.Bucket] += counter.Count
218 }
219 }
220 }
221 for _, g := range groups {
222 counters := make([]Counter, 0, len(g.counts))
223 for key, count := range g.counts {
224 signal, bucket, _ := strings.Cut(key, "\x00")
225 if count > 1_000_000 {
226 count = 1_000_000
227 }
228 counters = append(counters, Counter{Signal: signal, Bucket: bucket, Count: count})
229 }
230 sort.Slice(counters, func(i, j int) bool {
231 if counters[i].Signal == counters[j].Signal {
232 return counters[i].Bucket < counters[j].Bucket
233 }
234 return counters[i].Signal < counters[j].Signal
235 })
236 if len(counters) == 0 {
237 for _, path := range g.paths {
238 removeClaim(path, claimed)
239 }
240 continue
241 }
242 if err := c.post(ctx, "/metrics", metricsPayload{
243 InstallID: c.installID, Version: g.payload.Version, OS: g.payload.OS, Surface: "cli", Counters: counters,
244 }); err != nil {
245 return err
246 }
247 for _, path := range g.paths {
248 removeClaim(path, claimed)
249 }
250 }
251 return nil
252 }
253
254 func removeClaim(path string, claimed map[string]bool) {
255 if err := os.Remove(path); err == nil || os.IsNotExist(err) {
256 claimed[path] = false
257 }
258 }
259
260 func claimPendingFiles(dir string, now time.Time) ([]string, error) {
261 entries, err := os.ReadDir(dir)
262 if err != nil {
263 return nil, err
264 }
265 for _, entry := range entries {
266 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json.uploading") {
267 continue
268 }
269 path := filepath.Join(dir, entry.Name())
270 info, err := entry.Info()
271 if err != nil || now.Sub(info.ModTime()) < 2*time.Minute {
272 continue
273 }
274 _ = os.Rename(path, strings.TrimSuffix(path, ".uploading"))
275 }
276 entries, err = os.ReadDir(dir)
277 if err != nil {
278 return nil, err
279 }
280 var claimed []string
281 for _, entry := range entries {
282 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
283 continue
284 }
285 path := filepath.Join(dir, entry.Name())
286 claim := path + ".uploading"
287 if os.Rename(path, claim) == nil {
288 claimed = append(claimed, claim)
289 }
290 }
291 return claimed, nil
292 }
293
294 func validPendingPayload(p pendingPayload) bool {
295 if !releaseVersionPattern.MatchString(strings.TrimSpace(p.Version)) || len(p.Version) > 64 {
296 return false
297 }
298 switch p.OS {
299 case "android", "darwin", "freebsd", "linux", "windows":
300 default:
301 return false
302 }
303 return len(p.Counters) > 0 && len(p.Counters) <= 128
304 }
305
306 func validCounter(c Counter) bool {
307 return uploadSignals[c.Signal] && c.Count > 0 && c.Count <= 1_000_000 &&
308 len(c.Bucket) > 0 && len(c.Bucket) <= 96 && !unsafeBucketChars.MatchString(c.Bucket)
309 }
310
311 func (c *Client) post(ctx context.Context, path string, payload any) error {
312 b, err := json.Marshal(payload)
313 if err != nil {
314 return err
315 }
316 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+path, bytes.NewReader(b))
317 if err != nil {
318 return err
319 }
320 req.Header.Set("Content-Type", "application/json")
321 resp, err := c.http.Do(req)
322 if err != nil {
323 return err
324 }
325 defer resp.Body.Close()
326 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024))
327 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
328 return fmt.Errorf("telemetry: HTTP %d", resp.StatusCode)
329 }
330 return nil
331 }
332
332 lines GO