feat(diagnostics): harden ingestion and delivery

This commit is contained in:
2026-07-15 00:43:49 +02:00
parent ead4e09a60
commit b602a3acb6
42 changed files with 22428 additions and 141 deletions

6
services/diagnostics-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
.wrangler/
dist/
.dev.vars*
.env*
*.log

View 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.

View 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);

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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;
}

View 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 };
}

View 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;
}

View 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
View 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 {};

View 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,
};
}

View 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"]
}

View 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")}`;
}

View 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"]
}

View 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"],
},
});

File diff suppressed because it is too large Load Diff

View 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",
},
}