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