Fix prom-client double-registration on prod

/users/bruno (and any route that imports demo-bot.server.ts) was
returning 500 on prod after #258 because the route's module graph
loaded metrics.server.ts a second time, re-running collectDefaultMetrics
and `new client.Gauge(...)` — prom-client's global registry rejects
duplicate metric names, so the route module failed to load.

Guard every metric creation via `getSingleMetric` so a second module
load reuses the existing gauges/histograms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-19 10:44:57 +02:00
parent fe62b34eb3
commit 100187cb80
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9

View file

@ -1,28 +1,51 @@
import client from "prom-client"; import client from "prom-client";
// Collect default Node.js metrics (event loop, heap, GC) // React Router's build sometimes produces two module instances of this
client.collectDefaultMetrics(); // 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.
export const httpRequestDuration = new client.Histogram({ function getOrCreate<T extends client.Metric<string>>(
name: "http_request_duration_seconds", name: string,
help: "Duration of HTTP requests in seconds", create: () => T,
labelNames: ["method", "route", "status"] as const, ): T {
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], const existing = client.register.getSingleMetric(name);
}); if (existing) return existing as T;
return create();
}
/** // Default process/Node metrics: registered exactly once.
* Count of synthetic demo-bot routes currently in the database. Scraped if (!client.register.getSingleMetric("process_cpu_user_seconds_total")) {
* by the background worker after each generation run so the Grafana client.collectDefaultMetrics();
* board stays live without a /metrics handler on the worker process. }
*/
export const demoBotSyntheticRoutesTotal = new client.Gauge({
name: "demo_bot_synthetic_routes_total",
help: "Total synthetic demo-bot routes currently stored",
});
export const demoBotSyntheticActivitiesTotal = new client.Gauge({ export const httpRequestDuration = getOrCreate("http_request_duration_seconds", () =>
name: "demo_bot_synthetic_activities_total", new client.Histogram({
help: "Total synthetic demo-bot activities currently stored", 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",
}),
);
export const registry = client.register; export const registry = client.register;