| 1 | import { DEVELOPMENT_FINGERPRINT_PREFIX } from "./diagnostics_v2"; |
| 2 | import type { ReportPayload } from "./report_schema"; |
| 3 | |
| 4 | const GROUP_PATH_RE = /^\/stats\/group\/((?:dev:)?[0-9a-f]{64})$/; |
| 5 | const RESIZE_OBSERVER_NOTICE_RE = /^ResizeObserver loop (?:limit exceeded|completed with undelivered notifications\.?)$/; |
| 6 | |
| 7 | export type SeverityInput = { |
| 8 | kind: string; |
| 9 | version?: string; |
| 10 | source: string; |
| 11 | label: string; |
| 12 | errorType: string; |
| 13 | errorMessage: string; |
| 14 | topFrame: string; |
| 15 | channel?: string; |
| 16 | recovery?: string; |
| 17 | }; |
| 18 | |
| 19 | type ParsedVersion = { |
| 20 | version: string; |
| 21 | major: number; |
| 22 | minor: number; |
| 23 | patch: number; |
| 24 | }; |
| 25 | |
| 26 | export type RegressionDecision = "none" | "historical" | "suspected" | "confirmed"; |
| 27 | |
| 28 | // One-line human summary for the dashboard list. Frontend reports are formatted |
| 29 | // "[label]\n\n<detail>", so a bare label alone is folded together with its detail. |
| 30 | export function crashTitle(message: string): string { |
| 31 | const lines = message |
| 32 | .split("\n") |
| 33 | .map((line) => line.trim()) |
| 34 | .filter(Boolean); |
| 35 | let head = lines[0] ?? ""; |
| 36 | if (/^\[[^\]]+\]$/.test(head) && lines[1]) head = `${head} ${lines[1]}`; |
| 37 | return head.slice(0, 200); |
| 38 | } |
| 39 | |
| 40 | export function isDevelopmentReport(input: SeverityInput): boolean { |
| 41 | const channel = input.channel?.trim().toLowerCase(); |
| 42 | return channel === "dev" || channel === "test" || input.version?.trim().toLowerCase().startsWith("dev") === true; |
| 43 | } |
| 44 | |
| 45 | export function reportSubjectIdentity(report: Pick<ReportPayload, "version" | "channel" | "diagnostics">): { |
| 46 | version: string; |
| 47 | channel: string; |
| 48 | } { |
| 49 | return { |
| 50 | version: report.diagnostics?.subjectVersion || report.version, |
| 51 | channel: report.diagnostics?.subjectChannel || report.channel || "", |
| 52 | }; |
| 53 | } |
| 54 | |
| 55 | export function namespaceReportFingerprint(hash: string, development: boolean): string { |
| 56 | return development ? `${DEVELOPMENT_FINGERPRINT_PREFIX}${hash}` : hash; |
| 57 | } |
| 58 | |
| 59 | export function groupFingerprintFromPath(path: string): string | null { |
| 60 | return path.match(GROUP_PATH_RE)?.[1] ?? null; |
| 61 | } |
| 62 | |
| 63 | export function isKnownNonCrashDiagnostic(input: SeverityInput): boolean { |
| 64 | const message = input.errorMessage.trim(); |
| 65 | return ( |
| 66 | RESIZE_OBSERVER_NOTICE_RE.test(message) || |
| 67 | /Minified React error #520\b/.test(message) || |
| 68 | message.includes("additional File object is not a file on the disk") |
| 69 | ); |
| 70 | } |
| 71 | |
| 72 | export function isOpaqueScriptErrorReport(input: SeverityInput): boolean { |
| 73 | return ( |
| 74 | input.kind === "crash" && |
| 75 | input.source === "frontend.global" && |
| 76 | input.label === "window.error" && |
| 77 | input.errorType === "string" && |
| 78 | input.errorMessage.trim() === "Script error." && |
| 79 | input.topFrame.trim() === "" |
| 80 | ); |
| 81 | } |
| 82 | |
| 83 | function severityForKind(kind: string): string { |
| 84 | if (kind === "crash") return "high"; |
| 85 | if (kind === "performance" || kind === "bot" || kind === "exception") return "medium"; |
| 86 | return "low"; |
| 87 | } |
| 88 | |
| 89 | export function severityForReport(input: SeverityInput): string { |
| 90 | if (isDevelopmentReport(input) || isOpaqueScriptErrorReport(input) || isKnownNonCrashDiagnostic(input)) return "low"; |
| 91 | if ((input.source === "web.runtime.native" || input.source === "webview2.process.native") && input.recovery === "reload_succeeded") return "low"; |
| 92 | if ((input.source === "web.runtime.native" || input.source === "webview2.process.native") && input.kind === "exception") return "high"; |
| 93 | return severityForKind(input.kind); |
| 94 | } |
| 95 | |
| 96 | export function severityRank(severity: string): number { |
| 97 | return ({ low: 1, medium: 2, high: 3, critical: 4 })[severity] ?? 0; |
| 98 | } |
| 99 | |
| 100 | export function maxSeverity(current: string, incoming: string): string { |
| 101 | return severityRank(incoming) > severityRank(current) ? incoming : current; |
| 102 | } |
| 103 | |
| 104 | function parseReleaseVersion(version: string): ParsedVersion | null { |
| 105 | // The latest lane is restricted to shipped stable builds. Development, |
| 106 | // prerelease, and build-metadata values remain visible only in facets. |
| 107 | const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/); |
| 108 | if (!match) return null; |
| 109 | return { |
| 110 | version, |
| 111 | major: Number(match[1]), |
| 112 | minor: Number(match[2]), |
| 113 | patch: Number(match[3]), |
| 114 | }; |
| 115 | } |
| 116 | |
| 117 | export function compareReleaseVersions(subject: string, fixedIn: string): number | null { |
| 118 | const subjectVersion = parseReleaseVersion(subject); |
| 119 | const fixedVersion = parseReleaseVersion(fixedIn); |
| 120 | if (!subjectVersion || !fixedVersion) return null; |
| 121 | return subjectVersion.major - fixedVersion.major || subjectVersion.minor - fixedVersion.minor || subjectVersion.patch - fixedVersion.patch; |
| 122 | } |
| 123 | |
| 124 | export function regressionDecisionForReport(input: { |
| 125 | status?: string; |
| 126 | fixedIn?: string; |
| 127 | resolutionPlatform?: string; |
| 128 | resolutionRuntime?: string; |
| 129 | subjectVersion: string; |
| 130 | os: string; |
| 131 | runtime: string; |
| 132 | }): RegressionDecision { |
| 133 | if (input.status !== "resolved") return "none"; |
| 134 | if (input.resolutionPlatform && input.resolutionPlatform !== input.os) return "none"; |
| 135 | if (input.resolutionRuntime && input.resolutionRuntime !== input.runtime) return "none"; |
| 136 | |
| 137 | const comparison = compareReleaseVersions(input.subjectVersion, input.fixedIn ?? ""); |
| 138 | if (comparison === null) return "suspected"; |
| 139 | return comparison < 0 ? "historical" : "confirmed"; |
| 140 | } |
| 141 | |
| 142 | export function newestReleaseVersion(versions: string[]): string { |
| 143 | const parsed = versions |
| 144 | .filter((version) => version && version.toLowerCase() !== "dev") |
| 145 | .map(parseReleaseVersion) |
| 146 | .filter((version): version is ParsedVersion => version !== null); |
| 147 | parsed.sort( |
| 148 | (left, right) => |
| 149 | right.major - left.major || |
| 150 | right.minor - left.minor || |
| 151 | right.patch - left.patch || |
| 152 | right.version.localeCompare(left.version), |
| 153 | ); |
| 154 | return parsed[0]?.version ?? ""; |
| 155 | } |
| 156 |