Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).

Expand Down
12 changes: 8 additions & 4 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ErrorPanel message={e instanceof Error ? e.message : String(e)} />;
}

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",
Expand Down
2 changes: 0 additions & 2 deletions app/site/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 19 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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
Expand Down
30 changes: 25 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
17 changes: 15 additions & 2 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion lib/prometheus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down
2 changes: 1 addition & 1 deletion lib/prometheus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
56 changes: 47 additions & 9 deletions lib/synthetics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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() },
Expand All @@ -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<string, string>; value: [number, string] };
const sample = (metric: Record<string, string>, v: string): Sample => ({
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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,
Expand All @@ -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");
});
});

Expand Down Expand Up @@ -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;
});
});
Loading
Loading