返回 DeepSeek-Reasonix
metrics_app.go
根目录 / desktop / metrics_app.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/json"
6 "net/http"
7 "net/url"
8 "os"
9 "path/filepath"
10 "regexp"
11 "runtime"
12 "strconv"
13 "strings"
14 "sync"
15 "time"
16
17 "reasonix/internal/config"
18 "reasonix/internal/event"
19 "reasonix/internal/recovery"
20 )
21
22 // metrics_app.go is the aggregate desktop-metrics flush: anonymous (signal,
23 // bucket) counters observed from the event stream and safe desktop preference
24 // snapshots, POSTed once per launch. Never carries content, keys, prompts, paths,
25 // or base URLs; custom provider/model identifiers are normalized into bounded
26 // buckets. Gated on config desktop.metrics (default on), dev-skipped.
27
28 var metricsEndpoint = "https://crash.reasonix.io/v1/metrics"
29
30 const metricsPendingFile = "metrics-pending.json"
31 const metricsPostTimeout = 8 * time.Second
32
33 var statusCodePattern = regexp.MustCompile(`status (\d{3})`)
34 var metricsPendingMu sync.Mutex
35
36 type counters map[string]map[string]int // signal -> bucket -> count
37
38 func (c counters) add(signal, bucket string, n int) {
39 if c[signal] == nil {
40 c[signal] = map[string]int{}
41 }
42 c[signal][bucket] += n
43 }
44
45 func (c counters) merge(other counters) {
46 for sig, buckets := range other {
47 for b, n := range buckets {
48 c.add(sig, b, n)
49 }
50 }
51 }
52
53 // metricsAggregator accumulates one session's (signal, bucket) counts and merges
54 // them into a pending file that flushMetrics drains on the next launch.
55 type metricsAggregator struct {
56 path string
57 mu sync.Mutex
58 c counters
59 }
60
61 func newMetricsAggregator(configDir string) *metricsAggregator {
62 return &metricsAggregator{path: filepath.Join(configDir, metricsPendingFile), c: counters{}}
63 }
64
65 func (m *metricsAggregator) inc(signal, bucket string) {
66 m.mu.Lock()
67 m.c.add(signal, bucket, 1)
68 m.mu.Unlock()
69 }
70
71 func boolBucket(v bool) string {
72 if v {
73 return "on"
74 }
75 return "off"
76 }
77
78 func statusBarItemsCountBucket(n int) string {
79 if n < 0 {
80 n = 0
81 }
82 return "n_" + strconv.Itoa(n)
83 }
84
85 func countBucket(n int) string {
86 if n < 0 {
87 n = 0
88 }
89 switch {
90 case n == 0:
91 return "n_0"
92 case n == 1:
93 return "n_1"
94 case n <= 3:
95 return "n_2_3"
96 case n <= 5:
97 return "n_4_5"
98 default:
99 return "n_6_plus"
100 }
101 }
102
103 func knownBucket(value string, allowed ...string) string {
104 value = strings.ToLower(strings.TrimSpace(value))
105 for _, ok := range allowed {
106 if value == ok {
107 return value
108 }
109 }
110 return "other"
111 }
112
113 func knownBucketDefault(value, def string, allowed ...string) string {
114 if strings.TrimSpace(value) == "" {
115 value = def
116 }
117 return knownBucket(value, allowed...)
118 }
119
120 func metricBucket(value string) string {
121 value = strings.ToLower(strings.TrimSpace(value))
122 if value == "" {
123 return "default"
124 }
125 var b strings.Builder
126 lastUnderscore := false
127 for _, r := range value {
128 ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
129 if ok {
130 b.WriteRune(r)
131 lastUnderscore = false
132 continue
133 }
134 if !lastUnderscore {
135 b.WriteByte('_')
136 lastUnderscore = true
137 }
138 }
139 out := strings.Trim(b.String(), "_")
140 if out == "" {
141 return "other"
142 }
143 if len(out) > 96 {
144 return out[:96]
145 }
146 return out
147 }
148
149 func metricsOfficialProviderHost(baseURL string) string {
150 u, err := url.Parse(strings.TrimSpace(baseURL))
151 if err != nil {
152 return ""
153 }
154 return strings.ToLower(u.Hostname())
155 }
156
157 func officialProviderBucket(e *config.ProviderEntry) string {
158 if e == nil {
159 return ""
160 }
161 switch config.CanonicalDesktopOfficialProviderName(e.Name) {
162 case "deepseek":
163 if metricsOfficialProviderHost(e.BaseURL) == "api.deepseek.com" {
164 return "deepseek"
165 }
166 case "mimo-api":
167 if metricsOfficialProviderHost(e.BaseURL) == "api.xiaomimimo.com" {
168 return "mimoapi"
169 }
170 case "mimo-token-plan":
171 if metricsOfficialProviderHost(e.BaseURL) == "token-plan-cn.xiaomimimo.com" {
172 return "mimoplan"
173 }
174 }
175 return ""
176 }
177
178 func providerMetricsBucket(e *config.ProviderEntry) string {
179 if b := officialProviderBucket(e); b != "" {
180 return b
181 }
182 if e == nil {
183 return "unknown"
184 }
185 return metricBucket("custom_" + e.Name)
186 }
187
188 func safeModelBucket(c *config.Config, ref string) string {
189 ref = strings.TrimSpace(ref)
190 if ref == "" {
191 ref = c.DefaultModel
192 }
193 e, ok := c.ResolveModel(ref)
194 if !ok {
195 return "unresolved"
196 }
197 provider := providerMetricsBucket(e)
198 return metricBucket(provider + "_" + e.Model)
199 }
200
201 func plannerModelBucket(c *config.Config) string {
202 if strings.TrimSpace(c.Agent.PlannerModel) == "" {
203 return "off"
204 }
205 return safeModelBucket(c, c.Agent.PlannerModel)
206 }
207
208 func safeProviderAccessBucket(c *config.Config, name string) string {
209 if p, ok := c.Provider(name); ok {
210 return providerMetricsBucket(p)
211 }
212 return metricBucket("custom_" + name)
213 }
214
215 func (m *metricsAggregator) observeSettingsSnapshot(c *config.Config) {
216 if c == nil {
217 return
218 }
219 lang := c.DesktopLanguage()
220 if lang == "" {
221 lang = "auto"
222 }
223 themeStyle := c.DesktopThemeStyle()
224 if themeStyle == "" {
225 themeStyle = "default"
226 }
227 m.inc("settings_language", lang)
228 m.inc("client_surface", "desktop")
229 m.inc("client_version", metricBucket(version))
230 m.inc("settings_desktop_layout", c.DesktopLayoutStyle())
231 m.inc("settings_theme", c.DesktopTheme())
232 m.inc("settings_theme_style", themeStyle)
233 m.inc("settings_close_behavior", c.DesktopCloseBehavior())
234 m.inc("settings_display_mode", c.DesktopDisplayMode())
235 m.inc("settings_status_bar_style", c.DesktopStatusBarStyle())
236 m.inc("settings_status_bar_items_count", statusBarItemsCountBucket(len(c.DesktopStatusBarItems())))
237 m.inc("settings_check_updates", boolBucket(c.DesktopCheckUpdates()))
238 m.inc("settings_default_model", safeModelBucket(c, c.DefaultModel))
239 m.inc("settings_planner_model", plannerModelBucket(c))
240 m.inc("settings_subagent_model", safeModelBucket(c, c.Agent.SubagentModel))
241 m.inc("settings_subagent_effort", knownBucketDefault(c.Agent.SubagentEffort, "auto", "auto", "low", "medium", "high", "xhigh", "max", "off"))
242 m.inc("settings_reasoning_language", config.NormalizeReasoningLanguage(c.Agent.ReasoningLanguage))
243 m.inc("settings_provider_count", countBucket(len(c.Providers)))
244 m.inc("settings_provider_access_count", countBucket(len(c.Desktop.ProviderAccess)))
245 for _, name := range c.Desktop.ProviderAccess {
246 m.inc("settings_provider_access", safeProviderAccessBucket(c, name))
247 }
248 m.observeBotSettingsSnapshot(c)
249 }
250
251 func (m *metricsAggregator) observeBotSettingsSnapshot(c *config.Config) {
252 bot := c.Bot
253 m.inc("settings_bot_enabled", boolBucket(bot.Enabled))
254 m.inc("settings_bot_model", safeModelBucket(c, bot.Model))
255 m.inc("settings_bot_tool_approval", knownBucketDefault(bot.ToolApprovalMode, "ask", "ask", "auto", "yolo"))
256 m.inc("settings_bot_allowlist", boolBucket(bot.Allowlist.Enabled))
257 m.inc("settings_bot_allow_all", boolBucket(bot.Allowlist.AllowAll))
258 m.inc("settings_bot_qq_enabled", boolBucket(bot.QQ.Enabled))
259 m.inc("settings_bot_feishu_enabled", boolBucket(bot.Feishu.Enabled))
260 m.inc("settings_bot_weixin_enabled", boolBucket(bot.Weixin.Enabled))
261 m.inc("settings_bot_connection_count", countBucket(len(bot.Connections)))
262 for _, conn := range bot.Connections {
263 provider := knownBucket(conn.Provider, "qq", "feishu", "weixin")
264 m.inc("settings_bot_connection_provider", provider)
265 m.inc("settings_bot_connection_enabled", boolBucket(conn.Enabled))
266 m.inc("settings_bot_connection_status", knownBucket(conn.Status, "disconnected", "pending", "connected", "error"))
267 m.inc("settings_bot_connection_model", safeModelBucket(c, conn.Model))
268 m.inc("settings_bot_connection_approval", knownBucketDefault(conn.ToolApprovalMode, "default", "default", "ask", "auto", "yolo"))
269 }
270 }
271
272 func (a *App) recordSettingsMetricsSnapshot(c *config.Config) {
273 if version == "dev" || c == nil {
274 return
275 }
276 m := a.metrics.Load()
277 if m == nil {
278 return
279 }
280 m.observeSettingsSnapshot(c)
281 m.persist()
282 }
283
284 // recordDiagnosticMetric persists one bounded operational signal even when the
285 // native event arrives before Wails OnStartup installs the session aggregator.
286 func (a *App) recordDiagnosticMetric(signal, bucket string) {
287 if version == "dev" {
288 return
289 }
290 m := a.metrics.Load()
291 if m == nil {
292 cfg, err := config.Load()
293 if err != nil || !cfg.DesktopMetrics() {
294 return
295 }
296 m = newMetricsAggregator(config.MemoryUserDir())
297 }
298 m.inc(signal, metricBucket(bucket))
299 m.persist()
300 }
301
302 // observe maps one event to counter increments, reading only enumerated facts
303 // (finish reason, error class, cache-hit bucket) — never message text.
304 func (m *metricsAggregator) observe(e event.Event) {
305 switch e.Kind {
306 case event.Usage:
307 if e.Usage == nil {
308 return
309 }
310 if e.Usage.FinishReason != "" {
311 m.inc("finish_reason", e.Usage.FinishReason)
312 }
313 if e.Usage.CacheHitTokens+e.Usage.CacheMissTokens > 0 {
314 m.inc("cache_hit", cacheBucket(e.Usage.CacheHitTokens, e.Usage.CacheMissTokens))
315 }
316 case event.TurnDone:
317 m.inc("turns", "total")
318 if e.Err != nil && e.Outcome != event.TurnOutcomeRecoveryPaused {
319 m.inc("provider_error", errorClass(e.Err.Error()))
320 }
321 case event.ToolResult:
322 if e.Tool.Err != "" {
323 m.inc("tool_error", toolErrorClass(e.Tool.Err))
324 }
325 case event.CompactionDone:
326 m.inc("compaction", "total")
327 case event.Notice:
328 if e.Text == "No visible answer was produced; asking the assistant to respond again." || strings.HasPrefix(e.Detail, "empty final answer blocked") {
329 m.inc("empty_final", "total")
330 }
331 }
332 }
333
334 func cacheBucket(hit, miss int) string {
335 pct := float64(hit) / float64(hit+miss) * 100
336 switch {
337 case pct < 50:
338 return "0_50"
339 case pct < 80:
340 return "50_80"
341 case pct < 95:
342 return "80_95"
343 case pct < 99:
344 return "95_99"
345 default:
346 return "99_100"
347 }
348 }
349
350 // badRequestReason separates the 400s that need different fixes. Every arm
351 // returns a fixed label matched against a fixed substring, so nothing the
352 // provider echoed back can reach the bucket — the same constraint errorClass
353 // works under. Unrecognized shapes stay plain http_400 rather than guessing.
354 func badRequestReason(low string) string {
355 switch {
356 case strings.Contains(low, "image_url"), strings.Contains(low, "unknown variant"):
357 return "content"
358 case strings.Contains(low, "is not of type"), strings.Contains(low, "invalid schema for function"):
359 return "schema"
360 case strings.Contains(low, "thinking") && strings.Contains(low, "passed back"):
361 return "reasoning_replay"
362 case strings.Contains(low, "thinking") && (strings.Contains(low, "expected a boolean") || strings.Contains(low, "invalid type")):
363 return "thinking_shape"
364 case strings.Contains(low, "context length"), strings.Contains(low, "maximum context"), strings.Contains(low, "too long"):
365 return "context_length"
366 case strings.Contains(low, "tool_calls"), strings.Contains(low, "missing field name"):
367 return "tool_calls"
368 }
369 return ""
370 }
371
372 // errorClass extracts only the failure category — never the message itself, which
373 // can echo request content back from a provider.
374 func errorClass(msg string) string {
375 if mm := statusCodePattern.FindStringSubmatch(msg); mm != nil {
376 switch code := mm[1]; {
377 case code == "400":
378 if reason := badRequestReason(strings.ToLower(msg)); reason != "" {
379 return "http_400_" + reason
380 }
381 return "http_400"
382 case code == "401" || code == "403":
383 return "http_401"
384 case code == "429":
385 return "http_429"
386 case code[0] == '5':
387 return "http_5xx"
388 }
389 }
390 low := strings.ToLower(msg)
391 switch {
392 case strings.Contains(low, "authorization cancelled"):
393 return "authorization_cancelled"
394 case strings.Contains(low, "authorization failed"):
395 return "authorization_failed"
396 case strings.Contains(low, "package manager busy"):
397 return "package_manager_busy"
398 case strings.Contains(low, "package install failed"):
399 return "package_install_failed"
400 case strings.Contains(low, "package verify failed"), strings.Contains(low, "signature verification failed"):
401 return "package_verify_failed"
402 case strings.Contains(low, "reset"), strings.Contains(low, "interrupt"), strings.Contains(low, "eof"):
403 return "stream_interrupted"
404 case strings.Contains(low, "timeout"), strings.Contains(low, "deadline"):
405 return "timeout"
406 default:
407 return "other"
408 }
409 }
410
411 func toolErrorClass(msg string) string {
412 low := strings.ToLower(msg)
413 switch {
414 case strings.Contains(low, "permission"):
415 return "permission"
416 case strings.Contains(low, "plan mode"):
417 return "planmode"
418 case strings.Contains(low, "recovery"):
419 return "recovery"
420 case strings.Contains(low, "hook"):
421 return "hook"
422 case strings.Contains(low, "timeout"), strings.Contains(low, "deadline"):
423 return "timeout"
424 default:
425 return "exec"
426 }
427 }
428
429 // observeRecoveryMetrics merges content-free recovery counters from a controller
430 // (failure events, rule/review continues, human prompts/actions, reviewer errors).
431 func (m *metricsAggregator) observeRecoveryMetrics(stats recovery.Metrics) {
432 if m == nil {
433 return
434 }
435 add := func(signal string, n int64) {
436 for i := int64(0); i < n; i++ {
437 m.inc(signal, "total")
438 }
439 }
440 add("recovery_failure", stats.FailureEvents)
441 add("recovery_rule_continue", stats.RuleContinues)
442 add("recovery_review_continue", stats.ReviewContinues)
443 add("recovery_human_prompt", stats.HumanPrompts)
444 add("recovery_human_continue", stats.HumanContinues)
445 add("recovery_human_revise", stats.HumanRevises)
446 add("recovery_review_error", stats.ReviewErrors)
447 add("recovery_repeat_prompt", stats.RepeatPrompts)
448 if stats.ReviewLatencyCount > 0 {
449 avg := stats.ReviewLatencyMsSum / stats.ReviewLatencyCount
450 switch {
451 case avg < 500:
452 m.inc("recovery_review_latency", "lt_500ms")
453 case avg < 2000:
454 m.inc("recovery_review_latency", "lt_2s")
455 case avg < 10000:
456 m.inc("recovery_review_latency", "lt_10s")
457 default:
458 m.inc("recovery_review_latency", "gte_10s")
459 }
460 }
461 }
462
463 func observeControllerRecoveryMetrics(m *metricsAggregator, ctrl any) {
464 if m == nil || ctrl == nil {
465 return
466 }
467 if drainer, ok := ctrl.(interface {
468 DrainRecoveryMetrics() recovery.Metrics
469 }); ok {
470 m.observeRecoveryMetrics(drainer.DrainRecoveryMetrics())
471 }
472 }
473
474 // persist merges the session delta into the pending file and resets it, so a
475 // force-kill loses at most the counts since the last turn.
476 func (m *metricsAggregator) persist() {
477 m.mu.Lock()
478 if len(m.c) == 0 {
479 m.mu.Unlock()
480 return
481 }
482 delta := m.c
483 m.c = counters{}
484 m.mu.Unlock()
485
486 metricsPendingMu.Lock()
487 pending := readCounters(m.path)
488 pending.merge(delta)
489 writeCounters(m.path, pending)
490 metricsPendingMu.Unlock()
491 }
492
493 func readCounters(path string) counters {
494 b, err := readFileUTF8(path)
495 if err != nil {
496 return counters{}
497 }
498 var c counters
499 if json.Unmarshal(b, &c) != nil || c == nil {
500 return counters{}
501 }
502 return c
503 }
504
505 func writeCounters(path string, c counters) {
506 if b, err := json.Marshal(c); err == nil {
507 _ = os.WriteFile(path, b, 0o644)
508 }
509 }
510
511 type metricCounter struct {
512 Signal string `json:"signal"`
513 Bucket string `json:"bucket"`
514 Count int `json:"count"`
515 }
516
517 type metricsPayload struct {
518 InstallID string `json:"installId,omitempty"`
519 Version string `json:"version"`
520 OS string `json:"os"`
521 Counters []metricCounter `json:"counters"`
522 }
523
524 func flatten(c counters) []metricCounter {
525 out := make([]metricCounter, 0, len(c))
526 for sig, buckets := range c {
527 for b, n := range buckets {
528 if n > 0 {
529 out = append(out, metricCounter{Signal: sig, Bucket: b, Count: n})
530 }
531 }
532 }
533 return out
534 }
535
536 // flushMetrics drains the pending file from prior sessions and POSTs it, then
537 // clears it on success or folds it back to retry next launch. Runs at launch
538 // (mirroring the ping) so the current session's counts ship next time.
539 func (a *App) flushMetrics() {
540 if version == "dev" {
541 return
542 }
543 cfg, err := config.Load()
544 if err != nil || !cfg.DesktopMetrics() {
545 return
546 }
547 path := filepath.Join(config.MemoryUserDir(), metricsPendingFile)
548 temp := path + ".sending"
549 metricsPendingMu.Lock()
550 if os.Rename(path, temp) != nil {
551 metricsPendingMu.Unlock()
552 return // nothing pending
553 }
554 metricsPendingMu.Unlock()
555 flat := flatten(readCounters(temp))
556 payload := metricsPayload{Version: version, OS: runtime.GOOS, Counters: flat}
557 if id, err := installID(); err == nil {
558 payload.InstallID = id
559 }
560 if len(flat) == 0 || a.postMetrics(payload) {
561 _ = os.Remove(temp)
562 return
563 }
564 metricsPendingMu.Lock()
565 pending := readCounters(path)
566 pending.merge(readCounters(temp))
567 writeCounters(path, pending)
568 metricsPendingMu.Unlock()
569 _ = os.Remove(temp)
570 }
571
572 func (a *App) postMetrics(p metricsPayload) bool {
573 body, err := json.Marshal(p)
574 if err != nil {
575 return false
576 }
577 c, err := httpClient()
578 if err != nil {
579 return false
580 }
581 c.Timeout = metricsPostTimeout
582 req, err := http.NewRequestWithContext(a.bootContext(), http.MethodPost, metricsEndpoint, bytes.NewReader(body))
583 if err != nil {
584 return false
585 }
586 req.Header.Set("Content-Type", "application/json")
587 resp, err := c.Do(req)
588 if err != nil {
589 return false
590 }
591 resp.Body.Close()
592 return resp.StatusCode < 300
593 }
594
594 lines GO