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