From 0791b35cd98be376cabe20815ba853ed6609c321 Mon Sep 17 00:00:00 2001 From: Mark Ng'ang'a Date: Wed, 15 Jul 2026 18:50:05 +0300 Subject: [PATCH 1/5] Make it clear what REVALIDATE_SECONDS does * It is now explicit that it only controls the metrics cache * Next, we'll rename it so that future contributors do not run into the same confusion --- .env.example | 2 ++ README.md | 11 ++++++++--- app/page.tsx | 12 ++++++++---- app/site/[id]/page.tsx | 2 -- docs/architecture.md | 21 +++++++++++++++------ docs/configuration.md | 20 ++++++++++++++++---- lib/config.ts | 9 ++++++++- lib/synthetics.test.ts | 17 +++++++++++------ lib/synthetics.ts | 19 +++++++++++++------ lib/types.ts | 11 +++++++++++ 10 files changed, 92 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index 0502686..bda4013 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,8 @@ GRAFANA_PROM_TOKEN=glc_replace_me # CURRENT_WINDOW=1h # UPTIME_OPERATIONAL=99.9 # UPTIME_DEGRADED=95 +# Data-cache window (seconds): how often Grafana is queried and how old the +# "updated" timestamp can be. # REVALIDATE_SECONDS=60 # NEXT_PUBLIC_SITE_NAME=Code for Africa # NEXT_PUBLIC_SITE_TAGLINE=Status of our public services diff --git a/README.md b/README.md index aa0ab89..bba6f1e 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 | +| `REVALIDATE_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,13 @@ 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 `REVALIDATE_SECONDS`, + so the public page is cheap to serve under load and the `updated` timestamp + reflects when Grafana was last queried. `REVALIDATE_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` and the + detail route renders on demand. See + [`docs/configuration.md`](docs/configuration.md#revalidate_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..1f92b88 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) + │ data freshness bounded by the cache above, not route rendering ▼ components/* presentational UI + hand-rolled SVG charts ``` @@ -65,10 +65,19 @@ 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 + [`REVALIDATE_SECONDS`](configuration.md#revalidate_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.** `REVALIDATE_SECONDS` + cannot set a route's ISR interval — Next requires that to be a static literal + — so the overview (`/`) uses a fixed `revalidate` literal and the detail route + (`/site/[id]`) is rendered on demand because it reads `searchParams`. Either + way, both routes read the same cached data, so the effective freshness bound + is always `REVALIDATE_SECONDS`. - **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..ae43237 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -97,10 +97,22 @@ Accepts any Prometheus duration string (`5m`, `30m`, `1h`, `2h`, …). ### `REVALIDATE_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 single knob for **how often Grafana is queried** and therefore how + old the overview's `updated` timestamp can be — that value is derived from the + actual fetch time, not the page render time. + +> **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 and the detail route (`/site/[id]`) is +> server-rendered on demand (it reads the `?window=` search param). Both still +> read cached data, so `REVALIDATE_SECONDS` remains the effective bound on data +> freshness regardless of how often a route re-renders. --- diff --git a/lib/config.ts b/lib/config.ts index 5825d84..478f058 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -60,7 +60,14 @@ export const config = { degraded: Number(env("UPTIME_DEGRADED", "95")), }, - /** Cache window (seconds) for Prometheus responses / page revalidation. */ + /** + * 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 and how old + * the displayed `updated` value can be. 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` and the detail route is dynamic. + */ revalidate: Number(env("REVALIDATE_SECONDS", "60")), siteName: env("NEXT_PUBLIC_SITE_NAME", "Code for Africa"), diff --git a/lib/synthetics.test.ts b/lib/synthetics.test.ts index 267b18c..fdc2c7e 100644 --- a/lib/synthetics.test.ts +++ b/lib/synthetics.test.ts @@ -112,7 +112,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 +154,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 +173,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"); }); }); diff --git a/lib/synthetics.ts b/lib/synthetics.ts index 41c7e0c..19820af 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, @@ -88,7 +88,7 @@ function toKeyedNumbers( return out; } -async function fetchOverview(): Promise { +async function fetchOverview(): Promise { const checks = await listChecks(); const results = await Promise.all([ @@ -103,7 +103,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,15 +123,22 @@ 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: Math.floor(Date.now() / 1000) }; } /** * 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. + * 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: Math.floor(Date.now() / 1000) }; + } return unstable_cache(fetchOverview, ["overview"], { revalidate: config.revalidate, 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. */ From e0ec4d538b0873bbe0cfc642e9496a0be44aaa1f Mon Sep 17 00:00:00 2001 From: Mark Ng'ang'a Date: Wed, 15 Jul 2026 19:04:12 +0300 Subject: [PATCH 2/5] Rename REVALIDATE_SECONDS to METRICS_CACHE_SECONDS * This drops the Next.js "revalidate" terminology and makes it clearer that this is its own thing unrelated to Next.js revalidation --- .env.example | 4 ++-- AGENTS.md | 7 +++++-- README.md | 8 ++++---- docs/architecture.md | 8 ++++---- docs/configuration.md | 6 +++--- lib/config.ts | 2 +- lib/prometheus.test.ts | 2 +- lib/prometheus.ts | 2 +- lib/synthetics.test.ts | 2 +- lib/synthetics.ts | 19 ++++++++++--------- 10 files changed, 32 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index bda4013..0cea19b 100644 --- a/.env.example +++ b/.env.example @@ -27,8 +27,8 @@ GRAFANA_PROM_TOKEN=glc_replace_me # CURRENT_WINDOW=1h # UPTIME_OPERATIONAL=99.9 # UPTIME_DEGRADED=95 -# Data-cache window (seconds): how often Grafana is queried and how old the +# Metrics-cache window (seconds): how often Grafana is queried and how old the # "updated" timestamp can be. -# REVALIDATE_SECONDS=60 +# 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 bba6f1e..f51fdeb 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` | Metrics-cache window (seconds) for Grafana queries; see note below | +| `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,13 +198,13 @@ Grafana Synthetics ──► Prometheus (Mimir) ──► lib/prometheus.ts ─ Design notes: - Services are **discovered dynamically** from `sm_check_info` — nothing is hardcoded. -- The Prometheus client and data layer cache responses for `REVALIDATE_SECONDS`, +- 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. `REVALIDATE_SECONDS` sets **how often + 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` and the detail route renders on demand. See - [`docs/configuration.md`](docs/configuration.md#revalidate_seconds). + [`docs/configuration.md`](docs/configuration.md#metrics_cache_seconds). For a deeper walk-through, see [`docs/architecture.md`](docs/architecture.md). diff --git a/docs/architecture.md b/docs/architecture.md index 1f92b88..7d8e146 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,17 +67,17 @@ already-computed numbers and markup. - **Data freshness is governed by one cache layer.** The Prometheus client and the `lib/synthetics.ts` accessors cache responses for - [`REVALIDATE_SECONDS`](configuration.md#revalidate_seconds), which bounds how - often Grafana is queried no matter how much public traffic arrives. The + [`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.** `REVALIDATE_SECONDS` +- **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 and the detail route (`/site/[id]`) is rendered on demand because it reads `searchParams`. Either way, both routes read the same cached data, so the effective freshness bound - is always `REVALIDATE_SECONDS`. + is always `METRICS_CACHE_SECONDS`. - **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 ae43237..4f4b41e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -94,7 +94,7 @@ Accepts any Prometheus duration string (`5m`, `30m`, `1h`, `2h`, …). ## Caching -### `REVALIDATE_SECONDS` +### `METRICS_CACHE_SECONDS` - **Default:** `60` - The **metrics-cache window**: how long Grafana/Prometheus responses are cached @@ -111,8 +111,8 @@ Accepts any Prometheus duration string (`5m`, `30m`, `1h`, `2h`, …). > cannot be driven by an environment variable. The overview route (`/`) uses a > fixed `revalidate` literal and the detail route (`/site/[id]`) is > server-rendered on demand (it reads the `?window=` search param). Both still -> read cached data, so `REVALIDATE_SECONDS` remains the effective bound on data -> freshness regardless of how often a route re-renders. +> read cached data, so `METRICS_CACHE_SECONDS` remains the effective bound on +> data freshness regardless of how often a route re-renders. --- diff --git a/lib/config.ts b/lib/config.ts index 478f058..9da727b 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -68,7 +68,7 @@ export const config = { * interval: Next requires that to be a statically-analyzable literal, so the * overview route uses a fixed `revalidate` and the detail route is dynamic. */ - revalidate: Number(env("REVALIDATE_SECONDS", "60")), + 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 fdc2c7e..9bd9a05 100644 --- a/lib/synthetics.test.ts +++ b/lib/synthetics.test.ts @@ -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() }, diff --git a/lib/synthetics.ts b/lib/synthetics.ts index 19820af..e54b273 100644 --- a/lib/synthetics.ts +++ b/lib/synthetics.ts @@ -130,17 +130,18 @@ async function fetchOverview(): Promise { } /** - * 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. `fetchedAt` reflects that cached fetch time, - * so callers can report true metric freshness rather than render time. + * 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 { checks: mockOverview(), fetchedAt: Math.floor(Date.now() / 1000) }; } return unstable_cache(fetchOverview, ["overview"], { - revalidate: config.revalidate, + revalidate: config.metricsCacheSeconds, tags: ["status"], })(); } @@ -271,9 +272,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, @@ -284,7 +285,7 @@ export async function getSiteHistory( () => fetchSiteHistory(id, window), ["site-history", id, window], { - revalidate: config.revalidate, + revalidate: config.metricsCacheSeconds, tags: ["status"], }, )(); From 59951248d495922d83a873dc77400e78760df686 Mon Sep 17 00:00:00 2001 From: Mark Ng'ang'a Date: Wed, 15 Jul 2026 19:14:46 +0300 Subject: [PATCH 3/5] DRY up fetchedAt calculation * Extract the calculation into a function to avoid drift --- lib/synthetics.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/synthetics.ts b/lib/synthetics.ts index e54b273..61d7234 100644 --- a/lib/synthetics.ts +++ b/lib/synthetics.ts @@ -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 @@ -126,7 +131,7 @@ async function fetchOverview(): Promise { // 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: Math.floor(Date.now() / 1000) }; + return { checks: statuses, fetchedAt: nowSeconds() }; } /** @@ -138,7 +143,7 @@ async function fetchOverview(): Promise { */ export async function getOverview(): Promise { if (config.mock) { - return { checks: mockOverview(), fetchedAt: Math.floor(Date.now() / 1000) }; + return { checks: mockOverview(), fetchedAt: nowSeconds() }; } return unstable_cache(fetchOverview, ["overview"], { revalidate: config.metricsCacheSeconds, From 9aa273a6e3218779cc9809e15d5e638a05e85c27 Mon Sep 17 00:00:00 2001 From: Mark Ng'ang'a Date: Wed, 15 Jul 2026 19:28:33 +0300 Subject: [PATCH 4/5] Test METRICS_CACHE_SECONDS is passed to accessors --- lib/synthetics.test.ts | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/synthetics.test.ts b/lib/synthetics.test.ts index 9bd9a05..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", @@ -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 => ({ @@ -304,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; + }); +}); From ec272f8f7ea4b73e73d97ca9ea0b1a62c199530d Mon Sep 17 00:00:00 2001 From: Mark Ng'ang'a Date: Fri, 17 Jul 2026 12:54:56 +0300 Subject: [PATCH 5/5] Correct freshness claims in docs and comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit METRICS_CACHE_SECONDS was documented as *the* bound on data freshness for all routes. That is wrong for the overview page: `/` is cached by a fixed `revalidate = 60` ISR literal and only regenerates on that interval, so its effective freshness is max(revalidate, METRICS_CACHE_SECONDS). Lowering the metrics cache below 60s buys no extra freshness on `/` — it only speeds up the on-demand detail route. --- .env.example | 5 +++-- README.md | 7 +++++-- docs/architecture.md | 14 +++++++++----- docs/configuration.md | 22 +++++++++++++++------- lib/config.ts | 14 ++++++++++---- 5 files changed, 42 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 0cea19b..85f2a4d 100644 --- a/.env.example +++ b/.env.example @@ -27,8 +27,9 @@ GRAFANA_PROM_TOKEN=glc_replace_me # CURRENT_WINDOW=1h # UPTIME_OPERATIONAL=99.9 # UPTIME_DEGRADED=95 -# Metrics-cache window (seconds): how often Grafana is queried and how old the -# "updated" timestamp can be. +# 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/README.md b/README.md index f51fdeb..daa424c 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,11 @@ Design notes: 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` and the - detail route renders on demand. See + 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/docs/architecture.md b/docs/architecture.md index 7d8e146..4982255 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,7 @@ lib/synthetics.ts listChecks → getOverview → getSiteHistory │ (discover services, compute uptime/status/latency) ▼ Server Components app/page.tsx (overview, ISR), app/site/[id]/page.tsx (detail, dynamic) - │ data freshness bounded by the cache above, not route rendering + │ detail freshness ← cache above; overview ← max(cache, ISR interval) ▼ components/* presentational UI + hand-rolled SVG charts ``` @@ -74,10 +74,14 @@ already-computed numbers and markup. - **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 and the detail route - (`/site/[id]`) is rendered on demand because it reads `searchParams`. Either - way, both routes read the same cached data, so the effective freshness bound - is always `METRICS_CACHE_SECONDS`. + — 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 4f4b41e..a764a49 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -102,17 +102,25 @@ Accepts any Prometheus duration string (`5m`, `30m`, `1h`, `2h`, …). 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 single knob for **how often Grafana is queried** and therefore how - old the overview's `updated` timestamp can be — that value is derived from the - actual fetch time, not the page render time. +- 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 and the detail route (`/site/[id]`) is -> server-rendered on demand (it reads the `?window=` search param). Both still -> read cached data, so `METRICS_CACHE_SECONDS` remains the effective bound on -> data freshness regardless of how often a route re-renders. +> 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 9da727b..0aa8648 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -63,10 +63,16 @@ export const config = { /** * 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 and how old - * the displayed `updated` value can be. 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` and the detail route is dynamic. + * 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")),