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>( 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/`, 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"; }