trails/apps/journal/app/lib/metrics.server.test.ts
Ullrich Schäfer cd73093beb fix(journal): bound Prometheus route label cardinality
The httpRequestDuration histogram labeled every request with the raw URL
path (`url.split("?")[0]`), so every distinct path — `/activities/<uuid>`,
`/routes/<uuid>`, probed usernames, crawler junk — became a permanent
label-set. prom-client never evicts label-sets, so RSS grew linearly for
the life of the process and reset only on restart. On the flagship this
showed as ~25 MiB/day of unbounded journal memory growth; a live
`/api/metrics` scrape held 2,655 histogram series across 291 distinct
`route` values (344 KB body), dominated by per-UUID activity paths.

Add `normalizeRoute()` in metrics.server.ts: it collapses dynamic path
segments back to the `:param` templates declared in app/routes.ts (UUID
and numeric segments -> `:id`; `:username`/`:provider` slots keyed by
their preceding static segment), strips query/fragment, and enforces a
hard cap (MAX_ROUTES) that funnels anything beyond the known route table
into a single `/other` bucket as an absolute backstop. Cardinality is now
proportional to the route table, not to traffic.

Logs still record the raw path (`logger.info`) — only the metric label is
bounded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:58:01 +02:00

89 lines
3.3 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import { normalizeRoute, _resetRouteNormalizationForTest } from "./metrics.server.ts";
describe("normalizeRoute", () => {
beforeEach(() => {
_resetRouteNormalizationForTest();
});
it("passes static routes through unchanged", () => {
expect(normalizeRoute("/")).toBe("/");
expect(normalizeRoute("/feed")).toBe("/feed");
expect(normalizeRoute("/explore")).toBe("/explore");
expect(normalizeRoute("/settings/security")).toBe("/settings/security");
expect(normalizeRoute("/api/v1/routes")).toBe("/api/v1/routes");
expect(normalizeRoute("/legal/terms")).toBe("/legal/terms");
});
it("collapses UUID segments to :id", () => {
expect(
normalizeRoute("/activities/0257f241-d880-4ca6-b073-c8706f07a293"),
).toBe("/activities/:id");
expect(
normalizeRoute("/routes/0257f241-d880-4ca6-b073-c8706f07a293/edit"),
).toBe("/routes/:id/edit");
expect(
normalizeRoute("/api/v1/activities/0257F241-D880-4CA6-B073-C8706F07A293"),
).toBe("/api/v1/activities/:id");
});
it("collapses purely-numeric segments to :id", () => {
expect(normalizeRoute("/api/notifications/12345/read")).toBe(
"/api/notifications/:id/read",
);
});
it("collapses usernames to :username after a `users` segment", () => {
expect(normalizeRoute("/users/alice")).toBe("/users/:username");
expect(normalizeRoute("/users/bob/followers")).toBe(
"/users/:username/followers",
);
expect(normalizeRoute("/api/users/carol/follow")).toBe(
"/api/users/:username/follow",
);
});
it("collapses sync providers to :provider", () => {
expect(normalizeRoute("/api/sync/connect/wahoo")).toBe(
"/api/sync/connect/:provider",
);
expect(normalizeRoute("/api/sync/callback/garmin")).toBe(
"/api/sync/callback/:provider",
);
expect(
normalizeRoute(
"/api/sync/push/komoot/0257f241-d880-4ca6-b073-c8706f07a293",
),
).toBe("/api/sync/push/:provider/:id");
});
it("strips query strings and fragments before normalizing", () => {
expect(
normalizeRoute("/activities/0257f241-d880-4ca6-b073-c8706f07a293?foo=bar"),
).toBe("/activities/:id");
expect(normalizeRoute("/feed#section")).toBe("/feed");
});
it("preserves a dotted version segment (does not treat 2.1 as numeric)", () => {
expect(normalizeRoute("/nodeinfo/2.1")).toBe("/nodeinfo/2.1");
});
it("returns the same template for two different ids (bounded cardinality)", () => {
const a = normalizeRoute("/activities/0257f241-d880-4ca6-b073-c8706f07a293");
const b = normalizeRoute("/activities/ffffffff-d880-4ca6-b073-c8706f07a293");
expect(a).toBe(b);
expect(a).toBe("/activities/:id");
});
it("collapses unbounded junk to /other once the cap is exceeded", () => {
// Fill the tracking set with distinct unmatched paths up to the cap.
for (let i = 0; i < 200; i++) {
expect(normalizeRoute(`/scan-${i}`)).toBe(`/scan-${i}`);
}
// The next novel path can't be tracked, so it collapses to /other.
expect(normalizeRoute("/scan-200")).toBe("/other");
expect(normalizeRoute("/another-novel-path")).toBe("/other");
// Already-tracked paths are still returned verbatim.
expect(normalizeRoute("/scan-0")).toBe("/scan-0");
});
});