diff --git a/cloudflare/src/bindings.ts b/cloudflare/src/bindings.ts index 43e4416..caeb7e0 100644 --- a/cloudflare/src/bindings.ts +++ b/cloudflare/src/bindings.ts @@ -51,6 +51,7 @@ export interface Env { ELY_STORAGE: ElyR2Bucket; ELY_RATE_LIMITER: ElyRateLimit; ELY_API_AUDIT: ElyAnalyticsDataset; + ELY_DIAGNOSTICS: ElyAnalyticsDataset; ELY_ENVIRONMENT: string; ELY_AUTH_BASE_URL: string; ELY_AUTH_SECRET: string; diff --git a/cloudflare/src/index.ts b/cloudflare/src/index.ts index 801bb10..bdd6a34 100644 --- a/cloudflare/src/index.ts +++ b/cloudflare/src/index.ts @@ -33,6 +33,10 @@ import { publicSigningKeysKvKey, } from "./signing_keys.js"; import { handleSyncRoute } from "./sync_routes.js"; +import { + TelemetrySchemaError, + telemetryEventAcceptedDocument, +} from "./telemetry.js"; import { jsonResponse } from "./responses.js"; export default { @@ -199,6 +203,32 @@ export async function handleRequest(request: Request, env: Env): Promise { + try { + return jsonResponse( + await telemetryEventAcceptedDocument(request, env, context), + 202, + { "Cache-Control": "no-store" }, + ); + } catch (error) { + if (error instanceof TelemetrySchemaError) { + return jsonResponse( + { error: error.code }, + 400, + { "Cache-Control": "no-store" }, + ); + } + throw error; + } + }, + ); + } return jsonResponse({ error: "not_found" }, 404); } diff --git a/cloudflare/src/telemetry.ts b/cloudflare/src/telemetry.ts new file mode 100644 index 0000000..c8dc32a --- /dev/null +++ b/cloudflare/src/telemetry.ts @@ -0,0 +1,262 @@ +import type { AuthContext } from "./auth.js"; +import type { Env } from "./bindings.js"; + +const EVENT_TYPES = new Set([ + "app_startup", + "app_crash", + "webview_crash", + "sync_error", + "plugin_crash", + "update_result", +]); +const OUTCOMES = new Set(["success", "failure"]); +const PLATFORMS = new Set(["macos", "windows", "linux"]); +const ALLOWED_FIELDS = new Set([ + "version", + "event_type", + "occurred_at", + "app_version", + "platform", + "outcome", + "error_code", + "component", + "crash_kind", + "plugin_id", +]); +const SENSITIVE_FIELDS = new Set([ + "url", + "loaded_url", + "loadedurl", + "page_url", + "pageurl", + "title", + "page_title", + "pagetitle", + "search_term", + "searchterm", + "query", + "bookmark", + "bookmark_content", + "bookmarkcontent", + "history", + "history_content", + "historycontent", + "note", + "notes", + "notes_content", + "notescontent", + "cookie", + "cookies", + "form", + "form_input", + "forminput", + "input", + "field_value", + "fieldvalue", + "payload", + "body", + "page_text", + "pagetext", +]); +const APP_VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z._+-]{0,63}$/; +const CODE_PATTERN = /^[0-9A-Za-z][0-9A-Za-z._:-]{0,79}$/; + +type TelemetryRequestBody = Record; +type TelemetryEventType = + | "app_startup" + | "app_crash" + | "webview_crash" + | "sync_error" + | "plugin_crash" + | "update_result"; +type TelemetryOutcome = "success" | "failure"; +type TelemetryPlatform = "macos" | "windows" | "linux"; + +export interface TelemetryAcceptedDocument { + version: 1; + accepted: true; +} + +interface TelemetryEvent { + eventType: TelemetryEventType; + occurredAt: number; + appVersion: string; + platform: TelemetryPlatform; + outcome?: TelemetryOutcome; + errorCode?: string; + component?: string; + crashKind?: string; + pluginId?: string; +} + +export class TelemetrySchemaError extends Error { + constructor(readonly code: string) { + super(code); + this.name = "TelemetrySchemaError"; + } +} + +export async function telemetryEventAcceptedDocument( + request: Request, + env: Env, + context: AuthContext, +): Promise { + const event = await telemetryEvent(request); + env.ELY_DIAGNOSTICS.writeDataPoint({ + indexes: [env.ELY_ENVIRONMENT], + blobs: [ + event.eventType, + event.appVersion, + event.platform, + event.outcome ?? "", + event.errorCode ?? "", + event.component ?? "", + event.crashKind ?? "", + event.pluginId ?? "", + context.userId, + context.deviceId ?? "", + ], + doubles: [event.occurredAt, Date.now()], + }); + return { version: 1, accepted: true }; +} + +async function telemetryEvent(request: Request): Promise { + const body = await requestBody(request); + assertNoSensitiveFields(body); + assertOnlyFields(body); + if (body.version !== 1) { + throw new TelemetrySchemaError("telemetry_version_invalid"); + } + + const event: TelemetryEvent = { + eventType: eventType(body.event_type), + occurredAt: integer(body.occurred_at, "telemetry_occurred_at_invalid"), + appVersion: appVersion(body.app_version), + platform: platform(body.platform), + }; + assignOptionalString(event, "outcome", outcome(body.outcome)); + assignOptionalString(event, "errorCode", code(body.error_code, "telemetry_error_code_invalid")); + assignOptionalString(event, "component", code(body.component, "telemetry_component_invalid")); + assignOptionalString(event, "crashKind", code(body.crash_kind, "telemetry_crash_kind_invalid")); + assignOptionalString(event, "pluginId", code(body.plugin_id, "telemetry_plugin_id_invalid")); + assertRequiredEventFields(event); + return event; +} + +async function requestBody(request: Request): Promise { + let value: unknown; + try { + value = await request.json(); + } catch { + throw new TelemetrySchemaError("telemetry_json_invalid"); + } + if (!isRecord(value)) { + throw new TelemetrySchemaError("telemetry_body_invalid"); + } + return value; +} + +function assertNoSensitiveFields(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) { + assertNoSensitiveFields(item); + } + return; + } + if (!isRecord(value)) { + return; + } + for (const [field, fieldValue] of Object.entries(value)) { + if (SENSITIVE_FIELDS.has(field.toLowerCase())) { + throw new TelemetrySchemaError("telemetry_sensitive_field"); + } + assertNoSensitiveFields(fieldValue); + } +} + +function assertOnlyFields(value: TelemetryRequestBody): void { + for (const field of Object.keys(value)) { + if (!ALLOWED_FIELDS.has(field)) { + throw new TelemetrySchemaError("telemetry_unknown_field"); + } + } +} + +function eventType(value: unknown): TelemetryEventType { + if (typeof value !== "string" || !EVENT_TYPES.has(value)) { + throw new TelemetrySchemaError("telemetry_event_type_invalid"); + } + return value as TelemetryEventType; +} + +function platform(value: unknown): TelemetryPlatform { + if (typeof value !== "string" || !PLATFORMS.has(value)) { + throw new TelemetrySchemaError("telemetry_platform_invalid"); + } + return value as TelemetryPlatform; +} + +function outcome(value: unknown): TelemetryOutcome | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "string" || !OUTCOMES.has(value)) { + throw new TelemetrySchemaError("telemetry_outcome_invalid"); + } + return value as TelemetryOutcome; +} + +function appVersion(value: unknown): string { + if (typeof value !== "string" || !APP_VERSION_PATTERN.test(value)) { + throw new TelemetrySchemaError("telemetry_app_version_invalid"); + } + return value; +} + +function code(value: unknown, errorCode: string): string | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "string" || !CODE_PATTERN.test(value)) { + throw new TelemetrySchemaError(errorCode); + } + return value; +} + +function integer(value: unknown, errorCode: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new TelemetrySchemaError(errorCode); + } + return value; +} + +function assignOptionalString( + target: T, + field: K, + value: T[K] | undefined, +): void { + if (value !== undefined) { + target[field] = value; + } +} + +function assertRequiredEventFields(event: TelemetryEvent): void { + if ((event.eventType === "app_startup" || event.eventType === "update_result") + && event.outcome === undefined) { + throw new TelemetrySchemaError("telemetry_outcome_required"); + } + if (event.eventType === "sync_error" && event.errorCode === undefined) { + throw new TelemetrySchemaError("telemetry_error_code_required"); + } + if (event.eventType === "webview_crash" && event.crashKind === undefined) { + throw new TelemetrySchemaError("telemetry_crash_kind_required"); + } + if (event.eventType === "plugin_crash" && event.pluginId === undefined) { + throw new TelemetrySchemaError("telemetry_plugin_id_required"); + } +} + +function isRecord(value: unknown): value is TelemetryRequestBody { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/cloudflare/tests/api_controls.test.ts b/cloudflare/tests/api_controls.test.ts index 16c9a36..649dc88 100644 --- a/cloudflare/tests/api_controls.test.ts +++ b/cloudflare/tests/api_controls.test.ts @@ -334,6 +334,9 @@ function testEnv(options: TestEnvOptions = {}): Env { } }, }, + ELY_DIAGNOSTICS: { + writeDataPoint(): void {}, + }, }; } diff --git a/cloudflare/tests/devices_test_support.ts b/cloudflare/tests/devices_test_support.ts index 1453553..6e55226 100644 --- a/cloudflare/tests/devices_test_support.ts +++ b/cloudflare/tests/devices_test_support.ts @@ -11,6 +11,7 @@ export const PUBLIC_KEY = "a".repeat(64); export interface TestEnvOptions { auditEvents?: ElyAnalyticsDataPoint[]; + diagnosticEvents?: ElyAnalyticsDataPoint[]; d1?: RecordedD1Database; kvEntries?: [string, string][]; kvReads?: string[]; @@ -68,6 +69,13 @@ export function testEnv(options: TestEnvOptions): Env { } }, }, + ELY_DIAGNOSTICS: { + writeDataPoint(event?: ElyAnalyticsDataPoint): void { + if (event !== undefined) { + options.diagnosticEvents?.push(event); + } + }, + }, }; } diff --git a/cloudflare/tests/index.test.ts b/cloudflare/tests/index.test.ts index 3f367b0..349d585 100644 --- a/cloudflare/tests/index.test.ts +++ b/cloudflare/tests/index.test.ts @@ -369,6 +369,9 @@ function testEnv( ELY_API_AUDIT: { writeDataPoint(): void {}, }, + ELY_DIAGNOSTICS: { + writeDataPoint(): void {}, + }, }; } diff --git a/cloudflare/tests/telemetry_routes.test.ts b/cloudflare/tests/telemetry_routes.test.ts new file mode 100644 index 0000000..abe521c --- /dev/null +++ b/cloudflare/tests/telemetry_routes.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { ElyAnalyticsDataPoint } from "../src/bindings.js"; +import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js"; +import { handleRequest } from "../src/index.js"; +import { ACCESS_TOKEN, sessionDocument, testEnv } from "./devices_test_support.js"; + +const DEVICE_ID = "device-01"; + +describe("telemetry routes", () => { + it("records a minimized authenticated diagnostic event", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const auditEvents: ElyAnalyticsDataPoint[] = []; + const diagnosticEvents: ElyAnalyticsDataPoint[] = []; + const response = await handleRequest( + telemetryRequest({ + version: 1, + event_type: "sync_error", + occurred_at: 1_780_000_000, + app_version: "0.1.0", + platform: "macos", + error_code: "sync.pull.5xx", + component: "sync", + }), + testEnv({ + auditEvents, + diagnosticEvents, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 202); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), { version: 1, accepted: true }); + assert.equal(diagnosticEvents.length, 1); + assert.deepEqual(diagnosticEvents[0]?.indexes, ["local"]); + assert.deepEqual(diagnosticEvents[0]?.blobs, [ + "sync_error", + "0.1.0", + "macos", + "", + "sync.pull.5xx", + "sync", + "", + "", + "user-01", + DEVICE_ID, + ]); + assert.equal(diagnosticEvents[0]?.doubles?.[0], 1_780_000_000); + assert.ok((diagnosticEvents[0]?.doubles?.[1] ?? 0) > 0); + assert.deepEqual(auditEvents[0]?.blobs?.slice(0, 4), [ + "telemetry.events", + "POST", + "/api/telemetry/events", + "handled", + ]); + }); + + it("rejects diagnostic events containing sensitive fields", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const diagnosticEvents: ElyAnalyticsDataPoint[] = []; + const response = await handleRequest( + telemetryRequest({ + version: 1, + event_type: "app_startup", + occurred_at: 1_780_000_000, + app_version: "0.1.0", + platform: "macos", + outcome: "success", + url: "https://example.test/private?q=token", + }), + testEnv({ + diagnosticEvents, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "telemetry_sensitive_field" }); + assert.equal(diagnosticEvents.length, 0); + }); + + it("rejects unknown diagnostic fields", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const diagnosticEvents: ElyAnalyticsDataPoint[] = []; + const response = await handleRequest( + telemetryRequest({ + version: 1, + event_type: "update_result", + occurred_at: 1_780_000_000, + app_version: "0.1.0", + platform: "macos", + outcome: "failure", + metadata: { release_channel: "stable" }, + }), + testEnv({ + diagnosticEvents, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "telemetry_unknown_field" }); + assert.equal(diagnosticEvents.length, 0); + }); + + it("rejects unsupported diagnostic event types", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const diagnosticEvents: ElyAnalyticsDataPoint[] = []; + const response = await handleRequest( + telemetryRequest({ + version: 1, + event_type: "page_view", + occurred_at: 1_780_000_000, + app_version: "0.1.0", + platform: "macos", + }), + testEnv({ + diagnosticEvents, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "telemetry_event_type_invalid" }); + assert.equal(diagnosticEvents.length, 0); + }); + + it("rejects unauthenticated diagnostic events before recording telemetry", async () => { + const diagnosticEvents: ElyAnalyticsDataPoint[] = []; + const response = await handleRequest( + new Request("https://elydora.test/api/telemetry/events", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + version: 1, + event_type: "app_crash", + occurred_at: 1_780_000_000, + app_version: "0.1.0", + platform: "macos", + }), + }), + testEnv({ diagnosticEvents }), + ); + + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "authorization_missing" }); + assert.equal(diagnosticEvents.length, 0); + }); + + it("rejects unsupported methods before recording telemetry", async () => { + const diagnosticEvents: ElyAnalyticsDataPoint[] = []; + const response = await handleRequest( + new Request("https://elydora.test/api/telemetry/events", { method: "GET" }), + testEnv({ diagnosticEvents }), + ); + + assert.equal(response.status, 405); + assert.equal(response.headers.get("allow"), "POST"); + assert.deepEqual(await response.json(), { error: "method_not_allowed" }); + assert.equal(diagnosticEvents.length, 0); + }); +}); + +function telemetryRequest(body: Record): Request { + return new Request("https://elydora.test/api/telemetry/events", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); +} diff --git a/cloudflare/wrangler.toml b/cloudflare/wrangler.toml index 9363ae6..6a2d6be 100644 --- a/cloudflare/wrangler.toml +++ b/cloudflare/wrangler.toml @@ -25,6 +25,10 @@ ELY_AUTH_BASE_URL = "http://localhost:8787" binding = "ELY_API_AUDIT" dataset = "ely_api_audit" +[[analytics_engine_datasets]] +binding = "ELY_DIAGNOSTICS" +dataset = "ely_diagnostics" + [[ratelimits]] name = "ELY_RATE_LIMITER" namespace_id = "1001"