From d0a42eaac206a92e2ff25ebca4cbdbe67938698b Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 25 Oct 2026 13:11:54 +0530 Subject: [PATCH 1/2] perf(sites): Cloudflare purge adapter and real-user Web Vitals (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EDGE_PURGE_ADAPTER=cloudflare purges Cache-Tag tags in the zone that custom hostnames live in, in batches of 30, through the same outbox as the http adapter. - Public pages send their Core Web Vitals in one anonymous beacon to the API's POST /rum (SITE_RUM=off disables it). The API exports them as Prometheus histograms by route kind and edge cache status, which is read from Server-Timing (the Varnish recipe now adds edge;desc=HIT|MISS). /metrics also reports pending cache invalidations. - deploy/monitoring: a Grafana dashboard for the §9 targets: edge hit ratio, TTFB p75 on hits, LCP/INP/CLS p75 by route, pending invalidations. - docs/site-delivery.md §7a: the vendors compared against the #74 criteria, with a recommendation and the custom-domain TLS options. --- apps/api/.env.example | 4 + apps/api/src/metrics/routes.ts | 44 ++- apps/api/src/services/edge-purge.test.ts | 53 +++ apps/api/src/services/edge-purge.ts | 41 ++ apps/api/src/services/rum.test.ts | 60 +++ apps/api/src/services/rum.ts | 128 +++++++ apps/api/src/services/site-cache.ts | 14 + apps/sites/.env.example | 4 + .../app/sites/[teamId]/(site)/layout.tsx | 4 + apps/sites/components/web-vitals-beacon.tsx | 69 ++++ deploy/edge-cache/default.vcl | 4 + deploy/monitoring/README.md | 36 ++ deploy/monitoring/grafana-public-sites.json | 350 ++++++++++++++++++ docs/site-delivery.md | 55 ++- 14 files changed, 863 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/services/rum.test.ts create mode 100644 apps/api/src/services/rum.ts create mode 100644 apps/sites/components/web-vitals-beacon.tsx create mode 100644 deploy/monitoring/README.md create mode 100644 deploy/monitoring/grafana-public-sites.json diff --git a/apps/api/.env.example b/apps/api/.env.example index 4445418..6f87bea 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -150,6 +150,10 @@ SITES_REVALIDATE_URL= EDGE_PURGE_ADAPTER=http EDGE_PURGE_URL= EDGE_PURGE_SECRET= +# EDGE_PURGE_ADAPTER=cloudflare (hosted FrontLit): the zone custom hostnames +# live in and a token with the Zone → Cache Purge permission. +CLOUDFLARE_ZONE_ID= +CLOUDFLARE_API_TOKEN= # AI providers and credentials are configured per team in Settings → AI. diff --git a/apps/api/src/metrics/routes.ts b/apps/api/src/metrics/routes.ts index 50220c7..67c06e5 100644 --- a/apps/api/src/metrics/routes.ts +++ b/apps/api/src/metrics/routes.ts @@ -1,6 +1,13 @@ -import { Router } from "express"; +import express, { Router } from "express"; import { renderCounters } from "../services/metrics"; import { getOutboxQueueStats } from "../email/outbox"; +import { siteCacheOutboxStats } from "../services/site-cache"; +import { + allowRumBeacon, + recordRumBeacon, + renderRumMetrics, + rumBeaconSchema, +} from "../services/rum"; /** * Prometheus-format metrics (architecture.md §13.6). Deliberately outside @@ -12,20 +19,53 @@ import { getOutboxQueueStats } from "../email/outbox"; const router = Router(); router.get("/metrics", async (_req, res) => { - const { pendingCount, oldestPendingAgeSeconds } = await getOutboxQueueStats(); + const [{ pendingCount, oldestPendingAgeSeconds }, siteCache] = await Promise.all([ + getOutboxQueueStats(), + siteCacheOutboxStats(), + ]); const lines = [ renderCounters(), + renderRumMetrics(), "# TYPE outbox_queue_pending gauge", `outbox_queue_pending ${pendingCount}`, "# TYPE outbox_queue_oldest_pending_age_seconds gauge", ...(oldestPendingAgeSeconds !== null ? [`outbox_queue_oldest_pending_age_seconds ${oldestPendingAgeSeconds}`] : []), + "# HELP site_cache_invalidations_pending Public-site cache invalidations not yet delivered everywhere", + "# TYPE site_cache_invalidations_pending gauge", + `site_cache_invalidations_pending ${siteCache.pending}`, + "# TYPE site_cache_invalidations_oldest_age_seconds gauge", + ...(siteCache.oldestSeconds !== null + ? [ + `site_cache_invalidations_oldest_age_seconds ${Math.round(Number(siteCache.oldestSeconds))}`, + ] + : []), ]; res.set("content-type", "text/plain; version=0.0.4; charset=utf-8"); res.send(lines.filter(Boolean).join("\n") + "\n"); }); +/** + * Web Vitals beacon from public-site visitors (`navigator.sendBeacon`, sent + * as text/plain so no CORS preflight is needed). Anonymous and cheap: it + * only updates in-memory histograms, so it has its own per-client limit + * instead of the database-backed public limiter. + */ +router.post("/rum", express.text({ type: "*/*", limit: "4kb" }), (req, res) => { + if (!allowRumBeacon(req.ip ?? "unknown")) return res.status(429).end(); + let body: unknown; + try { + body = JSON.parse(typeof req.body === "string" ? req.body : ""); + } catch { + return res.status(400).end(); + } + const parsed = rumBeaconSchema.safeParse(body); + if (!parsed.success) return res.status(400).end(); + recordRumBeacon(parsed.data); + res.status(204).end(); +}); + export default router; diff --git a/apps/api/src/services/edge-purge.test.ts b/apps/api/src/services/edge-purge.test.ts index cef2e84..fa8fd88 100644 --- a/apps/api/src/services/edge-purge.test.ts +++ b/apps/api/src/services/edge-purge.test.ts @@ -13,6 +13,8 @@ afterEach(() => { delete process.env.EDGE_PURGE_URL; delete process.env.EDGE_PURGE_SECRET; delete process.env.EDGE_PURGE_ADAPTER; + delete process.env.CLOUDFLARE_ZONE_ID; + delete process.env.CLOUDFLARE_API_TOKEN; }); function configure() { @@ -78,3 +80,54 @@ describe("edge purge", () => { expect(fetchMock).toHaveBeenCalledOnce(); }); }); + +describe("cloudflare adapter", () => { + function configureCloudflare() { + process.env.EDGE_PURGE_ADAPTER = "cloudflare"; + process.env.CLOUDFLARE_ZONE_ID = "zone123"; + process.env.CLOUDFLARE_API_TOKEN = "cf-token"; + } + + it("purges tags in batches of 30 with the API token", async () => { + configureCloudflare(); + const fetchMock = vi.fn( + async (_url: string | URL, _init?: RequestInit) => + new Response(JSON.stringify({ success: true }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + const tags = Array.from({ length: 45 }, (_, i) => `page:team_a:p${i}`); + await deliverEdgePurge("edge", { createdAt: new Date() }, "team_a", tags); + expect(fetchMock).toHaveBeenCalledTimes(2); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe("https://api.cloudflare.com/client/v4/zones/zone123/purge_cache"); + expect((init?.headers as Record).authorization).toBe( + "Bearer cf-token", + ); + expect(JSON.parse(String(init?.body)).tags).toHaveLength(30); + expect(JSON.parse(String(fetchMock.mock.calls[1]![1]?.body)).tags).toHaveLength(15); + }); + + it("fails on an unsuccessful API answer so the outbox retries", async () => { + configureCloudflare(); + vi.stubGlobal( + "fetch", + async () => + new Response( + JSON.stringify({ success: false, errors: [{ message: "rate limited" }] }), + { + status: 429, + }, + ), + ); + await expect( + deliverEdgePurge("edge", { createdAt: new Date() }, "team_a", ["docs:team_a"]), + ).rejects.toThrow(/rate limited/); + }); + + it("is off until the zone and token are set", () => { + process.env.EDGE_PURGE_ADAPTER = "cloudflare"; + expect(edgePurgeAdapter()).toBeNull(); + configureCloudflare(); + expect(edgePurgeAdapter()).not.toBeNull(); + }); +}); diff --git a/apps/api/src/services/edge-purge.ts b/apps/api/src/services/edge-purge.ts index 87724e7..ed4d06b 100644 --- a/apps/api/src/services/edge-purge.ts +++ b/apps/api/src/services/edge-purge.ts @@ -34,6 +34,43 @@ export function httpPurgeAdapter(url: string, secret: string): EdgePurgeAdapter }; } +/** + * Hosted FrontLit on Cloudflare (for SaaS): purge by `Cache-Tag` in the zone + * every custom hostname is attached to. Tags carry the public team ID, so + * one team's purge never touches another's pages. Batched below + * Cloudflare's per-request tag limit. + */ +export function cloudflarePurgeAdapter( + zoneId: string, + apiToken: string, + baseUrl = "https://api.cloudflare.com/client/v4", +): EdgePurgeAdapter { + return { + async purge({ tags }) { + for (let start = 0; start < tags.length; start += 30) { + const response = await fetch(`${baseUrl}/zones/${zoneId}/purge_cache`, { + method: "POST", + headers: { + authorization: `Bearer ${apiToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ tags: tags.slice(start, start + 30) }), + signal: AbortSignal.timeout(10_000), + }); + const result = (await response.json().catch(() => null)) as { + success?: boolean; + errors?: { message?: string }[]; + } | null; + if (!response.ok || !result?.success) { + throw new Error( + `Cloudflare purge failed (${response.status}): ${result?.errors?.[0]?.message ?? "no details"}`, + ); + } + } + }, + }; +} + /** The configured adapter, or null when no edge cache needs purging. */ export function edgePurgeAdapter(env = process.env): EdgePurgeAdapter | null { const adapter = env.EDGE_PURGE_ADAPTER || "http"; @@ -41,6 +78,10 @@ export function edgePurgeAdapter(env = process.env): EdgePurgeAdapter | null { if (!env.EDGE_PURGE_URL || !env.EDGE_PURGE_SECRET) return null; return httpPurgeAdapter(env.EDGE_PURGE_URL, env.EDGE_PURGE_SECRET); } + if (adapter === "cloudflare") { + if (!env.CLOUDFLARE_ZONE_ID || !env.CLOUDFLARE_API_TOKEN) return null; + return cloudflarePurgeAdapter(env.CLOUDFLARE_ZONE_ID, env.CLOUDFLARE_API_TOKEN); + } throw new Error(`Unknown EDGE_PURGE_ADAPTER "${adapter}"`); } diff --git a/apps/api/src/services/rum.test.ts b/apps/api/src/services/rum.test.ts new file mode 100644 index 0000000..08d66c9 --- /dev/null +++ b/apps/api/src/services/rum.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + allowRumBeacon, + recordRumBeacon, + renderRumMetrics, + resetRumMetrics, + rumBeaconSchema, +} from "./rum"; + +beforeEach(() => resetRumMetrics()); + +describe("RUM metrics", () => { + it("records vitals as histograms by route and cache status", () => { + recordRumBeacon( + rumBeaconSchema.parse({ + metrics: [ + { name: "TTFB", value: 80 }, + { name: "LCP", value: 1200 }, + { name: "CLS", value: 0.02 }, + ], + route: "docs", + cache: "HIT", + }), + ); + const text = renderRumMetrics(); + expect(text).toContain( + 'site_web_vital_bucket{metric="TTFB",route="docs",cache="HIT",le="0.05"} 0', + ); + expect(text).toContain( + 'site_web_vital_bucket{metric="TTFB",route="docs",cache="HIT",le="0.1"} 1', + ); + expect(text).toContain( + 'site_web_vital_sum{metric="LCP",route="docs",cache="HIT"} 1.2', + ); + expect(text).toContain( + 'site_web_vital_bucket{metric="CLS",route="docs",cache="HIT",le="0.05"} 1', + ); + expect(text).toContain('site_page_loads_total{route="docs",cache="HIT"} 1'); + }); + + it("keeps labels bounded: unknown route and cache values collapse", () => { + const beacon = rumBeaconSchema.parse({ + metrics: [{ name: "TTFB", value: 10 }], + route: "/some/customer/path", + cache: "STALE", + }); + expect(beacon).toMatchObject({ route: "other", cache: "unknown" }); + expect( + rumBeaconSchema.safeParse({ metrics: [{ name: "FID", value: 1 }], route: "page" }) + .success, + ).toBe(false); + }); + + it("limits beacons per client and minute", () => { + for (let i = 0; i < 30; i++) expect(allowRumBeacon("1.2.3.4", 0)).toBe(true); + expect(allowRumBeacon("1.2.3.4", 0)).toBe(false); + expect(allowRumBeacon("5.6.7.8", 0)).toBe(true); + expect(allowRumBeacon("1.2.3.4", 61_000)).toBe(true); + }); +}); diff --git a/apps/api/src/services/rum.ts b/apps/api/src/services/rum.ts new file mode 100644 index 0000000..85f61e2 --- /dev/null +++ b/apps/api/src/services/rum.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; + +/** + * Real-user monitoring for the public sites (docs/site-delivery.md §9): + * Core Web Vitals from visitors' browsers, kept as Prometheus histograms + * next to the API's other metrics (`/metrics`). Per route kind and edge + * cache status, never per team or URL, so the series stay bounded. + */ +const METRICS = ["TTFB", "FCP", "LCP", "CLS", "INP"] as const; +const ROUTES = ["page", "docs", "blog", "changelog", "other"] as const; +const CACHE = ["HIT", "MISS", "unknown"] as const; + +// Seconds for timings, unitless for CLS. Edges bracket the targets +// (TTFB 0.1 s, LCP 1.5 s / 2.5 s, INP 0.2 s, CLS 0.1). +const BUCKETS: Record<(typeof METRICS)[number], number[]> = { + TTFB: [0.025, 0.05, 0.1, 0.2, 0.4, 0.8, 1.6, 3.2], + FCP: [0.5, 1, 1.5, 2, 2.5, 3, 4, 6], + LCP: [0.5, 1, 1.5, 2, 2.5, 3, 4, 6], + INP: [0.05, 0.1, 0.2, 0.3, 0.5, 1], + CLS: [0.01, 0.05, 0.1, 0.15, 0.25, 0.5], +}; + +export const rumBeaconSchema = z + .object({ + metrics: z + .array( + z.object({ + name: z.enum(METRICS), + // Milliseconds for timings (web-vitals' unit), unitless for CLS. + value: z.number().finite().nonnegative().max(600_000), + }), + ) + .min(1) + .max(10), + route: z.enum(ROUTES).catch("other"), + cache: z.enum(CACHE).catch("unknown"), + }) + .strip(); + +type Series = { buckets: number[]; sum: number; count: number }; +const histograms = new Map(); +const loads = new Map(); + +function key(...parts: string[]) { + return parts.join("\u0000"); +} + +export function recordRumBeacon(beacon: z.infer): void { + for (const { name, value } of beacon.metrics) { + const observed = name === "CLS" ? value : value / 1000; + const id = key(name, beacon.route, beacon.cache); + const series = histograms.get(id) ?? { + buckets: BUCKETS[name].map(() => 0), + sum: 0, + count: 0, + }; + BUCKETS[name].forEach((edge, index) => { + if (observed <= edge) series.buckets[index]! += 1; + }); + series.sum += observed; + series.count += 1; + histograms.set(id, series); + // One TTFB per page load: count loads by cache status for hit ratio. + if (name === "TTFB") { + const loadKey = key(beacon.route, beacon.cache); + loads.set(loadKey, (loads.get(loadKey) ?? 0) + 1); + } + } +} + +export function renderRumMetrics(): string { + const lines: string[] = []; + if (histograms.size) { + lines.push( + "# HELP site_web_vital Core Web Vitals from public-site visitors (seconds; CLS unitless)", + "# TYPE site_web_vital histogram", + ); + } + for (const [id, series] of histograms) { + const [name, route, cache] = id.split("\u0000") as [ + keyof typeof BUCKETS, + string, + string, + ]; + const labels = `metric="${name}",route="${route}",cache="${cache}"`; + BUCKETS[name].forEach((edge, index) => { + lines.push( + `site_web_vital_bucket{${labels},le="${edge}"} ${series.buckets[index]}`, + ); + }); + lines.push(`site_web_vital_bucket{${labels},le="+Inf"} ${series.count}`); + lines.push(`site_web_vital_sum{${labels}} ${series.sum}`); + lines.push(`site_web_vital_count{${labels}} ${series.count}`); + } + if (loads.size) { + lines.push( + "# HELP site_page_loads_total Public-site page loads by edge cache status", + "# TYPE site_page_loads_total counter", + ); + } + for (const [id, count] of loads) { + const [route, cache] = id.split("\u0000"); + lines.push(`site_page_loads_total{route="${route}",cache="${cache}"} ${count}`); + } + return lines.join("\n"); +} + +/** Beacons per client and minute; a page load sends one or two. */ +const LIMIT = 30; +const windows = new Map(); + +export function allowRumBeacon(client: string, now = Date.now()): boolean { + const window = windows.get(client); + if (!window || now - window.start > 60_000) { + if (windows.size > 50_000) windows.clear(); + windows.set(client, { start: now, count: 1 }); + return true; + } + window.count += 1; + return window.count <= LIMIT; +} + +/** Test-only. */ +export function resetRumMetrics(): void { + histograms.clear(); + loads.clear(); + windows.clear(); +} diff --git a/apps/api/src/services/site-cache.ts b/apps/api/src/services/site-cache.ts index c231c0d..3763bd6 100644 --- a/apps/api/src/services/site-cache.ts +++ b/apps/api/src/services/site-cache.ts @@ -255,6 +255,20 @@ function scheduleDelivery() { }, 50); } +/** Pending invalidations and the oldest one's age, for `/metrics`. */ +export async function siteCacheOutboxStats() { + const [row] = await db + .select({ + pending: sql`count(*)::int`, + oldest: sql< + number | null + >`extract(epoch from now() - min(${siteCacheOutbox.createdAt}))`, + }) + .from(siteCacheOutbox) + .where(isNull(siteCacheOutbox.completedAt)); + return { pending: row?.pending ?? 0, oldestSeconds: row?.oldest ?? null }; +} + export function startSiteCacheWorker(intervalMs = 1000): () => void { if (edgeDestinations().length) { try { diff --git a/apps/sites/.env.example b/apps/sites/.env.example index faa1f24..779332a 100644 --- a/apps/sites/.env.example +++ b/apps/sites/.env.example @@ -35,3 +35,7 @@ EDGE_CACHE_MODE=none EDGE_CACHE_TTL=60 # Optional separate host for /_next/static. Build time. ASSET_PREFIX= + +# Web Vitals beacon from public pages to apps/api's POST /rum (anonymous; +# see deploy/monitoring). Set to "off" to disable. +SITE_RUM= diff --git a/apps/sites/app/sites/[teamId]/(site)/layout.tsx b/apps/sites/app/sites/[teamId]/(site)/layout.tsx index eaaab2a..6173c7d 100644 --- a/apps/sites/app/sites/[teamId]/(site)/layout.tsx +++ b/apps/sites/app/sites/[teamId]/(site)/layout.tsx @@ -3,6 +3,7 @@ import { API_PUBLIC_URL } from "@/lib/config"; import { loadSiteTheme } from "@/lib/load-site-theme"; import { CodeInjector } from "@/components/code-injector"; import { ChatWidget } from "@/components/chat-widget"; +import { WebVitalsBeacon } from "@/components/web-vitals-beacon"; /** The public site: the team's code injection and floating chat widget. * `/embed` lives outside this group so the chat iframe gets neither @@ -28,6 +29,9 @@ export default async function SiteLayout({ body={settings?.codeInjectionBody} /> {children} + {process.env.SITE_RUM !== "off" && ( + + )} {chatbot?.enabled && ( timing.name === "edge" || timing.name === "cfCacheStatus", + ); + const value = entry?.description?.toUpperCase(); + return value === "HIT" || value === "MISS" ? value : "unknown"; +} + +/** + * Real-user Web Vitals (docs/site-delivery.md §9). Collects the page's + * Core Web Vitals and sends them in one beacon when the page is hidden. + * No cookies, no identifiers, no URL. + */ +export function WebVitalsBeacon({ endpoint }: { endpoint: string }) { + const pending = useRef([]); + + useReportWebVitals((metric) => { + if (!REPORTED.has(metric.name)) return; + pending.current = [ + ...pending.current.filter((item) => item.name !== metric.name), + { name: metric.name, value: metric.value }, + ]; + }); + + useEffect(() => { + const flush = () => { + if (!pending.current.length) return; + const body = JSON.stringify({ + metrics: pending.current, + route: routeKind(location.pathname), + cache: cacheStatus(), + }); + pending.current = []; + // text/plain keeps this a simple request: no CORS preflight. + navigator.sendBeacon?.(endpoint, new Blob([body], { type: "text/plain" })); + }; + const onHidden = () => { + if (document.visibilityState === "hidden") flush(); + }; + document.addEventListener("visibilitychange", onHidden); + window.addEventListener("pagehide", flush); + return () => { + document.removeEventListener("visibilitychange", onHidden); + window.removeEventListener("pagehide", flush); + }; + }, [endpoint]); + + return null; +} diff --git a/deploy/edge-cache/default.vcl b/deploy/edge-cache/default.vcl index 6b18547..e47577a 100644 --- a/deploy/edge-cache/default.vcl +++ b/deploy/edge-cache/default.vcl @@ -84,9 +84,13 @@ sub vcl_backend_response { sub vcl_deliver { unset resp.http.xkey; + # X-Edge-Cache for people; Server-Timing lets the page's Web Vitals + # beacon count edge hits (performance.getEntriesByType("navigation")). if (obj.hits > 0) { set resp.http.X-Edge-Cache = "HIT"; + set resp.http.Server-Timing = "edge;desc=HIT"; } else { set resp.http.X-Edge-Cache = "MISS"; + set resp.http.Server-Timing = "edge;desc=MISS"; } } diff --git a/deploy/monitoring/README.md b/deploy/monitoring/README.md new file mode 100644 index 0000000..3fb6781 --- /dev/null +++ b/deploy/monitoring/README.md @@ -0,0 +1,36 @@ +# Monitoring the public sites + +`apps/api` exposes Prometheus metrics at `/metrics` (keep it on a private +network). For the public sites it reports: + +| Metric | What it is | +| --- | --- | +| `site_web_vital{metric,route,cache}` | Histogram of real visitors' Core Web Vitals (`TTFB`, `FCP`, `LCP`, `INP` in seconds; `CLS` unitless), by route kind (`page`, `docs`, `blog`, `changelog`) and edge cache status | +| `site_page_loads_total{route,cache}` | Page loads by edge cache status (`HIT`, `MISS`, or `unknown` without an edge cache) | +| `site_cache_invalidations_pending` | Cache invalidations (site and edge) not yet delivered everywhere | +| `site_cache_invalidations_oldest_age_seconds` | Age of the oldest of them | + +Visitors' browsers send the vitals to `POST /rum` on the API once per page +view: no cookies, no identifiers, no URL. Set `SITE_RUM=off` on +`apps/sites` to turn it off. + +The edge cache status comes from a `Server-Timing` entry named `edge` (the +Varnish recipe in `deploy/edge-cache` adds it) or `cfCacheStatus`. + +Import [`grafana-public-sites.json`](grafana-public-sites.json) into Grafana +for the targets in `docs/site-delivery.md` §9: edge hit ratio, TTFB p75 on +hits, LCP, INP and CLS p75 by route, and pending invalidations. + +Useful queries: + +```promql +# Edge hit ratio +sum(rate(site_page_loads_total{cache="HIT"}[5m])) + / sum(rate(site_page_loads_total{cache=~"HIT|MISS"}[5m])) + +# LCP p75 for docs +histogram_quantile(0.75, sum by (le) (rate(site_web_vital_bucket{metric="LCP",route="docs"}[5m]))) +``` + +Counts live in the API process and reset when it restarts; with several +API replicas, scrape each one and sum. diff --git a/deploy/monitoring/grafana-public-sites.json b/deploy/monitoring/grafana-public-sites.json new file mode 100644 index 0000000..e73c9f9 --- /dev/null +++ b/deploy/monitoring/grafana-public-sites.json @@ -0,0 +1,350 @@ +{ + "title": "FrontLit public sites", + "uid": "frontlit-public-sites", + "schemaVersion": 39, + "time": { + "from": "now-24h", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "datasource", + "type": "datasource", + "query": "prometheus" + } + ] + }, + "panels": [ + { + "id": 1, + "type": "stat", + "title": "Edge cache hit ratio (target > 95%)", + "gridPos": { + "x": 0, + "y": 0, + "w": 8, + "h": 6 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0.95 + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rate(site_page_loads_total{cache=\"HIT\"}[$__rate_interval])) / sum(rate(site_page_loads_total{cache=~\"HIT|MISS\"}[$__rate_interval]))", + "legendFormat": "hit ratio" + } + ] + }, + { + "id": 2, + "type": "stat", + "title": "TTFB p75 on edge hits (target < 100 ms)", + "gridPos": { + "x": 8, + "y": 0, + "w": 8, + "h": 6 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 0.1 + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "histogram_quantile(0.75, sum by (le) (rate(site_web_vital_bucket{metric=\"TTFB\",cache=\"HIT\"}[$__rate_interval])))", + "legendFormat": "p75" + } + ] + }, + { + "id": 3, + "type": "stat", + "title": "Invalidations pending", + "gridPos": { + "x": 16, + "y": 0, + "w": 8, + "h": 6 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 10 + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "site_cache_invalidations_pending", + "legendFormat": "" + }, + { + "refId": "B", + "expr": "site_cache_invalidations_oldest_age_seconds", + "legendFormat": "oldest age (s)" + } + ] + }, + { + "id": 4, + "type": "timeseries", + "title": "TTFB p75 by route", + "gridPos": { + "x": 0, + "y": 6, + "w": 12, + "h": 8 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 0.1 + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "histogram_quantile(0.75, sum by (le, route) (rate(site_web_vital_bucket{metric=\"TTFB\"}[$__rate_interval])))", + "legendFormat": "{{route}}" + } + ] + }, + { + "id": 5, + "type": "timeseries", + "title": "LCP p75 by route (target < 1.5 s)", + "gridPos": { + "x": 12, + "y": 6, + "w": 12, + "h": 8 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1.5 + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "histogram_quantile(0.75, sum by (le, route) (rate(site_web_vital_bucket{metric=\"LCP\"}[$__rate_interval])))", + "legendFormat": "{{route}}" + } + ] + }, + { + "id": 6, + "type": "timeseries", + "title": "INP p75 by route (target < 200 ms)", + "gridPos": { + "x": 0, + "y": 14, + "w": 12, + "h": 8 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 0.2 + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "histogram_quantile(0.75, sum by (le, route) (rate(site_web_vital_bucket{metric=\"INP\"}[$__rate_interval])))", + "legendFormat": "{{route}}" + } + ] + }, + { + "id": 7, + "type": "timeseries", + "title": "CLS p75 by route (target < 0.1)", + "gridPos": { + "x": 12, + "y": 14, + "w": 12, + "h": 8 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "none", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 0.1 + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "histogram_quantile(0.75, sum by (le, route) (rate(site_web_vital_bucket{metric=\"CLS\"}[$__rate_interval])))", + "legendFormat": "{{route}}" + } + ] + }, + { + "id": 8, + "type": "timeseries", + "title": "Page loads by edge cache status", + "gridPos": { + "x": 0, + "y": 22, + "w": 24, + "h": 8 + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (cache) (rate(site_page_loads_total[$__rate_interval]))", + "legendFormat": "{{cache}}" + } + ] + } + ], + "description": "Real-user Web Vitals and cache health for apps/sites (docs/site-delivery.md \u00a79). Scrapes apps/api /metrics." +} diff --git a/docs/site-delivery.md b/docs/site-delivery.md index 7e42506..077009b 100644 --- a/docs/site-delivery.md +++ b/docs/site-delivery.md @@ -371,10 +371,63 @@ which prefetches on intent: hover, focus or touch. | `EDGE_PURGE_URL`, `EDGE_PURGE_SECRET` | unset | Target of the `http` adapter (set on `apps/api`) | | `EDGE_PORT`, `VARNISH_SIZE` | `8080`, `512M` | `deploy/edge-cache` only: Varnish port and cache size | | `ASSET_PREFIX` | unset | Separate host for `/_next/static` (build time) | -| `IMAGE_LOADER` | `default` | `default` (built-in optimizer) or a loader URL template | +| `IMAGE_LOADER` | — | Reserved, not implemented: images use the built-in optimizer (`lib/images.ts` is the single place to add a loader) | +| `SITE_RUM` | on | `off` stops the Web Vitals beacon | +| `CLOUDFLARE_ZONE_ID`, `CLOUDFLARE_API_TOKEN` | unset | `cloudflare` purge adapter (set on `apps/api`) | `SITES_BASE_URL` keeps its current meaning (links in emails). +## 7a. Hosted FrontLit edge (Phase 4) + +Self-hosters don't need any of this. For hosted FrontLit the edge is a +vendor CDN in `purge` mode, behind the same `EdgePurgeAdapter` (§5). + +**Vendors against the #74 criteria** (checked 2026-10-25; confirm prices +before committing): + +| Criterion | Cloudflare for SaaS | CloudFront SaaS Manager | Bunny | Fastly | +| --- | --- | --- | --- | --- | +| Custom-hostname TLS at scale | Custom hostnames: 100 included, then $0.10/hostname/month, up to 50,000 on self-serve plans | Per-tenant certificates | Free SSL per hostname | TLS domains by tier (e.g. 20 included, +$20/domain/month on the entry tier) | +| Apex domains | Enterprise add-on (apex proxying); otherwise the customer's DNS must flatten a CNAME | Yes (static IPs via anycast, extra cost) | A/ALIAS to Bunny | Yes (anycast IPs) | +| Purge by tag | `Cache-Tag`, all plans | Tag invalidation (`CacheTagConfig`); billed per tag after 1,000/month | `CDN-Tag`, but reportedly one tag per header value (FrontLit sends several) | `Surrogate-Key`, < 300 ms | +| Tiered cache / shield | Tiered Cache, free | Origin Shield | Origin Shield | Shielding | +| `stale-if-error` | Yes | Yes | Yes | Yes | +| Query string in key (`_rsc`) | Default | Configurable | Default | Default | + +**Recommendation: Cloudflare for SaaS.** Its tag purge needs no extra +code: FrontLit already sends `Cache-Tag` and the `cloudflare` adapter +purges the same tags. Hostname cost is low and tiered cache is included. +CloudFront would bill each purged tag; every publish purges two to four +tags twice. The open question is apex domains, below. + +**Where custom-domain TLS lives:** + +- *A. Cloudflare terminates TLS* (custom hostnames, HTTP validation). Caddy + and `/public/tls-ask` stay only for self-hosting. Custom domains point a + CNAME at the SaaS fallback origin; apex domains need apex proxying + (Enterprise) or a DNS provider that flattens CNAMEs. + `CUSTOM_DOMAIN_CNAME_TARGET` becomes the Cloudflare fallback hostname, and + `CUSTOM_DOMAIN_A_RECORDS` the apex-proxying IPs if bought. +- *B. Caddy keeps TLS* and Cloudflare only proxies the platform subdomains. + Custom domains don't get the edge. Simpler, but it misses the point for + the sites that need it most. + +Recommended: A, with apex proxying only once customers ask for apex domains +on the edge. Until then an apex keeps resolving to Caddy (origin) and is +served by Layer 1 alone. + +**Configuration** (on `apps/api`): `EDGE_PURGE_ADAPTER=cloudflare`, +`CLOUDFLARE_ZONE_ID`, `CLOUDFLARE_API_TOKEN` (permission: Zone → Cache +Purge). On `apps/sites`: `EDGE_CACHE_MODE=purge`. In Cloudflare: enable +Tiered Cache and leave "Cache Key" including the query string. + +**Real-user monitoring.** Public pages send Core Web Vitals to `POST /rum` +on the API (one beacon per page view, anonymous; `SITE_RUM=off` disables +it). The API exports them as Prometheus histograms with the edge cache +status read from `Server-Timing` (`edge` from the Varnish recipe, +`cfCacheStatus` from Cloudflare). `deploy/monitoring` has a Grafana +dashboard for the targets in §9. + ## 8. Phases | Phase | Issue | Scope | Depends on | From 89480430c5a76546643021119b797f7a29f5bb6f Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 25 Oct 2026 13:37:43 +0530 Subject: [PATCH 2/2] docs: document SITE_RUM and the Cloudflare purge settings for self-hosting --- .env.example | 12 ++++++++++-- docker-compose.yml | 2 ++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index e7a192b..68b824b 100644 --- a/.env.example +++ b/.env.example @@ -66,16 +66,24 @@ IMAGE_REMOTE_HOSTS= EDGE_CACHE_MODE=none EDGE_CACHE_TTL=60 # Purge adapter used by frontlit-api in purge mode: http (signed POST to -# EDGE_PURGE_URL). deploy/edge-cache sets the URL itself and only needs -# EDGE_PURGE_SECRET. +# EDGE_PURGE_URL) or cloudflare. deploy/edge-cache sets the URL itself and +# only needs EDGE_PURGE_SECRET. EDGE_PURGE_ADAPTER=http EDGE_PURGE_URL= EDGE_PURGE_SECRET= +# EDGE_PURGE_ADAPTER=cloudflare: the zone custom hostnames live in, and an +# API token with the Zone → Cache Purge permission. +CLOUDFLARE_ZONE_ID= +CLOUDFLARE_API_TOKEN= # deploy/edge-cache (Varnish) only: published port and cache size. EDGE_PORT=8080 VARNISH_SIZE=512M # Optional separate host for /_next/static. Build time. ASSET_PREFIX= + +# Public pages send anonymous Core Web Vitals to frontlit-api's POST /rum +# (scrape /metrics; see deploy/monitoring). Set to "off" to disable. +SITE_RUM= INTEGRATIONS_ENCRYPTION_KEY= GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= diff --git a/docker-compose.yml b/docker-compose.yml index bf02ae3..58f0fb7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -200,6 +200,8 @@ services: # Optional HTTP cache / CDN in front (docs/site-delivery.md §5). EDGE_CACHE_MODE: ${EDGE_CACHE_MODE:-none} EDGE_CACHE_TTL: ${EDGE_CACHE_TTL:-60} + # "off" disables the Web Vitals beacon (deploy/monitoring). + SITE_RUM: ${SITE_RUM:-} depends_on: frontlit-api: condition: service_healthy