| 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 |