Files
vnidrop/services/diagnostics-api

VniDrop diagnostics API

Cloudflare Worker for ingesting user-submitted bug reports. D1 stores searchable metadata; R2 stores the larger attached logs.

The service is designed for modest traffic and low operating cost:

  • one D1 row is written per bug report;
  • bug logs are stored in R2 instead of D1;
  • request limits reject oversized work before storage writes;
  • an hourly scheduled cleanup and an R2 lifecycle rule enforce retention;
  • no Queue, Durable Object, or KV resources are required.

Cloudflare quotas and prices change over time. Check the current Workers pricing, D1 pricing, and R2 pricing before relying on a particular free-plan capacity.

API

All ingest routes require:

X-VniDrop-Key: <INGEST_KEY>

The client also sends its anonymous installation ID:

X-VniDrop-Install-Id: <anonymous install UUID>
Method Path Body
GET /live process liveness; does not touch storage
GET /health authenticated readiness; checks required configuration and the D1 schema
POST /v1/bugs app bug-report payload

Report IDs are client-generated UUIDs. A client must reuse the same ID when retrying so D1 can acknowledge the request without storing it twice.

Accepted reports return 202. The default is a 262,144-byte request limit. Cloudflare rate-limit bindings allow 30 requests per installation and 120 requests per source, per ingest route, per minute. Source limits run before shared-key verification so rejected traffic is bounded too. These counters are eventually consistent and local to a Cloudflare location, so they are abuse mitigation rather than billing or authorization controls.

The API is consumed by native Android, iOS, and desktop clients and does not enable cross-origin browser access. If a browser-based client is added later, define a narrow origin allowlist instead of enabling wildcard CORS.

/health requires X-VniDrop-Key and uses the source limiter because it performs D1 reads. /live is the only unauthenticated probe and never touches storage.

Security model

INGEST_KEY fails closed when it is missing, but it is a shared value embedded in released app binaries. It can be extracted and therefore is not user authentication, a durable secret, or sufficient abuse protection by itself.

  • Store the Worker value with wrangler secret put; never put it in wrangler.jsonc, source control, logs, or command arguments.
  • Rotate the key when it is exposed and ship the matching app configuration.
  • Keep the two rate-limit namespaces unique within the Cloudflare account. A namespace reused by another Worker shares counters with it.
  • Use Cloudflare WAF or account-level rate-limiting rules if public abuse exceeds what the Worker bindings can absorb.
  • Do not log request bodies. Bug reports can contain contact details and attached logs.

Provision and deploy

Run these commands from this directory:

npm ci
npx wrangler login

npx wrangler d1 create vnidrop-diagnostics
npx wrangler r2 bucket create vnidrop-diagnostics

Replace the placeholder database_id in wrangler.jsonc with the UUID returned by wrangler d1 create. Set the ingest key interactively and configure the R2 retention rule once:

npx wrangler secret put INGEST_KEY
npx wrangler r2 bucket lifecycle add vnidrop-diagnostics diagnostics-retention --expire-days 90

Then apply migrations and deploy from the repository root:

make diagnostics-db-remote
make deploy-diagnostics

make deploy-diagnostics runs the complete check before Wrangler changes the remote Worker.

The lifecycle command changes the remote bucket. Before adding or changing a rule, inspect the current state with:

npx wrangler r2 bucket lifecycle list vnidrop-diagnostics

Local development

Create an ignored .dev.vars file containing a development-only key:

INGEST_KEY=local-development-only

Then initialize the local D1 database and run the Worker:

# From the repository root:
make diagnostics-db-local
make run-diagnostics

Wrangler keeps local D1 and R2 state under the ignored .wrangler/ directory. Use wrangler dev --test-scheduled when exercising the hourly cleanup handler.

Migrations and generated types

D1 migrations live in migrations/ and are recorded in D1's migration ledger. Never edit an applied migration; add the next numbered SQL file instead.

worker-configuration.d.ts is generated from wrangler.jsonc and committed so bindings cannot silently drift from the Worker code:

make diagnostics-typegen # from the repository root
npm run types:check   # verify the committed file is current

Secrets and optional, commented-out bindings are not generated. The source adds only those narrow extensions to the generated environment type.

Vitest runs inside the Workers runtime. Its setup applies the same numbered D1 migrations to the isolated local database assigned to each test file.

Retention

RETENTION_DAYS defaults to 90. The 17 * * * * cron trigger runs cleanup at 17 minutes past every hour. Cleanup works in bounded batches: it deletes each expired report's referenced R2 object before deleting that exact D1 row. The R2 lifecycle rule is an independent backstop for log objects, including objects left behind by a partial ingest failure. Each scheduled run can remove 7,200 bug rows while staying below D1's per-invocation query ceiling. Later hourly runs continue any backlog. Reaching the cap emits a structured warning with the remaining expired-row count; alert on that warning because retention is necessarily best-effort during sustained distributed abuse.

The Worker variable and bucket lifecycle are separate configuration surfaces. When changing retention, update both RETENTION_DAYS and the R2 lifecycle rule; changing one does not update the other. Cloudflare may delete expired R2 objects after the exact expiration time rather than synchronously at it.

App wiring

Keep the tracked root defaults empty. Configure release builds through the user-level ~/.gradle/gradle.properties or secured CI Gradle project properties:

vnidrop.diagnostics.included=true
vnidrop.diagnostics.endpoint=https://vnidrop-diagnostics.<your-subdomain>.workers.dev
vnidrop.diagnostics.ingestKey=<same value as INGEST_KEY>

Both the endpoint and key are required. When both are empty the app uses its offline-safe no-op transport; configuring only one fails the Gradle build. vnidrop.diagnostics.included=false routes bug reports to that no-op transport (never sent); a configured endpoint accepts an explicit user-submitted bug report. Treat the app-side key as an abuse-control token with the limitations described above.

Reading reports

npx wrangler d1 execute vnidrop-diagnostics --remote \
  --command "SELECT id, what_happened, status, occurred_at FROM bugs WHERE status = 'open' ORDER BY occurred_at DESC LIMIT 20"

R2 object keys use bugs/<id>/<attempt-id>/logs.txt. The unique attempt segment prevents a retry from overwriting an already accepted object before D1 detects the duplicate. There is no public administration endpoint; inspect reports through authenticated Cloudflare tools or a future Access-protected dashboard.