mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat(diagnostics): harden ingestion and delivery
This commit is contained in:
6
services/diagnostics-api/.gitignore
vendored
Normal file
6
services/diagnostics-api/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
dist/
|
||||
.dev.vars*
|
||||
.env*
|
||||
*.log
|
||||
196
services/diagnostics-api/README.md
Normal file
196
services/diagnostics-api/README.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# VniDrop diagnostics API
|
||||
|
||||
Cloudflare Worker for ingesting batched telemetry, crash reports, and user-submitted
|
||||
bug reports. D1 stores searchable metadata; R2 stores larger stack traces and logs.
|
||||
|
||||
The service is designed for modest traffic and low operating cost:
|
||||
|
||||
- one D1 row is written per telemetry batch, not per event;
|
||||
- crash stacks and bug logs are stored in R2 instead of D1;
|
||||
- request and batch 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](https://developers.cloudflare.com/workers/platform/pricing/),
|
||||
[D1 pricing](https://developers.cloudflare.com/d1/platform/pricing/), and
|
||||
[R2 pricing](https://developers.cloudflare.com/r2/pricing/) before relying on a
|
||||
particular free-plan capacity.
|
||||
|
||||
## API
|
||||
|
||||
All ingest routes require:
|
||||
|
||||
```http
|
||||
X-VniDrop-Key: <INGEST_KEY>
|
||||
```
|
||||
|
||||
The client also sends its anonymous installation ID:
|
||||
|
||||
```http
|
||||
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/events` | `{ batchId, installId, appVersion?, platform?, events: [...] }` |
|
||||
| `POST` | `/v1/crashes` | app crash payload |
|
||||
| `POST` | `/v1/bugs` | app bug-report payload |
|
||||
|
||||
Batch and 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`. Defaults are a 262,144-byte request limit and at
|
||||
most 50 events per batch. 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:
|
||||
|
||||
```bash
|
||||
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, apply the tracked D1
|
||||
migrations, and configure the R2 retention rule once:
|
||||
|
||||
```bash
|
||||
npx wrangler secret put INGEST_KEY
|
||||
npm run db:migrate:remote
|
||||
npx wrangler r2 bucket lifecycle add vnidrop-diagnostics diagnostics-retention --expire-days 90
|
||||
npm run check
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
`npm run deploy` also runs the complete `check` script automatically before
|
||||
Wrangler changes the remote Worker.
|
||||
|
||||
The lifecycle command changes the remote bucket. Before adding or changing a
|
||||
rule, inspect the current state with:
|
||||
|
||||
```bash
|
||||
npx wrangler r2 bucket lifecycle list vnidrop-diagnostics
|
||||
```
|
||||
|
||||
## Local development
|
||||
|
||||
Create an ignored `.dev.vars` file containing a development-only key:
|
||||
|
||||
```dotenv
|
||||
INGEST_KEY=local-development-only
|
||||
```
|
||||
|
||||
Then initialize the local D1 database and run the Worker:
|
||||
|
||||
```bash
|
||||
npm run db:migrate:local
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
npm run typegen # regenerate after changing bindings or vars
|
||||
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 stack and log objects, including
|
||||
objects left behind by a partial ingest failure. Each scheduled run can remove
|
||||
8,000 event batches and 7,200 rows from each report table 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 counts;
|
||||
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:
|
||||
|
||||
```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` disables
|
||||
automatic telemetry and crash upload, but a configured endpoint can still accept
|
||||
an explicit user-submitted bug report. Treat the app-side key as an abuse-control
|
||||
token with the limitations described above.
|
||||
|
||||
## Reading reports
|
||||
|
||||
```bash
|
||||
npx wrangler d1 execute vnidrop-diagnostics --remote \
|
||||
--command "SELECT id, exception_type, platform, occurred_at FROM crashes ORDER BY occurred_at DESC LIMIT 20"
|
||||
|
||||
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 `crashes/<id>/<attempt-id>/stack.txt` and
|
||||
`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.
|
||||
55
services/diagnostics-api/migrations/0001_initial.sql
Normal file
55
services/diagnostics-api/migrations/0001_initial.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
-- This migration also baselines databases previously initialized by schema.sql.
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_batches (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
received_at INTEGER NOT NULL,
|
||||
install_id TEXT NOT NULL,
|
||||
app_version TEXT,
|
||||
platform TEXT,
|
||||
event_count INTEGER NOT NULL,
|
||||
payload_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_event_batches_received ON event_batches (received_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_batches_install ON event_batches (install_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crashes (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
received_at INTEGER NOT NULL,
|
||||
occurred_at INTEGER NOT NULL,
|
||||
install_id TEXT NOT NULL,
|
||||
app_version TEXT,
|
||||
platform TEXT,
|
||||
exception_type TEXT,
|
||||
exception_message TEXT,
|
||||
fingerprint TEXT NOT NULL,
|
||||
diagnostics_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
stack_r2_key TEXT,
|
||||
breadcrumbs_json TEXT,
|
||||
schema_version INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crashes_received ON crashes (received_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_crashes_fingerprint ON crashes (fingerprint);
|
||||
CREATE INDEX IF NOT EXISTS idx_crashes_install ON crashes (install_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bugs (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
received_at INTEGER NOT NULL,
|
||||
install_id TEXT NOT NULL,
|
||||
app_version TEXT,
|
||||
platform TEXT,
|
||||
what_happened TEXT NOT NULL,
|
||||
expected TEXT NOT NULL,
|
||||
steps TEXT,
|
||||
contact TEXT,
|
||||
logs_r2_key TEXT,
|
||||
device_json TEXT,
|
||||
breadcrumbs_json TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
schema_version INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bugs_received ON bugs (received_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_bugs_status ON bugs (status);
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE bugs ADD COLUMN occurred_at INTEGER;
|
||||
|
||||
-- Existing reports predate this field; their receipt time is the best available value.
|
||||
UPDATE bugs SET occurred_at = received_at WHERE occurred_at IS NULL;
|
||||
3151
services/diagnostics-api/package-lock.json
generated
Normal file
3151
services/diagnostics-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
services/diagnostics-api/package.json
Normal file
30
services/diagnostics-api/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "vnidrop-diagnostics-api",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"predeploy": "npm run check",
|
||||
"deploy": "wrangler deploy",
|
||||
"deploy:dry-run": "wrangler deploy --dry-run",
|
||||
"db:migrate:local": "wrangler d1 migrations apply vnidrop-diagnostics --local",
|
||||
"db:migrate:remote": "wrangler d1 migrations apply vnidrop-diagnostics --remote",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typegen": "wrangler types",
|
||||
"types:check": "wrangler types --check",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p test/tsconfig.json",
|
||||
"check": "npm run types:check && npm run typecheck && npm test && npm run deploy:dry-run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "^0.18.4",
|
||||
"@types/node": "^26.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10",
|
||||
"wrangler": "^4.110.0"
|
||||
}
|
||||
}
|
||||
267
services/diagnostics-api/src/index.ts
Normal file
267
services/diagnostics-api/src/index.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
normalizeBug,
|
||||
normalizeCrash,
|
||||
normalizeEvents,
|
||||
readJsonObject,
|
||||
} from "./input";
|
||||
import {
|
||||
type DiagnosticsEnv,
|
||||
runRetention,
|
||||
storeBug,
|
||||
storeCrash,
|
||||
storeEvents,
|
||||
} from "./storage";
|
||||
|
||||
const DEFAULT_MAX_BODY_BYTES = 262_144;
|
||||
const HARD_MAX_BODY_BYTES = 1_048_576;
|
||||
const DEFAULT_MAX_EVENTS = 50;
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: DiagnosticsEnv, _ctx: ExecutionContext): Promise<Response> {
|
||||
const requestId = crypto.randomUUID();
|
||||
const url = new URL(request.url);
|
||||
try {
|
||||
if (request.method === "GET" && url.pathname === "/live") {
|
||||
return json({ ok: true, service: "vnidrop-diagnostics", schema: 1 }, 200, requestId);
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/health") {
|
||||
if (await sourceRateLimited(request, url.pathname, env)) {
|
||||
return json({ error: "rate_limited" }, 429, requestId, { "retry-after": "60" });
|
||||
}
|
||||
const authError = await authorize(request, env);
|
||||
if (authError) return json({ error: authError.error }, authError.status, requestId);
|
||||
return await readiness(env, requestId);
|
||||
}
|
||||
|
||||
if (!isIngestPath(url.pathname)) {
|
||||
return json({ error: "not_found" }, 404, requestId);
|
||||
}
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: responseHeaders(requestId, { allow: "POST, OPTIONS" }),
|
||||
});
|
||||
}
|
||||
if (request.method !== "POST") {
|
||||
return json(
|
||||
{ error: "method_not_allowed" },
|
||||
405,
|
||||
requestId,
|
||||
{ allow: "POST, OPTIONS" },
|
||||
);
|
||||
}
|
||||
|
||||
if (await sourceRateLimited(request, url.pathname, env)) {
|
||||
return json({ error: "rate_limited" }, 429, requestId, { "retry-after": "60" });
|
||||
}
|
||||
|
||||
const authError = await authorize(request, env);
|
||||
if (authError) return json({ error: authError.error }, authError.status, requestId);
|
||||
|
||||
if (await installRateLimited(request, url.pathname, env)) {
|
||||
return json({ error: "rate_limited" }, 429, requestId, { "retry-after": "60" });
|
||||
}
|
||||
|
||||
const maxBodyBytes = boundedPositiveInt(
|
||||
env.MAX_BODY_BYTES,
|
||||
DEFAULT_MAX_BODY_BYTES,
|
||||
1,
|
||||
HARD_MAX_BODY_BYTES,
|
||||
);
|
||||
const parsed = await readJsonObject(request, maxBodyBytes);
|
||||
if (!parsed.ok) return json({ error: parsed.error }, parsed.status, requestId);
|
||||
|
||||
switch (url.pathname) {
|
||||
case "/v1/events": {
|
||||
const maxEvents = boundedPositiveInt(env.MAX_EVENTS_PER_BATCH, DEFAULT_MAX_EVENTS, 1, 100);
|
||||
const normalized = normalizeEvents(parsed.value, maxEvents);
|
||||
if (!normalized.ok) {
|
||||
return json({ error: normalized.error }, normalized.status, requestId);
|
||||
}
|
||||
const result = await storeEvents(normalized.value, env);
|
||||
return json(
|
||||
{
|
||||
ok: true,
|
||||
id: result.id,
|
||||
stored: result.stored,
|
||||
duplicate: result.duplicate,
|
||||
},
|
||||
202,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
case "/v1/crashes": {
|
||||
const normalized = normalizeCrash(parsed.value);
|
||||
if (!normalized.ok) {
|
||||
return json({ error: normalized.error }, normalized.status, requestId);
|
||||
}
|
||||
const result = await storeCrash(normalized.value, env);
|
||||
return json(
|
||||
{
|
||||
ok: true,
|
||||
id: result.id,
|
||||
fingerprint: result.fingerprint,
|
||||
duplicate: result.duplicate,
|
||||
},
|
||||
202,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
case "/v1/bugs": {
|
||||
const normalized = normalizeBug(parsed.value);
|
||||
if (!normalized.ok) {
|
||||
return json({ error: normalized.error }, normalized.status, requestId);
|
||||
}
|
||||
const result = await storeBug(normalized.value, env);
|
||||
return json(
|
||||
{ ok: true, id: result.id, duplicate: result.duplicate },
|
||||
202,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "diagnostics request failed",
|
||||
requestId,
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
return json({ error: "internal" }, 500, requestId);
|
||||
}
|
||||
},
|
||||
|
||||
scheduled(
|
||||
controller: ScheduledController,
|
||||
env: DiagnosticsEnv,
|
||||
ctx: ExecutionContext,
|
||||
): void {
|
||||
ctx.waitUntil(
|
||||
runRetention(env).catch((error) => {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "diagnostics retention failed",
|
||||
scheduledTime: controller.scheduledTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
} satisfies ExportedHandler<DiagnosticsEnv>;
|
||||
|
||||
async function readiness(env: DiagnosticsEnv, requestId: string): Promise<Response> {
|
||||
if (!env.INGEST_KEY) {
|
||||
return json({ ok: false, error: "server_misconfigured" }, 503, requestId);
|
||||
}
|
||||
try {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare(
|
||||
"SELECT id, received_at, install_id, payload_json FROM event_batches LIMIT 1",
|
||||
),
|
||||
env.DB.prepare(
|
||||
"SELECT id, occurred_at, stack_r2_key, breadcrumbs_json FROM crashes LIMIT 1",
|
||||
),
|
||||
env.DB.prepare(
|
||||
"SELECT id, occurred_at, logs_r2_key, device_json FROM bugs LIMIT 1",
|
||||
),
|
||||
]);
|
||||
return json({ ok: true, service: "vnidrop-diagnostics", schema: 1 }, 200, requestId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "diagnostics readiness check failed",
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
return json({ ok: false, error: "dependency_unavailable" }, 503, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
async function authorize(
|
||||
request: Request,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<{ status: 401 | 503; error: "unauthorized" | "server_misconfigured" } | null> {
|
||||
const expected = env.INGEST_KEY;
|
||||
if (!expected) return { status: 503, error: "server_misconfigured" };
|
||||
const provided = request.headers.get("x-vnidrop-key") ?? "";
|
||||
if (!(await timingSafeEqual(provided, expected))) {
|
||||
return { status: 401, error: "unauthorized" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sourceRateLimited(
|
||||
request: Request,
|
||||
path: string,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<boolean> {
|
||||
const source = request.headers.get("cf-connecting-ip")?.slice(0, 64) || "unknown";
|
||||
const result = await env.SOURCE_RATE_LIMITER.limit({ key: `${source}:${path}` });
|
||||
return !result.success;
|
||||
}
|
||||
|
||||
async function installRateLimited(
|
||||
request: Request,
|
||||
path: string,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<boolean> {
|
||||
const source = request.headers.get("cf-connecting-ip")?.slice(0, 64) || "unknown";
|
||||
const installId = request.headers.get("x-vnidrop-install-id")?.trim().slice(0, 80) || source;
|
||||
const result = await env.INSTALL_RATE_LIMITER.limit({ key: `${installId}:${path}` });
|
||||
return !result.success;
|
||||
}
|
||||
|
||||
function isIngestPath(path: string): path is "/v1/events" | "/v1/crashes" | "/v1/bugs" {
|
||||
return path === "/v1/events" || path === "/v1/crashes" || path === "/v1/bugs";
|
||||
}
|
||||
|
||||
async function timingSafeEqual(provided: string, expected: string): Promise<boolean> {
|
||||
const encoder = new TextEncoder();
|
||||
const [providedHash, expectedHash] = await Promise.all([
|
||||
crypto.subtle.digest("SHA-256", encoder.encode(provided)),
|
||||
crypto.subtle.digest("SHA-256", encoder.encode(expected)),
|
||||
]);
|
||||
return crypto.subtle.timingSafeEqual(providedHash, expectedHash);
|
||||
}
|
||||
|
||||
function json(
|
||||
body: unknown,
|
||||
status: number,
|
||||
requestId: string,
|
||||
extraHeaders?: Readonly<Record<string, string>>,
|
||||
): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: responseHeaders(requestId, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...extraHeaders,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function responseHeaders(
|
||||
requestId: string,
|
||||
extraHeaders?: Readonly<Record<string, string>>,
|
||||
): Headers {
|
||||
const headers = new Headers(extraHeaders);
|
||||
headers.set("cache-control", "no-store");
|
||||
headers.set("x-content-type-options", "nosniff");
|
||||
headers.set("x-request-id", requestId);
|
||||
return headers;
|
||||
}
|
||||
|
||||
function boundedPositiveInt(
|
||||
raw: string | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
593
services/diagnostics-api/src/input.ts
Normal file
593
services/diagnostics-api/src/input.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export type InputFailure = {
|
||||
ok: false;
|
||||
status: 400 | 413 | 415;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type InputResult<T> = { ok: true; value: T } | InputFailure;
|
||||
|
||||
export type NormalizedProperties = Record<string, string>;
|
||||
|
||||
export interface NormalizedEvent {
|
||||
name: string;
|
||||
timestampMillis: number;
|
||||
properties: NormalizedProperties;
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export interface NormalizedBreadcrumb {
|
||||
name: string;
|
||||
timestampMillis: number;
|
||||
properties: NormalizedProperties;
|
||||
}
|
||||
|
||||
export interface NormalizedDevice {
|
||||
deviceName: string;
|
||||
deviceModel: string;
|
||||
operatingSystem: string;
|
||||
network: string;
|
||||
batteryLevel: string;
|
||||
}
|
||||
|
||||
export interface NormalizedEventsPayload {
|
||||
batchId: string;
|
||||
installId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
events: NormalizedEvent[];
|
||||
}
|
||||
|
||||
export interface NormalizedCrashPayload {
|
||||
id: string;
|
||||
installId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
exceptionType: string;
|
||||
exceptionMessage: string;
|
||||
stackTrace: string;
|
||||
occurredAt: number;
|
||||
diagnosticsEnabledAtCapture: boolean;
|
||||
breadcrumbs: NormalizedBreadcrumb[];
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export interface NormalizedBugPayload {
|
||||
id: string;
|
||||
installId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
occurredAt: number;
|
||||
whatHappened: string;
|
||||
expected: string;
|
||||
steps: string;
|
||||
contact: string;
|
||||
logs: string;
|
||||
device: NormalizedDevice;
|
||||
breadcrumbs: NormalizedBreadcrumb[];
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export const MAX_LOG_BYTES = 192 * 1024;
|
||||
export const MAX_BREADCRUMBS_JSON_BYTES = 16_000;
|
||||
export const MAX_DEVICE_JSON_BYTES = 4_000;
|
||||
|
||||
const MAX_PROPERTIES = 12;
|
||||
const MAX_BREADCRUMBS = 40;
|
||||
const MISSING = Symbol("missing");
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const UTF8_ENCODER = new TextEncoder();
|
||||
|
||||
export async function readJsonObject(
|
||||
request: Request,
|
||||
maxBytes: number,
|
||||
): Promise<InputResult<JsonObject>> {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
||||
throw new RangeError("maxBytes must be a positive safe integer");
|
||||
}
|
||||
|
||||
if (!isApplicationJson(request.headers.get("content-type"))) {
|
||||
return failure(415, "unsupported_media_type");
|
||||
}
|
||||
|
||||
const contentLength = request.headers.get("content-length");
|
||||
if (contentLength != null) {
|
||||
const trimmed = contentLength.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return failure(400, "invalid_content_length");
|
||||
}
|
||||
const normalizedLength = trimmed.replace(/^0+(?=\d)/, "");
|
||||
const maxLength = String(maxBytes);
|
||||
if (
|
||||
normalizedLength.length > maxLength.length ||
|
||||
(normalizedLength.length === maxLength.length && normalizedLength > maxLength)
|
||||
) {
|
||||
return failure(413, "payload_too_large");
|
||||
}
|
||||
const declaredBytes = Number(trimmed);
|
||||
if (!Number.isSafeInteger(declaredBytes)) {
|
||||
return failure(400, "invalid_content_length");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.body == null) {
|
||||
return failure(400, "invalid_json");
|
||||
}
|
||||
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||
try {
|
||||
reader = request.body.getReader();
|
||||
} catch {
|
||||
return failure(400, "invalid_body");
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
if (chunk.value.byteLength > maxBytes - totalBytes) {
|
||||
try {
|
||||
await reader.cancel("payload_too_large");
|
||||
} catch {
|
||||
// The size failure remains authoritative if cancellation also fails.
|
||||
}
|
||||
return failure(413, "payload_too_large");
|
||||
}
|
||||
totalBytes += chunk.value.byteLength;
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
} catch {
|
||||
return failure(400, "invalid_body");
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const bytes = joinChunks(chunks, totalBytes);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
|
||||
} catch {
|
||||
return failure(400, "invalid_utf8");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return failure(400, "invalid_json");
|
||||
}
|
||||
if (!isPlainObject(parsed)) {
|
||||
return failure(400, "invalid_body");
|
||||
}
|
||||
return success(parsed);
|
||||
}
|
||||
|
||||
export function normalizeEvents(
|
||||
body: JsonObject,
|
||||
maxEvents = 50,
|
||||
): InputResult<NormalizedEventsPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
if (!Number.isSafeInteger(maxEvents) || maxEvents <= 0) {
|
||||
throw new RangeError("maxEvents must be a positive safe integer");
|
||||
}
|
||||
|
||||
const batchId = idField(body, ["batchId", "batch_id"], "invalid_batch_id");
|
||||
if (!batchId.ok) return batchId;
|
||||
const installId = installIdField(body);
|
||||
if (!installId.ok) return installId;
|
||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
||||
if (!appVersion.ok) return appVersion;
|
||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
||||
if (!platform.ok) return platform;
|
||||
|
||||
const batchSchema = schemaVersion(body);
|
||||
if (!batchSchema.ok) return batchSchema;
|
||||
const rawEvents = pick(body, ["events"]);
|
||||
if (!Array.isArray(rawEvents)) return failure(400, "invalid_events");
|
||||
if (rawEvents.length === 0) return failure(400, "empty_batch");
|
||||
if (rawEvents.length > maxEvents) return failure(400, "batch_too_large");
|
||||
|
||||
const events: NormalizedEvent[] = [];
|
||||
for (const rawEvent of rawEvents) {
|
||||
const event = normalizeEvent(rawEvent);
|
||||
if (!event.ok) return event;
|
||||
events.push(event.value);
|
||||
}
|
||||
|
||||
return success({
|
||||
batchId: batchId.value,
|
||||
installId: installId.value,
|
||||
appVersion: appVersion.value,
|
||||
platform: platform.value,
|
||||
events,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCrash(body: JsonObject): InputResult<NormalizedCrashPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
|
||||
const id = idField(body, ["id"], "invalid_id");
|
||||
if (!id.ok) return id;
|
||||
const installId = installIdField(body);
|
||||
if (!installId.ok) return installId;
|
||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
||||
if (!appVersion.ok) return appVersion;
|
||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
||||
if (!platform.ok) return platform;
|
||||
const exceptionType = stringField(
|
||||
body,
|
||||
["exceptionType", "exception_type"],
|
||||
120,
|
||||
"invalid_exception_type",
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!exceptionType.ok) return exceptionType;
|
||||
const exceptionMessage = stringField(
|
||||
body,
|
||||
["exceptionMessage", "exception_message"],
|
||||
2_000,
|
||||
"invalid_exception_message",
|
||||
true,
|
||||
);
|
||||
if (!exceptionMessage.ok) return exceptionMessage;
|
||||
const stackTrace = stringField(
|
||||
body,
|
||||
["stackTrace", "stack_trace"],
|
||||
32_000,
|
||||
"invalid_stack_trace",
|
||||
true,
|
||||
);
|
||||
if (!stackTrace.ok) return stackTrace;
|
||||
const occurredAt = timestampField(
|
||||
body,
|
||||
["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"],
|
||||
);
|
||||
if (!occurredAt.ok) return occurredAt;
|
||||
const diagnosticsEnabled = booleanField(
|
||||
body,
|
||||
[
|
||||
"diagnosticsEnabledAtCapture",
|
||||
"diagnostics_enabled_at_capture",
|
||||
"diagnostics_enabled",
|
||||
],
|
||||
"invalid_diagnostics_enabled",
|
||||
true,
|
||||
);
|
||||
if (!diagnosticsEnabled.ok) return diagnosticsEnabled;
|
||||
const version = schemaVersion(body);
|
||||
if (!version.ok) return version;
|
||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
||||
if (!breadcrumbs.ok) return breadcrumbs;
|
||||
|
||||
return success({
|
||||
id: id.value,
|
||||
installId: installId.value,
|
||||
appVersion: appVersion.value,
|
||||
platform: platform.value,
|
||||
exceptionType: exceptionType.value,
|
||||
exceptionMessage: exceptionMessage.value,
|
||||
stackTrace: stackTrace.value,
|
||||
occurredAt: occurredAt.value,
|
||||
diagnosticsEnabledAtCapture: diagnosticsEnabled.value,
|
||||
breadcrumbs: breadcrumbs.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
|
||||
const id = idField(body, ["id"], "invalid_id");
|
||||
if (!id.ok) return id;
|
||||
const installId = installIdField(body);
|
||||
if (!installId.ok) return installId;
|
||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
||||
if (!appVersion.ok) return appVersion;
|
||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
||||
if (!platform.ok) return platform;
|
||||
const occurredAt = timestampField(
|
||||
body,
|
||||
["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"],
|
||||
);
|
||||
if (!occurredAt.ok) return occurredAt;
|
||||
const whatHappened = stringField(
|
||||
body,
|
||||
["whatHappened", "what_happened"],
|
||||
4_000,
|
||||
"missing_fields",
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!whatHappened.ok) return whatHappened;
|
||||
const expected = stringField(body, ["expected"], 4_000, "missing_fields", true, true);
|
||||
if (!expected.ok) return expected;
|
||||
const steps = stringField(body, ["steps"], 4_000, "invalid_steps");
|
||||
if (!steps.ok) return steps;
|
||||
const contact = stringField(body, ["contact"], 320, "invalid_contact");
|
||||
if (!contact.ok) return contact;
|
||||
const includeLogs = booleanField(
|
||||
body,
|
||||
["includeLogs", "include_logs"],
|
||||
"invalid_include_logs",
|
||||
false,
|
||||
);
|
||||
if (!includeLogs.ok) return includeLogs;
|
||||
const logs = stringField(body, ["logs"], MAX_LOG_BYTES, "invalid_logs");
|
||||
if (!logs.ok) return logs;
|
||||
const device = normalizeDevice(pick(body, ["device"]));
|
||||
if (!device.ok) return device;
|
||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
||||
if (!breadcrumbs.ok) return breadcrumbs;
|
||||
const version = schemaVersion(body);
|
||||
if (!version.ok) return version;
|
||||
|
||||
return success({
|
||||
id: id.value,
|
||||
installId: installId.value,
|
||||
appVersion: appVersion.value,
|
||||
platform: platform.value,
|
||||
occurredAt: occurredAt.value,
|
||||
whatHappened: whatHappened.value,
|
||||
expected: expected.value,
|
||||
steps: steps.value,
|
||||
contact: contact.value,
|
||||
logs: includeLogs.value === true ? logs.value : "",
|
||||
device: device.value,
|
||||
breadcrumbs: breadcrumbs.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEvent(raw: unknown): InputResult<NormalizedEvent> {
|
||||
if (!isPlainObject(raw)) return failure(400, "invalid_event");
|
||||
const name = stringField(raw, ["name"], 64, "invalid_event", true, true);
|
||||
if (!name.ok) return name;
|
||||
const timestamp = timestampField(raw, ["timestampMillis", "timestamp_millis", "ts"]);
|
||||
if (!timestamp.ok) return failure(400, "invalid_event");
|
||||
const properties = normalizeProperties(pick(raw, ["properties", "props"]), "invalid_event");
|
||||
if (!properties.ok) return properties;
|
||||
const version = schemaVersion(raw);
|
||||
if (!version.ok) return version;
|
||||
return success({
|
||||
name: name.value,
|
||||
timestampMillis: timestamp.value,
|
||||
properties: properties.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeBreadcrumbs(raw: unknown | typeof MISSING): InputResult<NormalizedBreadcrumb[]> {
|
||||
if (raw === MISSING) return success([]);
|
||||
if (!Array.isArray(raw)) return failure(400, "invalid_breadcrumbs");
|
||||
|
||||
const breadcrumbs: NormalizedBreadcrumb[] = [];
|
||||
for (const item of raw.slice(0, MAX_BREADCRUMBS)) {
|
||||
if (!isPlainObject(item)) return failure(400, "invalid_breadcrumbs");
|
||||
const name = stringField(item, ["name"], 64, "invalid_breadcrumbs", true, true);
|
||||
if (!name.ok) return name;
|
||||
const timestamp = timestampField(item, ["timestampMillis", "timestamp_millis", "ts"]);
|
||||
if (!timestamp.ok) return failure(400, "invalid_breadcrumbs");
|
||||
const properties = normalizeProperties(
|
||||
pick(item, ["properties", "props"]),
|
||||
"invalid_breadcrumbs",
|
||||
);
|
||||
if (!properties.ok) return properties;
|
||||
|
||||
breadcrumbs.push({
|
||||
name: name.value,
|
||||
timestampMillis: timestamp.value,
|
||||
properties: properties.value,
|
||||
});
|
||||
if (jsonBytes(breadcrumbs) > MAX_BREADCRUMBS_JSON_BYTES) {
|
||||
breadcrumbs.pop();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return success(breadcrumbs);
|
||||
}
|
||||
|
||||
function normalizeProperties(
|
||||
raw: unknown | typeof MISSING,
|
||||
error: string,
|
||||
): InputResult<NormalizedProperties> {
|
||||
if (raw === MISSING) return success({});
|
||||
if (!isPlainObject(raw)) return failure(400, error);
|
||||
|
||||
const entries: Array<[string, string]> = [];
|
||||
const normalizedKeys = new Set<string>();
|
||||
for (const [key, value] of Object.entries(raw).slice(0, MAX_PROPERTIES)) {
|
||||
if (typeof value !== "string") return failure(400, error);
|
||||
const normalizedKey = truncateUtf8(key, 40);
|
||||
if (normalizedKey.length === 0 || normalizedKeys.has(normalizedKey)) {
|
||||
return failure(400, error);
|
||||
}
|
||||
normalizedKeys.add(normalizedKey);
|
||||
entries.push([normalizedKey, truncateUtf8(value, 128)]);
|
||||
}
|
||||
return success(Object.fromEntries(entries));
|
||||
}
|
||||
|
||||
function normalizeDevice(raw: unknown | typeof MISSING): InputResult<NormalizedDevice> {
|
||||
if (raw === MISSING) raw = {};
|
||||
if (!isPlainObject(raw)) return failure(400, "invalid_device");
|
||||
|
||||
const deviceName = stringField(raw, ["deviceName", "device_name"], 128, "invalid_device");
|
||||
if (!deviceName.ok) return deviceName;
|
||||
const deviceModel = stringField(raw, ["deviceModel", "device_model"], 128, "invalid_device");
|
||||
if (!deviceModel.ok) return deviceModel;
|
||||
const operatingSystem = stringField(
|
||||
raw,
|
||||
["operatingSystem", "operating_system"],
|
||||
192,
|
||||
"invalid_device",
|
||||
);
|
||||
if (!operatingSystem.ok) return operatingSystem;
|
||||
const network = stringField(raw, ["network"], 96, "invalid_device");
|
||||
if (!network.ok) return network;
|
||||
const batteryLevel = stringField(raw, ["batteryLevel", "battery_level"], 64, "invalid_device");
|
||||
if (!batteryLevel.ok) return batteryLevel;
|
||||
|
||||
const device: NormalizedDevice = {
|
||||
deviceName: deviceName.value,
|
||||
deviceModel: deviceModel.value,
|
||||
operatingSystem: operatingSystem.value,
|
||||
network: network.value,
|
||||
batteryLevel: batteryLevel.value,
|
||||
};
|
||||
if (jsonBytes(device) > MAX_DEVICE_JSON_BYTES) {
|
||||
return failure(400, "invalid_device");
|
||||
}
|
||||
return success(device);
|
||||
}
|
||||
|
||||
function stringField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
maxBytes: number,
|
||||
error: string,
|
||||
required = false,
|
||||
nonEmpty = false,
|
||||
): InputResult<string> {
|
||||
const raw = pick(object, keys);
|
||||
if (raw === MISSING) {
|
||||
return required ? failure(400, error) : success("");
|
||||
}
|
||||
if (typeof raw !== "string") return failure(400, error);
|
||||
const value = truncateUtf8(raw, maxBytes);
|
||||
if (nonEmpty && value.trim().length === 0) return failure(400, error);
|
||||
return success(value);
|
||||
}
|
||||
|
||||
function idField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
): InputResult<string> {
|
||||
const raw = pick(object, keys);
|
||||
if (typeof raw !== "string" || !UUID_PATTERN.test(raw)) return failure(400, error);
|
||||
return success(raw.toLowerCase());
|
||||
}
|
||||
|
||||
function installIdField(object: JsonObject): InputResult<string> {
|
||||
const raw = pick(object, ["installId", "install_id"]);
|
||||
if (raw === MISSING || raw === "") return success("unknown");
|
||||
if (
|
||||
typeof raw !== "string" ||
|
||||
raw.trim().length === 0 ||
|
||||
/[\u0000-\u001f\u007f]/.test(raw)
|
||||
) {
|
||||
return failure(400, "invalid_install_id");
|
||||
}
|
||||
return success(truncateUtf8(raw, 80));
|
||||
}
|
||||
|
||||
function timestampField(object: JsonObject, keys: readonly string[]): InputResult<number> {
|
||||
const raw = pick(object, keys);
|
||||
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 0) {
|
||||
return failure(400, "invalid_timestamp");
|
||||
}
|
||||
return success(raw);
|
||||
}
|
||||
|
||||
function booleanField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
required: true,
|
||||
): InputResult<boolean>;
|
||||
function booleanField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
required: false,
|
||||
): InputResult<boolean | undefined>;
|
||||
function booleanField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
required: boolean,
|
||||
): InputResult<boolean | undefined> {
|
||||
const raw = pick(object, keys);
|
||||
if (raw === MISSING) {
|
||||
return required ? failure(400, error) : success(undefined);
|
||||
}
|
||||
return typeof raw === "boolean" ? success(raw) : failure(400, error);
|
||||
}
|
||||
|
||||
function schemaVersion(object: JsonObject): InputResult<1> {
|
||||
const raw = pick(object, ["schemaVersion", "schema_version"]);
|
||||
if (raw === MISSING || raw === 1) return success(1);
|
||||
return failure(400, "unsupported_schema_version");
|
||||
}
|
||||
|
||||
function pick(object: JsonObject, keys: readonly string[]): unknown | typeof MISSING {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(object, key)) return object[key];
|
||||
}
|
||||
return MISSING;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function isApplicationJson(contentType: string | null): boolean {
|
||||
if (contentType == null) return false;
|
||||
const [mediaType, ...parameters] = contentType.split(";");
|
||||
if (mediaType?.trim().toLowerCase() !== "application/json") return false;
|
||||
for (const parameter of parameters) {
|
||||
const separator = parameter.indexOf("=");
|
||||
if (separator < 0) continue;
|
||||
if (parameter.slice(0, separator).trim().toLowerCase() !== "charset") continue;
|
||||
const charset = parameter
|
||||
.slice(separator + 1)
|
||||
.trim()
|
||||
.replace(/^"(.*)"$/, "$1")
|
||||
.toLowerCase();
|
||||
if (charset !== "utf-8" && charset !== "utf8") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function joinChunks(chunks: readonly Uint8Array[], totalBytes: number): Uint8Array {
|
||||
if (chunks.length === 1) return chunks[0] ?? new Uint8Array();
|
||||
const bytes = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
const bytes = UTF8_ENCODER.encode(value);
|
||||
if (bytes.byteLength <= maxBytes) return value;
|
||||
for (let end = maxBytes; end >= Math.max(0, maxBytes - 3); end -= 1) {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
bytes.subarray(0, end),
|
||||
);
|
||||
} catch {
|
||||
// A UTF-8 boundary is at most three bytes behind the byte cap.
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function jsonBytes(value: unknown): number {
|
||||
return UTF8_ENCODER.encode(JSON.stringify(value)).byteLength;
|
||||
}
|
||||
|
||||
function success<T>(value: T): InputResult<T> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
function failure(status: 400 | 413 | 415, error: string): InputFailure {
|
||||
return { ok: false, status, error };
|
||||
}
|
||||
340
services/diagnostics-api/src/storage.ts
Normal file
340
services/diagnostics-api/src/storage.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import type {
|
||||
NormalizedBugPayload,
|
||||
NormalizedCrashPayload,
|
||||
NormalizedEventsPayload,
|
||||
} from "./input";
|
||||
|
||||
export type DiagnosticsEnv = Cloudflare.Env & {
|
||||
INGEST_KEY?: string;
|
||||
AE?: AnalyticsEngineDataset;
|
||||
};
|
||||
|
||||
export interface StoreResult {
|
||||
id: string;
|
||||
duplicate: boolean;
|
||||
stored: number;
|
||||
}
|
||||
|
||||
export async function storeEvents(
|
||||
payload: NormalizedEventsPayload,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<StoreResult> {
|
||||
const result = await env.DB.prepare(
|
||||
`INSERT INTO event_batches (id, received_at, install_id, app_version, platform, event_count, payload_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
)
|
||||
.bind(
|
||||
payload.batchId,
|
||||
Date.now(),
|
||||
payload.installId,
|
||||
payload.appVersion,
|
||||
payload.platform,
|
||||
payload.events.length,
|
||||
JSON.stringify(payload.events),
|
||||
)
|
||||
.run();
|
||||
const duplicate = result.meta.changes === 0;
|
||||
if (!duplicate && env.AE) {
|
||||
try {
|
||||
for (const event of payload.events) {
|
||||
env.AE.writeDataPoint({
|
||||
blobs: [
|
||||
event.name,
|
||||
payload.platform,
|
||||
payload.appVersion,
|
||||
payload.installId,
|
||||
JSON.stringify(event.properties),
|
||||
payload.batchId,
|
||||
],
|
||||
doubles: [event.timestampMillis, event.schemaVersion],
|
||||
indexes: [payload.installId],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// D1 remains the durable source of truth if the optional analytics index is unavailable.
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "failed to index diagnostics event batch",
|
||||
batchId: payload.batchId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: payload.batchId,
|
||||
duplicate,
|
||||
stored: duplicate ? 0 : payload.events.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function storeCrash(
|
||||
payload: NormalizedCrashPayload,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<StoreResult & { fingerprint: string }> {
|
||||
const database = env.DB.withSession("first-primary");
|
||||
const existing = await database
|
||||
.prepare("SELECT fingerprint FROM crashes WHERE id = ?")
|
||||
.bind(payload.id)
|
||||
.first<{ fingerprint: string }>();
|
||||
if (existing) {
|
||||
return { id: payload.id, duplicate: true, stored: 0, fingerprint: existing.fingerprint };
|
||||
}
|
||||
|
||||
const fingerprint = await crashFingerprint(payload.exceptionType, payload.stackTrace);
|
||||
const stackKey = payload.stackTrace
|
||||
? `crashes/${payload.id}/${crypto.randomUUID()}/stack.txt`
|
||||
: null;
|
||||
|
||||
if (stackKey) {
|
||||
await env.BLOBS.put(stackKey, payload.stackTrace, {
|
||||
httpMetadata: { contentType: "text/plain; charset=utf-8" },
|
||||
customMetadata: { installId: payload.installId, fingerprint },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await database
|
||||
.prepare(
|
||||
`INSERT INTO crashes (
|
||||
id, received_at, occurred_at, install_id, app_version, platform,
|
||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
||||
stack_r2_key, breadcrumbs_json, schema_version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
)
|
||||
.bind(
|
||||
payload.id,
|
||||
Date.now(),
|
||||
payload.occurredAt,
|
||||
payload.installId,
|
||||
payload.appVersion,
|
||||
payload.platform,
|
||||
payload.exceptionType,
|
||||
payload.exceptionMessage,
|
||||
fingerprint,
|
||||
payload.diagnosticsEnabledAtCapture ? 1 : 0,
|
||||
stackKey,
|
||||
JSON.stringify(payload.breadcrumbs),
|
||||
payload.schemaVersion,
|
||||
)
|
||||
.run();
|
||||
const duplicate = result.meta.changes === 0;
|
||||
if (duplicate) {
|
||||
const stored = await database
|
||||
.prepare("SELECT fingerprint FROM crashes WHERE id = ?")
|
||||
.bind(payload.id)
|
||||
.first<{ fingerprint: string }>();
|
||||
if (!stored) throw new Error("duplicate crash row was not readable");
|
||||
if (stackKey) await deleteAttemptBlob(env, stackKey);
|
||||
return { id: payload.id, duplicate: true, stored: 0, fingerprint: stored.fingerprint };
|
||||
}
|
||||
return { id: payload.id, duplicate: false, stored: 1, fingerprint };
|
||||
} catch (error) {
|
||||
if (stackKey) {
|
||||
await deleteAttemptBlob(env, stackKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function storeBug(
|
||||
payload: NormalizedBugPayload,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<StoreResult> {
|
||||
const database = env.DB.withSession("first-primary");
|
||||
const existing = await database
|
||||
.prepare("SELECT id FROM bugs WHERE id = ?")
|
||||
.bind(payload.id)
|
||||
.first<{ id: string }>();
|
||||
if (existing) return { id: payload.id, duplicate: true, stored: 0 };
|
||||
|
||||
const logsKey = payload.logs ? `bugs/${payload.id}/${crypto.randomUUID()}/logs.txt` : null;
|
||||
if (logsKey) {
|
||||
await env.BLOBS.put(logsKey, payload.logs, {
|
||||
httpMetadata: { contentType: "text/plain; charset=utf-8" },
|
||||
customMetadata: { installId: payload.installId },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await database
|
||||
.prepare(
|
||||
`INSERT INTO bugs (
|
||||
id, received_at, occurred_at, install_id, app_version, platform,
|
||||
what_happened, expected, steps, contact, logs_r2_key,
|
||||
device_json, breadcrumbs_json, status, schema_version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
)
|
||||
.bind(
|
||||
payload.id,
|
||||
Date.now(),
|
||||
payload.occurredAt,
|
||||
payload.installId,
|
||||
payload.appVersion,
|
||||
payload.platform,
|
||||
payload.whatHappened,
|
||||
payload.expected,
|
||||
payload.steps,
|
||||
payload.contact,
|
||||
logsKey,
|
||||
JSON.stringify(payload.device),
|
||||
JSON.stringify(payload.breadcrumbs),
|
||||
payload.schemaVersion,
|
||||
)
|
||||
.run();
|
||||
const duplicate = result.meta.changes === 0;
|
||||
if (duplicate && logsKey) {
|
||||
await deleteAttemptBlob(env, logsKey);
|
||||
}
|
||||
return { id: payload.id, duplicate, stored: duplicate ? 0 : 1 };
|
||||
} catch (error) {
|
||||
if (logsKey) {
|
||||
await deleteAttemptBlob(env, logsKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRetention(env: DiagnosticsEnv): Promise<void> {
|
||||
const retentionDays = boundedPositiveInt(env.RETENTION_DAYS, 90, 1, 3_650);
|
||||
const cutoff = Date.now() - retentionDays * 86_400_000;
|
||||
// Eight full passes plus the backlog check use at most 43 of D1's 50 queries per invocation.
|
||||
for (let pass = 0; pass < 8; pass += 1) {
|
||||
const hasFullBatch = await runRetentionPass(env, cutoff);
|
||||
if (!hasFullBatch) return;
|
||||
}
|
||||
const [events, crashes, bugs] = await env.DB.batch<{ count: number }>([
|
||||
env.DB.prepare("SELECT COUNT(*) AS count FROM event_batches WHERE received_at < ?").bind(
|
||||
cutoff,
|
||||
),
|
||||
env.DB.prepare("SELECT COUNT(*) AS count FROM crashes WHERE received_at < ?").bind(cutoff),
|
||||
env.DB.prepare("SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?").bind(cutoff),
|
||||
]);
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
message: "diagnostics retention reached its per-run pass limit",
|
||||
cutoff,
|
||||
backlog: {
|
||||
eventBatches: events.results[0]?.count ?? 0,
|
||||
crashes: crashes.results[0]?.count ?? 0,
|
||||
bugs: bugs.results[0]?.count ?? 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function runRetentionPass(env: DiagnosticsEnv, cutoff: number): Promise<boolean> {
|
||||
const reportBatchSize = 900;
|
||||
const eventBatchSize = 1_000;
|
||||
const [crashes, bugs] = await Promise.all([
|
||||
expiredBlobRows(env.DB, "crashes", "stack_r2_key", cutoff, reportBatchSize),
|
||||
expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize),
|
||||
]);
|
||||
|
||||
const blobKeys = [...crashes, ...bugs]
|
||||
.map((row) => row.blobKey)
|
||||
.filter((key): key is string => key !== null);
|
||||
for (let offset = 0; offset < blobKeys.length; offset += 1_000) {
|
||||
await env.BLOBS.delete(blobKeys.slice(offset, offset + 1_000));
|
||||
}
|
||||
|
||||
const statements = [retentionStatement(env.DB, "event_batches", cutoff, eventBatchSize)];
|
||||
if (crashes.length > 0) statements.push(deleteRowsById(env.DB, "crashes", crashes));
|
||||
if (bugs.length > 0) statements.push(deleteRowsById(env.DB, "bugs", bugs));
|
||||
const [eventsResult] = await env.DB.batch(statements);
|
||||
return (
|
||||
eventsResult.meta.changes === eventBatchSize ||
|
||||
crashes.length === reportBatchSize ||
|
||||
bugs.length === reportBatchSize
|
||||
);
|
||||
}
|
||||
|
||||
interface ExpiredBlobRow {
|
||||
id: string;
|
||||
blobKey: string | null;
|
||||
}
|
||||
|
||||
async function expiredBlobRows(
|
||||
database: D1Database,
|
||||
table: "crashes" | "bugs",
|
||||
column: "stack_r2_key" | "logs_r2_key",
|
||||
cutoff: number,
|
||||
batchSize: number,
|
||||
): Promise<ExpiredBlobRow[]> {
|
||||
const result = await database
|
||||
.prepare(
|
||||
`SELECT id, ${column} AS blobKey
|
||||
FROM ${table}
|
||||
WHERE received_at < ?
|
||||
ORDER BY received_at
|
||||
LIMIT ?`,
|
||||
)
|
||||
.bind(cutoff, batchSize)
|
||||
.all<ExpiredBlobRow>();
|
||||
return result.results;
|
||||
}
|
||||
|
||||
function retentionStatement(
|
||||
database: D1Database,
|
||||
table: "event_batches" | "crashes" | "bugs",
|
||||
cutoff: number,
|
||||
batchSize: number,
|
||||
): D1PreparedStatement {
|
||||
return database
|
||||
.prepare(
|
||||
`DELETE FROM ${table}
|
||||
WHERE rowid IN (
|
||||
SELECT rowid FROM ${table} WHERE received_at < ? ORDER BY received_at LIMIT ?
|
||||
)`,
|
||||
)
|
||||
.bind(cutoff, batchSize);
|
||||
}
|
||||
|
||||
function deleteRowsById(
|
||||
database: D1Database,
|
||||
table: "crashes" | "bugs",
|
||||
rows: ExpiredBlobRow[],
|
||||
): D1PreparedStatement {
|
||||
return database
|
||||
.prepare(`DELETE FROM ${table} WHERE id IN (SELECT value FROM json_each(?))`)
|
||||
.bind(JSON.stringify(rows.map((row) => row.id)));
|
||||
}
|
||||
|
||||
async function crashFingerprint(exceptionType: string, stackTrace: string): Promise<string> {
|
||||
const topFrames = stackTrace
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join("\n");
|
||||
const bytes = new TextEncoder().encode(`${exceptionType}\n${topFrames}`);
|
||||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
||||
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function deleteAttemptBlob(env: DiagnosticsEnv, key: string): Promise<void> {
|
||||
try {
|
||||
await env.BLOBS.delete(key);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "failed to remove uncommitted diagnostics blob",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPositiveInt(
|
||||
raw: string | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
4
services/diagnostics-api/test/apply-migrations.ts
Normal file
4
services/diagnostics-api/test/apply-migrations.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { applyD1Migrations } from "cloudflare:test";
|
||||
|
||||
await applyD1Migrations(env.DB, env.TEST_MIGRATIONS);
|
||||
12
services/diagnostics-api/test/env.d.ts
vendored
Normal file
12
services/diagnostics-api/test/env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { D1Migration } from "@cloudflare/vitest-pool-workers";
|
||||
|
||||
declare global {
|
||||
namespace Cloudflare {
|
||||
interface Env {
|
||||
INGEST_KEY: string;
|
||||
TEST_MIGRATIONS: D1Migration[];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
266
services/diagnostics-api/test/input.test.ts
Normal file
266
services/diagnostics-api/test/input.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_BREADCRUMBS_JSON_BYTES,
|
||||
MAX_DEVICE_JSON_BYTES,
|
||||
MAX_LOG_BYTES,
|
||||
normalizeBug,
|
||||
normalizeCrash,
|
||||
normalizeEvents,
|
||||
readJsonObject,
|
||||
} from "../src/input";
|
||||
|
||||
const ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const INSTALL_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const ENCODER = new TextEncoder();
|
||||
|
||||
describe("readJsonObject", () => {
|
||||
it("enforces the byte limit on a chunked body without Content-Length", async () => {
|
||||
const bytes = ENCODER.encode(JSON.stringify({ value: "😀".repeat(40) }));
|
||||
const request = chunkedJsonRequest([
|
||||
bytes.subarray(0, 7),
|
||||
bytes.subarray(7, 31),
|
||||
bytes.subarray(31),
|
||||
]);
|
||||
|
||||
expect(request.headers.has("content-length")).toBe(false);
|
||||
await expect(readJsonObject(request, 64)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 413,
|
||||
error: "payload_too_large",
|
||||
});
|
||||
});
|
||||
|
||||
it("joins chunks before fatally decoding UTF-8", async () => {
|
||||
const bytes = ENCODER.encode(JSON.stringify({ value: "😀" }));
|
||||
const emojiStart = bytes.indexOf(0xf0);
|
||||
const request = chunkedJsonRequest([
|
||||
bytes.subarray(0, emojiStart + 2),
|
||||
bytes.subarray(emojiStart + 2),
|
||||
]);
|
||||
|
||||
await expect(readJsonObject(request, bytes.byteLength)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { value: "😀" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed UTF-8", async () => {
|
||||
const request = chunkedJsonRequest([
|
||||
new Uint8Array([0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xc3, 0x28, 0x22, 0x7d]),
|
||||
]);
|
||||
|
||||
await expect(readJsonObject(request, 100)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "invalid_utf8",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires application/json with a UTF-8 charset", async () => {
|
||||
const missing = new Request("https://example.test/v1/events", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
const wrongCharset = chunkedJsonRequest([ENCODER.encode("{}")], "application/json; charset=utf-16");
|
||||
|
||||
await expect(readJsonObject(missing, 100)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 415,
|
||||
error: "unsupported_media_type",
|
||||
});
|
||||
await expect(readJsonObject(wrongCharset, 100)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 415,
|
||||
error: "unsupported_media_type",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates Content-Length before reading the body", async () => {
|
||||
const invalid = chunkedJsonRequest([ENCODER.encode("{}")], "application/json", "invalid");
|
||||
const oversized = chunkedJsonRequest(
|
||||
[ENCODER.encode("{}")],
|
||||
"application/json",
|
||||
"999999999999999999999999999999999999",
|
||||
);
|
||||
|
||||
await expect(readJsonObject(invalid, 100)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "invalid_content_length",
|
||||
});
|
||||
await expect(readJsonObject(oversized, 100)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 413,
|
||||
error: "payload_too_large",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a non-object JSON root", async () => {
|
||||
const request = chunkedJsonRequest([ENCODER.encode("null")]);
|
||||
|
||||
await expect(readJsonObject(request, 100)).resolves.toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "invalid_body",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizers", () => {
|
||||
it("preserves false booleans and rejects their string representation", () => {
|
||||
const crash = crashPayload(false);
|
||||
const normalizedCrash = normalizeCrash(crash);
|
||||
expect(normalizedCrash.ok).toBe(true);
|
||||
if (normalizedCrash.ok) {
|
||||
expect(normalizedCrash.value.diagnosticsEnabledAtCapture).toBe(false);
|
||||
}
|
||||
expect(normalizeCrash(crashPayload("false"))).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "invalid_diagnostics_enabled",
|
||||
});
|
||||
|
||||
const bug = bugPayload({ include_logs: false, logs: "discard me" });
|
||||
const normalizedBug = normalizeBug(bug);
|
||||
expect(normalizedBug.ok).toBe(true);
|
||||
if (normalizedBug.ok) expect(normalizedBug.value.logs).toBe("");
|
||||
const missingConsent = normalizeBug(bugPayload({ logs: "discard me" }));
|
||||
expect(missingConsent.ok && missingConsent.value.logs).toBe("");
|
||||
expect(normalizeBug(bugPayload({ include_logs: "false" }))).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "invalid_include_logs",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps logs, breadcrumbs, and device JSON within valid byte budgets", () => {
|
||||
const properties = Object.fromEntries(
|
||||
Array.from({ length: 12 }, (_, index) => [`key-${index}-${"\u0000".repeat(40)}`, "\u0000".repeat(128)]),
|
||||
);
|
||||
const breadcrumbs = Array.from({ length: 40 }, (_, index) => ({
|
||||
name: `crumb-${index}`,
|
||||
timestamp_millis: index,
|
||||
properties,
|
||||
}));
|
||||
const result = normalizeBug(
|
||||
bugPayload({
|
||||
include_logs: true,
|
||||
logs: "😀".repeat(60_000),
|
||||
breadcrumbs,
|
||||
device: {
|
||||
device_name: "\u0000".repeat(200),
|
||||
device_model: "\u0000".repeat(200),
|
||||
operating_system: "\u0000".repeat(300),
|
||||
network: "\u0000".repeat(150),
|
||||
battery_level: "\u0000".repeat(100),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const breadcrumbsJson = JSON.stringify(result.value.breadcrumbs);
|
||||
const deviceJson = JSON.stringify(result.value.device);
|
||||
expect(ENCODER.encode(result.value.logs).byteLength).toBe(MAX_LOG_BYTES);
|
||||
expect(ENCODER.encode(breadcrumbsJson).byteLength).toBeLessThanOrEqual(
|
||||
MAX_BREADCRUMBS_JSON_BYTES,
|
||||
);
|
||||
expect(ENCODER.encode(deviceJson).byteLength).toBeLessThanOrEqual(MAX_DEVICE_JSON_BYTES);
|
||||
expect(JSON.parse(breadcrumbsJson)).toEqual(result.value.breadcrumbs);
|
||||
expect(JSON.parse(deviceJson)).toEqual(result.value.device);
|
||||
});
|
||||
|
||||
it("requires stable report IDs and validates supplied IDs and schema versions", () => {
|
||||
const result = normalizeEvents({
|
||||
events: [{ name: "opened", ts: 1, schema_version: 1 }],
|
||||
});
|
||||
expect(result).toEqual({ ok: false, status: 400, error: "invalid_batch_id" });
|
||||
const legacyInstall = normalizeEvents({
|
||||
batch_id: ID,
|
||||
install_id: "legacy-test-install",
|
||||
events: [{ name: "opened", ts: 1 }],
|
||||
});
|
||||
expect(legacyInstall.ok && legacyInstall.value.installId).toBe("legacy-test-install");
|
||||
const missingInstall = normalizeEvents({
|
||||
batch_id: ID,
|
||||
events: [{ name: "opened", ts: 1 }],
|
||||
});
|
||||
expect(missingInstall.ok && missingInstall.value.installId).toBe("unknown");
|
||||
expect(
|
||||
normalizeEvents({
|
||||
batch_id: ID,
|
||||
install_id: "bad\u0000install",
|
||||
events: [{ name: "opened", ts: 1 }],
|
||||
}),
|
||||
).toEqual({ ok: false, status: 400, error: "invalid_install_id" });
|
||||
|
||||
expect(
|
||||
normalizeEvents({
|
||||
batch_id: "not-a-uuid",
|
||||
events: [{ name: "opened", timestamp_millis: 1 }],
|
||||
}),
|
||||
).toEqual({ ok: false, status: 400, error: "invalid_batch_id" });
|
||||
expect(
|
||||
normalizeEvents({
|
||||
batch_id: ID,
|
||||
install_id: INSTALL_ID,
|
||||
events: [{ name: "opened", timestamp_millis: 1, schema_version: 2 }],
|
||||
}),
|
||||
).toEqual({ ok: false, status: 400, error: "unsupported_schema_version" });
|
||||
});
|
||||
});
|
||||
|
||||
function chunkedJsonRequest(
|
||||
chunks: readonly Uint8Array[],
|
||||
contentType = "application/json; charset=utf-8",
|
||||
contentLength?: string,
|
||||
): Request {
|
||||
return new Request("https://example.test/v1/events", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
...(contentLength == null ? {} : { "content-length": contentLength }),
|
||||
},
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(chunk);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function crashPayload(diagnosticsEnabled: unknown): Record<string, unknown> {
|
||||
return {
|
||||
id: ID,
|
||||
install_id: INSTALL_ID,
|
||||
app_version: "1.0",
|
||||
platform: "test",
|
||||
exception_type: "ExampleError",
|
||||
exception_message: "message",
|
||||
stack_trace: "stack",
|
||||
occurred_at: 1,
|
||||
diagnostics_enabled: diagnosticsEnabled,
|
||||
schema_version: 1,
|
||||
breadcrumbs: [],
|
||||
};
|
||||
}
|
||||
|
||||
function bugPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: ID,
|
||||
install_id: INSTALL_ID,
|
||||
app_version: "1.0",
|
||||
platform: "test",
|
||||
occurred_at: 1,
|
||||
what_happened: "It failed",
|
||||
expected: "It worked",
|
||||
steps: "Open the app",
|
||||
contact: "",
|
||||
logs: "",
|
||||
device: {},
|
||||
breadcrumbs: [],
|
||||
schema_version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
7
services/diagnostics-api/test/tsconfig.json
Normal file
7
services/diagnostics-api/test/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/vitest-pool-workers/types", "node"]
|
||||
},
|
||||
"include": ["../worker-configuration.d.ts", "../vitest.config.ts", "./**/*.ts"]
|
||||
}
|
||||
606
services/diagnostics-api/test/worker.test.ts
Normal file
606
services/diagnostics-api/test/worker.test.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
import { env, exports } from "cloudflare:workers";
|
||||
import { createExecutionContext } from "cloudflare:test";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import worker from "../src/index";
|
||||
import type {
|
||||
NormalizedBugPayload,
|
||||
NormalizedCrashPayload,
|
||||
NormalizedEventsPayload,
|
||||
} from "../src/input";
|
||||
import {
|
||||
type DiagnosticsEnv,
|
||||
runRetention,
|
||||
storeBug,
|
||||
storeCrash,
|
||||
storeEvents,
|
||||
} from "../src/storage";
|
||||
|
||||
const INSTALL_ID = "10000000-0000-4000-8000-000000000000";
|
||||
|
||||
describe("diagnostics Worker", () => {
|
||||
it("keeps public routing narrow and fails closed", async () => {
|
||||
const live = await exports.default.fetch(new Request("https://diagnostics.test/live"));
|
||||
expect(live.status).toBe(200);
|
||||
expect(await live.json()).toMatchObject({ ok: true, schema: 1 });
|
||||
|
||||
const health = await exports.default.fetch(healthRequest());
|
||||
expect(health.status).toBe(200);
|
||||
expect(await health.json()).toMatchObject({ ok: true });
|
||||
const unauthorizedHealth = await exports.default.fetch(healthRequest("wrong-key"));
|
||||
expect(unauthorizedHealth.status).toBe(401);
|
||||
|
||||
const unknown = await exports.default.fetch(
|
||||
new Request("https://diagnostics.test/v1/not-real", { method: "POST" }),
|
||||
);
|
||||
expect(unknown.status).toBe(404);
|
||||
|
||||
const unauthorized = await exports.default.fetch(
|
||||
jsonRequest("/v1/events", eventPayload(uuid(1)), "wrong-key"),
|
||||
);
|
||||
expect(unauthorized.status).toBe(401);
|
||||
expect(await unauthorized.json()).toEqual({ error: "unauthorized" });
|
||||
expect(unauthorized.headers.get("x-request-id")).toMatch(
|
||||
/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
|
||||
const preflight = await exports.default.fetch(
|
||||
new Request("https://diagnostics.test/v1/events", { method: "OPTIONS" }),
|
||||
);
|
||||
expect(preflight.status).toBe(204);
|
||||
expect(preflight.headers.get("access-control-allow-origin")).toBeNull();
|
||||
expect(preflight.headers.get("allow")).toBe("POST, OPTIONS");
|
||||
});
|
||||
|
||||
it("applies the source limit before shared-key verification", async () => {
|
||||
let installLimitCalls = 0;
|
||||
const limitedEnv: DiagnosticsEnv = {
|
||||
...env,
|
||||
SOURCE_RATE_LIMITER: {
|
||||
limit: async () => ({ success: false }),
|
||||
} as RateLimit,
|
||||
INSTALL_RATE_LIMITER: {
|
||||
limit: async () => {
|
||||
installLimitCalls += 1;
|
||||
return { success: true };
|
||||
},
|
||||
} as RateLimit,
|
||||
};
|
||||
const context = createExecutionContext();
|
||||
|
||||
const response = await worker.fetch(
|
||||
jsonRequest("/v1/events", eventPayload(uuid(3)), "wrong-key", "198.51.100.3"),
|
||||
limitedEnv,
|
||||
context,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.headers.get("retry-after")).toBe("60");
|
||||
expect(await response.json()).toEqual({ error: "rate_limited" });
|
||||
expect(installLimitCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("returns structured errors for invalid bodies and asynchronous storage failures", async () => {
|
||||
const invalid = await exports.default.fetch(jsonRequest("/v1/events", null));
|
||||
expect(invalid.status).toBe(400);
|
||||
expect(await invalid.json()).toEqual({ error: "invalid_body" });
|
||||
|
||||
const rejection = new Error("simulated D1 rejection");
|
||||
const statement = {
|
||||
bind: () => statement,
|
||||
first: async () => Promise.reject(rejection),
|
||||
run: async () => Promise.reject(rejection),
|
||||
};
|
||||
const rejectingDatabase = {
|
||||
prepare: () => statement,
|
||||
} as unknown as D1Database;
|
||||
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
|
||||
|
||||
const healthContext = createExecutionContext();
|
||||
const unhealthy = await worker.fetch(
|
||||
healthRequest(env.INGEST_KEY, "198.51.100.4"),
|
||||
rejectingEnv,
|
||||
healthContext,
|
||||
);
|
||||
expect(unhealthy.status).toBe(503);
|
||||
expect(await unhealthy.json()).toEqual({ ok: false, error: "dependency_unavailable" });
|
||||
|
||||
const ingestContext = createExecutionContext();
|
||||
const failedIngest = await worker.fetch(
|
||||
jsonRequest("/v1/events", eventPayload(uuid(2)), env.INGEST_KEY, "198.51.100.2"),
|
||||
rejectingEnv,
|
||||
ingestContext,
|
||||
);
|
||||
expect(failedIngest.status).toBe(500);
|
||||
expect(await failedIngest.json()).toEqual({ error: "internal" });
|
||||
});
|
||||
|
||||
it("deduplicates event batches using the client batch ID", async () => {
|
||||
const id = uuid(10);
|
||||
const first = await exports.default.fetch(jsonRequest("/v1/events", eventPayload(id)));
|
||||
const second = await exports.default.fetch(jsonRequest("/v1/events", eventPayload(id)));
|
||||
|
||||
expect(first.status).toBe(202);
|
||||
expect(await first.json()).toMatchObject({
|
||||
ok: true,
|
||||
id,
|
||||
stored: 1,
|
||||
duplicate: false,
|
||||
});
|
||||
expect(second.status).toBe(202);
|
||||
expect(await second.json()).toMatchObject({
|
||||
ok: true,
|
||||
id,
|
||||
stored: 0,
|
||||
duplicate: true,
|
||||
});
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT event_count AS eventCount, payload_json AS payloadJson FROM event_batches WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ eventCount: number; payloadJson: string }>();
|
||||
expect(row?.eventCount).toBe(1);
|
||||
expect(JSON.parse(row?.payloadJson ?? "null")).toEqual([
|
||||
{
|
||||
name: "app_open",
|
||||
timestampMillis: 1,
|
||||
properties: { screen: "home" },
|
||||
schemaVersion: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps D1 idempotency when the optional analytics index is enabled", async () => {
|
||||
const points: AnalyticsEngineDataPoint[] = [];
|
||||
const analytics = {
|
||||
writeDataPoint: (point: AnalyticsEngineDataPoint) => points.push(point),
|
||||
} as AnalyticsEngineDataset;
|
||||
const analyticsEnv: DiagnosticsEnv = { ...env, AE: analytics };
|
||||
const payload: NormalizedEventsPayload = {
|
||||
batchId: uuid(11),
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
events: [
|
||||
{
|
||||
name: "indexed",
|
||||
timestampMillis: 1,
|
||||
properties: {},
|
||||
schemaVersion: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(await storeEvents(payload, analyticsEnv)).toMatchObject({ duplicate: false, stored: 1 });
|
||||
expect(await storeEvents(payload, analyticsEnv)).toMatchObject({ duplicate: true, stored: 0 });
|
||||
expect(points).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the accepted crash blob when a duplicate request arrives", async () => {
|
||||
const id = uuid(20);
|
||||
const first = await exports.default.fetch(
|
||||
jsonRequest("/v1/crashes", crashPayload(id, "first stack")),
|
||||
);
|
||||
const second = await exports.default.fetch(
|
||||
jsonRequest("/v1/crashes", crashPayload(id, "second stack")),
|
||||
);
|
||||
|
||||
expect(first.status).toBe(202);
|
||||
const firstBody = await first.json<{ fingerprint: string }>();
|
||||
expect(firstBody).toMatchObject({ ok: true, id, duplicate: false });
|
||||
expect(second.status).toBe(202);
|
||||
const secondBody = await second.json<{ fingerprint: string }>();
|
||||
expect(secondBody).toMatchObject({ ok: true, id, duplicate: true });
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT stack_r2_key AS stackKey, breadcrumbs_json AS breadcrumbsJson,
|
||||
fingerprint
|
||||
FROM crashes WHERE id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ stackKey: string; breadcrumbsJson: string; fingerprint: string }>();
|
||||
expect(row?.stackKey).toMatch(new RegExp(`^crashes/${id}/[0-9a-f-]+/stack\\.txt$`));
|
||||
expect(firstBody.fingerprint).toBe(row?.fingerprint);
|
||||
expect(secondBody.fingerprint).toBe(row?.fingerprint);
|
||||
expect(JSON.parse(row?.breadcrumbsJson ?? "null")).toEqual([]);
|
||||
expect(await (await env.BLOBS.get(row?.stackKey ?? "missing"))?.text()).toBe("first stack");
|
||||
|
||||
const objects = await env.BLOBS.list({ prefix: `crashes/${id}/` });
|
||||
expect(objects.objects.map((object) => object.key)).toEqual([row?.stackKey]);
|
||||
});
|
||||
|
||||
it("stores bug metadata as JSON and cleans the duplicate upload attempt", async () => {
|
||||
const id = uuid(30);
|
||||
const payload = bugPayload(id, "first logs");
|
||||
const first = await exports.default.fetch(jsonRequest("/v1/bugs", payload));
|
||||
const second = await exports.default.fetch(
|
||||
jsonRequest("/v1/bugs", bugPayload(id, "second logs")),
|
||||
);
|
||||
|
||||
expect(first.status).toBe(202);
|
||||
expect(await first.json()).toMatchObject({ ok: true, id, duplicate: false });
|
||||
expect(second.status).toBe(202);
|
||||
expect(await second.json()).toMatchObject({ ok: true, id, duplicate: true });
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT occurred_at AS occurredAt, logs_r2_key AS logsKey,
|
||||
device_json AS deviceJson, breadcrumbs_json AS breadcrumbsJson
|
||||
FROM bugs WHERE id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
.first<{
|
||||
occurredAt: number;
|
||||
logsKey: string;
|
||||
deviceJson: string;
|
||||
breadcrumbsJson: string;
|
||||
}>();
|
||||
expect(row?.occurredAt).toBe(3);
|
||||
expect(JSON.parse(row?.deviceJson ?? "null")).toEqual({
|
||||
deviceName: "Test device",
|
||||
deviceModel: "Model",
|
||||
operatingSystem: "Test OS",
|
||||
network: "offline",
|
||||
batteryLevel: "90%",
|
||||
});
|
||||
expect(JSON.parse(row?.breadcrumbsJson ?? "null")).toEqual([
|
||||
{ name: "opened", timestampMillis: 2, properties: {} },
|
||||
]);
|
||||
expect(await (await env.BLOBS.get(row?.logsKey ?? "missing"))?.text()).toBe("first logs");
|
||||
|
||||
const objects = await env.BLOBS.list({ prefix: `bugs/${id}/` });
|
||||
expect(objects.objects.map((object) => object.key)).toEqual([row?.logsKey]);
|
||||
});
|
||||
|
||||
it("acknowledges known report IDs without touching an unavailable blob store", async () => {
|
||||
const crash = normalizedCrash(uuid(31), "accepted stack");
|
||||
const bug = normalizedBug(uuid(32), "accepted logs");
|
||||
const firstCrash = await storeCrash(crash, env);
|
||||
await storeBug(bug, env);
|
||||
let blobWrites = 0;
|
||||
const unavailableBlobs = {
|
||||
put: async () => {
|
||||
blobWrites += 1;
|
||||
throw new Error("simulated R2 outage");
|
||||
},
|
||||
} as unknown as R2Bucket;
|
||||
const unavailableEnv: DiagnosticsEnv = { ...env, BLOBS: unavailableBlobs };
|
||||
|
||||
await expect(
|
||||
storeCrash({ ...crash, stackTrace: "retry stack" }, unavailableEnv),
|
||||
).resolves.toEqual({
|
||||
id: crash.id,
|
||||
duplicate: true,
|
||||
stored: 0,
|
||||
fingerprint: firstCrash.fingerprint,
|
||||
});
|
||||
await expect(
|
||||
storeBug({ ...bug, logs: "retry logs" }, unavailableEnv),
|
||||
).resolves.toEqual({ id: bug.id, duplicate: true, stored: 0 });
|
||||
expect(blobWrites).toBe(0);
|
||||
});
|
||||
|
||||
it("removes uploaded report blobs when D1 rejects the metadata write", async () => {
|
||||
const rejection = new Error("simulated D1 write rejection");
|
||||
const rejectingDatabase = databaseWithSession(
|
||||
() => null,
|
||||
async () => Promise.reject(rejection),
|
||||
);
|
||||
const rejectingEnv: DiagnosticsEnv = { ...env, DB: rejectingDatabase };
|
||||
const crashId = uuid(33);
|
||||
const bugId = uuid(34);
|
||||
|
||||
const crashResponse = await worker.fetch(
|
||||
jsonRequest("/v1/crashes", crashPayload(crashId, "orphan candidate"), env.INGEST_KEY, "198.51.100.33"),
|
||||
rejectingEnv,
|
||||
createExecutionContext(),
|
||||
);
|
||||
const bugResponse = await worker.fetch(
|
||||
jsonRequest("/v1/bugs", bugPayload(bugId, "orphan candidate"), env.INGEST_KEY, "198.51.100.34"),
|
||||
rejectingEnv,
|
||||
createExecutionContext(),
|
||||
);
|
||||
|
||||
expect(crashResponse.status).toBe(500);
|
||||
expect(await crashResponse.json()).toEqual({ error: "internal" });
|
||||
expect(bugResponse.status).toBe(500);
|
||||
expect(await bugResponse.json()).toEqual({ error: "internal" });
|
||||
expect((await env.BLOBS.list({ prefix: `crashes/${crashId}/` })).objects).toEqual([]);
|
||||
expect((await env.BLOBS.list({ prefix: `bugs/${bugId}/` })).objects).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes expired rows and their exact R2 objects while preserving current data", async () => {
|
||||
const oldEventId = uuid(40);
|
||||
const oldCrashId = uuid(41);
|
||||
const oldBugId = uuid(42);
|
||||
const currentEventId = uuid(43);
|
||||
const oldCrashKey = `crashes/${oldCrashId}/retention/stack.txt`;
|
||||
const oldBugKey = `bugs/${oldBugId}/retention/logs.txt`;
|
||||
const oldReceivedAt = Date.now() - 100 * 86_400_000;
|
||||
|
||||
await Promise.all([
|
||||
env.BLOBS.put(oldCrashKey, "expired crash"),
|
||||
env.BLOBS.put(oldBugKey, "expired logs"),
|
||||
]);
|
||||
await env.DB.batch([
|
||||
env.DB.prepare(
|
||||
`INSERT INTO event_batches
|
||||
(id, received_at, install_id, app_version, platform, event_count, payload_json)
|
||||
VALUES (?, ?, ?, '', '', 1, '[]')`,
|
||||
).bind(oldEventId, oldReceivedAt, INSTALL_ID),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO event_batches
|
||||
(id, received_at, install_id, app_version, platform, event_count, payload_json)
|
||||
VALUES (?, ?, ?, '', '', 1, '[]')`,
|
||||
).bind(currentEventId, Date.now(), INSTALL_ID),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO crashes
|
||||
(id, received_at, occurred_at, install_id, app_version, platform,
|
||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
||||
stack_r2_key, breadcrumbs_json, schema_version)
|
||||
VALUES (?, ?, ?, ?, '', '', 'Error', '', 'fingerprint', 1, ?, '[]', 1)`,
|
||||
).bind(oldCrashId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldCrashKey),
|
||||
env.DB.prepare(
|
||||
`INSERT INTO bugs
|
||||
(id, received_at, occurred_at, install_id, app_version, platform,
|
||||
what_happened, expected, steps, contact, logs_r2_key,
|
||||
device_json, breadcrumbs_json, status, schema_version)
|
||||
VALUES (?, ?, ?, ?, '', '', 'failed', 'worked', '', '', ?, '{}', '[]', 'open', 1)`,
|
||||
).bind(oldBugId, oldReceivedAt, oldReceivedAt, INSTALL_ID, oldBugKey),
|
||||
]);
|
||||
|
||||
await runRetention(env);
|
||||
|
||||
for (const [table, id] of [
|
||||
["event_batches", oldEventId],
|
||||
["crashes", oldCrashId],
|
||||
["bugs", oldBugId],
|
||||
] as const) {
|
||||
const row = await env.DB.prepare(`SELECT id FROM ${table} WHERE id = ?`).bind(id).first();
|
||||
expect(row).toBeNull();
|
||||
}
|
||||
expect(await env.BLOBS.head(oldCrashKey)).toBeNull();
|
||||
expect(await env.BLOBS.head(oldBugKey)).toBeNull();
|
||||
expect(
|
||||
await env.DB.prepare("SELECT id FROM event_batches WHERE id = ?").bind(currentEventId).first(),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("bounds a full retention run below the D1 per-invocation query limit", async () => {
|
||||
let queryCount = 0;
|
||||
let batchCalls = 0;
|
||||
const blobDeleteBatchSizes: number[] = [];
|
||||
const rows = Array.from({ length: 900 }, (_, index) => ({
|
||||
id: `expired-${index}`,
|
||||
blobKey: `expired/${index}`,
|
||||
}));
|
||||
const database = {
|
||||
prepare: () => {
|
||||
const statement = {
|
||||
bind: () => statement,
|
||||
all: async () => {
|
||||
queryCount += 1;
|
||||
return d1Result(rows, 0);
|
||||
},
|
||||
};
|
||||
return statement;
|
||||
},
|
||||
batch: async (statements: D1PreparedStatement[]) => {
|
||||
batchCalls += 1;
|
||||
queryCount += statements.length;
|
||||
if (batchCalls === 9) {
|
||||
return statements.map(() => d1Result([{ count: 1 }], 0));
|
||||
}
|
||||
return statements.map((_, index) => d1Result([], index === 0 ? 1_000 : 900));
|
||||
},
|
||||
} as unknown as D1Database;
|
||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const blobs = {
|
||||
delete: async (keys: string | string[]) => {
|
||||
blobDeleteBatchSizes.push(typeof keys === "string" ? 1 : keys.length);
|
||||
},
|
||||
} as unknown as R2Bucket;
|
||||
|
||||
try {
|
||||
await runRetention({ ...env, DB: database, BLOBS: blobs });
|
||||
} finally {
|
||||
warning.mockRestore();
|
||||
}
|
||||
|
||||
expect(queryCount).toBe(43);
|
||||
expect(blobDeleteBatchSizes).toHaveLength(16);
|
||||
expect(Math.max(...blobDeleteBatchSizes)).toBe(1_000);
|
||||
});
|
||||
|
||||
it("converges an expired report backlog across bounded retention runs", async () => {
|
||||
const oldReceivedAt = Date.now() - 100 * 86_400_000;
|
||||
await env.DB.prepare(
|
||||
`WITH digits(value) AS (
|
||||
VALUES (0), (1), (2), (3), (4), (5), (6), (7), (8), (9)
|
||||
), sequence(value) AS (
|
||||
SELECT thousands.value * 1000 + hundreds.value * 100 + tens.value * 10 + ones.value + 1
|
||||
FROM digits AS thousands
|
||||
CROSS JOIN digits AS hundreds
|
||||
CROSS JOIN digits AS tens
|
||||
CROSS JOIN digits AS ones
|
||||
WHERE thousands.value * 1000 + hundreds.value * 100 + tens.value * 10 + ones.value < 7201
|
||||
)
|
||||
INSERT INTO crashes (
|
||||
id, received_at, occurred_at, install_id, app_version, platform,
|
||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
||||
stack_r2_key, breadcrumbs_json, schema_version
|
||||
)
|
||||
SELECT 'retention-backlog-' || printf('%04d', value), ?, ?, ?, '', '',
|
||||
'Error', '', 'fingerprint-' || value, 0, NULL, '[]', 1
|
||||
FROM sequence`,
|
||||
)
|
||||
.bind(oldReceivedAt, oldReceivedAt, INSTALL_ID)
|
||||
.run();
|
||||
const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await runRetention(env);
|
||||
const afterFirstRun = await env.DB.prepare(
|
||||
"SELECT COUNT(*) AS count FROM crashes WHERE id LIKE 'retention-backlog-%'",
|
||||
).first<{ count: number }>();
|
||||
expect(afterFirstRun?.count).toBe(1);
|
||||
|
||||
await runRetention(env);
|
||||
const afterSecondRun = await env.DB.prepare(
|
||||
"SELECT COUNT(*) AS count FROM crashes WHERE id LIKE 'retention-backlog-%'",
|
||||
).first<{ count: number }>();
|
||||
expect(afterSecondRun?.count).toBe(0);
|
||||
} finally {
|
||||
warning.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function jsonRequest(
|
||||
path: string,
|
||||
body: unknown,
|
||||
key = env.INGEST_KEY,
|
||||
source = "198.51.100.1",
|
||||
): Request {
|
||||
return new Request(`https://diagnostics.test${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cf-connecting-ip": source,
|
||||
"x-vnidrop-install-id": INSTALL_ID,
|
||||
"x-vnidrop-key": key,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function healthRequest(key = env.INGEST_KEY, source = "198.51.100.1"): Request {
|
||||
return new Request("https://diagnostics.test/health", {
|
||||
headers: {
|
||||
"cf-connecting-ip": source,
|
||||
"x-vnidrop-key": key,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function eventPayload(batchId: string): Record<string, unknown> {
|
||||
return {
|
||||
batchId,
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
events: [
|
||||
{
|
||||
name: "app_open",
|
||||
timestampMillis: 1,
|
||||
properties: { screen: "home" },
|
||||
schemaVersion: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function crashPayload(id: string, stackTrace: string): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
exceptionType: "TestError",
|
||||
exceptionMessage: "failed",
|
||||
stackTrace,
|
||||
timestampMillis: 2,
|
||||
diagnosticsEnabledAtCapture: true,
|
||||
breadcrumbs: [],
|
||||
schemaVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function bugPayload(id: string, logs: string): Record<string, unknown> {
|
||||
return {
|
||||
id,
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
timestampMillis: 3,
|
||||
whatHappened: "It failed",
|
||||
expected: "It should work",
|
||||
steps: "Open the app",
|
||||
contact: "",
|
||||
includeLogs: true,
|
||||
logs,
|
||||
device: {
|
||||
deviceName: "Test device",
|
||||
deviceModel: "Model",
|
||||
operatingSystem: "Test OS",
|
||||
network: "offline",
|
||||
batteryLevel: "90%",
|
||||
},
|
||||
breadcrumbs: [{ name: "opened", timestampMillis: 2, properties: {} }],
|
||||
schemaVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedCrash(id: string, stackTrace: string): NormalizedCrashPayload {
|
||||
return {
|
||||
id,
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
exceptionType: "TestError",
|
||||
exceptionMessage: "failed",
|
||||
stackTrace,
|
||||
occurredAt: 2,
|
||||
diagnosticsEnabledAtCapture: true,
|
||||
breadcrumbs: [],
|
||||
schemaVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedBug(id: string, logs: string): NormalizedBugPayload {
|
||||
return {
|
||||
id,
|
||||
installId: INSTALL_ID,
|
||||
appVersion: "1.0",
|
||||
platform: "test",
|
||||
occurredAt: 3,
|
||||
whatHappened: "It failed",
|
||||
expected: "It should work",
|
||||
steps: "Open the app",
|
||||
contact: "",
|
||||
logs,
|
||||
device: {
|
||||
deviceName: "Test device",
|
||||
deviceModel: "Model",
|
||||
operatingSystem: "Test OS",
|
||||
network: "offline",
|
||||
batteryLevel: "90%",
|
||||
},
|
||||
breadcrumbs: [],
|
||||
schemaVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function databaseWithSession(
|
||||
first: () => unknown,
|
||||
run: () => Promise<D1Result>,
|
||||
): D1Database {
|
||||
const statement = {
|
||||
bind: () => statement,
|
||||
first: async () => first(),
|
||||
run,
|
||||
} as unknown as D1PreparedStatement;
|
||||
const session = {
|
||||
prepare: () => statement,
|
||||
} as unknown as D1DatabaseSession;
|
||||
return {
|
||||
withSession: () => session,
|
||||
} as unknown as D1Database;
|
||||
}
|
||||
|
||||
function d1Result<T>(results: T[], changes: number): D1Result<T> {
|
||||
return { success: true, results, meta: { changes } } as D1Result<T>;
|
||||
}
|
||||
|
||||
function uuid(suffix: number): string {
|
||||
return `00000000-0000-4000-8000-${suffix.toString().padStart(12, "0")}`;
|
||||
}
|
||||
14
services/diagnostics-api/tsconfig.json
Normal file
14
services/diagnostics-api/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["./worker-configuration.d.ts"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
|
||||
}
|
||||
23
services/diagnostics-api/vitest.config.ts
Normal file
23
services/diagnostics-api/vitest.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-pool-workers";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const migrationsPath = resolve(dirname(fileURLToPath(import.meta.url)), "migrations");
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
cloudflareTest(async () => ({
|
||||
wrangler: { configPath: "./wrangler.jsonc" },
|
||||
miniflare: {
|
||||
bindings: {
|
||||
INGEST_KEY: "test-ingest-key",
|
||||
TEST_MIGRATIONS: await readD1Migrations(migrationsPath),
|
||||
},
|
||||
},
|
||||
})),
|
||||
],
|
||||
test: {
|
||||
setupFiles: ["./test/apply-migrations.ts"],
|
||||
},
|
||||
});
|
||||
14719
services/diagnostics-api/worker-configuration.d.ts
vendored
Normal file
14719
services/diagnostics-api/worker-configuration.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
57
services/diagnostics-api/wrangler.jsonc
Normal file
57
services/diagnostics-api/wrangler.jsonc
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"$schema": "./node_modules/wrangler/config-schema.json",
|
||||
"name": "vnidrop-diagnostics",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-07-14",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 0.1,
|
||||
},
|
||||
"triggers": {
|
||||
"crons": ["17 * * * *"],
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "vnidrop-diagnostics",
|
||||
// Replace this placeholder with the ID returned by `wrangler d1 create`.
|
||||
"database_id": "00000000-0000-0000-0000-000000000000",
|
||||
"migrations_dir": "migrations",
|
||||
},
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "BLOBS",
|
||||
"bucket_name": "vnidrop-diagnostics",
|
||||
},
|
||||
],
|
||||
"ratelimits": [
|
||||
{
|
||||
"name": "INSTALL_RATE_LIMITER",
|
||||
"namespace_id": "1001",
|
||||
"simple": {
|
||||
"limit": 30,
|
||||
"period": 60,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "SOURCE_RATE_LIMITER",
|
||||
"namespace_id": "1002",
|
||||
"simple": {
|
||||
"limit": 120,
|
||||
"period": 60,
|
||||
},
|
||||
},
|
||||
],
|
||||
// Optional: bind Analytics Engine as `AE` when event volume justifies it.
|
||||
// "analytics_engine_datasets": [
|
||||
// { "binding": "AE", "dataset": "vnidrop_events" },
|
||||
// ],
|
||||
"vars": {
|
||||
"MAX_BODY_BYTES": "262144",
|
||||
"MAX_EVENTS_PER_BATCH": "50",
|
||||
"RETENTION_DAYS": "90",
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user