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

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