From aeaa0b39356f50d54b663427a9e88fe3a764bd3a Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 2 Oct 2026 21:17:56 -0400 Subject: [PATCH] feat(site): one wide event per request for the email and release routes Each request now emits a single structured event with deployment context and why it ended (bot check, gate, validation, missing settings, SES error type), to Axiom and the function log with the ingest result. No address, IP, header, body or error message is ever recorded. --- apps/site/app/api/email/README.md | 17 +- apps/site/app/api/email/_lib/email.test.ts | 18 -- apps/site/app/api/email/_lib/events.test.ts | 106 ++++++++++ apps/site/app/api/email/_lib/index.ts | 188 +++++++++++++----- apps/site/app/api/email/_lib/ses.ts | 18 ++ apps/site/app/api/email/_lib/telemetry.ts | 22 -- .../site/app/api/email/_lib/validator.test.ts | 30 ++- apps/site/app/api/email/_lib/validator.ts | 48 ++++- apps/site/app/api/email/route.ts | 10 +- apps/site/app/api/update/README.md | 14 +- apps/site/app/api/update/_lib/index.ts | 126 +++++++----- apps/site/app/api/update/_lib/routes.test.ts | 38 ++-- .../app/api/update/_lib/telemetry.test.ts | 118 +---------- apps/site/app/api/update/_lib/telemetry.ts | 51 +---- apps/site/lib/log/index.test.ts | 186 +++++++++++++++++ apps/site/lib/log/index.ts | 162 +++++++++++++++ apps/site/scripts/smoke-feed.ts | 19 +- apps/site/tsconfig.json | 1 + 18 files changed, 821 insertions(+), 351 deletions(-) create mode 100644 apps/site/app/api/email/_lib/events.test.ts delete mode 100644 apps/site/app/api/email/_lib/telemetry.ts create mode 100644 apps/site/lib/log/index.test.ts create mode 100644 apps/site/lib/log/index.ts diff --git a/apps/site/app/api/email/README.md b/apps/site/app/api/email/README.md index 9cace426..2b970906 100644 --- a/apps/site/app/api/email/README.md +++ b/apps/site/app/api/email/README.md @@ -125,11 +125,18 @@ disable/redact request bodies and recipient-bearing telemetry/traces. Avoid even destinations that retain addresses; use aggregate SES reputation/bounce/complaint metrics instead. -The only Axiom event is `{ event: "email_requested", route: "/api/email", -_time: "..." }` for a valid, non-honeypot, rate-admitted submission. It receives -no request data: no email, IP, IP digest, headers, user agent or provider error. -Ingest runs via Next's `after()`, once, without retries; absent config and -ingest failures are silent and do not affect sending. +Every request emits one wide event (`lib/log`, shared with the release routes): +deployment context, `status_code`, `outcome`, `duration_ms`, and why it ended: +`bot` (`human`/`bot`/`verified_bot`/`unavailable`), `rejection` +(`origin_mismatch`, `content_type`, `unreadable_body`, `honeypot`, +`invalid_syntax`, `rate_limited`), `validation` +(`accepted`/`invalid`/`uncertain`/`unavailable`), `failure` +(`botid_unavailable`, `validation_`, `email_unconfigured` with +`missing_config` variable names, `send_failed`), `error` (error type and HTTP +status only) and `delivery` (`sent`/`preview`). It never carries the email +address, its domain, an IP, headers, the body or any error message. It is sent +to Axiom once via `after()` and written to the function log with the ingest +result; logging never affects sending. Validation accepts plain ASCII addresses with a dotted domain, caps addresses at 254 characters (64 for the local part), and rejects display names, empty diff --git a/apps/site/app/api/email/_lib/email.test.ts b/apps/site/app/api/email/_lib/email.test.ts index 0f621856..3689dbb9 100644 --- a/apps/site/app/api/email/_lib/email.test.ts +++ b/apps/site/app/api/email/_lib/email.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"; import { SendEmailCommand, type SESv2ClientConfig } from "@aws-sdk/client-sesv2"; import { createEmailHandler } from "./index"; import { createRateLimit, windowMs } from "./rate-limit"; -import { logEmailRequested } from "./telemetry"; import { createTransport, type EmailEnv, type SesFactory } from "./transport"; import { validEmail } from "./validation"; import { fakeValidator } from "./validator"; @@ -280,20 +279,3 @@ describe("abuse guard", () => { expect(validEmail(email)).toBe(false); }); }); - -test("Axiom event contains only a request fact, route and timestamp; failures are silent", async () => { - const bodies: string[] = []; - await logEmailRequested( - { NODE_ENV: "production", AXIOM_TOKEN: "fake", AXIOM_DATASET: "site events" }, - async (url, init) => { - expect(url).toBe("https://api.axiom.co/v1/ingest/site%20events"); - bodies.push( - (await new Response(init.body).text()).replace(/"_time":"[^"]+"/, '"_time":"timestamp"') - ); - throw new Error("private provider payload"); - } - ); - expect(bodies).toEqual([ - JSON.stringify([{ event: "email_requested", route: "/api/email", _time: "timestamp" }]), - ]); -}); diff --git a/apps/site/app/api/email/_lib/events.test.ts b/apps/site/app/api/email/_lib/events.test.ts new file mode 100644 index 00000000..7e0e04ba --- /dev/null +++ b/apps/site/app/api/email/_lib/events.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import type { WideEvent } from "../../../../lib/log"; +import { createEmailHandler } from "./index"; +import { EmailValidationError, type EmailValidator } from "./validator"; + +const email = "private-recipient@example.com"; + +type Deps = Partial[0]>; + +function submit(deps: Deps = {}) { + const handler = createEmailHandler({ + checkBot: async () => ({ isBot: false, isVerifiedBot: false }), + validator: { validate: async () => true }, + transport: () => ({ preview: false, send: async () => {} }), + rateLimit: () => 0, + ...deps, + }); + + const event: WideEvent = {}; + + return handler( + new Request("https://polaris.lux.dev/api/email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, website: "" }), + }), + event + ).then((response) => ({ status: response.status, event })); +} + +const throwing = (error: Error): EmailValidator => ({ + validate: async () => { + throw error; + }, +}); + +describe("email wide event", () => { + test.each<[string, Deps, number, WideEvent]>([ + [ + "BotID failure", + { + checkBot: async () => { + throw new Error(email); + }, + }, + 503, + { bot: "unavailable", failure: "botid_unavailable", error: { type: "Error" } }, + ], + [ + "validator provider error", + { + validator: throwing( + new EmailValidationError("provider", { type: "InvalidIdentityToken", http_status: 400 }) + ), + }, + 503, + { + bot: "human", + validation: "unavailable", + failure: "validation_provider", + error: { type: "InvalidIdentityToken", http_status: 400 }, + }, + ], + [ + "uncertain verdict", + { validator: throwing(new EmailValidationError("uncertain")) }, + 503, + { validation: "uncertain", failure: "validation_uncertain" }, + ], + [ + "invalid recipient", + { validator: { validate: async () => false } }, + 422, + { validation: "invalid" }, + ], + [ + "missing configuration", + { transport: () => null, configProblems: () => ["AWS_ROLE_ARN"] }, + 503, + { validation: "accepted", failure: "email_unconfigured", missing_config: ["AWS_ROLE_ARN"] }, + ], + [ + "SES send failure", + { + transport: () => ({ + preview: false, + send: async () => { + throw Object.assign(new Error(`Rejected ${email}`), { + name: "MessageRejected", + $metadata: { httpStatusCode: 400 }, + }); + }, + }), + }, + 503, + { failure: "send_failed", error: { type: "MessageRejected", http_status: 400 } }, + ], + ["delivered", {}, 200, { bot: "human", validation: "accepted", delivery: "sent" }], + ])("%s records why, never the address", async (_, deps, status, fields) => { + const result = await submit(deps); + + expect(result.status).toBe(status); + expect(result.event).toMatchObject(fields); + expect(JSON.stringify(result.event)).not.toContain("private-recipient"); + }); +}); diff --git a/apps/site/app/api/email/_lib/index.ts b/apps/site/app/api/email/_lib/index.ts index 71148bd0..3c6d1a79 100644 --- a/apps/site/app/api/email/_lib/index.ts +++ b/apps/site/app/api/email/_lib/index.ts @@ -1,6 +1,13 @@ +import { errorInfo, type WideEvent } from "../../../../lib/log"; import type { EmailTransport } from "./transport"; import type { BotCheck } from "./bot"; -import { unavailableValidator, type EmailValidator } from "./validator"; +import { Option } from "effect"; +import { + decodeEmailValidationError, + EmailValidationError, + unavailableValidator, + type EmailValidator, +} from "./validator"; import { readSubmission, validEmail } from "./validation"; type Dependencies = { @@ -8,7 +15,9 @@ type Dependencies = { validator?: EmailValidator; transport: () => EmailTransport | null; rateLimit: (headers: Headers) => number; - requested: () => void; + /** Names of missing or invalid settings, recorded when the transport is unavailable. */ + configProblems?: () => readonly string[]; + requested?: () => void; }; function reply(status: number, message: string, headers?: Readonly>) { @@ -18,76 +27,153 @@ function reply(status: number, message: string, headers?: Readonly => { - try { - const verification = await checkBot(request); +/** Bot detection first; any detection failure fails closed. */ +async function botGate(checkBot: BotCheck, request: Request, event: WideEvent) { + try { + const verification = await checkBot(request); - if (verification.isBot || verification.isVerifiedBot) - return reply(403, "This request was blocked. Please try again from your browser."); - } catch { - return reply(503, "Email is unavailable. Please try again later."); - } + if (verification.isVerifiedBot) event.bot = "verified_bot"; + else event.bot = verification.isBot ? "bot" : "human"; + + if (verification.isBot || verification.isVerifiedBot) + return reply(403, "This request was blocked. Please try again from your browser."); + } catch (error) { + event.bot = "unavailable"; + event.failure = "botid_unavailable"; + event.error = errorInfo(error); + + return reply(503, "Email is unavailable. Please try again later."); + } + + return null; +} + +function requestGate(request: Request, event: WideEvent) { + const origin = request.headers.get("origin"); + const publicUrl = new URL(request.url); + const host = request.headers.get("host"); + + // Next can normalize the internal URL to localhost in development; Host is the browser's authority. + if (host) publicUrl.host = host; - const origin = request.headers.get("origin"); - const publicUrl = new URL(request.url); - const host = request.headers.get("host"); + if (origin && origin !== publicUrl.origin) { + event.rejection = "origin_mismatch"; - // Next can normalize the internal URL to localhost in development; Host is the browser's authority. - if (host) publicUrl.host = host; + return reply(403, "Send this form from the Polaris site."); + } - if (origin && origin !== publicUrl.origin) - return reply(403, "Send this form from the Polaris site."); + if (request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") { + event.rejection = "content_type"; - if (request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") { - return reply(415, "Send the email form as JSON."); + return reply(415, "Send the email form as JSON."); + } + + return null; +} + +function validationFailure(failure: EmailValidationError, event: WideEvent) { + event.validation = failure.reason === "uncertain" ? "uncertain" : "unavailable"; + event.failure = `validation_${failure.reason}`; + + if (failure.cause_info) event.error = failure.cause_info; +} + +async function deliver( + deps: Dependencies, + validator: EmailValidator, + email: string, + event: WideEvent +) { + try { + const accepted = await validator.validate(email); + event.validation = accepted ? "accepted" : "invalid"; + + if (!accepted) return reply(422, "Use another email address."); + } catch (error) { + validationFailure( + Option.getOrElse( + decodeEmailValidationError(error), + () => new EmailValidationError("provider", errorInfo(error)) + ), + event + ); + + return reply(503, "The email could not be sent. Please try again later."); + } + + const sender = deps.transport(); + + if (!sender) { + event.failure = "email_unconfigured"; + event.missing_config = deps.configProblems?.() ?? []; + + return reply(503, "Email is not available yet. Please try again later."); + } + + try { + await sender.send(email); + } catch (error) { + event.failure = "send_failed"; + event.error = errorInfo(error); + + return reply(503, "The email could not be sent. Please try again later."); + } + + event.delivery = sender.preview ? "preview" : "sent"; + + return Response.json( + {}, + { + headers: { + "Cache-Control": "no-store", + "X-Polaris-Email-Preview": sender.preview ? "1" : "0", + }, } + ); +} + +export function createEmailHandler(deps: Dependencies) { + const validator = deps.validator ?? unavailableValidator; + + return async (request: Request, event: WideEvent = {}): Promise => { + const blocked = (await botGate(deps.checkBot, request, event)) ?? requestGate(request, event); + + if (blocked) return blocked; let submission; try { submission = await readSubmission(request); } catch { + event.rejection = "unreadable_body"; + return reply(400, "Enter a valid email address."); } - if (submission.website !== "") return reply(200, "Check your inbox for the Mac download."); + if (submission.website !== "") { + event.rejection = "honeypot"; - if (!validEmail(submission.email)) return reply(400, "Enter a valid email address."); + return reply(200, "Check your inbox for the Mac download."); + } - const retryAfter = rateLimit(request.headers); + if (!validEmail(submission.email)) { + event.rejection = "invalid_syntax"; + + return reply(400, "Enter a valid email address."); + } + + const retryAfter = deps.rateLimit(request.headers); + + if (retryAfter) { + event.rejection = "rate_limited"; - if (retryAfter) return reply(429, "Too many requests. Try again in 15 minutes.", { "Retry-After": String(retryAfter), }); - requested(); - - try { - if (!(await validator.validate(submission.email))) - return reply(422, "Use another email address."); - const sender = transport(); - - if (!sender) return reply(503, "Email is not available yet. Please try again later."); - await sender.send(submission.email); - - return Response.json( - {}, - { - headers: { - "Cache-Control": "no-store", - "X-Polaris-Email-Preview": sender.preview ? "1" : "0", - }, - } - ); - } catch { - return reply(503, "The email could not be sent. Please try again later."); } + + deps.requested?.(); + + return deliver(deps, validator, submission.email, event); }; } diff --git a/apps/site/app/api/email/_lib/ses.ts b/apps/site/app/api/email/_lib/ses.ts index 809f7fe8..c0923e12 100644 --- a/apps/site/app/api/email/_lib/ses.ts +++ b/apps/site/app/api/email/_lib/ses.ts @@ -35,3 +35,21 @@ export function readSesConfig( credentials: credentialsFor(roleArn), }; } + +/** Names (never values) of the settings that keep production email unconfigured. */ +export function sesConfigProblems(env: EmailEnv): string[] { + const problems: string[] = []; + + if (!env.AWS_REGION?.trim()) problems.push("AWS_REGION"); + + if (!validEmail(env.POLARIS_EMAIL_FROM?.trim() ?? "")) problems.push("POLARIS_EMAIL_FROM"); + + if (!env.POLARIS_EMAIL_CONFIGURATION_SET?.trim()) + problems.push("POLARIS_EMAIL_CONFIGURATION_SET"); + + if (!ROLE_ARN.test(env.AWS_ROLE_ARN?.trim() ?? "")) problems.push("AWS_ROLE_ARN"); + + if (env.POLARIS_EMAIL_TRANSPORT === "fake") problems.push("POLARIS_EMAIL_TRANSPORT"); + + return problems; +} diff --git a/apps/site/app/api/email/_lib/telemetry.ts b/apps/site/app/api/email/_lib/telemetry.ts deleted file mode 100644 index 6397fde3..00000000 --- a/apps/site/app/api/email/_lib/telemetry.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { EmailEnv } from "./transport"; - -/** This function accepts no request data, so telemetry cannot carry an address or IP. */ -export async function logEmailRequested( - env: EmailEnv, - send: (input: string, init: RequestInit) => Promise = fetch -) { - if (env.NODE_ENV !== "production" || !env.AXIOM_TOKEN || !env.AXIOM_DATASET) return; - - try { - await send(`https://api.axiom.co/v1/ingest/${encodeURIComponent(env.AXIOM_DATASET)}`, { - method: "POST", - headers: { Authorization: `Bearer ${env.AXIOM_TOKEN}`, "Content-Type": "application/json" }, - body: JSON.stringify([ - { event: "email_requested", route: "/api/email", _time: new Date().toISOString() }, - ]), - signal: AbortSignal.timeout(3000), - }); - } catch { - // Telemetry is best effort; provider errors may contain private data and are discarded. - } -} diff --git a/apps/site/app/api/email/_lib/validator.test.ts b/apps/site/app/api/email/_lib/validator.test.ts index ff55ac93..c26580ec 100644 --- a/apps/site/app/api/email/_lib/validator.test.ts +++ b/apps/site/app/api/email/_lib/validator.test.ts @@ -1,8 +1,6 @@ -import { Schema } from "effect"; import { describe, expect, spyOn, test } from "bun:test"; import { GetEmailAddressInsightsCommand, type SESv2ClientConfig } from "@aws-sdk/client-sesv2"; import { createEmailHandler } from "./index"; -import { logEmailRequested } from "./telemetry"; import { createTransport } from "./transport"; import { createValidationCounters, @@ -64,16 +62,6 @@ function handlerFixture( checkBot: async () => ({ isBot: false, isVerifiedBot: false }), validator, rateLimit: () => 0, - requested: () => { - void logEmailRequested( - { ...env, AXIOM_TOKEN: "fake", AXIOM_DATASET: "fake" }, - async (_, init) => { - telemetry.push(Schema.decodeUnknownSync(Schema.String)(init.body)); - - return new Response(); - } - ); - }, transport: () => { opened++; @@ -86,15 +74,23 @@ function handlerFixture( }, }); - const request = () => - handler( + const request = async () => { + const event = {}; + + const response = await handler( new Request("https://polaris.lux.dev/api/email", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, website: "" }), - }) + }), + event ); + telemetry.push(JSON.stringify(event)); + + return response; + }; + return { request, sent: () => sent, @@ -320,7 +316,7 @@ describe("SES Insights configuration and privacy", () => { throw new Error("Expected validation to fail"); }, (error) => { - expect(error).toEqual(new Error("Email validation unavailable")); + expect(String(error)).toBe("EmailValidationError: Email validation unavailable"); } ); expect(calls).toBe(0); @@ -357,7 +353,7 @@ describe("SES Insights configuration and privacy", () => { await validator.validate(email); throw new Error("expected validation to fail"); } catch (error) { - expect(error).toEqual(new Error("Email validation unavailable")); + expect(String(error)).toBe("EmailValidationError: Email validation unavailable"); expect(error).not.toHaveProperty("cause"); expect(String(error)).not.toContain(email); } diff --git a/apps/site/app/api/email/_lib/validator.ts b/apps/site/app/api/email/_lib/validator.ts index b56d259d..8dfa4188 100644 --- a/apps/site/app/api/email/_lib/validator.ts +++ b/apps/site/app/api/email/_lib/validator.ts @@ -3,7 +3,8 @@ import { SESv2Client, type SESv2ClientConfig, } from "@aws-sdk/client-sesv2"; -import { Schema } from "effect"; +import { Option, Schema } from "effect"; +import { errorInfo, type ErrorInfo } from "../../../../lib/log"; import { readSesConfig, type EmailEnv } from "./ses"; /** Provider seam: false rejects the recipient, a rejection fails closed, true permits SES. */ @@ -11,11 +12,33 @@ export interface EmailValidator { validate(email: string): Promise; } +export type ValidationFailure = "unconfigured" | "uncertain" | "timeout" | "malformed" | "provider"; + +/** A fail-closed validation with a loggable reason; its message stays generic. */ +export class EmailValidationError extends Error { + override readonly name = "EmailValidationError"; + + constructor( + readonly reason: ValidationFailure, + readonly cause_info?: ErrorInfo + ) { + super("Email validation unavailable"); + } +} + +class ValidationTimeout extends Error { + override readonly name = "ValidationTimeout"; +} + +export const decodeEmailValidationError = Schema.decodeUnknownOption( + Schema.instanceOf(EmailValidationError) +); + export const fakeValidator: EmailValidator = { validate: async () => true }; export const unavailableValidator: EmailValidator = { validate: async () => { - throw new Error("Email validation unavailable"); + throw new EmailValidationError("unconfigured"); }, }; @@ -105,7 +128,7 @@ async function getInsights(client: ReturnType, email: string, d const deadline = new Promise((_, reject) => { timer = setTimeout(() => { - reject(new Error("Email validation unavailable")); + reject(new ValidationTimeout()); controller.abort(); }, deadlineMs); }); @@ -124,6 +147,8 @@ async function getInsights(client: ReturnType, email: string, d } } +const decodeTimeout = Schema.decodeUnknownOption(Schema.instanceOf(ValidationTimeout)); + export function createValidator( env: EmailEnv, makeClient: InsightsFactory = (config) => new SESv2Client(config), @@ -139,7 +164,7 @@ export function createValidator( try { const config = readSesConfig(env); - if (!config) throw new Error("Email validation unavailable"); + if (!config) throw new EmailValidationError("unconfigured"); const client = makeClient({ ...config, @@ -151,15 +176,22 @@ export function createValidator( } finally { client.destroy?.(); } - } catch { + } catch (error) { counters.record("unavailable"); - // SDK and schema failures can contain the address; never retain a cause or provider message. - throw new Error("Email validation unavailable"); + // SDK and schema failures can contain the address: keep only the error's type and status. + + if (Option.isSome(decodeEmailValidationError(error))) throw error; + + if (Option.isSome(decodeTimeout(error))) throw new EmailValidationError("timeout"); + + if (Schema.isSchemaError(error)) throw new EmailValidationError("malformed"); + + throw new EmailValidationError("provider", errorInfo(error)); } counters.record(outcome); - if (outcome === "uncertain") throw new Error("Email validation unavailable"); + if (outcome === "uncertain") throw new EmailValidationError("uncertain"); return outcome === "accepted"; }, diff --git a/apps/site/app/api/email/route.ts b/apps/site/app/api/email/route.ts index 4babf7a0..06c69008 100644 --- a/apps/site/app/api/email/route.ts +++ b/apps/site/app/api/email/route.ts @@ -1,8 +1,8 @@ -import { after } from "next/server"; +import { withWideEvent } from "../../../lib/log"; import { createEmailHandler } from "./_lib"; import { checkEmailBot } from "./_lib/bot"; import { createRateLimit } from "./_lib/rate-limit"; -import { logEmailRequested } from "./_lib/telemetry"; +import { sesConfigProblems } from "./_lib/ses"; import { createTransport } from "./_lib/transport"; import { createValidator } from "./_lib/validator"; @@ -10,10 +10,12 @@ export const runtime = "nodejs"; const limit = createRateLimit(); -export const POST = createEmailHandler({ +const handler = createEmailHandler({ checkBot: checkEmailBot, validator: { validate: (email) => createValidator(process.env).validate(email) }, transport: () => createTransport(process.env), rateLimit: (headers) => limit(headers, process.env.VERCEL === "1"), - requested: () => after(() => logEmailRequested(process.env)), + configProblems: () => sesConfigProblems(process.env), }); + +export const POST = withWideEvent("/api/email", (request, event) => handler(request, event)); diff --git a/apps/site/app/api/update/README.md b/apps/site/app/api/update/README.md index 4771fc4a..f5400669 100644 --- a/apps/site/app/api/update/README.md +++ b/apps/site/app/api/update/README.md @@ -57,20 +57,26 @@ Set server-only `AXIOM_TOKEN` (an ingest token scoped to the dataset) and arrays posted to `https://api.axiom.co/v1/ingest/` with a Bearer token. No Axiom SDK or automatic request capture is used. -Each handled GET schedules exactly one event with Next.js `after()`, including -400, 503 and cached Release lookups: +Each handled GET emits exactly one wide event (`lib/log`) via Next.js `after()`, +including 400, 503 and cached Release lookups. It is sent to Axiom and written +to the function log as one JSON line with the ingest result (`axiom`: `ok`, +`unconfigured`, `rejected_` or `failed_`). Shared fields: +`_time`, `request_id` (`x-vercel-id`), `method`, `route`, `service`, `commit`, +`deployment_id`, `environment`, `region`, `status_code`, `outcome` +(`success`/`rejected`/`error`) and `duration_ms`. Route fields: | Field | Meaning | | --- | --- | | `_time` | Request timestamp, ISO 8601. | | `event` | `update_check` or `download`. | -| `route` | `/api/update/darwin-arm64/[version]` or `/download/mac`; never a raw URL. | | `version` | Valid caller semver; null for downloads or malformed versions. | | `arch` | `arm64`, the artifact architecture this route serves. | | `macos_version` | Valid explicit header, else numeric Mac OS X User-Agent extract, else null. Browser UAs can report a compatibility version; the explicit header is authoritative. | | `install_id` | Optional UUID v4; null otherwise. | | `country` | Two-letter uppercase `x-vercel-ip-country` from Vercel, else null. | -| `status` | HTTP response status. | +| `release_version` | Tag of the latest published Release, or null. | +| `update` | `current` or `available` for update checks. | +| `failure` | `no_release` or `release_unavailable` (with `error`: type and HTTP status only). | Country comes directly from Vercel's geo header; the application never reads, stores or forwards the IP. It also excludes raw headers, User-Agent, URL/query, diff --git a/apps/site/app/api/update/_lib/index.ts b/apps/site/app/api/update/_lib/index.ts index dfe6e9b4..1c2598d2 100644 --- a/apps/site/app/api/update/_lib/index.ts +++ b/apps/site/app/api/update/_lib/index.ts @@ -1,12 +1,14 @@ -import { after } from "next/server"; +import { errorInfo, withWideEvent, type ErrorInfo, type WideEvent } from "../../../../lib/log"; import { latestRelease, releaseAsset, type PublishedRelease } from "./releases.ts"; -import { ingestEvent, requestEvent, type RequestEvent } from "./telemetry.ts"; +import { requestFields } from "./telemetry.ts"; import { compareVersions, parseVersion } from "./version.ts"; +type WideEventOptions = NonNullable[2]>; + interface Dependencies { latest: () => Promise; - schedule: (callback: () => Promise) => void; - log: (event: RequestEvent) => Promise; + /** Overrides for the wide event's scheduling, emission, environment and clock (tests). */ + log?: WideEventOptions; } const headers = { "Cache-Control": "no-store" }; @@ -20,73 +22,97 @@ const unavailable = () => } ); +const UPDATE_ROUTE = "/api/update/darwin-arm64/[version]"; + +const DOWNLOAD_ROUTE = "/download/mac"; + export function createReleaseRoutes(deps: Dependencies) { - function logged( - request: Request, - route: "update_check" | "download", - version: string | null, - response: Response - ) { - const event = requestEvent(request, route, version, response.status); - deps.schedule(() => deps.log(event)); - - return response; + async function release(event: WideEvent) { + const found = await deps.latest(); + event.release_version = found ? found.tag_name : null; + + if (!found) event.failure = "no_release"; + + return found; } - async function updateResponse(version: string) { + function failed(error: ErrorInfo, event: WideEvent) { + event.failure = "release_unavailable"; + event.error = error; + + return unavailable(); + } + + async function updateResponse(version: string, event: WideEvent) { const caller = parseVersion(version); - if (!caller) + if (!caller) { + event.rejection = "invalid_version"; + return Response.json({ error: "Use a valid semantic version." }, { status: 400, headers }); + } try { - const release = await deps.latest(); + const latest = await release(event); + + if (!latest || compareVersions(caller, latest.version) >= 0) { + event.update = "current"; - if (!release || compareVersions(caller, release.version) >= 0) return new Response(null, { status: 204, headers }); + } + + event.update = "available"; return Response.json( { - url: releaseAsset(release, "zip"), - name: release.name ?? release.tag_name, - notes: release.body ?? "", - pub_date: release.published_at, + url: releaseAsset(latest, "zip"), + name: latest.name ?? latest.tag_name, + notes: latest.body ?? "", + pub_date: latest.published_at, }, { headers } ); - } catch { - return unavailable(); + } catch (error) { + return failed(errorInfo(error), event); + } + } + + async function downloadResponse(event: WideEvent) { + try { + const latest = await release(event); + + return latest + ? new Response(null, { + status: 307, + headers: { ...headers, Location: releaseAsset(latest, "dmg") }, + }) + : unavailable(); + } catch (error) { + return failed(errorInfo(error), event); } } return { - async update(this: void, request: Request, context: { params: Promise<{ version: string }> }) { - const { version } = await context.params; - - return logged(request, "update_check", version, await updateResponse(version)); - }, - async download(this: void, request: Request) { - let response: Response; - - try { - const release = await deps.latest(); - response = release - ? new Response(null, { - status: 307, - headers: { ...headers, Location: releaseAsset(release, "dmg") }, - }) - : unavailable(); - } catch { - response = unavailable(); - } + update: withWideEvent<{ params: Promise<{ version: string }> }>( + UPDATE_ROUTE, + async (request, event, context) => { + const { version } = await context.params; + Object.assign(event, requestFields(request, "update_check", version)); + + return updateResponse(version, event); + }, + deps.log + ), + download: withWideEvent( + DOWNLOAD_ROUTE, + async (request, event) => { + Object.assign(event, requestFields(request, "download", null)); - return logged(request, "download", null, response); - }, + return downloadResponse(event); + }, + deps.log + ), }; } -export const releaseRoutes = createReleaseRoutes({ - latest: latestRelease, - schedule: after, - log: ingestEvent, -}); +export const releaseRoutes = createReleaseRoutes({ latest: latestRelease }); diff --git a/apps/site/app/api/update/_lib/routes.test.ts b/apps/site/app/api/update/_lib/routes.test.ts index 5e69cf63..df66ef72 100644 --- a/apps/site/app/api/update/_lib/routes.test.ts +++ b/apps/site/app/api/update/_lib/routes.test.ts @@ -2,21 +2,24 @@ import { expect, test } from "bun:test"; import { fixture, zip, dmg } from "./fixtures.ts"; import { createReleaseRoutes } from "./index.ts"; import { publishedRelease, type PublishedRelease } from "./releases.ts"; -import type { RequestEvent } from "./telemetry.ts"; +import type { WideEvent } from "../../../../lib/log"; function setup( latest: () => Promise = async () => publishedRelease(fixture) ) { const callbacks: (() => Promise)[] = []; - const events: RequestEvent[] = []; + const events: WideEvent[] = []; const routes = createReleaseRoutes({ latest, - schedule: (callback) => { - callbacks.push(callback); - }, - log: async (event) => { - events.push(event); + log: { + env: {}, + schedule: (callback) => { + callbacks.push(callback); + }, + emit: async (event) => { + events.push(event); + }, }, }); @@ -44,7 +47,7 @@ test.each(["1.2.3", "1.2.3+local", "1.2.4", "2.0.0"])( await Promise.all(callbacks.map((callback) => callback())); expect(events).toHaveLength(2); expect(events[0]?.version).toBe(version); - expect(events[0]?.status).toBe(204); + expect(events[0]?.status_code).toBe(204); } ); @@ -67,7 +70,7 @@ test.each(["1.2.2", "1.2.3-rc.1", "1.1.99", "0.9.9"])( }); expect(callbacks).toHaveLength(1); await callbacks[0]!(); - expect(events[0]?.status).toBe(200); + expect(events[0]?.status_code).toBe(200); } ); @@ -88,7 +91,12 @@ test("malformed versions return 400 without lookup and without logging raw input test("download redirects temporarily to the latest DMG and logs once", async () => { const { routes, callbacks, events } = setup(); - const response = await routes.download(new Request("https://polaris.lux.dev/download/mac")); + + const response = await routes.download( + new Request("https://polaris.lux.dev/download/mac"), + undefined + ); + expect(response.status).toBe(307); expect(response.headers.get("location")).toBe(dmg); expect(response.headers.get("cache-control")).toBe("no-store"); @@ -98,7 +106,7 @@ test("download redirects temporarily to the latest DMG and logs once", async () event: "download", route: "/download/mac", version: null, - status: 307, + status_code: 307, }); }); @@ -111,9 +119,11 @@ test("no published Release means no update, and download unavailable; both log", }) ).status ).toBe(204); - expect((await routes.download(new Request("https://polaris.lux.dev/"))).status).toBe(503); + expect((await routes.download(new Request("https://polaris.lux.dev/"), undefined)).status).toBe( + 503 + ); await Promise.all(callbacks.map((callback) => callback())); - expect(events.map((event) => event.status)).toEqual([204, 503]); + expect(events.map((event) => event.status_code)).toEqual([204, 503]); }); test("upstream failures or missing assets return retryable 503 and still log once", async () => { @@ -129,7 +139,7 @@ test("upstream failures or missing assets return retryable 503 and still log onc params: Promise.resolve({ version: "1.0.0" }), }); - const download = await routes.download(new Request("https://polaris.lux.dev/")); + const download = await routes.download(new Request("https://polaris.lux.dev/"), undefined); for (const response of [update, download]) { expect(response.status).toBe(503); diff --git a/apps/site/app/api/update/_lib/telemetry.test.ts b/apps/site/app/api/update/_lib/telemetry.test.ts index 49967cd3..f760c01e 100644 --- a/apps/site/app/api/update/_lib/telemetry.test.ts +++ b/apps/site/app/api/update/_lib/telemetry.test.ts @@ -1,10 +1,10 @@ -import { expect, spyOn, test } from "bun:test"; -import { ingestEvent, INSTALL_ID_HEADER, MACOS_VERSION_HEADER, requestEvent } from "./telemetry.ts"; +import { expect, test } from "bun:test"; +import { INSTALL_ID_HEADER, MACOS_VERSION_HEADER, requestFields } from "./telemetry.ts"; const installId = "6a00ec60-187a-4a97-818c-0d9d18961f06"; test("allowlists event fields; drops IP, raw UA, headers, query, cookies and invalid version", () => { - const event = requestEvent( + const event = requestFields( new Request("https://polaris.lux.dev/download/mac?email=private@example.com", { headers: { [INSTALL_ID_HEADER]: installId, @@ -19,27 +19,23 @@ test("allowlists event fields; drops IP, raw UA, headers, query, cookies and inv }, }), "update_check", - "203.0.113.10", - 400 + "203.0.113.10" ); expect(event).toEqual({ - _time: event._time, event: "update_check", - route: "/api/update/darwin-arm64/[version]", version: null, arch: "arm64", macos_version: "26.0.1", install_id: installId, country: "US", - status: 400, }); expect(JSON.stringify(event)).not.toContain("203.0.113.10"); expect(JSON.stringify(event)).not.toContain("private"); }); test("extracts macOS version from UA without keeping UA, ignores arbitrary telemetry headers", () => { - const event = requestEvent( + const event = requestFields( new Request("https://polaris.lux.dev/", { headers: { "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_6_1) Name private@example.com", @@ -49,107 +45,15 @@ test("extracts macOS version from UA without keeping UA, ignores arbitrary telem }, }), "download", - null, - 307 + null ); expect(event.macos_version).toBe("15.6.1"); expect(event.install_id).toBeNull(); expect(event.country).toBeNull(); - expect( - requestEvent(new Request("https://polaris.lux.dev/"), "download", null, 307) - ).toMatchObject({ macos_version: null, install_id: null, country: null }); -}); - -test("one Axiom ingest sends only the sanitized event to the configured dataset", async () => { - let calls = 0; - const event = requestEvent(new Request("https://polaris.lux.dev/"), "update_check", "1.2.3", 204); - - const fetcher = Object.assign( - async (url: string | URL | Request, options?: RequestInit) => { - calls++; - expect(url).toBe("https://api.axiom.co/v1/ingest/releases%20test"); - expect(options?.method).toBe("POST"); - expect(options?.headers).toEqual({ - Authorization: "Bearer fake-token", - "Content-Type": "application/json", - }); - expect(options?.body).toBe(JSON.stringify([event])); - expect(options?.cache).toBe("no-store"); - - return Response.json({ ingested: 1, failed: 0 }); - }, - { preconnect() {} } - ); - - await ingestEvent(event, { AXIOM_TOKEN: "fake-token", AXIOM_DATASET: "releases test" }, fetcher); - expect(calls).toBe(1); -}); - -test("Axiom failures do not throw, retry or log response/request details", async () => { - const warning = spyOn(console, "warn").mockImplementation(() => {}); - - try { - const event = requestEvent(new Request("https://polaris.lux.dev/"), "download", null, 307); - - for (const result of [ - new Response("private IP 203.0.113.10", { status: 401 }), - Response.json({ ingested: 0, failed: 1, failures: ["private"] }), - Response.json({ unexpected: "private" }), - ]) { - let calls = 0; - await ingestEvent( - event, - { AXIOM_TOKEN: "fake", AXIOM_DATASET: "test" }, - Object.assign( - async () => { - calls++; - - return result; - }, - { preconnect() {} } - ) - ); - expect(calls).toBe(1); - } - - await ingestEvent( - event, - { AXIOM_TOKEN: "fake", AXIOM_DATASET: "test" }, - Object.assign( - async () => { - throw new Error("private IP 203.0.113.10"); - }, - { preconnect() {} } - ) - ); - expect(warning.mock.calls).toEqual( - Array.from({ length: 4 }, () => ["Release request logging failed"]) - ); - } finally { - warning.mockRestore(); - } -}); - -test("missing Axiom configuration is visible without making a network request", async () => { - const warning = spyOn(console, "warn").mockImplementation(() => {}); - - try { - let calls = 0; - await ingestEvent( - requestEvent(new Request("https://polaris.lux.dev/"), "download", null, 307), - {}, - Object.assign( - async () => { - calls++; - throw new Error("must not fetch"); - }, - { preconnect() {} } - ) - ); - expect(calls).toBe(0); - expect(warning).toHaveBeenCalledWith("Release request logging is unconfigured"); - } finally { - warning.mockRestore(); - } + expect(requestFields(new Request("https://polaris.lux.dev/"), "download", null)).toMatchObject({ + macos_version: null, + install_id: null, + country: null, + }); }); diff --git a/apps/site/app/api/update/_lib/telemetry.ts b/apps/site/app/api/update/_lib/telemetry.ts index 3687b0d0..34aed8d8 100644 --- a/apps/site/app/api/update/_lib/telemetry.ts +++ b/apps/site/app/api/update/_lib/telemetry.ts @@ -1,4 +1,3 @@ -import { Schema } from "effect"; import { parseVersion } from "./version.ts"; export const INSTALL_ID_HEADER = "X-Polaris-Install-ID"; @@ -21,65 +20,21 @@ function macosVersion(headers: Headers) { return extracted ?? null; } -export function requestEvent( +/** Allowlisted request fields for the wide event: no IP, raw User-Agent, query or headers. */ +export function requestFields( request: Request, route: "update_check" | "download", - version: string | null, - status: number + version: string | null ) { const installId = request.headers.get(INSTALL_ID_HEADER) ?? ""; const country = request.headers.get("x-vercel-ip-country") ?? ""; return { - _time: new Date().toISOString(), event: route, - route: route === "update_check" ? "/api/update/darwin-arm64/[version]" : "/download/mac", version: version && parseVersion(version) ? version : null, arch: "arm64", macos_version: macosVersion(request.headers)?.replaceAll("_", ".") ?? null, install_id: uuid.test(installId) ? installId.toLowerCase() : null, country: /^[A-Z]{2}$/.test(country) ? country : null, - status, }; } - -export type RequestEvent = ReturnType; - -const decodeIngest = Schema.decodeUnknownSync( - Schema.Struct({ ingested: Schema.Number, failed: Schema.Number }) -); - -export async function ingestEvent( - event: RequestEvent, - env: Readonly<{ - AXIOM_TOKEN?: string | undefined; - AXIOM_DATASET?: string | undefined; - }> = { AXIOM_TOKEN: process.env.AXIOM_TOKEN, AXIOM_DATASET: process.env.AXIOM_DATASET }, - fetcher: typeof fetch = fetch -) { - if (!env.AXIOM_TOKEN || !env.AXIOM_DATASET) { - console.warn("Release request logging is unconfigured"); - - return; - } - - try { - const response = await fetcher( - `https://api.axiom.co/v1/ingest/${encodeURIComponent(env.AXIOM_DATASET)}`, - { - method: "POST", - headers: { Authorization: `Bearer ${env.AXIOM_TOKEN}`, "Content-Type": "application/json" }, - body: JSON.stringify([event]), - cache: "no-store", - signal: AbortSignal.timeout(5_000), - } - ); - - if (!response.ok) throw new Error("Ingest rejected"); - const receipt = decodeIngest(await response.json()); - - if (receipt.ingested !== 1 || receipt.failed !== 0) throw new Error("Ingest rejected"); - } catch { - console.warn("Release request logging failed"); - } -} diff --git a/apps/site/lib/log/index.test.ts b/apps/site/lib/log/index.test.ts new file mode 100644 index 00000000..d979652d --- /dev/null +++ b/apps/site/lib/log/index.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "bun:test"; +import { + createEmit, + errorInfo, + withWideEvent, + type Fetcher, + type Level, + type WideEvent, +} from "./index"; + +const env = { + NODE_ENV: "production", + VERCEL_GIT_COMMIT_SHA: "0123456789abcdef0123", + VERCEL_DEPLOYMENT_ID: "dpl_test", + VERCEL_ENV: "production", + VERCEL_REGION: "iad1", +}; + +function harness() { + const emitted: Array<{ event: WideEvent; level: Level }> = []; + const tasks: Array<() => Promise> = []; + let clock = 1_000; + + const options = { + env, + now: () => (clock += 25), + schedule: (task: () => Promise) => { + tasks.push(task); + }, + emit: async (event: WideEvent, level: Level) => { + emitted.push({ event, level }); + }, + }; + + return { emitted, tasks, options, flush: () => Promise.all(tasks.map((task) => task())) }; +} + +describe("withWideEvent", () => { + test("emits exactly one event per request with deployment context, status and timing", async () => { + const h = harness(); + + const handler = withWideEvent( + "/api/thing", + async (_, event) => { + event.step = "done"; + + return new Response(null, { status: 204 }); + }, + h.options + ); + + await handler( + new Request("https://polaris.lux.dev/api/thing", { headers: { "x-vercel-id": "iad1::abc" } }), + undefined + ); + await h.flush(); + + expect(h.emitted).toHaveLength(1); + expect(h.emitted[0]?.level).toBe("info"); + expect(h.emitted[0]?.event).toMatchObject({ + request_id: "iad1::abc", + method: "GET", + route: "/api/thing", + service: "polaris-site", + commit: "0123456789ab", + deployment_id: "dpl_test", + environment: "production", + region: "iad1", + step: "done", + status_code: 204, + outcome: "success", + duration_ms: 25, + }); + }); + + test.each([ + [422, "rejected", "info"], + [503, "error", "error"], + ])("status %i is %s at level %s", async (status, outcome, level) => { + const h = harness(); + const handler = withWideEvent("/x", async () => new Response(null, { status }), h.options); + + await handler(new Request("https://polaris.lux.dev/x"), undefined); + await h.flush(); + expect(h.emitted[0]).toMatchObject({ level, event: { status_code: status, outcome } }); + }); + + test("a thrown handler is recorded as a 500 with the error type but not its message", async () => { + const h = harness(); + + const handler = withWideEvent( + "/x", + async () => { + throw new TypeError("private@example.com"); + }, + h.options + ); + + expect(handler(new Request("https://polaris.lux.dev/x"), undefined)).rejects.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await h.flush(); + expect(h.emitted[0]).toMatchObject({ + level: "error", + event: { status_code: 500, outcome: "error", error: { type: "TypeError" } }, + }); + expect(JSON.stringify(h.emitted)).not.toContain("private@example.com"); + }); +}); + +describe("errorInfo", () => { + test("keeps an AWS error's name and HTTP status, never its message", () => { + const error = Object.assign(new Error("Access denied for private@example.com"), { + name: "AccessDeniedException", + $metadata: { httpStatusCode: 403 }, + }); + + expect(errorInfo(error)).toEqual({ type: "AccessDeniedException", http_status: 403 }); + }); + + test("an unusual name or a non-error collapses to a generic type", () => { + expect(errorInfo(Object.assign(new Error("x"), { name: "has spaces and @" }))).toEqual({ + type: "Error", + }); + expect(errorInfo("private")).toEqual({ type: "unknown" }); + }); +}); + +describe("emit", () => { + const event = { route: "/x" }; + + async function emitted( + fetcher: Fetcher, + emitEnv: Record = { + ...env, + AXIOM_TOKEN: "t", + AXIOM_DATASET: "polaris site", + } + ) { + const lines: Array<{ level: Level; event: WideEvent }> = []; + await createEmit(fetcher, (level, line) => lines.push({ level, event: line }))( + event, + "info", + emitEnv + ); + + return lines; + } + + const fetcher = + (respond: () => Promise): Fetcher => + async () => + respond(); + + test("ships to Axiom, then writes one line with the ingest result", async () => { + let url = ""; + + const lines = await emitted(async (input) => { + url = input; + + return Response.json({ ingested: 1, failed: 0 }); + }); + + expect(url).toBe("https://api.axiom.co/v1/ingest/polaris%20site"); + expect(lines).toEqual([{ level: "info", event: { route: "/x", axiom: "ok" } }]); + }); + + test.each([ + [fetcher(async () => new Response("private", { status: 401 })), "rejected_401"], + [fetcher(async () => Response.json({ ingested: 0, failed: 1 })), "rejected_receipt"], + [fetcher(async () => Promise.reject(new TypeError("private"))), "failed_TypeError"], + ])("records a failed ingest without its details %#", async (respond, axiom) => { + const lines = await emitted(respond); + + expect(lines[0]?.event.axiom).toBe(axiom); + expect(JSON.stringify(lines)).not.toContain("private"); + }); + + test("unconfigured Axiom still writes the line", async () => { + const lines = await emitted( + fetcher(async () => Promise.reject(new Error("must not fetch"))), + env + ); + + expect(lines[0]?.event.axiom).toBe("unconfigured"); + }); +}); diff --git a/apps/site/lib/log/index.ts b/apps/site/lib/log/index.ts new file mode 100644 index 00000000..eb1c743e --- /dev/null +++ b/apps/site/lib/log/index.ts @@ -0,0 +1,162 @@ +/** + * One wide event per request (canonical log line): handlers add fields to `event`, + * `withWideEvent` adds timing, status and deployment context and emits it once. + * Events never carry an email address, IP, request body, header dump or error message. + */ +import { after } from "next/server"; +import { Function, Option, Schema } from "effect"; + +/** An error reduced to its type and, for AWS SDK errors, its HTTP status. */ +export type ErrorInfo = { readonly type: string; readonly http_status?: number }; + +export type Field = string | number | boolean | null | readonly string[] | ErrorInfo; + +export type WideEvent = Record; + +export type Level = "info" | "error"; + +export type LogEnv = Readonly>; + +export type Emit = (event: WideEvent, level: Level, env: LogEnv) => Promise; + +const errorName = /^[\w.:-]{1,64}$/; + +const Metadata = Schema.Struct({ + $metadata: Schema.Struct({ httpStatusCode: Schema.optional(Schema.Number) }), +}); + +const decodeMetadata = Schema.decodeUnknownOption(Metadata); + +const unknownError: ErrorInfo = { type: "unknown" }; + +function describe(error: Error): ErrorInfo { + const type = errorName.test(error.name) ? error.name : "Error"; + const httpStatus = Option.getOrUndefined(decodeMetadata(error))?.$metadata.httpStatusCode; + + return httpStatus === undefined ? { type } : { type, http_status: httpStatus }; +} + +/** The caught value's error type and HTTP status; never its message. */ +export const errorInfo = Function.flow( + Schema.decodeUnknownOption(Schema.instanceOf(Error)), + Option.match({ onNone: () => unknownError, onSome: describe }) +); + +export function deployment(env: LogEnv) { + return { + service: "polaris-site", + commit: env.VERCEL_GIT_COMMIT_SHA?.slice(0, 12) ?? null, + deployment_id: env.VERCEL_DEPLOYMENT_ID ?? null, + environment: env.VERCEL_ENV ?? env.NODE_ENV ?? null, + region: env.VERCEL_REGION ?? null, + }; +} + +const decodeIngest = Schema.decodeUnknownSync( + Schema.Struct({ ingested: Schema.Number, failed: Schema.Number }) +); + +/** The slice of `fetch` the logger uses; `fetch` itself satisfies it. */ +export type Fetcher = (url: string, init: RequestInit) => Promise; + +async function ship(event: WideEvent, env: LogEnv, fetcher: Fetcher): Promise { + if (!env.AXIOM_TOKEN || !env.AXIOM_DATASET) return "unconfigured"; + + if (env.NODE_ENV !== "production") return "skipped"; + + try { + const response = await fetcher( + `https://api.axiom.co/v1/ingest/${encodeURIComponent(env.AXIOM_DATASET)}`, + { + method: "POST", + headers: { Authorization: `Bearer ${env.AXIOM_TOKEN}`, "Content-Type": "application/json" }, + body: JSON.stringify([event]), + cache: "no-store", + signal: AbortSignal.timeout(5_000), + } + ); + + if (!response.ok) return `rejected_${response.status}`; + const receipt = decodeIngest(await response.json()); + + return receipt.ingested === 1 && receipt.failed === 0 ? "ok" : "rejected_receipt"; + } catch (error) { + return `failed_${errorInfo(error).type}`; + } +} + +function write(level: Level, event: WideEvent) { + const line = JSON.stringify(event); + + if (level === "error") console.error(line); + else console.log(line); +} + +/** Sends the event to Axiom, then writes it as one JSON line with the ingest result. */ +export function createEmit( + fetcher: Fetcher = fetch, + out: (level: Level, event: WideEvent) => void = write +): Emit { + return async (event, level, env) => { + const axiom = await ship(event, env, fetcher); + out(level, { ...event, axiom }); + }; +} + +export const emit = createEmit(); + +type Options = { + env: LogEnv; + schedule: (task: () => Promise) => void; + emit: Emit; + now: () => number; +}; + +const defaults = (): Options => ({ env: process.env, schedule: after, emit, now: Date.now }); + +function startEvent(request: Request, route: string, env: LogEnv, started: number): WideEvent { + // oxlint-disable-next-line anti-slop/no-known-value-widening -- a wide event is open by design: handlers add fields after it starts. + return { + _time: new Date(started).toISOString(), + request_id: request.headers.get("x-vercel-id") ?? crypto.randomUUID(), + method: request.method, + route, + ...deployment(env), + }; +} + +/** Wraps a route handler so it emits exactly one wide event per request. */ +export function withWideEvent( + route: string, + handler: (request: Request, event: WideEvent, context: C) => Promise, + overrides: Partial = {} +) { + return async (request: Request, context: C): Promise => { + const options = { ...defaults(), ...overrides }; + const started = options.now(); + const event = startEvent(request, route, options.env, started); + + let level: Level = "info"; + + try { + const response = await handler(request, event, context); + event.status_code = response.status; + + if (response.status >= 500) { + event.outcome = "error"; + level = "error"; + } else event.outcome = response.status >= 400 ? "rejected" : "success"; + + return response; + } catch (error) { + event.status_code = 500; + event.outcome = "error"; + event.error = errorInfo(error); + level = "error"; + throw error; + } finally { + event.duration_ms = options.now() - started; + options.schedule(() => options.emit(event, level, options.env)); + } + }; +} diff --git a/apps/site/scripts/smoke-feed.ts b/apps/site/scripts/smoke-feed.ts index 8c15e811..709ba38a 100644 --- a/apps/site/scripts/smoke-feed.ts +++ b/apps/site/scripts/smoke-feed.ts @@ -18,14 +18,27 @@ const decodeEvent = Schema.decodeUnknownSync( Schema.Array( Schema.Struct({ _time: Schema.String, - event: Schema.Literals(["update_check", "download"]), + request_id: Schema.String, + method: Schema.Literal("GET"), route: Schema.String, + service: Schema.Literal("polaris-site"), + commit: Schema.NullOr(Schema.String), + deployment_id: Schema.NullOr(Schema.String), + environment: Schema.NullOr(Schema.String), + region: Schema.NullOr(Schema.String), + event: Schema.Literals(["update_check", "download"]), version: Schema.NullOr(Schema.String), arch: Schema.Literal("arm64"), macos_version: Schema.String, install_id: Schema.String, country: Schema.String, - status: Schema.Number, + release_version: Schema.optional(Schema.NullOr(Schema.String)), + update: Schema.optional(Schema.Literals(["current", "available"])), + rejection: Schema.optional(Schema.Literal("invalid_version")), + failure: Schema.optional(Schema.String), + status_code: Schema.Number, + outcome: Schema.Literals(["success", "rejected", "error"]), + duration_ms: Schema.Number, }) ), { onExcessProperty: "error" } @@ -157,7 +170,7 @@ async function checkEvents(statuses: number[]) { if (events.length === statuses.length) { assert.deepEqual( - events.map((event) => event.status), + events.map((event) => event.status_code), statuses ); assert.equal(readFileSync(githubTrace, "utf8"), "lookup\n"); diff --git a/apps/site/tsconfig.json b/apps/site/tsconfig.json index aea774c7..9fc4645d 100644 --- a/apps/site/tsconfig.json +++ b/apps/site/tsconfig.json @@ -13,6 +13,7 @@ "types.d.ts", "app", "components", + "lib", "scripts", "next.config.ts", "instrumentation-client.ts",