返回 DeepSeek-Reasonix
crash_app.go
根目录 / desktop / crash_app.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "crypto/rand"
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "fmt"
11 "net/http"
12 "regexp"
13 "runtime"
14 "strings"
15
16 "reasonix/internal/config"
17 )
18
19 // crash_app.go is the crash/feedback/performance reporting surface. Frontend
20 // reports are sent on an explicit user click. Native fatal/lifecycle reports are
21 // queued locally and sent on a later launch only when desktop telemetry is on.
22
23 var crashEndpoint = "https://crash.reasonix.io/v1/report"
24
25 const maxCrashDetailBytes = 16 << 10
26 const maxCrashStackBytes = 8 << 10
27 const maxCrashFieldBytes = 4 << 10
28 const maxCrashBreadcrumbs = 30
29
30 var (
31 userPathSegment = regexp.MustCompile(`(?i)([A-Z]:\\Users\\|/(?:home|Users)/)[^/\\:\s"']+`)
32 emailPattern = regexp.MustCompile(`\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`)
33 secretKeyValuePattern = regexp.MustCompile(`(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|authorization|secret|password|passwd|pwd|token)\b\s*[:=]\s*(?:Bearer\s+)?['"]?[^'"\s,;]+['"]?`)
34 bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{16,}`)
35 explicitKeyPattern = regexp.MustCompile(`\b(?:sk|rk)-(?:proj-)?[A-Za-z0-9_-]{16,}\b`)
36 envIdentifierPattern = regexp.MustCompile(`\b[A-Z][A-Z0-9_]*(?:API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD)[A-Z0-9_]*\b`)
37 jwtPattern = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b`)
38 longHexPattern = regexp.MustCompile(`\b[0-9a-fA-F]{32,}\b`)
39 longBase64Pattern = regexp.MustCompile(`[A-Za-z0-9+/]{40,}={0,2}`)
40 longBase64URLPattern = regexp.MustCompile(`\b[A-Za-z0-9_-]{48,}\b`)
41 )
42
43 func scrubUserPaths(s string) string {
44 return userPathSegment.ReplaceAllString(s, "${1}_")
45 }
46
47 func scrubSensitiveText(s string) string {
48 s = scrubUserPaths(s)
49 s = emailPattern.ReplaceAllString(s, "[redacted-email]")
50 s = bearerTokenPattern.ReplaceAllString(s, "Bearer [redacted]")
51 s = secretKeyValuePattern.ReplaceAllString(s, "${1}=[redacted]")
52 s = envIdentifierPattern.ReplaceAllString(s, "[redacted-env]")
53 s = jwtPattern.ReplaceAllString(s, "[redacted-jwt]")
54 s = explicitKeyPattern.ReplaceAllString(s, "[redacted-key]")
55 s = longHexPattern.ReplaceAllString(s, "[redacted-hex]")
56 s = longBase64Pattern.ReplaceAllString(s, "[redacted-token]")
57 s = longBase64URLPattern.ReplaceAllString(s, "[redacted-token]")
58 return s
59 }
60
61 type crashBreadcrumb struct {
62 T int64 `json:"t,omitempty"`
63 Cat string `json:"cat,omitempty"`
64 Msg string `json:"msg,omitempty"`
65 }
66
67 type crashDiagnostics struct {
68 SubjectVersion string `json:"subjectVersion,omitempty"`
69 SubjectBuildCommit string `json:"subjectBuildCommit,omitempty"`
70 SubjectChannel string `json:"subjectChannel,omitempty"`
71 ObserverVersion string `json:"observerVersion,omitempty"`
72 ObserverBuildCommit string `json:"observerBuildCommit,omitempty"`
73 RunID string `json:"runId,omitempty"`
74 IncidentID string `json:"incidentId,omitempty"`
75 ProcessRole string `json:"processRole,omitempty"`
76 LastPhase string `json:"lastPhase,omitempty"`
77 LastPhaseAt string `json:"lastPhaseAt,omitempty"`
78 ObservedAt string `json:"observedAt,omitempty"`
79 TerminationReason string `json:"terminationReason,omitempty"`
80 CleanupOutcome string `json:"cleanupOutcome,omitempty"`
81 ExitCode *int32 `json:"exitCode,omitempty"`
82 Signal string `json:"signal,omitempty"`
83 Evidence string `json:"evidence,omitempty"`
84 Category string `json:"category,omitempty"`
85 LegacyParsed bool `json:"legacyParsed,omitempty"`
86 }
87
88 // webRuntimeDiagnostic and webView2Diagnostic are decode-only: pending reports
89 // written by the retired WebView2/WebKitGTK shell must still decode and forward
90 // after upgrade. No producer remains under the Electron shell.
91 type webRuntimeDiagnostic struct {
92 Engine string `json:"engine"`
93 Kind string `json:"kind"`
94 Reason string `json:"reason"`
95 ExitCode *int32 `json:"exitCode,omitempty"`
96 ProcessDescription string `json:"processDescription,omitempty"`
97 FailureSourceModule string `json:"failureSourceModule,omitempty"`
98 RuntimeVersion string `json:"runtimeVersion"`
99 GPUMode string `json:"gpuMode"`
100 CompatibilityMode bool `json:"compatibilityMode,omitempty"`
101 Recovery string `json:"recovery"`
102 }
103
104 type webView2Diagnostic struct {
105 Kind string `json:"kind"`
106 Reason string `json:"reason"`
107 ExitCode *int32 `json:"exitCode,omitempty"`
108 ProcessDescription string `json:"processDescription,omitempty"`
109 FailureSourceModule string `json:"failureSourceModule,omitempty"`
110 RuntimeVersion string `json:"runtimeVersion"`
111 GPUDisabled bool `json:"gpuDisabled"`
112 Recovery string `json:"recovery"`
113 }
114
115 type crashReport struct {
116 EventID string `json:"eventId,omitempty"`
117 DedupKey string `json:"dedupKey,omitempty"`
118 InstallID string `json:"installId,omitempty"`
119 Kind string `json:"kind"`
120 Version string `json:"version"`
121 OS string `json:"os"`
122 Arch string `json:"arch"`
123 Message string `json:"message"`
124 Device deviceInfo `json:"device"`
125 SchemaVersion int `json:"schemaVersion,omitempty"`
126 Source string `json:"source,omitempty"`
127 Label string `json:"label,omitempty"`
128 ErrorType string `json:"errorType,omitempty"`
129 ErrorMessage string `json:"errorMessage,omitempty"`
130 ErrorFamily string `json:"errorFamily,omitempty"`
131 Stack string `json:"stack,omitempty"`
132 ComponentStack string `json:"componentStack,omitempty"`
133 TopFrame string `json:"topFrame,omitempty"`
134 FingerprintHint string `json:"fingerprintHint,omitempty"`
135 BuildCommit string `json:"buildCommit,omitempty"`
136 Channel string `json:"channel,omitempty"`
137 Language string `json:"language,omitempty"`
138 View string `json:"view,omitempty"`
139 Breadcrumbs []crashBreadcrumb `json:"breadcrumbs,omitempty"`
140 OccurredAt string `json:"occurredAt,omitempty"`
141 Diagnostics *crashDiagnostics `json:"diagnostics,omitempty"`
142 WebRuntime *webRuntimeDiagnostic `json:"webRuntime,omitempty"`
143 // WebView2 is retained only so pending reports written by the retired
144 // WebView2 shell can still be decoded and forwarded after upgrade.
145 WebView2 *webView2Diagnostic `json:"webview2,omitempty"`
146 }
147
148 type frontendCrashPayload struct {
149 SchemaVersion int `json:"schemaVersion"`
150 Kind string `json:"kind"`
151 Source string `json:"source"`
152 Label string `json:"label"`
153 Message string `json:"message"`
154 ErrorType string `json:"errorType"`
155 ErrorMessage string `json:"errorMessage"`
156 ErrorFamily string `json:"errorFamily"`
157 Stack string `json:"stack"`
158 ComponentStack string `json:"componentStack"`
159 TopFrame string `json:"topFrame"`
160 FingerprintHint string `json:"fingerprintHint"`
161 BuildCommit string `json:"buildCommit"`
162 Channel string `json:"channel"`
163 Language string `json:"language"`
164 View string `json:"view"`
165 Breadcrumbs []crashBreadcrumb `json:"breadcrumbs"`
166 OccurredAt string `json:"occurredAt"`
167 }
168
169 func normalizeReportKind(kind string) (string, bool) {
170 switch strings.TrimSpace(kind) {
171 case "crash", "exception", "feedback", "performance", "bot":
172 return strings.TrimSpace(kind), true
173 default:
174 return "", false
175 }
176 }
177
178 func clipCrashField(s string, max int) string {
179 if len(s) > max {
180 return s[:max]
181 }
182 return s
183 }
184
185 func sanitizeCrashField(s string, max int) string {
186 return clipCrashField(scrubUserPaths(strings.TrimSpace(s)), max)
187 }
188
189 func sanitizeCrashText(s string, max int) string {
190 return clipCrashField(strings.TrimSpace(scrubSensitiveText(s)), max)
191 }
192
193 func sanitizeBreadcrumbs(in []crashBreadcrumb) []crashBreadcrumb {
194 if len(in) > maxCrashBreadcrumbs {
195 in = in[len(in)-maxCrashBreadcrumbs:]
196 }
197 out := make([]crashBreadcrumb, 0, len(in))
198 for _, b := range in {
199 cat := sanitizeCrashField(b.Cat, 64)
200 msg := sanitizeCrashText(b.Msg, 240)
201 if cat == "" && msg == "" {
202 continue
203 }
204 out = append(out, crashBreadcrumb{T: b.T, Cat: cat, Msg: msg})
205 }
206 return out
207 }
208
209 func baseCrashReport(kind string) crashReport {
210 return crashReport{
211 Kind: kind,
212 Version: version,
213 OS: runtime.GOOS,
214 Arch: runtime.GOARCH,
215 Device: collectDeviceInfo(),
216 Channel: channel,
217 }
218 }
219
220 func ensureCrashIdentity(report *crashReport) error {
221 if report.EventID == "" {
222 value := make([]byte, 16)
223 if _, err := rand.Read(value); err != nil {
224 return fmt.Errorf("generate crash event id: %w", err)
225 }
226 report.EventID = hex.EncodeToString(value)
227 }
228 if report.DedupKey == "" {
229 basis := strings.Join([]string{
230 report.Kind,
231 report.Version,
232 report.Source,
233 report.Label,
234 report.ErrorType,
235 normalizeCrashFingerprintField(report.ErrorMessage),
236 normalizeCrashFingerprintField(report.TopFrame),
237 normalizeCrashFingerprintField(report.FingerprintHint),
238 }, "\n")
239 sum := sha256.Sum256([]byte(basis))
240 report.DedupKey = hex.EncodeToString(sum[:])
241 }
242 return nil
243 }
244
245 var crashFingerprintNumber = regexp.MustCompile(`\b\d+\b`)
246
247 func normalizeCrashFingerprintField(value string) string {
248 return crashFingerprintNumber.ReplaceAllString(strings.ToLower(strings.TrimSpace(value)), "<n>")
249 }
250
251 func topFrameFromStack(stack string) string {
252 for line := range strings.SplitSeq(stack, "\n") {
253 line = strings.TrimSpace(line)
254 if line == "" {
255 continue
256 }
257 if strings.Contains(line, ".go:") || strings.Contains(line, ".ts:") || strings.Contains(line, ".tsx:") || strings.Contains(line, ".js:") || strings.Contains(line, ".jsx:") {
258 if strings.Contains(line, "/runtime/") || strings.Contains(line, `\runtime\`) || strings.Contains(line, "crash_pending.go") {
259 continue
260 }
261 return sanitizeCrashText(line, 300)
262 }
263 }
264 return ""
265 }
266
267 func nativeResourceContext() string {
268 var m runtime.MemStats
269 runtime.ReadMemStats(&m)
270 mb := func(n uint64) string {
271 return fmt.Sprintf("%.1f MB", float64(n)/1024/1024)
272 }
273 return strings.Join([]string{
274 "go heap alloc: " + mb(m.Alloc),
275 "go heap sys: " + mb(m.HeapSys),
276 "go total sys: " + mb(m.Sys),
277 fmt.Sprintf("goroutines: %d", runtime.NumGoroutine()),
278 fmt.Sprintf("gc cycles: %d", m.NumGC),
279 }, "\n")
280 }
281
282 func appendNativeResourceContext(kind, message string) string {
283 if kind != "performance" {
284 return message
285 }
286 return sanitizeCrashText(message+"\n\n--- native runtime context ---\n"+nativeResourceContext(), maxCrashDetailBytes)
287 }
288
289 func crashReportFromDetail(kind, detail string) (crashReport, error) {
290 rawKind := kind
291 kind, ok := normalizeReportKind(kind)
292 if !ok {
293 return crashReport{}, fmt.Errorf("unknown report kind %q", rawKind)
294 }
295 if strings.TrimSpace(detail) == "" {
296 return crashReport{}, fmt.Errorf("empty report")
297 }
298 r := baseCrashReport(kind)
299
300 var payload frontendCrashPayload
301 if json.Unmarshal([]byte(detail), &payload) == nil && payload.SchemaVersion == 2 {
302 if payloadKind, ok := normalizeReportKind(payload.Kind); ok {
303 r.Kind = payloadKind
304 }
305 r.SchemaVersion = currentCrashSchema
306 r.Source = sanitizeCrashField(payload.Source, 32)
307 r.Label = sanitizeCrashField(payload.Label, 64)
308 r.ErrorType = sanitizeCrashField(payload.ErrorType, 128)
309 r.ErrorMessage = sanitizeCrashText(payload.ErrorMessage, maxCrashFieldBytes)
310 r.ErrorFamily = sanitizeCrashField(payload.ErrorFamily, 128)
311 r.Stack = sanitizeCrashText(payload.Stack, maxCrashStackBytes)
312 r.ComponentStack = sanitizeCrashText(payload.ComponentStack, maxCrashStackBytes)
313 r.TopFrame = sanitizeCrashText(payload.TopFrame, 300)
314 r.FingerprintHint = sanitizeCrashText(payload.FingerprintHint, 300)
315 r.BuildCommit = sanitizeCrashField(payload.BuildCommit, 64)
316 r.Channel = sanitizeCrashField(payload.Channel, 32)
317 r.Language = sanitizeCrashField(payload.Language, 64)
318 r.View = sanitizeCrashText(payload.View, 200)
319 r.Breadcrumbs = sanitizeBreadcrumbs(payload.Breadcrumbs)
320 r.OccurredAt = sanitizeCrashField(payload.OccurredAt, 64)
321 r.Message = sanitizeCrashText(payload.Message, maxCrashDetailBytes)
322 if r.TopFrame == "" {
323 r.TopFrame = topFrameFromStack(r.Stack)
324 }
325 if r.Message == "" {
326 r.Message = sanitizeCrashText(fmt.Sprintf("[%s]\n\n%s", r.Label, r.ErrorMessage), maxCrashDetailBytes)
327 }
328 if r.Source == "" {
329 r.Source = "frontend"
330 }
331 r.Message = appendNativeResourceContext(r.Kind, r.Message)
332 return r, nil
333 }
334
335 r.SchemaVersion = 1
336 r.Source = "legacy"
337 r.Label = kind
338 r.Message = sanitizeCrashText(detail, maxCrashDetailBytes)
339 r.Message = appendNativeResourceContext(r.Kind, r.Message)
340 return r, nil
341 }
342
343 func (a *App) ReportCrash(kind, detail string) error {
344 r, err := crashReportFromDetail(kind, detail)
345 if err != nil {
346 return err
347 }
348 c, err := httpClient()
349 if err != nil {
350 return err
351 }
352 if err := ensureCrashIdentity(&r); err != nil {
353 return err
354 }
355 if r.Kind == "crash" || r.Kind == "exception" {
356 r.Diagnostics = a.currentCrashDiagnostics("confirmed", "crash")
357 r.Diagnostics.ProcessRole = "renderer"
358 if r.BuildCommit != "" {
359 r.Diagnostics.SubjectBuildCommit = r.BuildCommit
360 }
361 }
362 return postCrashReport(a.reqCtx(), c, crashEndpoint, r)
363 }
364
365 func postCrashReport(ctx context.Context, c *http.Client, endpoint string, r crashReport) error {
366 // Pending crash files deliberately omit the anonymous installation id. Add
367 // it only at send time, under the same desktop.telemetry opt-in as pings.
368 if cfg, err := config.Load(); err == nil && cfg.DesktopTelemetry() {
369 if id, idErr := installID(); idErr == nil {
370 r.InstallID = id
371 }
372 }
373 body, err := json.Marshal(r)
374 if err != nil {
375 return err
376 }
377 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
378 if err != nil {
379 return err
380 }
381 req.Header.Set("Content-Type", "application/json")
382 resp, err := c.Do(req)
383 if err != nil {
384 return err
385 }
386 defer resp.Body.Close()
387 if resp.StatusCode >= 300 {
388 return fmt.Errorf("crash endpoint returned %s", resp.Status)
389 }
390 return nil
391 }
392
392 lines GO