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. */