diff --git a/.env.example b/.env.example index 0502686..85f2a4d 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,9 @@ GRAFANA_PROM_TOKEN=glc_replace_me # CURRENT_WINDOW=1h # UPTIME_OPERATIONAL=99.9 # UPTIME_DEGRADED=95 -# REVALIDATE_SECONDS=60 +# Metrics-cache window (seconds): how often Grafana is queried. Does NOT set the +# route ISR interval, so the overview (/) only refreshes on its fixed 60s +# revalidate — setting this below 60 only speeds up the detail route. +# METRICS_CACHE_SECONDS=60 # NEXT_PUBLIC_SITE_NAME=Code for Africa # NEXT_PUBLIC_SITE_TAGLINE=Status of our public services diff --git a/AGENTS.md b/AGENTS.md index b542c13..7188232 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,8 +70,11 @@ change touches shared data flow, routing, config, or user-visible behavior. do not pass tokens or raw auth details into Client Components. - Data-fetching modules that use Prometheus or `next/cache` should remain server-only. Follow the existing `import "server-only"` pattern. -- Pages currently use ISR via `export const revalidate = 60`; if you change cache - behavior, keep `REVALIDATE_SECONDS` and `config.revalidate` in mind. +- The overview route uses ISR via a fixed `export const revalidate = 60` literal + (Next requires a static value); the detail route renders on demand. Data-cache + freshness is separate and env-driven via `METRICS_CACHE_SECONDS` / + `config.metricsCacheSeconds`. Keep both layers in mind if you change cache + behavior. - Use `next/link`, `next/font`, and App Router metadata APIs according to the local Next docs, not memory of older Next releases. diff --git a/README.md b/README.md index aa0ab89..daa424c 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ and when to change it. | `UPTIME_OPERATIONAL` | `99.9` | Uptime % at/above which a service is green | | `UPTIME_DEGRADED` | `95` | Uptime % at/above which a service is amber (red below) | | `CURRENT_WINDOW` | `1h` | Window used to decide current up/down | -| `REVALIDATE_SECONDS` | `60` | Cache / page-revalidation window in seconds | +| `METRICS_CACHE_SECONDS` | `60` | Metrics-cache window (seconds) for Grafana queries; see note below | | `SM_METRIC_*` | SM schema defaults | Override metric names if your stack differs | > ⚠️ Anything prefixed `NEXT_PUBLIC_` is shipped to the browser. The Grafana @@ -198,8 +198,16 @@ Grafana Synthetics ──► Prometheus (Mimir) ──► lib/prometheus.ts ─ Design notes: - Services are **discovered dynamically** from `sm_check_info` — nothing is hardcoded. -- Pages use ISR (`export const revalidate`) and the Prometheus client caches - responses, so the public page is cheap to serve under load. +- The Prometheus client and data layer cache responses for `METRICS_CACHE_SECONDS`, + so the public page is cheap to serve under load and the `updated` timestamp + reflects when Grafana was last queried. `METRICS_CACHE_SECONDS` sets **how often + Grafana is queried**, not the route ISR interval — Next requires the latter to + be a static literal, so the overview route uses a fixed `revalidate` (60s) and + the detail route renders on demand. Because the overview HTML only regenerates + on that ISR interval, effective `/` freshness is `max(revalidate, + METRICS_CACHE_SECONDS)`; setting the cache below 60s only speeds up the detail + route. See + [`docs/configuration.md`](docs/configuration.md#metrics_cache_seconds). For a deeper walk-through, see [`docs/architecture.md`](docs/architecture.md). diff --git a/app/page.tsx b/app/page.tsx index bb1cb48..01aa248 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,19 +2,23 @@ import { ErrorPanel, MockNotice } from "@/components/Notice"; import { Overview } from "@/components/Overview"; import { config } from "@/lib/config"; import { getOverview } from "@/lib/synthetics"; -import type { CheckStatus } from "@/lib/types"; +import type { OverviewData } from "@/lib/types"; export const revalidate = 60; export default async function Page() { - let checks: CheckStatus[]; + let data: OverviewData; try { - checks = await getOverview(); + data = await getOverview(); } catch (e) { return ; } - const updated = `${new Date().toLocaleTimeString("en-GB", { + const { checks, fetchedAt } = data; + + // Derived from the data's fetch time, not render time, so it reflects when the + // metrics were last refreshed even when the route regenerates more often. + const updated = `${new Date(fetchedAt * 1000).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: "UTC", diff --git a/app/site/[id]/page.tsx b/app/site/[id]/page.tsx index f2f7d83..d46c1ae 100644 --- a/app/site/[id]/page.tsx +++ b/app/site/[id]/page.tsx @@ -15,8 +15,6 @@ import { type WindowKey, } from "@/lib/types"; -export const revalidate = 60; - function parseWindow(value: string | undefined): WindowKey { return (WINDOW_KEYS as string[]).includes(value ?? "") ? (value as WindowKey) diff --git a/docs/architecture.md b/docs/architecture.md index bae1ef9..4982255 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,8 +18,8 @@ lib/prometheus.ts minimal cached HTTP client: instantQuery / rangeQuery lib/synthetics.ts listChecks → getOverview → getSiteHistory │ (discover services, compute uptime/status/latency) ▼ -Server Components app/page.tsx (overview), app/site/[id]/page.tsx (detail) - │ ISR via `export const revalidate` +Server Components app/page.tsx (overview, ISR), app/site/[id]/page.tsx (detail, dynamic) + │ detail freshness ← cache above; overview ← max(cache, ISR interval) ▼ components/* presentational UI + hand-rolled SVG charts ``` @@ -65,10 +65,23 @@ already-computed numbers and markup. configured windows (and [`CURRENT_WINDOW`](configuration.md#current-updown-window) for the live dot). -- **Caching happens in two layers.** The Prometheus client caches responses, and - pages use ISR — both governed by - [`REVALIDATE_SECONDS`](configuration.md#revalidate_seconds). This keeps public - traffic from translating one-to-one into Prometheus queries. +- **Data freshness is governed by one cache layer.** The Prometheus client and + the `lib/synthetics.ts` accessors cache responses for + [`METRICS_CACHE_SECONDS`](configuration.md#metrics_cache_seconds), which bounds + how often Grafana is queried no matter how much public traffic arrives. The + overview's `updated` value is stamped inside that cache, so it reports true + metric freshness rather than render time. + +- **Route rendering is separate from the data cache.** `METRICS_CACHE_SECONDS` + cannot set a route's ISR interval — Next requires that to be a static literal + — so the overview (`/`) uses a fixed `revalidate` literal (60s) and the detail + route (`/site/[id]`) is rendered on demand because it reads `searchParams`. + The detail route reads cached data on every request, so its freshness is + bounded by `METRICS_CACHE_SECONDS`. The overview page is different: its HTML is + only regenerated when the segment revalidates, so effective overview freshness + is `max(revalidate, METRICS_CACHE_SECONDS)` — lowering `METRICS_CACHE_SECONDS` + below the 60s ISR floor does not make `/` any fresher, it only speeds up the + detail route. - **Secrets stay on the server.** The Grafana credentials have no `NEXT_PUBLIC_` prefix, so they are only ever read in Server Components / the diff --git a/docs/configuration.md b/docs/configuration.md index ae63ab1..a764a49 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -94,13 +94,33 @@ Accepts any Prometheus duration string (`5m`, `30m`, `1h`, `2h`, …). ## Caching -### `REVALIDATE_SECONDS` +### `METRICS_CACHE_SECONDS` - **Default:** `60` -- Controls two things at once: how long Prometheus responses are cached - server-side, and the ISR page-revalidation window. Higher values mean fewer - Prometheus queries (cheaper, but staler data); lower values mean fresher data - at higher query cost. `60` is a good balance for most public pages. +- The **metrics-cache window**: how long Grafana/Prometheus responses are cached + server-side (the `fetch` cache in `lib/prometheus.ts` and the `unstable_cache` + wrappers in `lib/synthetics.ts`). Higher values mean fewer Prometheus queries + (cheaper, but staler data); lower values mean fresher data at higher query + cost. `60` is a good balance for most public pages. +- This is the knob for **how often Grafana is queried**. The overview's + `updated` timestamp is derived from the actual fetch time (not the page render + time), so it always reports the true age of the displayed metrics. + +> **What it does _not_ control: the route-revalidation (ISR) interval.** Next.js +> requires a route's `revalidate` to be a statically-analyzable literal, so it +> cannot be driven by an environment variable. The overview route (`/`) uses a +> fixed `revalidate` literal (60s) and the detail route (`/site/[id]`) is +> server-rendered on demand (it reads the `?window=` search param). +> +> This matters for the overview page: its HTML — the metrics **and** the +> `updated` label — is only regenerated when the segment revalidates, so +> effective `/` freshness is `max(revalidate, METRICS_CACHE_SECONDS)`. Setting +> `METRICS_CACHE_SECONDS` **below** the 60s ISR floor does not make `/` query +> Grafana any fresher — visitors keep the same overview HTML until the route +> regenerates (and stale-while-revalidate serves the old HTML while regeneration +> runs in the background). It only speeds up the on-demand detail route. At or +> above the ISR interval, `METRICS_CACHE_SECONDS` is the effective bound on both +> routes. --- diff --git a/lib/config.ts b/lib/config.ts index 5825d84..0aa8648 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -60,8 +60,21 @@ export const config = { degraded: Number(env("UPTIME_DEGRADED", "95")), }, - /** Cache window (seconds) for Prometheus responses / page revalidation. */ - revalidate: Number(env("REVALIDATE_SECONDS", "60")), + /** + * Metrics-cache window (seconds) for Grafana/Prometheus responses. Governs the + * `fetch` cache in lib/prometheus.ts and the `unstable_cache` wrappers in + * lib/synthetics.ts — i.e. how often Grafana is actually queried. + * + * It does NOT set the route-segment ISR interval: Next requires that to be a + * statically-analyzable literal, so the overview route uses a fixed + * `revalidate` (60s) and the detail route is dynamic. That fixed interval is a + * floor on overview freshness: the `/` HTML (data and its `updated` label) is + * only regenerated when the segment revalidates, so effective overview + * freshness is max(revalidate, metricsCacheSeconds). Lowering this below the + * ISR interval makes `/` query Grafana no fresher; it only speeds up the + * on-demand detail route. Raising it above the ISR interval bounds both. + */ + metricsCacheSeconds: Number(env("METRICS_CACHE_SECONDS", "60")), siteName: env("NEXT_PUBLIC_SITE_NAME", "Code for Africa"), tagline: env("NEXT_PUBLIC_SITE_TAGLINE", "Status of our public services"), diff --git a/lib/prometheus.test.ts b/lib/prometheus.test.ts index 849b401..25c6391 100644 --- a/lib/prometheus.test.ts +++ b/lib/prometheus.test.ts @@ -7,7 +7,7 @@ const { mockConfig } = vi.hoisted(() => ({ promUrl: "https://prom.example.net/api/prom/", promUser: "12345", promToken: "glc_token", - revalidate: 60, + metricsCacheSeconds: 60, }, })); vi.mock("./config", () => ({ config: mockConfig })); diff --git a/lib/prometheus.ts b/lib/prometheus.ts index a09308a..d522ed8 100644 --- a/lib/prometheus.ts +++ b/lib/prometheus.ts @@ -36,7 +36,7 @@ async function call( "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams(params).toString(), - next: { revalidate: config.revalidate }, + next: { revalidate: config.metricsCacheSeconds }, }); if (!res.ok) { diff --git a/lib/synthetics.test.ts b/lib/synthetics.test.ts index 267b18c..b7f084a 100644 --- a/lib/synthetics.test.ts +++ b/lib/synthetics.test.ts @@ -8,7 +8,7 @@ const SITE_A_ID = checkId("Site A", "https://a.org"); // Shared mock surface. `prom.instantQuery` / `prom.rangeQuery` are driven // per-test via mockImplementation; query strings are matched on the metric // tokens and range below. -const { mockConfig, mockData, prom } = vi.hoisted(() => ({ +const { mockConfig, mockData, prom, unstableCache } = vi.hoisted(() => ({ mockConfig: { metrics: { info: "sm_info", @@ -19,7 +19,7 @@ const { mockConfig, mockData, prom } = vi.hoisted(() => ({ }, mock: false, currentWindow: "1h", - revalidate: 60, + metricsCacheSeconds: 60, thresholds: { operational: 99.9, degraded: 95 }, }, mockData: { mockOverview: vi.fn(), mockSiteHistory: vi.fn() }, @@ -28,13 +28,16 @@ const { mockConfig, mockData, prom } = vi.hoisted(() => ({ rangeQuery: vi.fn(), escapeLabel: vi.fn((s: string) => s), }, + // Passthrough that also records the options each accessor caches with, so + // tests can assert the data-cache revalidate window is wired from config. + unstableCache: vi.fn((fn: unknown) => fn), })); vi.mock("./config", () => ({ config: mockConfig })); vi.mock("./mock", () => mockData); vi.mock("./prometheus", () => prom); // Run the cached function straight through, bypassing Next's cache layer. -vi.mock("next/cache", () => ({ unstable_cache: (fn: unknown) => fn })); +vi.mock("next/cache", () => ({ unstable_cache: unstableCache })); type Sample = { metric: Record; value: [number, string] }; const sample = (metric: Record, v: string): Sample => ({ @@ -112,7 +115,9 @@ describe("getOverview", () => { const fixture = [{ id: "fixture" }]; mockData.mockOverview.mockReturnValue(fixture); - expect(await getOverview()).toBe(fixture); + const overview = await getOverview(); + expect(overview.checks).toBe(fixture); + expect(typeof overview.fetchedAt).toBe("number"); expect(prom.instantQuery).not.toHaveBeenCalled(); }); @@ -152,8 +157,11 @@ describe("getOverview", () => { it("maps per-window uptime and response, deriving an up status", async () => { wireOverview("100"); - const [site, ...rest] = await getOverview(); + const { checks, fetchedAt } = await getOverview(); + const [site, ...rest] = checks; + // Freshness is stamped inside the cached fetch, not at render time. + expect(typeof fetchedAt).toBe("number"); expect(rest).toHaveLength(0); expect(site).toMatchObject({ id: SITE_A_ID, @@ -168,14 +176,14 @@ describe("getOverview", () => { it("derives a down status when current reachability is zero", async () => { wireOverview("0"); - const [site] = await getOverview(); - expect(site.status).toBe("down"); + const { checks } = await getOverview(); + expect(checks[0].status).toBe("down"); }); it("derives an unknown status when current reachability is absent", async () => { wireOverview(""); - const [site] = await getOverview(); - expect(site.status).toBe("unknown"); + const { checks } = await getOverview(); + expect(checks[0].status).toBe("unknown"); }); }); @@ -299,3 +307,33 @@ describe("getSiteHistory", () => { ).toBe(true); }); }); + +describe("data-cache configuration", () => { + // unstable_cache(fn, keyParts, options) — the options object is the 3rd arg. + const lastCacheRevalidate = (): unknown => { + const calls = unstableCache.mock.calls as unknown as unknown[][]; + const options = calls.at(-1)?.[2] as { revalidate?: unknown } | undefined; + return options?.revalidate; + }; + + it("caches the overview with the configured metrics-cache window", async () => { + mockConfig.metricsCacheSeconds = 300; + prom.instantQuery.mockResolvedValue([]); + + await getOverview(); + + // Wired from config, not a route-style hardcoded literal. + expect(lastCacheRevalidate()).toBe(300); + mockConfig.metricsCacheSeconds = 60; + }); + + it("caches per-site history with the configured metrics-cache window", async () => { + mockConfig.metricsCacheSeconds = 300; + prom.instantQuery.mockResolvedValue([]); + + await getSiteHistory("anything", "24h"); + + expect(lastCacheRevalidate()).toBe(300); + mockConfig.metricsCacheSeconds = 60; + }); +}); diff --git a/lib/synthetics.ts b/lib/synthetics.ts index 41c7e0c..61d7234 100644 --- a/lib/synthetics.ts +++ b/lib/synthetics.ts @@ -7,8 +7,8 @@ import { mockOverview, mockSiteHistory } from "./mock"; import { escapeLabel, instantQuery, rangeQuery } from "./prometheus"; import { type Check, - type CheckStatus, type MetricByWindow, + type OverviewData, type ResponsePoint, type ResponseStats, type SiteHistory, @@ -23,6 +23,11 @@ const M = config.metrics; const STAT_RES = "1h"; +/** Current time as unix seconds — stamped as the data's fetch time. */ +function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} + /** PromQL range-vector duration for a window (e.g. "7d"). */ function promRange(window: WindowKey): string { return window; // 24h / 7d / 30d / 1y are all valid PromQL durations @@ -88,7 +93,7 @@ function toKeyedNumbers( return out; } -async function fetchOverview(): Promise { +async function fetchOverview(): Promise { const checks = await listChecks(); const results = await Promise.all([ @@ -103,7 +108,7 @@ async function fetchOverview(): Promise { toKeyedNumbers(results[1 + WINDOWS.length + i]), ); - return checks.map((c) => { + const statuses = checks.map((c) => { const k = checkIdentity(c.job, c.instance); const uptime = {} as MetricByWindow; const responseMs = {} as MetricByWindow; @@ -123,17 +128,25 @@ async function fetchOverview(): Promise { responseMs, }; }); + + // Stamped here, inside the cached function, so it records when Grafana was + // actually queried — the value only advances on a cache miss. + return { checks: statuses, fetchedAt: nowSeconds() }; } /** - * Public overview accessor — cached for `config.revalidate` seconds. This bounds - * total Grafana query volume to one set of queries per refresh window, no matter - * how many visitors hit the page. + * Public overview accessor — cached for `config.metricsCacheSeconds` seconds. + * This bounds total Grafana query volume to one set of queries per refresh + * window, no matter how many visitors hit the page. `fetchedAt` reflects that + * cached fetch time, so callers can report true metric freshness rather than + * render time. */ -export async function getOverview(): Promise { - if (config.mock) return mockOverview(); +export async function getOverview(): Promise { + if (config.mock) { + return { checks: mockOverview(), fetchedAt: nowSeconds() }; + } return unstable_cache(fetchOverview, ["overview"], { - revalidate: config.revalidate, + revalidate: config.metricsCacheSeconds, tags: ["status"], })(); } @@ -264,9 +277,9 @@ async function fetchSiteHistory( } /** - * Public per-site accessor — cached per (id, window) for `config.revalidate` - * seconds. A thousand views of the same site in that window cost one set of - * Grafana queries, not a thousand. + * Public per-site accessor — cached per (id, window) for + * `config.metricsCacheSeconds` seconds. A thousand views of the same site in + * that window cost one set of Grafana queries, not a thousand. */ export async function getSiteHistory( id: string, @@ -277,7 +290,7 @@ export async function getSiteHistory( () => fetchSiteHistory(id, window), ["site-history", id, window], { - revalidate: config.revalidate, + revalidate: config.metricsCacheSeconds, tags: ["status"], }, )(); diff --git a/lib/types.ts b/lib/types.ts index 8aee3a9..9099eee 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -35,6 +35,17 @@ export interface CheckStatus extends Check { responseMs: MetricByWindow; } +/** + * The overview payload plus the time its data was actually fetched from + * Grafana. `fetchedAt` is captured inside the data cache, so it reflects + * metric freshness (the last cache miss), not page render time. + */ +export interface OverviewData { + checks: CheckStatus[]; + /** Unix seconds at which the underlying Grafana data was fetched. */ + fetchedAt: number; +} + /** One time bucket of the uptime history bar strip. */ export interface UptimeBucket { /** Bucket start, unix seconds. */