trails/apps/journal/app/lib/metrics.server.ts
Ullrich Schäfer 881991ca18
feat(journal): federation protocol doc + delivery observability
Task group 4 of federation-hardening.

4.1 — FEDERATION.md at the repo root: actor discovery (WebFinger, actor,
NodeInfo), object/activity types with real JSON examples (Note, Create,
Delete, the narrow follow-graph inbox), addressing, HTTP-Signature
expectations, the two-layer dedup contract, durable delivery/retry
policy, and blocklist moderation semantics — precise enough for another
implementation to interoperate. Linked from README and docs/architecture.

4.2 — three prom-client metrics + a journal dashboard row:
- `federation_delivery_total{outcome}` — incremented in deliver-activity
  (delivered/skipped/failed).
- `federation_inbox_dropped_total{reason}` — incremented at every inbox
  drop (duplicate | blocked); this is the counter deferred from task 3.2.
- `federation_queue_depth` — gauge sampled at scrape time in
  /api/metrics from PgBossMessageQueue.getDepth(); the restart-loss
  regression detector.
Grafana journal.json gains a Federation row (delivery rate, queue depth,
inbox drops); the logs panels shift down to make room.

Verified: dashboard JSON valid; journal typecheck + lint clean; unit
suite 357 passing (route-template guard unaffected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:11:43 +02:00

224 lines
7.1 KiB
TypeScript

import client from "prom-client";
// React Router's build sometimes produces two module instances of this
// file (server entry + route module graph). prom-client's registry is a
// process-wide singleton, so re-running the `new Gauge(...)` / `new
// Histogram(...)` / `collectDefaultMetrics()` calls on the second load
// throws "metric already registered". Guard everything via
// `getSingleMetric` so a second module load reuses the existing objects.
function getOrCreate<T extends client.Metric<string>>(
name: string,
create: () => T,
): T {
const existing = client.register.getSingleMetric(name);
if (existing) return existing as T;
return create();
}
// Default process/Node metrics: registered exactly once.
if (!client.register.getSingleMetric("process_cpu_user_seconds_total")) {
client.collectDefaultMetrics();
}
export const httpRequestDuration = getOrCreate("http_request_duration_seconds", () =>
new client.Histogram({
name: "http_request_duration_seconds",
help: "Duration of HTTP requests in seconds",
labelNames: ["method", "route", "status"] as const,
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
}),
);
export const demoBotSyntheticRoutesTotal = getOrCreate(
"demo_bot_synthetic_routes_total",
() =>
new client.Gauge({
name: "demo_bot_synthetic_routes_total",
help: "Total synthetic demo-bot routes currently stored",
}),
);
export const demoBotSyntheticActivitiesTotal = getOrCreate(
"demo_bot_synthetic_activities_total",
() =>
new client.Gauge({
name: "demo_bot_synthetic_activities_total",
help: "Total synthetic demo-bot activities currently stored",
}),
);
// --- Federation metrics (spec: federation-operations "Federation delivery
// observability") ---------------------------------------------------------
/** Outbound delivery attempts by outcome (delivered | skipped | failed). */
export const federationDeliveryTotal = getOrCreate(
"federation_delivery_total",
() =>
new client.Counter({
name: "federation_delivery_total",
help: "Outbound federation delivery attempts by outcome",
labelNames: ["outcome"] as const,
}),
);
/** Inbound activities dropped, by reason (duplicate | blocked). */
export const federationInboxDroppedTotal = getOrCreate(
"federation_inbox_dropped_total",
() =>
new client.Counter({
name: "federation_inbox_dropped_total",
help: "Inbound federation activities dropped before side effects, by reason",
labelNames: ["reason"] as const,
}),
);
/** Messages waiting in the durable Fedify queue. Set at scrape time by the
* metrics route (the restart-loss regression detector). */
export const federationQueueDepth = getOrCreate(
"federation_queue_depth",
() =>
new client.Gauge({
name: "federation_queue_depth",
help: "Messages waiting in the durable Fedify (pg-boss) message queue",
}),
);
export const registry = client.register;
// --- Route label normalization -------------------------------------------
//
// The `route` label on httpRequestDuration MUST be bounded. prom-client
// stores one label-set per distinct combination and NEVER evicts, so
// feeding it raw request paths (`/activities/<uuid>`, probed usernames,
// crawler junk) leaks memory linearly for the life of the process.
//
// We match each request path against the journal's known route templates
// (mirroring app/routes.ts) and label it with the matched template; a
// `:param` segment matches any single path segment. Anything matching no
// template — vulnerability scanners, typos, unmounted paths — collapses to
// a single `/other` bucket. Cardinality is therefore hard-bounded to
// (number of templates + 1), independent of traffic, and real routes can
// never be crowded out by scanner noise (the failure mode of the earlier
// first-come-first-served cap).
//
// IMPORTANT: ROUTE_TEMPLATES must stay in sync with app/routes.ts. The
// co-located test imports the route config, flattens it to full paths, and
// fails if the two ever drift — so adding a route there forces an update
// here.
const ROUTE_TEMPLATES: readonly string[] = [
"/",
"/.well-known/trails-cool",
"/.well-known/webfinger",
"/.well-known/nodeinfo",
"/nodeinfo/2.1",
"/users/:username/inbox",
"/users/:username/outbox",
"/oauth/authorize",
"/oauth/token",
"/auth/register",
"/auth/login",
"/auth/verify",
"/auth/logout",
"/auth/accept-terms",
"/api/auth/register",
"/api/auth/login",
"/routes",
"/routes/new",
"/routes/:id",
"/routes/:id/edit",
"/api/e2e/seed",
"/api/e2e/route/:id",
"/api/e2e/komoot",
"/api/routes/:id/callback",
"/api/routes/:id/edit-in-planner",
"/api/routes/:id/gpx",
"/activities",
"/activities/new",
"/activities/:id",
"/users/:username",
"/users/:username/followers",
"/users/:username/following",
"/api/users/:username/follow",
"/api/users/:username/unfollow",
"/follows/requests",
"/follows/outgoing",
"/api/follows/:id/approve",
"/api/follows/:id/reject",
"/feed",
"/explore",
"/api/events",
"/notifications",
"/api/notifications/:id/read",
"/api/notifications/read-all",
"/settings",
"/settings/profile",
"/settings/account",
"/settings/security",
"/settings/connections",
"/settings/connections/komoot",
"/api/settings/profile",
"/api/settings/email",
"/api/settings/passkey/delete",
"/api/settings/delete-account",
"/sync/import/komoot",
"/sync/import/garmin",
"/sync/import/:provider",
"/api/sync/komoot/verify",
"/api/sync/komoot/connect",
"/api/sync/komoot/import",
"/api/sync/komoot/import-status",
"/api/sync/connect/:provider",
"/api/sync/callback/:provider",
"/api/sync/disconnect/:provider",
"/api/sync/webhook/:provider",
"/api/sync/push/:provider/:routeId",
"/privacy",
"/legal/imprint",
"/legal/terms",
"/legal/privacy",
"/api/v1/routes",
"/api/v1/routes/compute",
"/api/v1/routes/:id",
"/api/v1/activities",
"/api/v1/activities/:id",
"/api/v1/auth/devices",
"/api/v1/auth/devices/:id",
"/api/v1/uploads",
];
export { ROUTE_TEMPLATES };
const isParam = (seg: string): boolean => seg.startsWith(":");
// Pre-split templates into segments, ordered most-specific first (fewest
// `:param` segments) so a literal like `/routes/new` is matched before the
// parametric `/routes/:id`.
const TEMPLATE_SEGMENTS = ROUTE_TEMPLATES.map((template) => ({
template,
segments: template === "/" ? [] : template.slice(1).split("/"),
})).sort(
(a, b) =>
a.segments.filter(isParam).length - b.segments.filter(isParam).length,
);
/** Map a request path to one of the known route templates, or `/other`. */
export function normalizeRoute(pathname: string): string {
const path = (pathname.split("?")[0] ?? "/").split("#")[0] || "/";
const segs = path.replace(/\/+$/, "").split("/").filter((s) => s !== "");
if (segs.length === 0) return "/";
for (const { template, segments } of TEMPLATE_SEGMENTS) {
if (segments.length !== segs.length) continue;
let matched = true;
for (let i = 0; i < segments.length; i++) {
const t = segments[i]!;
if (!isParam(t) && t !== segs[i]) {
matched = false;
break;
}
}
if (matched) return template;
}
return "/other";
}