| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "net/http" |
| 9 | "regexp" |
| 10 | "runtime" |
| 11 | "strings" |
| 12 | ) |
| 13 | |
| 14 | // crash_app.go is the crash/feedback/performance reporting surface. Frontend |
| 15 | // reports are sent on an explicit user click. Native fatal/lifecycle reports are |
| 16 | // queued locally and sent on a later launch only when desktop telemetry is on. |
| 17 | |
| 18 | var crashEndpoint = "https://crash.reasonix.io/v1/report" |
| 19 | |
| 20 | const maxCrashDetailBytes = 16 << 10 |
| 21 | const maxCrashStackBytes = 8 << 10 |
| 22 | const maxCrashFieldBytes = 4 << 10 |
| 23 | const maxCrashBreadcrumbs = 30 |
| 24 | |
| 25 | var ( |
| 26 | userPathSegment = regexp.MustCompile(`(?i)([A-Z]:\\Users\\|/(?:home|Users)/)[^/\\:\s"']+`) |
| 27 | emailPattern = regexp.MustCompile(`\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`) |
| 28 | 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,;]+['"]?`) |
| 29 | bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{16,}`) |
| 30 | explicitKeyPattern = regexp.MustCompile(`\b(?:sk|rk)-(?:proj-)?[A-Za-z0-9_-]{16,}\b`) |
| 31 | envIdentifierPattern = regexp.MustCompile(`\b[A-Z][A-Z0-9_]*(?:API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD)[A-Z0-9_]*\b`) |
| 32 | jwtPattern = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b`) |
| 33 | longHexPattern = regexp.MustCompile(`\b[0-9a-fA-F]{32,}\b`) |
| 34 | longBase64Pattern = regexp.MustCompile(`[A-Za-z0-9+/]{40,}={0,2}`) |
| 35 | longBase64URLPattern = regexp.MustCompile(`\b[A-Za-z0-9_-]{48,}\b`) |
| 36 | ) |
| 37 | |
| 38 | func scrubUserPaths(s string) string { |
| 39 | return userPathSegment.ReplaceAllString(s, "${1}_") |
| 40 | } |
| 41 | |
| 42 | func scrubSensitiveText(s string) string { |
| 43 | s = scrubUserPaths(s) |
| 44 | s = emailPattern.ReplaceAllString(s, "[redacted-email]") |
| 45 | s = bearerTokenPattern.ReplaceAllString(s, "Bearer [redacted]") |
| 46 | s = secretKeyValuePattern.ReplaceAllString(s, "${1}=[redacted]") |
| 47 | s = envIdentifierPattern.ReplaceAllString(s, "[redacted-env]") |
| 48 | s = jwtPattern.ReplaceAllString(s, "[redacted-jwt]") |
| 49 | s = explicitKeyPattern.ReplaceAllString(s, "[redacted-key]") |
| 50 | s = longHexPattern.ReplaceAllString(s, "[redacted-hex]") |
| 51 | s = longBase64Pattern.ReplaceAllString(s, "[redacted-token]") |
| 52 | s = longBase64URLPattern.ReplaceAllString(s, "[redacted-token]") |
| 53 | return s |
| 54 | } |
| 55 | |
| 56 | type crashBreadcrumb struct { |
| 57 | T int64 `json:"t,omitempty"` |
| 58 | Cat string `json:"cat,omitempty"` |
| 59 | Msg string `json:"msg,omitempty"` |
| 60 | } |
| 61 | |
| 62 | type crashReport struct { |
| 63 | Kind string `json:"kind"` |
| 64 | Version string `json:"version"` |
| 65 | OS string `json:"os"` |
| 66 | Arch string `json:"arch"` |
| 67 | Message string `json:"message"` |
| 68 | Device deviceInfo `json:"device"` |
| 69 | SchemaVersion int `json:"schemaVersion,omitempty"` |
| 70 | Source string `json:"source,omitempty"` |
| 71 | Label string `json:"label,omitempty"` |
| 72 | ErrorType string `json:"errorType,omitempty"` |
| 73 | ErrorMessage string `json:"errorMessage,omitempty"` |
| 74 | Stack string `json:"stack,omitempty"` |
| 75 | ComponentStack string `json:"componentStack,omitempty"` |
| 76 | TopFrame string `json:"topFrame,omitempty"` |
| 77 | FingerprintHint string `json:"fingerprintHint,omitempty"` |
| 78 | BuildCommit string `json:"buildCommit,omitempty"` |
| 79 | Channel string `json:"channel,omitempty"` |
| 80 | Language string `json:"language,omitempty"` |
| 81 | View string `json:"view,omitempty"` |
| 82 | Breadcrumbs []crashBreadcrumb `json:"breadcrumbs,omitempty"` |
| 83 | OccurredAt string `json:"occurredAt,omitempty"` |
| 84 | } |
| 85 | |
| 86 | type frontendCrashPayload struct { |
| 87 | SchemaVersion int `json:"schemaVersion"` |
| 88 | Kind string `json:"kind"` |
| 89 | Source string `json:"source"` |
| 90 | Label string `json:"label"` |
| 91 | Message string `json:"message"` |
| 92 | ErrorType string `json:"errorType"` |
| 93 | ErrorMessage string `json:"errorMessage"` |
| 94 | Stack string `json:"stack"` |
| 95 | ComponentStack string `json:"componentStack"` |
| 96 | TopFrame string `json:"topFrame"` |
| 97 | FingerprintHint string `json:"fingerprintHint"` |
| 98 | BuildCommit string `json:"buildCommit"` |
| 99 | Channel string `json:"channel"` |
| 100 | Language string `json:"language"` |
| 101 | View string `json:"view"` |
| 102 | Breadcrumbs []crashBreadcrumb `json:"breadcrumbs"` |
| 103 | OccurredAt string `json:"occurredAt"` |
| 104 | } |
| 105 | |
| 106 | func normalizeReportKind(kind string) (string, bool) { |
| 107 | switch strings.TrimSpace(kind) { |
| 108 | case "crash", "exception", "feedback", "performance", "bot": |
| 109 | return strings.TrimSpace(kind), true |
| 110 | default: |
| 111 | return "", false |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func clipCrashField(s string, max int) string { |
| 116 | if len(s) > max { |
| 117 | return s[:max] |
| 118 | } |
| 119 | return s |
| 120 | } |
| 121 | |
| 122 | func sanitizeCrashField(s string, max int) string { |
| 123 | return clipCrashField(scrubUserPaths(strings.TrimSpace(s)), max) |
| 124 | } |
| 125 | |
| 126 | func sanitizeCrashText(s string, max int) string { |
| 127 | return clipCrashField(strings.TrimSpace(scrubSensitiveText(s)), max) |
| 128 | } |
| 129 | |
| 130 | func sanitizeBreadcrumbs(in []crashBreadcrumb) []crashBreadcrumb { |
| 131 | if len(in) > maxCrashBreadcrumbs { |
| 132 | in = in[len(in)-maxCrashBreadcrumbs:] |
| 133 | } |
| 134 | out := make([]crashBreadcrumb, 0, len(in)) |
| 135 | for _, b := range in { |
| 136 | cat := sanitizeCrashField(b.Cat, 64) |
| 137 | msg := sanitizeCrashText(b.Msg, 240) |
| 138 | if cat == "" && msg == "" { |
| 139 | continue |
| 140 | } |
| 141 | out = append(out, crashBreadcrumb{T: b.T, Cat: cat, Msg: msg}) |
| 142 | } |
| 143 | return out |
| 144 | } |
| 145 | |
| 146 | func baseCrashReport(kind string) crashReport { |
| 147 | return crashReport{ |
| 148 | Kind: kind, |
| 149 | Version: version, |
| 150 | OS: runtime.GOOS, |
| 151 | Arch: runtime.GOARCH, |
| 152 | Device: collectDeviceInfo(), |
| 153 | Channel: channel, |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | func topFrameFromStack(stack string) string { |
| 158 | for _, line := range strings.Split(stack, "\n") { |
| 159 | line = strings.TrimSpace(line) |
| 160 | if line == "" { |
| 161 | continue |
| 162 | } |
| 163 | if strings.Contains(line, ".go:") || strings.Contains(line, ".ts:") || strings.Contains(line, ".tsx:") || strings.Contains(line, ".js:") || strings.Contains(line, ".jsx:") { |
| 164 | if strings.Contains(line, "/runtime/") || strings.Contains(line, `\runtime\`) || strings.Contains(line, "crash_pending.go") { |
| 165 | continue |
| 166 | } |
| 167 | return sanitizeCrashText(line, 300) |
| 168 | } |
| 169 | } |
| 170 | return "" |
| 171 | } |
| 172 | |
| 173 | func nativeResourceContext() string { |
| 174 | var m runtime.MemStats |
| 175 | runtime.ReadMemStats(&m) |
| 176 | mb := func(n uint64) string { |
| 177 | return fmt.Sprintf("%.1f MB", float64(n)/1024/1024) |
| 178 | } |
| 179 | return strings.Join([]string{ |
| 180 | "go heap alloc: " + mb(m.Alloc), |
| 181 | "go heap sys: " + mb(m.HeapSys), |
| 182 | "go total sys: " + mb(m.Sys), |
| 183 | fmt.Sprintf("goroutines: %d", runtime.NumGoroutine()), |
| 184 | fmt.Sprintf("gc cycles: %d", m.NumGC), |
| 185 | }, "\n") |
| 186 | } |
| 187 | |
| 188 | func appendNativeResourceContext(kind, message string) string { |
| 189 | if kind != "performance" { |
| 190 | return message |
| 191 | } |
| 192 | return sanitizeCrashText(message+"\n\n--- native runtime context ---\n"+nativeResourceContext(), maxCrashDetailBytes) |
| 193 | } |
| 194 | |
| 195 | func crashReportFromDetail(kind, detail string) (crashReport, error) { |
| 196 | rawKind := kind |
| 197 | kind, ok := normalizeReportKind(kind) |
| 198 | if !ok { |
| 199 | return crashReport{}, fmt.Errorf("unknown report kind %q", rawKind) |
| 200 | } |
| 201 | if strings.TrimSpace(detail) == "" { |
| 202 | return crashReport{}, fmt.Errorf("empty report") |
| 203 | } |
| 204 | r := baseCrashReport(kind) |
| 205 | |
| 206 | var payload frontendCrashPayload |
| 207 | if json.Unmarshal([]byte(detail), &payload) == nil && payload.SchemaVersion == 2 { |
| 208 | if payloadKind, ok := normalizeReportKind(payload.Kind); ok { |
| 209 | r.Kind = payloadKind |
| 210 | } |
| 211 | r.SchemaVersion = payload.SchemaVersion |
| 212 | r.Source = sanitizeCrashField(payload.Source, 32) |
| 213 | r.Label = sanitizeCrashField(payload.Label, 64) |
| 214 | r.ErrorType = sanitizeCrashField(payload.ErrorType, 128) |
| 215 | r.ErrorMessage = sanitizeCrashText(payload.ErrorMessage, maxCrashFieldBytes) |
| 216 | r.Stack = sanitizeCrashText(payload.Stack, maxCrashStackBytes) |
| 217 | r.ComponentStack = sanitizeCrashText(payload.ComponentStack, maxCrashStackBytes) |
| 218 | r.TopFrame = sanitizeCrashText(payload.TopFrame, 300) |
| 219 | r.FingerprintHint = sanitizeCrashText(payload.FingerprintHint, 300) |
| 220 | r.BuildCommit = sanitizeCrashField(payload.BuildCommit, 64) |
| 221 | r.Channel = sanitizeCrashField(payload.Channel, 32) |
| 222 | r.Language = sanitizeCrashField(payload.Language, 64) |
| 223 | r.View = sanitizeCrashText(payload.View, 200) |
| 224 | r.Breadcrumbs = sanitizeBreadcrumbs(payload.Breadcrumbs) |
| 225 | r.OccurredAt = sanitizeCrashField(payload.OccurredAt, 64) |
| 226 | r.Message = sanitizeCrashText(payload.Message, maxCrashDetailBytes) |
| 227 | if r.TopFrame == "" { |
| 228 | r.TopFrame = topFrameFromStack(r.Stack) |
| 229 | } |
| 230 | if r.Message == "" { |
| 231 | r.Message = sanitizeCrashText(fmt.Sprintf("[%s]\n\n%s", r.Label, r.ErrorMessage), maxCrashDetailBytes) |
| 232 | } |
| 233 | if r.Source == "" { |
| 234 | r.Source = "frontend" |
| 235 | } |
| 236 | r.Message = appendNativeResourceContext(r.Kind, r.Message) |
| 237 | return r, nil |
| 238 | } |
| 239 | |
| 240 | r.SchemaVersion = 1 |
| 241 | r.Source = "legacy" |
| 242 | r.Label = kind |
| 243 | r.Message = sanitizeCrashText(detail, maxCrashDetailBytes) |
| 244 | r.Message = appendNativeResourceContext(r.Kind, r.Message) |
| 245 | return r, nil |
| 246 | } |
| 247 | |
| 248 | func (a *App) ReportCrash(kind, detail string) error { |
| 249 | r, err := crashReportFromDetail(kind, detail) |
| 250 | if err != nil { |
| 251 | return err |
| 252 | } |
| 253 | c, err := httpClient() |
| 254 | if err != nil { |
| 255 | return err |
| 256 | } |
| 257 | return postCrashReport(a.reqCtx(), c, crashEndpoint, r) |
| 258 | } |
| 259 | |
| 260 | func postCrashReport(ctx context.Context, c *http.Client, endpoint string, r crashReport) error { |
| 261 | body, err := json.Marshal(r) |
| 262 | if err != nil { |
| 263 | return err |
| 264 | } |
| 265 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) |
| 266 | if err != nil { |
| 267 | return err |
| 268 | } |
| 269 | req.Header.Set("Content-Type", "application/json") |
| 270 | resp, err := c.Do(req) |
| 271 | if err != nil { |
| 272 | return err |
| 273 | } |
| 274 | defer resp.Body.Close() |
| 275 | if resp.StatusCode >= 300 { |
| 276 | return fmt.Errorf("crash endpoint returned %s", resp.Status) |
| 277 | } |
| 278 | return nil |
| 279 | } |
| 280 |