Skip to content

Commit 7726082

Browse files
committed
Error reports: stable client id, env-aware R2 keys
- Drop server-side `ERR-XXXXX` regeneration. The api server now validates and reuses the client-supplied `meta.id` so the dialog preview, upload toast, and Discord embed all show the same id. The trailing UUID in the R2 key already guarantees object uniqueness; on the astronomically rare collision we retry with a fresh UUID, never a fresh id. - New R2 key shape: `error-reports/{prod|dev}/{yyyy-mm-dd}/{ERR-XXXXX}-{uuid}.zip`. Env first (release → `prod`, debug → `dev`) so dev-run reports sort separately. Legacy keys without the env segment still aged out via the 90-day R2 lifecycle; `extractDateSegment` reads the date out of both shapes so eviction keeps working oldest-first. - Discord title prefix is now explicit both ways: `[DEV]` for debug builds, `[PROD]` for release. - Reject malformed/missing `meta.id` with 400. Added tests for the stable id flow, the `dev/` key shape, and the new validation paths. - Update `apps/api-server/CLAUDE.md` with the new key shape and id behavior.
1 parent e181036 commit 7726082

7 files changed

Lines changed: 174 additions & 47 deletions

File tree

apps/api-server/CLAUDE.md

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,11 @@ Cron (every 12h): scheduled handler runs three jobs:
190190
A single `scheduled` handler runs every 12 hours (`0 */12 * * *`). It runs three independent jobs, each in its own
191191
try-catch so one failure doesn't block the others:
192192

193-
1. **Crash notifications** (every invocation): queries `crash_reports WHERE notified_at IS NULL`, groups by
194-
`top_function`, marks rows as notified, then sends a summary email via Resend. Marks before sending to prefer missed
195-
notifications over duplicates. Requires `CRASH_NOTIFICATION_EMAIL` and `RESEND_API_KEY`.
193+
1. **Crash notifications** (every invocation): queries `crash_reports WHERE notified_at IS NULL`, sorted newest-first,
194+
marks rows as notified, then sends an email via Resend with one row per crash report (When, Env, ID, Site, Signal,
195+
Version). Marks before sending to prefer missed notifications over duplicates. Pre-fix-\* this grouped by
196+
`top_function`; the per-row layout is easier to scan and includes the user-visible `CRASH-XXXXX` id. Requires
197+
`CRASH_NOTIFICATION_EMAIL` and `RESEND_API_KEY`.
196198

197199
2. **Daily aggregation** (00:00 UTC only): aggregates yesterday's `update_checks` into `daily_active_users` via
198200
`INSERT OR IGNORE ... GROUP BY`, then prunes raw update checks older than 7 days. Idempotent via existence check.
@@ -253,9 +255,10 @@ SHA-256 + daily salt for deduplication without storing PII. D1 write is fire-and
253255

254256
**Crash report tracking:** Uses D1 (binding: `TELEMETRY_DB`, table: `crash_reports`). Receives crash reports from the
255257
desktop app via `POST /crash-report`. Columns: hashed_ip, app_version, os_version, arch, signal, top_function,
256-
backtrace. IP is hashed with SHA-256 + daily salt (same pattern as update checks). Validates payload size (max 64 KB)
257-
and required fields before writing. D1 write is fire-and-forget via `waitUntil` + `.catch(() => {})`. No authentication
258-
required.
258+
backtrace, build_mode (`'release'` / `'debug'`, nullable for legacy rows), short_id (`CRASH-XXXXX`, nullable for legacy
259+
rows). IP is hashed with SHA-256 + daily salt (same pattern as update checks). Validates payload size (max 64 KB),
260+
required fields, and the shape of optional fields before writing. D1 write is fire-and-forget via `waitUntil` +
261+
`.catch(() => {})`. No authentication required.
259262

260263
**Device tracking (fair use):** On each `/validate` call with a `deviceId`, the server tracks the device in KV
261264
(`devices:{seatTransactionId}`) and logs to Analytics Engine (binding: `DEVICE_COUNTS`, dataset: `cmdr_device_counts`).
@@ -269,12 +272,18 @@ and its own 6-device allowance.
269272
licensed). Without this, there's no signal for how many people actually run the app — Umami only tracks website visitors
270273
and download tracking only captures installs.
271274

275+
**Error report R2 key shape:** `error-reports/{prod|dev}/{yyyy-mm-dd}/{ERR-XXXXX}-{uuid}.zip`. The env segment (`prod`
276+
for release builds, `dev` for debug builds, inferred from `meta.buildMode`) keeps dev-run reports out of the production
277+
sort order. Legacy keys (`error-reports/{yyyy-mm-dd}/...` — pre-env-prefix) still exist; eviction reads the date segment
278+
via `extractDateSegment` which handles both shapes. The 90-day R2 lifecycle drains the legacy shape naturally — there's
279+
no migration.
280+
272281
**Error report eviction (8/6 GB watermarks + lifecycle):** Three layers keep the bucket bounded.
273282

274283
1. **On-upload eviction**: every `POST /error-report` schedules `tryEvict` in `waitUntil(...)`. If `total_bytes` (KV) >
275284
8 GB and `eviction_in_progress` (KV, 60-s TTL lock) isn't set, lists R2 objects under `error-reports/`, sorts
276-
oldest-first by date prefix in the key (`yyyy-mm-dd`) then by `uploaded`, deletes until ≤ 6 GB, then resets the
277-
counter to the recomputed ground truth.
285+
oldest-first by the embedded `yyyy-mm-dd` segment (via `extractDateSegment`, which handles both new and legacy key
286+
shapes) then by `uploaded`, deletes until ≤ 6 GB, then resets the counter to the recomputed ground truth.
278287
2. **Daily cron sweep**: corrects KV drift by recomputing from R2 and re-running `tryEvict`.
279288
3. **R2 lifecycle rule**: 90-day expiration applied at provisioning time via `scripts/setup-cf-infra.sh`.
280289

@@ -289,7 +298,10 @@ for presigned URLs. Convenience of click-to-download outweighs leak risk because
289298

290299
**Short ID generation:** `generateShortId(prefix, len)` in `license.ts` produces IDs like `ERR-A2345` from the same
291300
unambiguous alphabet (`23456789ABCDEFGHJKMNPQRSTUVWXYZ`) as license short codes. Rejection sampling avoids modulo bias.
292-
The error report route retries up to 3 times on R2 HEAD collision.
301+
The error report route does NOT regenerate the id server-side — it validates the client-supplied `meta.id` against the
302+
shape `^ERR-[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{5}$` and uses it as-is. On the astronomically rare R2 key collision (same
303+
id + same date + UUID clash), the route retries with a fresh UUID — never a fresh id — so the user-visible id from the
304+
preview dialog stays stable through to the toast.
293305

294306
## Local development
295307

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ describe('buildErrorReportPayload', () => {
7373
"value": "[Download bundle](https://example.com/bundle.zip?sig=abc) (link valid 7 days)",
7474
},
7575
],
76-
"title": "Error report ERR-A2345",
76+
"title": "[PROD] Error report ERR-A2345",
7777
},
7878
],
7979
}
@@ -106,9 +106,9 @@ describe('buildErrorReportPayload', () => {
106106
expect(payload.embeds[0].title).toBe('[DEV] Error report ERR-A2345')
107107
})
108108

109-
it('does not prefix the title when buildMode is release', () => {
109+
it('prefixes the title with [PROD] when buildMode is release', () => {
110110
const payload = buildErrorReportPayload(baseNotification) as { embeds: { title: string }[] }
111-
expect(payload.embeds[0].title).toBe('Error report ERR-A2345')
111+
expect(payload.embeds[0].title).toBe('[PROD] Error report ERR-A2345')
112112
})
113113
})
114114

apps/api-server/src/discord.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ export interface ErrorReportNotification {
1010
id: string
1111
kind: 'user' | 'auto'
1212
/**
13-
* Forwarded from the manifest. `'debug'` reports come from a dev build of the
14-
* desktop app (`cfg!(debug_assertions)`), and we prefix the embed title with
15-
* `[DEV]` so triage can tell them apart from production traffic at a glance.
16-
* Defaults to `'release'` upstream when older clients don't set it.
13+
* Forwarded from the manifest. The embed title gets `[DEV]` for debug builds
14+
* (`cfg!(debug_assertions)`) and `[PROD]` for release builds so triage can tell
15+
* them apart at a glance regardless of channel. Defaults to `'release'` upstream
16+
* when older clients don't set it.
1717
*/
1818
buildMode: 'release' | 'debug'
1919
appVersion: string
@@ -67,7 +67,7 @@ export function buildErrorReportPayload(n: ErrorReportNotification): unknown {
6767
fields.push({ name: 'User note', value: truncatedNote })
6868
}
6969

70-
const titlePrefix = n.buildMode === 'debug' ? '[DEV] ' : ''
70+
const titlePrefix = n.buildMode === 'debug' ? '[DEV] ' : '[PROD] '
7171
return {
7272
embeds: [
7373
{

apps/api-server/src/error-report-eviction.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,43 @@ describe('tryEvict', () => {
192192
expect(result.newTotal).toBe(3 * GB)
193193
})
194194

195+
it('sorts both legacy and new key shapes by the embedded date', async () => {
196+
// Mixed shapes: legacy (no env segment) vs new (`prod/`). The legacy 2026-01-01
197+
// entry should evict before the newer `prod/2026-03-01` entry, even though the
198+
// raw key strings compare differently.
199+
const objs: StubObj[] = [
200+
{
201+
key: `${ERROR_REPORT_PREFIX}prod/2026-03-01/ERR-NEWER-u.zip`,
202+
size: 2 * GB,
203+
uploaded: new Date('2026-03-01'),
204+
},
205+
{
206+
key: `${ERROR_REPORT_PREFIX}2026-01-01/ERR-OLDER-u.zip`,
207+
size: 2 * GB,
208+
uploaded: new Date('2026-01-01'),
209+
},
210+
{
211+
key: `${ERROR_REPORT_PREFIX}dev/2026-04-01/ERR-EVEN-NEWER-u.zip`,
212+
size: 1 * GB,
213+
uploaded: new Date('2026-04-01'),
214+
},
215+
]
216+
const bucket = createR2(objs)
217+
const kv = createKv({ [TOTAL_BYTES_KEY]: String(5 * GB) })
218+
219+
await tryEvict(
220+
{ ERROR_REPORTS_BUCKET: bucket, ERROR_REPORT_META: kv },
221+
{ highWatermark: 4 * GB, lowWatermark: 3 * GB },
222+
)
223+
224+
const remaining = await bucket.list({ prefix: ERROR_REPORT_PREFIX })
225+
// Oldest by embedded date (2026-01-01) should be the one gone.
226+
expect(remaining.objects.map((o) => o.key).sort()).toEqual([
227+
`${ERROR_REPORT_PREFIX}dev/2026-04-01/ERR-EVEN-NEWER-u.zip`,
228+
`${ERROR_REPORT_PREFIX}prod/2026-03-01/ERR-NEWER-u.zip`,
229+
])
230+
})
231+
195232
it('evicts oldest by key date prefix, then upload time for ties', async () => {
196233
const objs: StubObj[] = [
197234
// Same day — two uploads with different upload times

apps/api-server/src/error-report-eviction.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import type { Bindings } from './types'
1111
* - `total_bytes` — running byte total across all bundles. Approximate, drift-prone.
1212
* - `eviction_in_progress` — short-lived (60 s TTL) lock to prevent concurrent eviction.
1313
*
14-
* The R2 key shape is `error-reports/{yyyy-mm-dd}/{ERR-XXXXX}-{uuid}.zip`.
14+
* The R2 key shape is `error-reports/{prod|dev}/{yyyy-mm-dd}/{ERR-XXXXX}-{uuid}.zip`.
15+
*
16+
* Legacy shape (still present in the bucket; aged out via the 90-day R2 lifecycle):
17+
* `error-reports/{yyyy-mm-dd}/{ERR-XXXXX}-{uuid}.zip`. Eviction sorts oldest-first
18+
* via {@link extractDateSegment}, which handles both shapes.
1519
*/
1620

1721
/** Eviction starts when total bytes exceed this. */
@@ -70,6 +74,25 @@ interface ListedObject {
7074
uploaded: Date
7175
}
7276

77+
/**
78+
* Pull the `yyyy-mm-dd` date segment out of an R2 key. Handles both shapes:
79+
* - new: `error-reports/{prod|dev}/yyyy-mm-dd/{id}-{uuid}.zip`
80+
* - legacy: `error-reports/yyyy-mm-dd/{id}-{uuid}.zip`
81+
*
82+
* Returns the empty string when the key matches neither shape — that pushes
83+
* unrecognized keys to the front of an ascending sort, so they get evicted first.
84+
*/
85+
export function extractDateSegment(key: string): string {
86+
if (!key.startsWith(ERROR_REPORT_PREFIX)) return ''
87+
const rest = key.slice(ERROR_REPORT_PREFIX.length)
88+
const segments = rest.split('/')
89+
// Pick the first segment that looks like a date. Both shapes have exactly one.
90+
for (const segment of segments) {
91+
if (/^\d{4}-\d{2}-\d{2}$/.test(segment)) return segment
92+
}
93+
return ''
94+
}
95+
7396
/** Fetch every object under the prefix into memory. R2 list page = 1000 objects max. */
7497
async function listAllObjects(bucket: R2Bucket): Promise<ListedObject[]> {
7598
const out: ListedObject[] = []
@@ -109,11 +132,15 @@ export async function tryEvict(
109132
let freedBytes = 0
110133
try {
111134
const all = await listAllObjects(env.ERROR_REPORTS_BUCKET)
112-
// Sort oldest first. Date prefix in the key is the primary signal (yyyy-mm-dd
113-
// sorts lexically); R2 `uploaded` breaks ties for same-day uploads.
135+
// Sort oldest first. The date segment inside the key is the primary signal
136+
// (yyyy-mm-dd sorts lexically) — extracted via `extractDateSegment` so the
137+
// sort works across both the new `{env}/{date}` layout and the legacy
138+
// `{date}` layout. R2 `uploaded` breaks ties for same-day uploads.
114139
all.sort((a, b) => {
115-
if (a.key < b.key) return -1
116-
if (a.key > b.key) return 1
140+
const da = extractDateSegment(a.key)
141+
const db = extractDateSegment(b.key)
142+
if (da < db) return -1
143+
if (da > db) return 1
117144
return a.uploaded.getTime() - b.uploaded.getTime()
118145
})
119146

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

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ function createR2(): R2Bucket & { _store: Map<string, StoredObj> } {
1313
const store = new Map<string, StoredObj>()
1414
return {
1515
_store: store,
16+
head: (key: string) => Promise.resolve(store.has(key) ? ({ key } as unknown as R2Object) : null),
1617
put: async (
1718
key: string,
1819
value: ReadableStream | ArrayBuffer | Uint8Array | string,
@@ -121,6 +122,7 @@ function buildMultipart(bundleBytes: Uint8Array, meta: unknown, bundleName = 'bu
121122
}
122123

123124
const validMeta = {
125+
id: 'ERR-A2345',
124126
kind: 'user' as const,
125127
appVersion: '0.13.0',
126128
osVersion: '15.3.1',
@@ -138,18 +140,18 @@ afterEach(() => {
138140
})
139141

140142
describe('POST /error-report', () => {
141-
it('returns 200 with an ERR-XXXXX id on a valid upload', async () => {
143+
it('returns 200 echoing the client-supplied id on a valid upload', async () => {
142144
const bindings = createBindings()
143145
const fd = buildMultipart(new Uint8Array([1, 2, 3, 4]), validMeta)
144146

145147
const res = await app.request('/error-report', { method: 'POST', body: fd }, bindings)
146148

147149
expect(res.status).toBe(200)
148150
const body = await res.json<{ id: string }>()
149-
expect(body.id).toMatch(/^ERR-[23456789A-HJ-NP-Z]{5}$/)
151+
expect(body.id).toBe(validMeta.id)
150152
})
151153

152-
it('writes the bundle to R2 with the expected key shape and metadata', async () => {
154+
it('writes the bundle to R2 with the new env/date key shape and metadata', async () => {
153155
const bucket = createR2()
154156
const bindings = createBindings({ ERROR_REPORTS_BUCKET: bucket })
155157
const fd = buildMultipart(new Uint8Array([9, 9, 9]), validMeta)
@@ -158,7 +160,8 @@ describe('POST /error-report', () => {
158160
const { id } = await res.json<{ id: string }>()
159161

160162
const [[key, obj]] = [...bucket._store.entries()]
161-
expect(key).toMatch(new RegExp(`^error-reports/\\d{4}-\\d{2}-\\d{2}/${id}-[0-9a-f-]{36}\\.zip$`))
163+
// Default `validMeta` has no `buildMode`, which is treated as `'release'` → `prod`.
164+
expect(key).toMatch(new RegExp(`^error-reports/prod/\\d{4}-\\d{2}-\\d{2}/${id}-[0-9a-f-]{36}\\.zip$`))
162165
expect(obj.customMetadata).toMatchObject({
163166
id,
164167
kind: 'user',
@@ -170,6 +173,41 @@ describe('POST /error-report', () => {
170173
expect(obj.size).toBe(3)
171174
})
172175

176+
it('places debug-build uploads under the `dev/` env prefix', async () => {
177+
const bucket = createR2()
178+
const bindings = createBindings({ ERROR_REPORTS_BUCKET: bucket })
179+
const fd = buildMultipart(new Uint8Array([1, 2, 3]), { ...validMeta, buildMode: 'debug' })
180+
181+
await app.request('/error-report', { method: 'POST', body: fd }, bindings)
182+
183+
const [[key]] = [...bucket._store.entries()]
184+
expect(key).toMatch(new RegExp(`^error-reports/dev/\\d{4}-\\d{2}-\\d{2}/${validMeta.id}-[0-9a-f-]{36}\\.zip$`))
185+
})
186+
187+
it('returns 400 when the meta id is missing', async () => {
188+
const bindings = createBindings()
189+
const { id: _id, ...rest } = validMeta
190+
void _id
191+
const fd = buildMultipart(new Uint8Array([1]), rest)
192+
193+
const res = await app.request('/error-report', { method: 'POST', body: fd }, bindings)
194+
195+
expect(res.status).toBe(400)
196+
const body = await res.json<{ error: string }>()
197+
expect(body.error).toBe('Invalid meta shape')
198+
})
199+
200+
it('returns 400 when the meta id is malformed', async () => {
201+
const bindings = createBindings()
202+
const fd = buildMultipart(new Uint8Array([1]), { ...validMeta, id: 'ERR-LOWER' })
203+
204+
const res = await app.request('/error-report', { method: 'POST', body: fd }, bindings)
205+
206+
expect(res.status).toBe(400)
207+
const body = await res.json<{ error: string }>()
208+
expect(body.error).toBe('Invalid meta shape')
209+
})
210+
173211
it('returns 413 for a bundle over 10 MB', async () => {
174212
const bindings = createBindings()
175213
// 11 MB of 0s

apps/api-server/src/error-report.ts

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Hono } from 'hono'
22
import { AwsClient } from 'aws4fetch'
33
import type { Bindings } from './types'
4-
import { generateShortId } from './license'
54
import {
65
ERROR_REPORT_PREFIX,
76
incrementTotalBytes,
@@ -14,13 +13,23 @@ import { postErrorReportNotification, postEvictionNotification } from './discord
1413
const errorReport = new Hono<{ Bindings: Bindings }>()
1514

1615
const MAX_BUNDLE_BYTES = 10 * 1024 * 1024 // 10 MB hard cap on the multipart payload
17-
const SHORT_ID_LEN = 5
18-
const SHORT_ID_PREFIX = 'ERR'
19-
const COLLISION_RETRY_LIMIT = 3
2016
const PRESIGN_TTL_SECONDS = 7 * 24 * 60 * 60 // R2 max for presigned URLs
2117
const DEFAULT_BUCKET_NAME = 'cmdr-error-reports'
2218

19+
/**
20+
* Matches the client-side `ERR-XXXXX` short ID produced by
21+
* `error_reporter::generate_short_id` (alphabet kept in sync in
22+
* `apps/desktop/src-tauri/src/short_id.rs`).
23+
*/
24+
const SHORT_ID_PATTERN = /^ERR-[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{5}$/
25+
2326
export interface ErrorReportMeta {
27+
/**
28+
* Client-generated `ERR-XXXXX` shown in the UI before upload. The server uses this
29+
* id as-is — the trailing UUID in the R2 key guarantees object uniqueness, so we
30+
* never regenerate. The server validates the shape and rejects malformed ids.
31+
*/
32+
id: string
2433
kind: 'user' | 'auto'
2534
/**
2635
* Set by the desktop client from `cfg!(debug_assertions)`. `'debug'` reports
@@ -40,6 +49,7 @@ export interface ErrorReportMeta {
4049
function isValidMeta(value: unknown): value is ErrorReportMeta {
4150
if (!value || typeof value !== 'object') return false
4251
const v = value as Record<string, unknown>
52+
if (typeof v['id'] !== 'string' || !SHORT_ID_PATTERN.test(v['id'])) return false
4353
if (v['kind'] !== 'user' && v['kind'] !== 'auto') return false
4454
for (const k of ['appVersion', 'osVersion', 'arch', 'generatedAt']) {
4555
const val = v[k]
@@ -54,19 +64,18 @@ function todayDatePrefix(): string {
5464
return new Date().toISOString().slice(0, 10) // YYYY-MM-DD
5565
}
5666

57-
/** Pick a fresh `ERR-XXXXX` short ID, retrying on R2 HEAD collision (rare). */
58-
async function generateUniqueId(bucket: R2Bucket, datePrefix: string): Promise<string | null> {
59-
for (let i = 0; i < COLLISION_RETRY_LIMIT; i++) {
60-
const id = generateShortId(SHORT_ID_PREFIX, SHORT_ID_LEN)
61-
// Check anything already starting with this ID under today's prefix.
62-
const list = await bucket.list({ prefix: `${ERROR_REPORT_PREFIX}${datePrefix}/${id}-`, limit: 1 })
63-
if (list.objects.length === 0) return id
64-
}
65-
return null
67+
/** `'prod'` for release builds, `'dev'` for debug. Friendlier than `release`/`debug` for ops. */
68+
function envSegment(buildMode: 'release' | 'debug' | undefined): 'prod' | 'dev' {
69+
return buildMode === 'debug' ? 'dev' : 'prod'
6670
}
6771

68-
function buildR2Key(datePrefix: string, id: string, uuid: string): string {
69-
return `${ERROR_REPORT_PREFIX}${datePrefix}/${id}-${uuid}.zip`
72+
/**
73+
* R2 key shape: `error-reports/{prod|dev}/yyyy-mm-dd/{ERR-XXXXX}-{uuid}.zip`.
74+
* Env first so dev and prod sort into separate sub-prefixes (eviction by oldest still
75+
* works within each environment because the date segment sorts lexically).
76+
*/
77+
function buildR2Key(env: 'prod' | 'dev', datePrefix: string, id: string, uuid: string): string {
78+
return `${ERROR_REPORT_PREFIX}${env}/${datePrefix}/${id}-${uuid}.zip`
7079
}
7180

7281
/**
@@ -194,14 +203,18 @@ errorReport.post('/error-report', async (c) => {
194203
return c.json({ error: 'Invalid meta shape' }, 400)
195204
}
196205

206+
const id = meta.id
197207
const datePrefix = todayDatePrefix()
198-
const id = await generateUniqueId(c.env.ERROR_REPORTS_BUCKET, datePrefix)
199-
if (!id) {
200-
return c.json({ error: 'Could not allocate ID' }, 503)
208+
const env = envSegment(meta.buildMode)
209+
// The trailing UUID guarantees object uniqueness on its own. On the astronomically
210+
// rare (id, date, uuid) collision, retry with a fresh UUID — never a fresh id, so
211+
// the user-visible id the dialog showed stays stable.
212+
let key = buildR2Key(env, datePrefix, id, crypto.randomUUID())
213+
for (let attempt = 0; attempt < 3; attempt++) {
214+
const existing = await c.env.ERROR_REPORTS_BUCKET.head(key)
215+
if (!existing) break
216+
key = buildR2Key(env, datePrefix, id, crypto.randomUUID())
201217
}
202-
203-
const uuid = crypto.randomUUID()
204-
const key = buildR2Key(datePrefix, id, uuid)
205218
const sizeBytes = bundle.size
206219
const uploadedUnixSeconds = Math.floor(Date.now() / 1000)
207220

0 commit comments

Comments
 (0)