mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat(diagnostics): harden ingestion and delivery
This commit is contained in:
267
services/diagnostics-api/src/index.ts
Normal file
267
services/diagnostics-api/src/index.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
normalizeBug,
|
||||
normalizeCrash,
|
||||
normalizeEvents,
|
||||
readJsonObject,
|
||||
} from "./input";
|
||||
import {
|
||||
type DiagnosticsEnv,
|
||||
runRetention,
|
||||
storeBug,
|
||||
storeCrash,
|
||||
storeEvents,
|
||||
} from "./storage";
|
||||
|
||||
const DEFAULT_MAX_BODY_BYTES = 262_144;
|
||||
const HARD_MAX_BODY_BYTES = 1_048_576;
|
||||
const DEFAULT_MAX_EVENTS = 50;
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: DiagnosticsEnv, _ctx: ExecutionContext): Promise<Response> {
|
||||
const requestId = crypto.randomUUID();
|
||||
const url = new URL(request.url);
|
||||
try {
|
||||
if (request.method === "GET" && url.pathname === "/live") {
|
||||
return json({ ok: true, service: "vnidrop-diagnostics", schema: 1 }, 200, requestId);
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/health") {
|
||||
if (await sourceRateLimited(request, url.pathname, env)) {
|
||||
return json({ error: "rate_limited" }, 429, requestId, { "retry-after": "60" });
|
||||
}
|
||||
const authError = await authorize(request, env);
|
||||
if (authError) return json({ error: authError.error }, authError.status, requestId);
|
||||
return await readiness(env, requestId);
|
||||
}
|
||||
|
||||
if (!isIngestPath(url.pathname)) {
|
||||
return json({ error: "not_found" }, 404, requestId);
|
||||
}
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: responseHeaders(requestId, { allow: "POST, OPTIONS" }),
|
||||
});
|
||||
}
|
||||
if (request.method !== "POST") {
|
||||
return json(
|
||||
{ error: "method_not_allowed" },
|
||||
405,
|
||||
requestId,
|
||||
{ allow: "POST, OPTIONS" },
|
||||
);
|
||||
}
|
||||
|
||||
if (await sourceRateLimited(request, url.pathname, env)) {
|
||||
return json({ error: "rate_limited" }, 429, requestId, { "retry-after": "60" });
|
||||
}
|
||||
|
||||
const authError = await authorize(request, env);
|
||||
if (authError) return json({ error: authError.error }, authError.status, requestId);
|
||||
|
||||
if (await installRateLimited(request, url.pathname, env)) {
|
||||
return json({ error: "rate_limited" }, 429, requestId, { "retry-after": "60" });
|
||||
}
|
||||
|
||||
const maxBodyBytes = boundedPositiveInt(
|
||||
env.MAX_BODY_BYTES,
|
||||
DEFAULT_MAX_BODY_BYTES,
|
||||
1,
|
||||
HARD_MAX_BODY_BYTES,
|
||||
);
|
||||
const parsed = await readJsonObject(request, maxBodyBytes);
|
||||
if (!parsed.ok) return json({ error: parsed.error }, parsed.status, requestId);
|
||||
|
||||
switch (url.pathname) {
|
||||
case "/v1/events": {
|
||||
const maxEvents = boundedPositiveInt(env.MAX_EVENTS_PER_BATCH, DEFAULT_MAX_EVENTS, 1, 100);
|
||||
const normalized = normalizeEvents(parsed.value, maxEvents);
|
||||
if (!normalized.ok) {
|
||||
return json({ error: normalized.error }, normalized.status, requestId);
|
||||
}
|
||||
const result = await storeEvents(normalized.value, env);
|
||||
return json(
|
||||
{
|
||||
ok: true,
|
||||
id: result.id,
|
||||
stored: result.stored,
|
||||
duplicate: result.duplicate,
|
||||
},
|
||||
202,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
case "/v1/crashes": {
|
||||
const normalized = normalizeCrash(parsed.value);
|
||||
if (!normalized.ok) {
|
||||
return json({ error: normalized.error }, normalized.status, requestId);
|
||||
}
|
||||
const result = await storeCrash(normalized.value, env);
|
||||
return json(
|
||||
{
|
||||
ok: true,
|
||||
id: result.id,
|
||||
fingerprint: result.fingerprint,
|
||||
duplicate: result.duplicate,
|
||||
},
|
||||
202,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
case "/v1/bugs": {
|
||||
const normalized = normalizeBug(parsed.value);
|
||||
if (!normalized.ok) {
|
||||
return json({ error: normalized.error }, normalized.status, requestId);
|
||||
}
|
||||
const result = await storeBug(normalized.value, env);
|
||||
return json(
|
||||
{ ok: true, id: result.id, duplicate: result.duplicate },
|
||||
202,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "diagnostics request failed",
|
||||
requestId,
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
return json({ error: "internal" }, 500, requestId);
|
||||
}
|
||||
},
|
||||
|
||||
scheduled(
|
||||
controller: ScheduledController,
|
||||
env: DiagnosticsEnv,
|
||||
ctx: ExecutionContext,
|
||||
): void {
|
||||
ctx.waitUntil(
|
||||
runRetention(env).catch((error) => {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "diagnostics retention failed",
|
||||
scheduledTime: controller.scheduledTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
} satisfies ExportedHandler<DiagnosticsEnv>;
|
||||
|
||||
async function readiness(env: DiagnosticsEnv, requestId: string): Promise<Response> {
|
||||
if (!env.INGEST_KEY) {
|
||||
return json({ ok: false, error: "server_misconfigured" }, 503, requestId);
|
||||
}
|
||||
try {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare(
|
||||
"SELECT id, received_at, install_id, payload_json FROM event_batches LIMIT 1",
|
||||
),
|
||||
env.DB.prepare(
|
||||
"SELECT id, occurred_at, stack_r2_key, breadcrumbs_json FROM crashes LIMIT 1",
|
||||
),
|
||||
env.DB.prepare(
|
||||
"SELECT id, occurred_at, logs_r2_key, device_json FROM bugs LIMIT 1",
|
||||
),
|
||||
]);
|
||||
return json({ ok: true, service: "vnidrop-diagnostics", schema: 1 }, 200, requestId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "diagnostics readiness check failed",
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
return json({ ok: false, error: "dependency_unavailable" }, 503, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
async function authorize(
|
||||
request: Request,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<{ status: 401 | 503; error: "unauthorized" | "server_misconfigured" } | null> {
|
||||
const expected = env.INGEST_KEY;
|
||||
if (!expected) return { status: 503, error: "server_misconfigured" };
|
||||
const provided = request.headers.get("x-vnidrop-key") ?? "";
|
||||
if (!(await timingSafeEqual(provided, expected))) {
|
||||
return { status: 401, error: "unauthorized" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sourceRateLimited(
|
||||
request: Request,
|
||||
path: string,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<boolean> {
|
||||
const source = request.headers.get("cf-connecting-ip")?.slice(0, 64) || "unknown";
|
||||
const result = await env.SOURCE_RATE_LIMITER.limit({ key: `${source}:${path}` });
|
||||
return !result.success;
|
||||
}
|
||||
|
||||
async function installRateLimited(
|
||||
request: Request,
|
||||
path: string,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<boolean> {
|
||||
const source = request.headers.get("cf-connecting-ip")?.slice(0, 64) || "unknown";
|
||||
const installId = request.headers.get("x-vnidrop-install-id")?.trim().slice(0, 80) || source;
|
||||
const result = await env.INSTALL_RATE_LIMITER.limit({ key: `${installId}:${path}` });
|
||||
return !result.success;
|
||||
}
|
||||
|
||||
function isIngestPath(path: string): path is "/v1/events" | "/v1/crashes" | "/v1/bugs" {
|
||||
return path === "/v1/events" || path === "/v1/crashes" || path === "/v1/bugs";
|
||||
}
|
||||
|
||||
async function timingSafeEqual(provided: string, expected: string): Promise<boolean> {
|
||||
const encoder = new TextEncoder();
|
||||
const [providedHash, expectedHash] = await Promise.all([
|
||||
crypto.subtle.digest("SHA-256", encoder.encode(provided)),
|
||||
crypto.subtle.digest("SHA-256", encoder.encode(expected)),
|
||||
]);
|
||||
return crypto.subtle.timingSafeEqual(providedHash, expectedHash);
|
||||
}
|
||||
|
||||
function json(
|
||||
body: unknown,
|
||||
status: number,
|
||||
requestId: string,
|
||||
extraHeaders?: Readonly<Record<string, string>>,
|
||||
): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: responseHeaders(requestId, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...extraHeaders,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function responseHeaders(
|
||||
requestId: string,
|
||||
extraHeaders?: Readonly<Record<string, string>>,
|
||||
): Headers {
|
||||
const headers = new Headers(extraHeaders);
|
||||
headers.set("cache-control", "no-store");
|
||||
headers.set("x-content-type-options", "nosniff");
|
||||
headers.set("x-request-id", requestId);
|
||||
return headers;
|
||||
}
|
||||
|
||||
function boundedPositiveInt(
|
||||
raw: string | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
593
services/diagnostics-api/src/input.ts
Normal file
593
services/diagnostics-api/src/input.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export type InputFailure = {
|
||||
ok: false;
|
||||
status: 400 | 413 | 415;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type InputResult<T> = { ok: true; value: T } | InputFailure;
|
||||
|
||||
export type NormalizedProperties = Record<string, string>;
|
||||
|
||||
export interface NormalizedEvent {
|
||||
name: string;
|
||||
timestampMillis: number;
|
||||
properties: NormalizedProperties;
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export interface NormalizedBreadcrumb {
|
||||
name: string;
|
||||
timestampMillis: number;
|
||||
properties: NormalizedProperties;
|
||||
}
|
||||
|
||||
export interface NormalizedDevice {
|
||||
deviceName: string;
|
||||
deviceModel: string;
|
||||
operatingSystem: string;
|
||||
network: string;
|
||||
batteryLevel: string;
|
||||
}
|
||||
|
||||
export interface NormalizedEventsPayload {
|
||||
batchId: string;
|
||||
installId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
events: NormalizedEvent[];
|
||||
}
|
||||
|
||||
export interface NormalizedCrashPayload {
|
||||
id: string;
|
||||
installId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
exceptionType: string;
|
||||
exceptionMessage: string;
|
||||
stackTrace: string;
|
||||
occurredAt: number;
|
||||
diagnosticsEnabledAtCapture: boolean;
|
||||
breadcrumbs: NormalizedBreadcrumb[];
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export interface NormalizedBugPayload {
|
||||
id: string;
|
||||
installId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
occurredAt: number;
|
||||
whatHappened: string;
|
||||
expected: string;
|
||||
steps: string;
|
||||
contact: string;
|
||||
logs: string;
|
||||
device: NormalizedDevice;
|
||||
breadcrumbs: NormalizedBreadcrumb[];
|
||||
schemaVersion: 1;
|
||||
}
|
||||
|
||||
export const MAX_LOG_BYTES = 192 * 1024;
|
||||
export const MAX_BREADCRUMBS_JSON_BYTES = 16_000;
|
||||
export const MAX_DEVICE_JSON_BYTES = 4_000;
|
||||
|
||||
const MAX_PROPERTIES = 12;
|
||||
const MAX_BREADCRUMBS = 40;
|
||||
const MISSING = Symbol("missing");
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const UTF8_ENCODER = new TextEncoder();
|
||||
|
||||
export async function readJsonObject(
|
||||
request: Request,
|
||||
maxBytes: number,
|
||||
): Promise<InputResult<JsonObject>> {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
||||
throw new RangeError("maxBytes must be a positive safe integer");
|
||||
}
|
||||
|
||||
if (!isApplicationJson(request.headers.get("content-type"))) {
|
||||
return failure(415, "unsupported_media_type");
|
||||
}
|
||||
|
||||
const contentLength = request.headers.get("content-length");
|
||||
if (contentLength != null) {
|
||||
const trimmed = contentLength.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return failure(400, "invalid_content_length");
|
||||
}
|
||||
const normalizedLength = trimmed.replace(/^0+(?=\d)/, "");
|
||||
const maxLength = String(maxBytes);
|
||||
if (
|
||||
normalizedLength.length > maxLength.length ||
|
||||
(normalizedLength.length === maxLength.length && normalizedLength > maxLength)
|
||||
) {
|
||||
return failure(413, "payload_too_large");
|
||||
}
|
||||
const declaredBytes = Number(trimmed);
|
||||
if (!Number.isSafeInteger(declaredBytes)) {
|
||||
return failure(400, "invalid_content_length");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.body == null) {
|
||||
return failure(400, "invalid_json");
|
||||
}
|
||||
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||
try {
|
||||
reader = request.body.getReader();
|
||||
} catch {
|
||||
return failure(400, "invalid_body");
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
if (chunk.value.byteLength > maxBytes - totalBytes) {
|
||||
try {
|
||||
await reader.cancel("payload_too_large");
|
||||
} catch {
|
||||
// The size failure remains authoritative if cancellation also fails.
|
||||
}
|
||||
return failure(413, "payload_too_large");
|
||||
}
|
||||
totalBytes += chunk.value.byteLength;
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
} catch {
|
||||
return failure(400, "invalid_body");
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const bytes = joinChunks(chunks, totalBytes);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
|
||||
} catch {
|
||||
return failure(400, "invalid_utf8");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return failure(400, "invalid_json");
|
||||
}
|
||||
if (!isPlainObject(parsed)) {
|
||||
return failure(400, "invalid_body");
|
||||
}
|
||||
return success(parsed);
|
||||
}
|
||||
|
||||
export function normalizeEvents(
|
||||
body: JsonObject,
|
||||
maxEvents = 50,
|
||||
): InputResult<NormalizedEventsPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
if (!Number.isSafeInteger(maxEvents) || maxEvents <= 0) {
|
||||
throw new RangeError("maxEvents must be a positive safe integer");
|
||||
}
|
||||
|
||||
const batchId = idField(body, ["batchId", "batch_id"], "invalid_batch_id");
|
||||
if (!batchId.ok) return batchId;
|
||||
const installId = installIdField(body);
|
||||
if (!installId.ok) return installId;
|
||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
||||
if (!appVersion.ok) return appVersion;
|
||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
||||
if (!platform.ok) return platform;
|
||||
|
||||
const batchSchema = schemaVersion(body);
|
||||
if (!batchSchema.ok) return batchSchema;
|
||||
const rawEvents = pick(body, ["events"]);
|
||||
if (!Array.isArray(rawEvents)) return failure(400, "invalid_events");
|
||||
if (rawEvents.length === 0) return failure(400, "empty_batch");
|
||||
if (rawEvents.length > maxEvents) return failure(400, "batch_too_large");
|
||||
|
||||
const events: NormalizedEvent[] = [];
|
||||
for (const rawEvent of rawEvents) {
|
||||
const event = normalizeEvent(rawEvent);
|
||||
if (!event.ok) return event;
|
||||
events.push(event.value);
|
||||
}
|
||||
|
||||
return success({
|
||||
batchId: batchId.value,
|
||||
installId: installId.value,
|
||||
appVersion: appVersion.value,
|
||||
platform: platform.value,
|
||||
events,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCrash(body: JsonObject): InputResult<NormalizedCrashPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
|
||||
const id = idField(body, ["id"], "invalid_id");
|
||||
if (!id.ok) return id;
|
||||
const installId = installIdField(body);
|
||||
if (!installId.ok) return installId;
|
||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
||||
if (!appVersion.ok) return appVersion;
|
||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
||||
if (!platform.ok) return platform;
|
||||
const exceptionType = stringField(
|
||||
body,
|
||||
["exceptionType", "exception_type"],
|
||||
120,
|
||||
"invalid_exception_type",
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!exceptionType.ok) return exceptionType;
|
||||
const exceptionMessage = stringField(
|
||||
body,
|
||||
["exceptionMessage", "exception_message"],
|
||||
2_000,
|
||||
"invalid_exception_message",
|
||||
true,
|
||||
);
|
||||
if (!exceptionMessage.ok) return exceptionMessage;
|
||||
const stackTrace = stringField(
|
||||
body,
|
||||
["stackTrace", "stack_trace"],
|
||||
32_000,
|
||||
"invalid_stack_trace",
|
||||
true,
|
||||
);
|
||||
if (!stackTrace.ok) return stackTrace;
|
||||
const occurredAt = timestampField(
|
||||
body,
|
||||
["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"],
|
||||
);
|
||||
if (!occurredAt.ok) return occurredAt;
|
||||
const diagnosticsEnabled = booleanField(
|
||||
body,
|
||||
[
|
||||
"diagnosticsEnabledAtCapture",
|
||||
"diagnostics_enabled_at_capture",
|
||||
"diagnostics_enabled",
|
||||
],
|
||||
"invalid_diagnostics_enabled",
|
||||
true,
|
||||
);
|
||||
if (!diagnosticsEnabled.ok) return diagnosticsEnabled;
|
||||
const version = schemaVersion(body);
|
||||
if (!version.ok) return version;
|
||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
||||
if (!breadcrumbs.ok) return breadcrumbs;
|
||||
|
||||
return success({
|
||||
id: id.value,
|
||||
installId: installId.value,
|
||||
appVersion: appVersion.value,
|
||||
platform: platform.value,
|
||||
exceptionType: exceptionType.value,
|
||||
exceptionMessage: exceptionMessage.value,
|
||||
stackTrace: stackTrace.value,
|
||||
occurredAt: occurredAt.value,
|
||||
diagnosticsEnabledAtCapture: diagnosticsEnabled.value,
|
||||
breadcrumbs: breadcrumbs.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload> {
|
||||
if (!isPlainObject(body)) return failure(400, "invalid_body");
|
||||
|
||||
const id = idField(body, ["id"], "invalid_id");
|
||||
if (!id.ok) return id;
|
||||
const installId = installIdField(body);
|
||||
if (!installId.ok) return installId;
|
||||
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
|
||||
if (!appVersion.ok) return appVersion;
|
||||
const platform = stringField(body, ["platform"], 40, "invalid_platform");
|
||||
if (!platform.ok) return platform;
|
||||
const occurredAt = timestampField(
|
||||
body,
|
||||
["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"],
|
||||
);
|
||||
if (!occurredAt.ok) return occurredAt;
|
||||
const whatHappened = stringField(
|
||||
body,
|
||||
["whatHappened", "what_happened"],
|
||||
4_000,
|
||||
"missing_fields",
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!whatHappened.ok) return whatHappened;
|
||||
const expected = stringField(body, ["expected"], 4_000, "missing_fields", true, true);
|
||||
if (!expected.ok) return expected;
|
||||
const steps = stringField(body, ["steps"], 4_000, "invalid_steps");
|
||||
if (!steps.ok) return steps;
|
||||
const contact = stringField(body, ["contact"], 320, "invalid_contact");
|
||||
if (!contact.ok) return contact;
|
||||
const includeLogs = booleanField(
|
||||
body,
|
||||
["includeLogs", "include_logs"],
|
||||
"invalid_include_logs",
|
||||
false,
|
||||
);
|
||||
if (!includeLogs.ok) return includeLogs;
|
||||
const logs = stringField(body, ["logs"], MAX_LOG_BYTES, "invalid_logs");
|
||||
if (!logs.ok) return logs;
|
||||
const device = normalizeDevice(pick(body, ["device"]));
|
||||
if (!device.ok) return device;
|
||||
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
|
||||
if (!breadcrumbs.ok) return breadcrumbs;
|
||||
const version = schemaVersion(body);
|
||||
if (!version.ok) return version;
|
||||
|
||||
return success({
|
||||
id: id.value,
|
||||
installId: installId.value,
|
||||
appVersion: appVersion.value,
|
||||
platform: platform.value,
|
||||
occurredAt: occurredAt.value,
|
||||
whatHappened: whatHappened.value,
|
||||
expected: expected.value,
|
||||
steps: steps.value,
|
||||
contact: contact.value,
|
||||
logs: includeLogs.value === true ? logs.value : "",
|
||||
device: device.value,
|
||||
breadcrumbs: breadcrumbs.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEvent(raw: unknown): InputResult<NormalizedEvent> {
|
||||
if (!isPlainObject(raw)) return failure(400, "invalid_event");
|
||||
const name = stringField(raw, ["name"], 64, "invalid_event", true, true);
|
||||
if (!name.ok) return name;
|
||||
const timestamp = timestampField(raw, ["timestampMillis", "timestamp_millis", "ts"]);
|
||||
if (!timestamp.ok) return failure(400, "invalid_event");
|
||||
const properties = normalizeProperties(pick(raw, ["properties", "props"]), "invalid_event");
|
||||
if (!properties.ok) return properties;
|
||||
const version = schemaVersion(raw);
|
||||
if (!version.ok) return version;
|
||||
return success({
|
||||
name: name.value,
|
||||
timestampMillis: timestamp.value,
|
||||
properties: properties.value,
|
||||
schemaVersion: version.value,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeBreadcrumbs(raw: unknown | typeof MISSING): InputResult<NormalizedBreadcrumb[]> {
|
||||
if (raw === MISSING) return success([]);
|
||||
if (!Array.isArray(raw)) return failure(400, "invalid_breadcrumbs");
|
||||
|
||||
const breadcrumbs: NormalizedBreadcrumb[] = [];
|
||||
for (const item of raw.slice(0, MAX_BREADCRUMBS)) {
|
||||
if (!isPlainObject(item)) return failure(400, "invalid_breadcrumbs");
|
||||
const name = stringField(item, ["name"], 64, "invalid_breadcrumbs", true, true);
|
||||
if (!name.ok) return name;
|
||||
const timestamp = timestampField(item, ["timestampMillis", "timestamp_millis", "ts"]);
|
||||
if (!timestamp.ok) return failure(400, "invalid_breadcrumbs");
|
||||
const properties = normalizeProperties(
|
||||
pick(item, ["properties", "props"]),
|
||||
"invalid_breadcrumbs",
|
||||
);
|
||||
if (!properties.ok) return properties;
|
||||
|
||||
breadcrumbs.push({
|
||||
name: name.value,
|
||||
timestampMillis: timestamp.value,
|
||||
properties: properties.value,
|
||||
});
|
||||
if (jsonBytes(breadcrumbs) > MAX_BREADCRUMBS_JSON_BYTES) {
|
||||
breadcrumbs.pop();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return success(breadcrumbs);
|
||||
}
|
||||
|
||||
function normalizeProperties(
|
||||
raw: unknown | typeof MISSING,
|
||||
error: string,
|
||||
): InputResult<NormalizedProperties> {
|
||||
if (raw === MISSING) return success({});
|
||||
if (!isPlainObject(raw)) return failure(400, error);
|
||||
|
||||
const entries: Array<[string, string]> = [];
|
||||
const normalizedKeys = new Set<string>();
|
||||
for (const [key, value] of Object.entries(raw).slice(0, MAX_PROPERTIES)) {
|
||||
if (typeof value !== "string") return failure(400, error);
|
||||
const normalizedKey = truncateUtf8(key, 40);
|
||||
if (normalizedKey.length === 0 || normalizedKeys.has(normalizedKey)) {
|
||||
return failure(400, error);
|
||||
}
|
||||
normalizedKeys.add(normalizedKey);
|
||||
entries.push([normalizedKey, truncateUtf8(value, 128)]);
|
||||
}
|
||||
return success(Object.fromEntries(entries));
|
||||
}
|
||||
|
||||
function normalizeDevice(raw: unknown | typeof MISSING): InputResult<NormalizedDevice> {
|
||||
if (raw === MISSING) raw = {};
|
||||
if (!isPlainObject(raw)) return failure(400, "invalid_device");
|
||||
|
||||
const deviceName = stringField(raw, ["deviceName", "device_name"], 128, "invalid_device");
|
||||
if (!deviceName.ok) return deviceName;
|
||||
const deviceModel = stringField(raw, ["deviceModel", "device_model"], 128, "invalid_device");
|
||||
if (!deviceModel.ok) return deviceModel;
|
||||
const operatingSystem = stringField(
|
||||
raw,
|
||||
["operatingSystem", "operating_system"],
|
||||
192,
|
||||
"invalid_device",
|
||||
);
|
||||
if (!operatingSystem.ok) return operatingSystem;
|
||||
const network = stringField(raw, ["network"], 96, "invalid_device");
|
||||
if (!network.ok) return network;
|
||||
const batteryLevel = stringField(raw, ["batteryLevel", "battery_level"], 64, "invalid_device");
|
||||
if (!batteryLevel.ok) return batteryLevel;
|
||||
|
||||
const device: NormalizedDevice = {
|
||||
deviceName: deviceName.value,
|
||||
deviceModel: deviceModel.value,
|
||||
operatingSystem: operatingSystem.value,
|
||||
network: network.value,
|
||||
batteryLevel: batteryLevel.value,
|
||||
};
|
||||
if (jsonBytes(device) > MAX_DEVICE_JSON_BYTES) {
|
||||
return failure(400, "invalid_device");
|
||||
}
|
||||
return success(device);
|
||||
}
|
||||
|
||||
function stringField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
maxBytes: number,
|
||||
error: string,
|
||||
required = false,
|
||||
nonEmpty = false,
|
||||
): InputResult<string> {
|
||||
const raw = pick(object, keys);
|
||||
if (raw === MISSING) {
|
||||
return required ? failure(400, error) : success("");
|
||||
}
|
||||
if (typeof raw !== "string") return failure(400, error);
|
||||
const value = truncateUtf8(raw, maxBytes);
|
||||
if (nonEmpty && value.trim().length === 0) return failure(400, error);
|
||||
return success(value);
|
||||
}
|
||||
|
||||
function idField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
): InputResult<string> {
|
||||
const raw = pick(object, keys);
|
||||
if (typeof raw !== "string" || !UUID_PATTERN.test(raw)) return failure(400, error);
|
||||
return success(raw.toLowerCase());
|
||||
}
|
||||
|
||||
function installIdField(object: JsonObject): InputResult<string> {
|
||||
const raw = pick(object, ["installId", "install_id"]);
|
||||
if (raw === MISSING || raw === "") return success("unknown");
|
||||
if (
|
||||
typeof raw !== "string" ||
|
||||
raw.trim().length === 0 ||
|
||||
/[\u0000-\u001f\u007f]/.test(raw)
|
||||
) {
|
||||
return failure(400, "invalid_install_id");
|
||||
}
|
||||
return success(truncateUtf8(raw, 80));
|
||||
}
|
||||
|
||||
function timestampField(object: JsonObject, keys: readonly string[]): InputResult<number> {
|
||||
const raw = pick(object, keys);
|
||||
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 0) {
|
||||
return failure(400, "invalid_timestamp");
|
||||
}
|
||||
return success(raw);
|
||||
}
|
||||
|
||||
function booleanField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
required: true,
|
||||
): InputResult<boolean>;
|
||||
function booleanField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
required: false,
|
||||
): InputResult<boolean | undefined>;
|
||||
function booleanField(
|
||||
object: JsonObject,
|
||||
keys: readonly string[],
|
||||
error: string,
|
||||
required: boolean,
|
||||
): InputResult<boolean | undefined> {
|
||||
const raw = pick(object, keys);
|
||||
if (raw === MISSING) {
|
||||
return required ? failure(400, error) : success(undefined);
|
||||
}
|
||||
return typeof raw === "boolean" ? success(raw) : failure(400, error);
|
||||
}
|
||||
|
||||
function schemaVersion(object: JsonObject): InputResult<1> {
|
||||
const raw = pick(object, ["schemaVersion", "schema_version"]);
|
||||
if (raw === MISSING || raw === 1) return success(1);
|
||||
return failure(400, "unsupported_schema_version");
|
||||
}
|
||||
|
||||
function pick(object: JsonObject, keys: readonly string[]): unknown | typeof MISSING {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(object, key)) return object[key];
|
||||
}
|
||||
return MISSING;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function isApplicationJson(contentType: string | null): boolean {
|
||||
if (contentType == null) return false;
|
||||
const [mediaType, ...parameters] = contentType.split(";");
|
||||
if (mediaType?.trim().toLowerCase() !== "application/json") return false;
|
||||
for (const parameter of parameters) {
|
||||
const separator = parameter.indexOf("=");
|
||||
if (separator < 0) continue;
|
||||
if (parameter.slice(0, separator).trim().toLowerCase() !== "charset") continue;
|
||||
const charset = parameter
|
||||
.slice(separator + 1)
|
||||
.trim()
|
||||
.replace(/^"(.*)"$/, "$1")
|
||||
.toLowerCase();
|
||||
if (charset !== "utf-8" && charset !== "utf8") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function joinChunks(chunks: readonly Uint8Array[], totalBytes: number): Uint8Array {
|
||||
if (chunks.length === 1) return chunks[0] ?? new Uint8Array();
|
||||
const bytes = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
const bytes = UTF8_ENCODER.encode(value);
|
||||
if (bytes.byteLength <= maxBytes) return value;
|
||||
for (let end = maxBytes; end >= Math.max(0, maxBytes - 3); end -= 1) {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
bytes.subarray(0, end),
|
||||
);
|
||||
} catch {
|
||||
// A UTF-8 boundary is at most three bytes behind the byte cap.
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function jsonBytes(value: unknown): number {
|
||||
return UTF8_ENCODER.encode(JSON.stringify(value)).byteLength;
|
||||
}
|
||||
|
||||
function success<T>(value: T): InputResult<T> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
function failure(status: 400 | 413 | 415, error: string): InputFailure {
|
||||
return { ok: false, status, error };
|
||||
}
|
||||
340
services/diagnostics-api/src/storage.ts
Normal file
340
services/diagnostics-api/src/storage.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import type {
|
||||
NormalizedBugPayload,
|
||||
NormalizedCrashPayload,
|
||||
NormalizedEventsPayload,
|
||||
} from "./input";
|
||||
|
||||
export type DiagnosticsEnv = Cloudflare.Env & {
|
||||
INGEST_KEY?: string;
|
||||
AE?: AnalyticsEngineDataset;
|
||||
};
|
||||
|
||||
export interface StoreResult {
|
||||
id: string;
|
||||
duplicate: boolean;
|
||||
stored: number;
|
||||
}
|
||||
|
||||
export async function storeEvents(
|
||||
payload: NormalizedEventsPayload,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<StoreResult> {
|
||||
const result = await env.DB.prepare(
|
||||
`INSERT INTO event_batches (id, received_at, install_id, app_version, platform, event_count, payload_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
)
|
||||
.bind(
|
||||
payload.batchId,
|
||||
Date.now(),
|
||||
payload.installId,
|
||||
payload.appVersion,
|
||||
payload.platform,
|
||||
payload.events.length,
|
||||
JSON.stringify(payload.events),
|
||||
)
|
||||
.run();
|
||||
const duplicate = result.meta.changes === 0;
|
||||
if (!duplicate && env.AE) {
|
||||
try {
|
||||
for (const event of payload.events) {
|
||||
env.AE.writeDataPoint({
|
||||
blobs: [
|
||||
event.name,
|
||||
payload.platform,
|
||||
payload.appVersion,
|
||||
payload.installId,
|
||||
JSON.stringify(event.properties),
|
||||
payload.batchId,
|
||||
],
|
||||
doubles: [event.timestampMillis, event.schemaVersion],
|
||||
indexes: [payload.installId],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// D1 remains the durable source of truth if the optional analytics index is unavailable.
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "failed to index diagnostics event batch",
|
||||
batchId: payload.batchId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: payload.batchId,
|
||||
duplicate,
|
||||
stored: duplicate ? 0 : payload.events.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function storeCrash(
|
||||
payload: NormalizedCrashPayload,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<StoreResult & { fingerprint: string }> {
|
||||
const database = env.DB.withSession("first-primary");
|
||||
const existing = await database
|
||||
.prepare("SELECT fingerprint FROM crashes WHERE id = ?")
|
||||
.bind(payload.id)
|
||||
.first<{ fingerprint: string }>();
|
||||
if (existing) {
|
||||
return { id: payload.id, duplicate: true, stored: 0, fingerprint: existing.fingerprint };
|
||||
}
|
||||
|
||||
const fingerprint = await crashFingerprint(payload.exceptionType, payload.stackTrace);
|
||||
const stackKey = payload.stackTrace
|
||||
? `crashes/${payload.id}/${crypto.randomUUID()}/stack.txt`
|
||||
: null;
|
||||
|
||||
if (stackKey) {
|
||||
await env.BLOBS.put(stackKey, payload.stackTrace, {
|
||||
httpMetadata: { contentType: "text/plain; charset=utf-8" },
|
||||
customMetadata: { installId: payload.installId, fingerprint },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await database
|
||||
.prepare(
|
||||
`INSERT INTO crashes (
|
||||
id, received_at, occurred_at, install_id, app_version, platform,
|
||||
exception_type, exception_message, fingerprint, diagnostics_enabled,
|
||||
stack_r2_key, breadcrumbs_json, schema_version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
)
|
||||
.bind(
|
||||
payload.id,
|
||||
Date.now(),
|
||||
payload.occurredAt,
|
||||
payload.installId,
|
||||
payload.appVersion,
|
||||
payload.platform,
|
||||
payload.exceptionType,
|
||||
payload.exceptionMessage,
|
||||
fingerprint,
|
||||
payload.diagnosticsEnabledAtCapture ? 1 : 0,
|
||||
stackKey,
|
||||
JSON.stringify(payload.breadcrumbs),
|
||||
payload.schemaVersion,
|
||||
)
|
||||
.run();
|
||||
const duplicate = result.meta.changes === 0;
|
||||
if (duplicate) {
|
||||
const stored = await database
|
||||
.prepare("SELECT fingerprint FROM crashes WHERE id = ?")
|
||||
.bind(payload.id)
|
||||
.first<{ fingerprint: string }>();
|
||||
if (!stored) throw new Error("duplicate crash row was not readable");
|
||||
if (stackKey) await deleteAttemptBlob(env, stackKey);
|
||||
return { id: payload.id, duplicate: true, stored: 0, fingerprint: stored.fingerprint };
|
||||
}
|
||||
return { id: payload.id, duplicate: false, stored: 1, fingerprint };
|
||||
} catch (error) {
|
||||
if (stackKey) {
|
||||
await deleteAttemptBlob(env, stackKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function storeBug(
|
||||
payload: NormalizedBugPayload,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<StoreResult> {
|
||||
const database = env.DB.withSession("first-primary");
|
||||
const existing = await database
|
||||
.prepare("SELECT id FROM bugs WHERE id = ?")
|
||||
.bind(payload.id)
|
||||
.first<{ id: string }>();
|
||||
if (existing) return { id: payload.id, duplicate: true, stored: 0 };
|
||||
|
||||
const logsKey = payload.logs ? `bugs/${payload.id}/${crypto.randomUUID()}/logs.txt` : null;
|
||||
if (logsKey) {
|
||||
await env.BLOBS.put(logsKey, payload.logs, {
|
||||
httpMetadata: { contentType: "text/plain; charset=utf-8" },
|
||||
customMetadata: { installId: payload.installId },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await database
|
||||
.prepare(
|
||||
`INSERT INTO bugs (
|
||||
id, received_at, occurred_at, install_id, app_version, platform,
|
||||
what_happened, expected, steps, contact, logs_r2_key,
|
||||
device_json, breadcrumbs_json, status, schema_version
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
)
|
||||
.bind(
|
||||
payload.id,
|
||||
Date.now(),
|
||||
payload.occurredAt,
|
||||
payload.installId,
|
||||
payload.appVersion,
|
||||
payload.platform,
|
||||
payload.whatHappened,
|
||||
payload.expected,
|
||||
payload.steps,
|
||||
payload.contact,
|
||||
logsKey,
|
||||
JSON.stringify(payload.device),
|
||||
JSON.stringify(payload.breadcrumbs),
|
||||
payload.schemaVersion,
|
||||
)
|
||||
.run();
|
||||
const duplicate = result.meta.changes === 0;
|
||||
if (duplicate && logsKey) {
|
||||
await deleteAttemptBlob(env, logsKey);
|
||||
}
|
||||
return { id: payload.id, duplicate, stored: duplicate ? 0 : 1 };
|
||||
} catch (error) {
|
||||
if (logsKey) {
|
||||
await deleteAttemptBlob(env, logsKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRetention(env: DiagnosticsEnv): Promise<void> {
|
||||
const retentionDays = boundedPositiveInt(env.RETENTION_DAYS, 90, 1, 3_650);
|
||||
const cutoff = Date.now() - retentionDays * 86_400_000;
|
||||
// Eight full passes plus the backlog check use at most 43 of D1's 50 queries per invocation.
|
||||
for (let pass = 0; pass < 8; pass += 1) {
|
||||
const hasFullBatch = await runRetentionPass(env, cutoff);
|
||||
if (!hasFullBatch) return;
|
||||
}
|
||||
const [events, crashes, bugs] = await env.DB.batch<{ count: number }>([
|
||||
env.DB.prepare("SELECT COUNT(*) AS count FROM event_batches WHERE received_at < ?").bind(
|
||||
cutoff,
|
||||
),
|
||||
env.DB.prepare("SELECT COUNT(*) AS count FROM crashes WHERE received_at < ?").bind(cutoff),
|
||||
env.DB.prepare("SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?").bind(cutoff),
|
||||
]);
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
message: "diagnostics retention reached its per-run pass limit",
|
||||
cutoff,
|
||||
backlog: {
|
||||
eventBatches: events.results[0]?.count ?? 0,
|
||||
crashes: crashes.results[0]?.count ?? 0,
|
||||
bugs: bugs.results[0]?.count ?? 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function runRetentionPass(env: DiagnosticsEnv, cutoff: number): Promise<boolean> {
|
||||
const reportBatchSize = 900;
|
||||
const eventBatchSize = 1_000;
|
||||
const [crashes, bugs] = await Promise.all([
|
||||
expiredBlobRows(env.DB, "crashes", "stack_r2_key", cutoff, reportBatchSize),
|
||||
expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize),
|
||||
]);
|
||||
|
||||
const blobKeys = [...crashes, ...bugs]
|
||||
.map((row) => row.blobKey)
|
||||
.filter((key): key is string => key !== null);
|
||||
for (let offset = 0; offset < blobKeys.length; offset += 1_000) {
|
||||
await env.BLOBS.delete(blobKeys.slice(offset, offset + 1_000));
|
||||
}
|
||||
|
||||
const statements = [retentionStatement(env.DB, "event_batches", cutoff, eventBatchSize)];
|
||||
if (crashes.length > 0) statements.push(deleteRowsById(env.DB, "crashes", crashes));
|
||||
if (bugs.length > 0) statements.push(deleteRowsById(env.DB, "bugs", bugs));
|
||||
const [eventsResult] = await env.DB.batch(statements);
|
||||
return (
|
||||
eventsResult.meta.changes === eventBatchSize ||
|
||||
crashes.length === reportBatchSize ||
|
||||
bugs.length === reportBatchSize
|
||||
);
|
||||
}
|
||||
|
||||
interface ExpiredBlobRow {
|
||||
id: string;
|
||||
blobKey: string | null;
|
||||
}
|
||||
|
||||
async function expiredBlobRows(
|
||||
database: D1Database,
|
||||
table: "crashes" | "bugs",
|
||||
column: "stack_r2_key" | "logs_r2_key",
|
||||
cutoff: number,
|
||||
batchSize: number,
|
||||
): Promise<ExpiredBlobRow[]> {
|
||||
const result = await database
|
||||
.prepare(
|
||||
`SELECT id, ${column} AS blobKey
|
||||
FROM ${table}
|
||||
WHERE received_at < ?
|
||||
ORDER BY received_at
|
||||
LIMIT ?`,
|
||||
)
|
||||
.bind(cutoff, batchSize)
|
||||
.all<ExpiredBlobRow>();
|
||||
return result.results;
|
||||
}
|
||||
|
||||
function retentionStatement(
|
||||
database: D1Database,
|
||||
table: "event_batches" | "crashes" | "bugs",
|
||||
cutoff: number,
|
||||
batchSize: number,
|
||||
): D1PreparedStatement {
|
||||
return database
|
||||
.prepare(
|
||||
`DELETE FROM ${table}
|
||||
WHERE rowid IN (
|
||||
SELECT rowid FROM ${table} WHERE received_at < ? ORDER BY received_at LIMIT ?
|
||||
)`,
|
||||
)
|
||||
.bind(cutoff, batchSize);
|
||||
}
|
||||
|
||||
function deleteRowsById(
|
||||
database: D1Database,
|
||||
table: "crashes" | "bugs",
|
||||
rows: ExpiredBlobRow[],
|
||||
): D1PreparedStatement {
|
||||
return database
|
||||
.prepare(`DELETE FROM ${table} WHERE id IN (SELECT value FROM json_each(?))`)
|
||||
.bind(JSON.stringify(rows.map((row) => row.id)));
|
||||
}
|
||||
|
||||
async function crashFingerprint(exceptionType: string, stackTrace: string): Promise<string> {
|
||||
const topFrames = stackTrace
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join("\n");
|
||||
const bytes = new TextEncoder().encode(`${exceptionType}\n${topFrames}`);
|
||||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
||||
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function deleteAttemptBlob(env: DiagnosticsEnv, key: string): Promise<void> {
|
||||
try {
|
||||
await env.BLOBS.delete(key);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "failed to remove uncommitted diagnostics blob",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedPositiveInt(
|
||||
raw: string | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
Reference in New Issue
Block a user