Skip to content

Commit e89a63a

Browse files
committed
Crash reports: per-row email, short ID, build mode
- New columns on `crash_reports` (D1): `build_mode` (`'release'` / `'debug'`, nullable) and `short_id` (`CRASH-XXXXX`, nullable + indexed). Both nullable so existing rows stay untouched. Two new migrations: `0003_crash_build_mode.sql`, `0004_crash_short_id.sql`. - Desktop crash file now carries `buildMode` and `shortId`. The short id is generated at write time via the shared `short_id` module and surfaced in the next-launch dialog so the user has something to reference when they reach out. - `POST /crash-report` accepts and validates both optional fields, persisting them through to D1. - Cron crash-notification email rewritten: one row per crash report instead of grouping by `top_function`. Columns: When, Env, ID, Site, Signal, Version, sorted newest-first. `'?'` for old rows that don't have `buildMode`/`shortId`. Renamed `CrashSummaryEntry` → `CrashEmailRow`. - Bindings regenerated. Tests updated to cover the new fields and the per-row email layout. - `crash_reporter` and api-server `CLAUDE.md` docs updated.
1 parent 7726082 commit e89a63a

12 files changed

Lines changed: 256 additions & 53 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- Distinguish dev-build crashes from production crashes in the email summary.
2+
-- Nullable: rows written before this column existed stay NULL.
3+
ALTER TABLE crash_reports ADD COLUMN build_mode TEXT;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- User-visible short ID surfaced in the crash dialog and the email summary.
2+
-- Nullable: rows written before this column existed stay NULL.
3+
ALTER TABLE crash_reports ADD COLUMN short_id TEXT;
4+
5+
CREATE INDEX idx_crash_reports_short_id ON crash_reports(short_id);

apps/api-server/src/crash-report.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,48 @@ describe('POST /crash-report', () => {
7676
expect(prepareCall).toContain('INSERT INTO crash_reports')
7777

7878
const bindArgs = bindMock.mock.calls[0]
79-
// bindArgs: [hashedIp, appVersion, osVersion, arch, signal, topFunction, backtraceTruncated]
79+
// bindArgs: [hashedIp, appVersion, osVersion, arch, signal, topFunction, backtraceTruncated, buildMode, shortId]
8080
expect(bindArgs[0]).toMatch(/^[0-9a-f]{64}$/) // SHA-256 hex
8181
expect(bindArgs[1]).toBe('1.2.3') // appVersion
8282
expect(bindArgs[2]).toBe('15.3.1') // osVersion
8383
expect(bindArgs[3]).toBe('arm64') // arch
8484
expect(bindArgs[4]).toBe('SIGSEGV') // signal
8585
expect(bindArgs[5]).toBe('cmdr::sync_status::get_ubiquitous_bool') // topFunction
86+
expect(bindArgs[7]).toBeNull() // buildMode (not supplied by validCrashReport)
87+
expect(bindArgs[8]).toBeNull() // shortId (not supplied by validCrashReport)
88+
})
89+
90+
it('stores buildMode and shortId when supplied', async () => {
91+
const { db, bindMock } = createMockD1()
92+
const bindings = createBindings({ TELEMETRY_DB: db })
93+
94+
const report = { ...validCrashReport, buildMode: 'debug', shortId: 'CRASH-A2345' }
95+
96+
await postCrashReport(report, bindings)
97+
98+
const bindArgs = bindMock.mock.calls[0]
99+
expect(bindArgs[7]).toBe('debug')
100+
expect(bindArgs[8]).toBe('CRASH-A2345')
101+
})
102+
103+
it('returns 400 for invalid buildMode', async () => {
104+
const bindings = createBindings()
105+
const report = { ...validCrashReport, buildMode: 'staging' }
106+
107+
const res = await postCrashReport(report, bindings)
108+
expect(res.status).toBe(400)
109+
const body = await res.json<{ error: string }>()
110+
expect(body.error).toBe('Invalid buildMode')
111+
})
112+
113+
it('returns 400 for malformed shortId', async () => {
114+
const bindings = createBindings()
115+
const report = { ...validCrashReport, shortId: 'CRASH-lowercase' }
116+
117+
const res = await postCrashReport(report, bindings)
118+
expect(res.status).toBe(400)
119+
const body = await res.json<{ error: string }>()
120+
expect(body.error).toBe('Invalid shortId')
86121
})
87122

88123
it('extracts the first cmdr frame as topFunction', async () => {

apps/api-server/src/email.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
import { Resend } from 'resend'
22
import type { LicenseType } from './license'
33

4-
export interface CrashSummaryEntry {
5-
topFunction: string
6-
count: number
7-
versions: string[]
8-
mostRecent: string
4+
/**
5+
* One row in the crash notification email. The email lists every crash report (no
6+
* grouping by `top_function` like the previous incarnation) so each row maps to a
7+
* single D1 row, with the short id letting the user trace it back.
8+
*/
9+
export interface CrashEmailRow {
10+
/** `created_at` in ISO 8601. */
11+
when: string
12+
/** Friendly env (`'prod'` for release, `'dev'` for debug, `'?'` for unknown). */
13+
env: 'prod' | 'dev' | '?'
14+
/** `CRASH-XXXXX`, or `'?'` for rows from older clients. */
15+
id: string
16+
/** `top_function`. */
17+
site: string
18+
signal: string
19+
version: string
920
}
1021

1122
interface CrashNotificationParams {
12-
crashes: CrashSummaryEntry[]
23+
crashes: CrashEmailRow[]
1324
totalCount: number
1425
to: string
1526
resendApiKey: string
@@ -23,10 +34,12 @@ export async function sendCrashNotificationEmail(params: CrashNotificationParams
2334
.map(
2435
(entry) => `
2536
<tr>
26-
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; font-family: monospace; font-size: 13px;">${escapeHtml(entry.topFunction)}</td>
27-
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; text-align: center;">${String(entry.count)}</td>
28-
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; font-size: 13px;">${escapeHtml(entry.versions.join(', '))}</td>
29-
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; font-size: 13px;">${escapeHtml(entry.mostRecent)}</td>
37+
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; font-size: 13px; white-space: nowrap;">${escapeHtml(entry.when)}</td>
38+
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; font-size: 13px; text-align: center;">${escapeHtml(entry.env)}</td>
39+
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; font-family: monospace; font-size: 13px;">${escapeHtml(entry.id)}</td>
40+
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; font-family: monospace; font-size: 13px;">${escapeHtml(entry.site)}</td>
41+
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; font-size: 13px;">${escapeHtml(entry.signal)}</td>
42+
<td style="padding: 8px 12px; border: 1px solid #e5e7eb; font-size: 13px;">${escapeHtml(entry.version)}</td>
3043
</tr>`,
3144
)
3245
.join('\n')
@@ -41,16 +54,18 @@ export async function sendCrashNotificationEmail(params: CrashNotificationParams
4154
<head>
4255
<meta charset="utf-8">
4356
</head>
44-
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
57+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 720px; margin: 0 auto; padding: 20px;">
4558
<h2 style="color: #dc2626;">${escapeHtml(subject)}</h2>
4659
4760
<table style="border-collapse: collapse; width: 100%; margin: 16px 0;">
4861
<thead>
4962
<tr>
50-
<th style="padding: 8px 12px; border: 1px solid #e5e7eb; text-align: left; background: #f9fafb;">Crash site</th>
51-
<th style="padding: 8px 12px; border: 1px solid #e5e7eb; text-align: center; background: #f9fafb;">Count</th>
52-
<th style="padding: 8px 12px; border: 1px solid #e5e7eb; text-align: left; background: #f9fafb;">Versions</th>
53-
<th style="padding: 8px 12px; border: 1px solid #e5e7eb; text-align: left; background: #f9fafb;">Most recent</th>
63+
<th style="padding: 8px 12px; border: 1px solid #e5e7eb; text-align: left; background: #f9fafb;">When</th>
64+
<th style="padding: 8px 12px; border: 1px solid #e5e7eb; text-align: center; background: #f9fafb;">Env</th>
65+
<th style="padding: 8px 12px; border: 1px solid #e5e7eb; text-align: left; background: #f9fafb;">ID</th>
66+
<th style="padding: 8px 12px; border: 1px solid #e5e7eb; text-align: left; background: #f9fafb;">Site</th>
67+
<th style="padding: 8px 12px; border: 1px solid #e5e7eb; text-align: left; background: #f9fafb;">Signal</th>
68+
<th style="padding: 8px 12px; border: 1px solid #e5e7eb; text-align: left; background: #f9fafb;">Version</th>
5469
</tr>
5570
</thead>
5671
<tbody>

apps/api-server/src/scheduled.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ beforeEach(() => {
7777
})
7878

7979
describe('handleCrashNotifications', () => {
80-
it('sends email for un-notified crash reports', async () => {
80+
it('sends one row per un-notified crash report', async () => {
8181
const responses = new Map<string, unknown>([
8282
[
8383
'SELECT id',
@@ -91,6 +91,8 @@ describe('handleCrashNotifications', () => {
9191
signal: 'SIGSEGV',
9292
top_function: 'cmdr::sync::run',
9393
created_at: '2026-03-23T10:00:00Z',
94+
build_mode: 'release',
95+
short_id: 'CRASH-A2345',
9496
},
9597
{
9698
id: 2,
@@ -100,6 +102,8 @@ describe('handleCrashNotifications', () => {
100102
signal: 'SIGSEGV',
101103
top_function: 'cmdr::sync::run',
102104
created_at: '2026-03-23T11:00:00Z',
105+
build_mode: 'debug',
106+
short_id: 'CRASH-B6789',
103107
},
104108
{
105109
id: 3,
@@ -109,6 +113,8 @@ describe('handleCrashNotifications', () => {
109113
signal: 'SIGABRT',
110114
top_function: 'cmdr_lib::indexer::build',
111115
created_at: '2026-03-23T12:00:00Z',
116+
build_mode: null,
117+
short_id: null,
112118
},
113119
],
114120
},
@@ -125,8 +131,17 @@ describe('handleCrashNotifications', () => {
125131
expect(emailCall.subject).toBe('Cmdr: 3 new crash reports')
126132
expect(emailCall.to).toBe('test@example.com')
127133
expect(emailCall.from).toBe('Cmdr Crash Alerts <noreply@getcmdr.com>')
134+
// Per-row rendering — each crash shows up with its top_function and short id.
128135
expect(emailCall.html).toContain('cmdr::sync::run')
129136
expect(emailCall.html).toContain('cmdr_lib::indexer::build')
137+
expect(emailCall.html).toContain('CRASH-A2345')
138+
expect(emailCall.html).toContain('CRASH-B6789')
139+
// Env column shows friendly labels.
140+
expect(emailCall.html).toContain('>prod<')
141+
expect(emailCall.html).toContain('>dev<')
142+
// Row 3 has neither build_mode nor short_id — both render as `?`.
143+
const questionMarkCells = emailCall.html.match(/>\?</g)?.length ?? 0
144+
expect(questionMarkCells).toBeGreaterThanOrEqual(2)
130145

131146
// Verify rows were marked as notified (UPDATE query was called)
132147
const updateCall = calls.find((c) => c.sql.includes('UPDATE crash_reports'))
@@ -153,6 +168,8 @@ describe('handleCrashNotifications', () => {
153168
signal: 'SIGSEGV',
154169
top_function: 'cmdr::sync::run',
155170
created_at: '2026-03-23T10:00:00Z',
171+
build_mode: 'release',
172+
short_id: 'CRASH-A2345',
156173
},
157174
],
158175
},

apps/api-server/src/scheduled.ts

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
1-
import { sendCrashNotificationEmail, sendDbSizeAlert, type CrashSummaryEntry } from './email'
1+
import { sendCrashNotificationEmail, sendDbSizeAlert, type CrashEmailRow } from './email'
22
import type { Bindings } from './types'
33
import { recomputeTotal, tryEvict, EVICTION_HIGH_WATERMARK } from './error-report-eviction'
44
import { postEvictionNotification } from './discord'
55

66
const dbSizeThresholdBytes = 100 * 1024 * 1024 // 100 MB
77

8+
/** Map the DB `build_mode` column to the friendly `prod`/`dev` label users see. */
9+
function buildModeToEnv(buildMode: string | null | undefined): 'prod' | 'dev' | '?' {
10+
if (buildMode === 'release') return 'prod'
11+
if (buildMode === 'debug') return 'dev'
12+
return '?'
13+
}
14+
815
async function handleCrashNotifications(env: Bindings): Promise<void> {
916
if (!env.CRASH_NOTIFICATION_EMAIL || !env.RESEND_API_KEY) return
1017

18+
// One row per crash, newest first. No grouping — the email shows every report.
1119
const { results } = await env.TELEMETRY_DB.prepare(
12-
`SELECT id, app_version, os_version, arch, signal, top_function, created_at
13-
FROM crash_reports WHERE notified_at IS NULL`,
20+
`SELECT id, app_version, os_version, arch, signal, top_function, created_at, build_mode, short_id
21+
FROM crash_reports
22+
WHERE notified_at IS NULL
23+
ORDER BY created_at DESC`,
1424
).all<{
1525
id: number
1626
app_version: string
@@ -19,32 +29,19 @@ async function handleCrashNotifications(env: Bindings): Promise<void> {
1929
signal: string
2030
top_function: string
2131
created_at: string
32+
build_mode: string | null
33+
short_id: string | null
2234
}>()
2335

2436
if (results.length === 0) return
2537

26-
// Group by top_function
27-
const grouped = new Map<string, { count: number; versions: Set<string>; mostRecent: string }>()
28-
for (const row of results) {
29-
const existing = grouped.get(row.top_function)
30-
if (existing) {
31-
existing.count++
32-
existing.versions.add(row.app_version)
33-
if (row.created_at > existing.mostRecent) existing.mostRecent = row.created_at
34-
} else {
35-
grouped.set(row.top_function, {
36-
count: 1,
37-
versions: new Set([row.app_version]),
38-
mostRecent: row.created_at,
39-
})
40-
}
41-
}
42-
43-
const crashes: CrashSummaryEntry[] = [...grouped.entries()].map(([topFunction, data]) => ({
44-
topFunction,
45-
count: data.count,
46-
versions: [...data.versions],
47-
mostRecent: data.mostRecent,
38+
const crashes: CrashEmailRow[] = results.map((row) => ({
39+
when: row.created_at,
40+
env: buildModeToEnv(row.build_mode),
41+
id: row.short_id ?? '?',
42+
site: row.top_function,
43+
signal: row.signal,
44+
version: row.app_version,
4845
}))
4946

5047
const ids = results.map((r) => r.id)

apps/api-server/src/telemetry.ts

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,16 @@ interface CrashReport {
1313
osVersion: string
1414
arch: string
1515
signal: string
16+
/** Optional. `"release"` or `"debug"`; older clients don't set it (stored as NULL). */
17+
buildMode?: 'release' | 'debug'
18+
/** Optional. `CRASH-XXXXX` short ID generated by the desktop client. */
19+
shortId?: string
1620
backtraceFrames?: string[]
1721
[key: string]: unknown
1822
}
1923

24+
const crashShortIdPattern = /^CRASH-[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{5}$/
25+
2026
/** Extract the first app-code frame from a backtrace (contains `cmdr` or `cmdr_lib`). */
2127
function extractTopFunction(frames: string[] | undefined): string {
2228
if (!frames || !Array.isArray(frames)) return 'unknown'
@@ -28,6 +34,31 @@ function extractTopFunction(frames: string[] | undefined): string {
2834
return 'unknown'
2935
}
3036

37+
/**
38+
* Validate the runtime shape of a `POST /crash-report` body. Returns `null` if the
39+
* body is well-formed; otherwise returns the error message to surface as 400. We
40+
* type the input as `Record<string, unknown>` (not `CrashReport`) so the optional-
41+
* field checks aren't statically narrowed away — values arrive from `JSON.parse`
42+
* and can be any shape an attacker chooses.
43+
*/
44+
function validateCrashReportShape(report: Record<string, unknown>): string | null {
45+
for (const field of crashReportRequiredFields) {
46+
const value = report[field]
47+
if (typeof value !== 'string' || value.length === 0) {
48+
return `Missing required field: ${field}`
49+
}
50+
}
51+
const buildMode = report.buildMode
52+
if (buildMode !== undefined && buildMode !== 'release' && buildMode !== 'debug') {
53+
return 'Invalid buildMode'
54+
}
55+
const shortId = report.shortId
56+
if (shortId !== undefined && (typeof shortId !== 'string' || !crashShortIdPattern.test(shortId))) {
57+
return 'Invalid shortId'
58+
}
59+
return null
60+
}
61+
3162
telemetry.post('/crash-report', async (c) => {
3263
// Reject oversized payloads before parsing
3364
const contentLength = c.req.header('content-length')
@@ -46,19 +77,21 @@ telemetry.post('/crash-report', async (c) => {
4677
return c.json({ error: 'Report too large' }, 400)
4778
}
4879

49-
let report: CrashReport
80+
let rawReport: unknown
5081
try {
51-
report = JSON.parse(rawBody) as CrashReport
82+
rawReport = JSON.parse(rawBody)
5283
} catch {
5384
return c.json({ error: 'Invalid JSON' }, 400)
5485
}
86+
if (!rawReport || typeof rawReport !== 'object') {
87+
return c.json({ error: 'Invalid JSON' }, 400)
88+
}
5589

56-
// Validate required fields
57-
for (const field of crashReportRequiredFields) {
58-
if (typeof report[field] !== 'string' || report[field].length === 0) {
59-
return c.json({ error: `Missing required field: ${field}` }, 400)
60-
}
90+
const validationError = validateCrashReportShape(rawReport as Record<string, unknown>)
91+
if (validationError) {
92+
return c.json({ error: validationError }, 400)
6193
}
94+
const report = rawReport as CrashReport
6295

6396
// Hash IP with daily salt for deduplication (same pattern as update-check)
6497
const ip = c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? 'unknown'
@@ -69,12 +102,23 @@ telemetry.post('/crash-report', async (c) => {
69102
const topFunction = extractTopFunction(report.backtraceFrames)
70103
const backtraceTruncated = JSON.stringify(report.backtraceFrames ?? []).slice(0, maxBacktraceBytes)
71104

72-
// Write to D1 (fire-and-forget)
105+
// Write to D1 (fire-and-forget). `build_mode` and `short_id` are nullable —
106+
// rows from older clients stay NULL.
73107
const dbWrite = c.env.TELEMETRY_DB.prepare(
74-
`INSERT INTO crash_reports (hashed_ip, app_version, os_version, arch, signal, top_function, backtrace)
75-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
108+
`INSERT INTO crash_reports (hashed_ip, app_version, os_version, arch, signal, top_function, backtrace, build_mode, short_id)
109+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
76110
)
77-
.bind(hashedIp, report.appVersion, report.osVersion, report.arch, report.signal, topFunction, backtraceTruncated)
111+
.bind(
112+
hashedIp,
113+
report.appVersion,
114+
report.osVersion,
115+
report.arch,
116+
report.signal,
117+
topFunction,
118+
backtraceTruncated,
119+
report.buildMode ?? null,
120+
report.shortId ?? null,
121+
)
78122
.run()
79123
.catch(() => {}) // Don't let D1 failure block the response
80124

apps/desktop/src-tauri/src/crash_reporter/CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ Both paths write to `crash-report.json` in the app data dir (same dir as `settin
4747
- Sanitized panic message
4848
- Active feature flags (booleans/enums only: `indexing.enabled`, `ai.provider`, `developer.mcpEnabled`,
4949
`developer.verboseLogging`)
50+
- `buildMode` (`"release"` or `"debug"`, from `cfg!(debug_assertions)`) — lets the api server distinguish dev-run
51+
crashes from production ones in the email summary
52+
- `shortId` (`CRASH-XXXXX`) — generated at crash-file-write time via [`crate::short_id::generate("CRASH")`]
53+
(shared with error reports). Shown to the user in the next-launch dialog so they can reference the report.
5054

5155
## What we never send
5256

0 commit comments

Comments
 (0)