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