trails/apps/planner/app/lib/metrics.server.ts
Ullrich Schäfer 963902514b
Failover across multiple Overpass upstreams
Public Overpass instances go bad without warning — we just lived
through a day where overpass.private.coffee (our single upstream)
returned 504s after 60s while lz4.overpass-api.de served the same
query in 0.6s. Survive that by trying a list of upstreams in order
until one responds usefully.

Server changes:
- OVERPASS_URLS env — comma-separated list, tried in order. Falls
  back to OVERPASS_URL for backward compat, then to a built-in
  default pair (lz4.overpass-api.de, overpass-api.de).
- Per-upstream timeout of 10s via AbortSignal.timeout, so a saturated
  instance fails over in seconds, not minutes.
- Client abort propagation via AbortSignal.any — if the browser
  cancels (e.g. user panned the map), the in-flight upstream fetch is
  aborted too. Stops wasting upstream capacity on requests nobody
  wants.
- Rate-limited-body detection (Overpass returns 200 with a
  `rate_limited` marker when throttling) triggers failover.
- Per-upstream labels on overpass_upstream_requests_total and
  overpass_upstream_duration_seconds so the dashboard can break out
  health + latency by instance. Histogram buckets extended to 30s.

Compose: OVERPASS_URLS passthrough with the default pair hard-wired,
overridable via SOPS.

Tests: 8 cases covering the new fetchWithFailover helper — happy
path, 5xx failover, rate_limited failover, network error failover,
timeout tagging, all-upstreams-fail, client abort stops the loop,
per-attempt latency observation.

Follow-ups left out of scope:
- Client-side debounce (UI concern).
- Moving `.observe()` after body read for accuracy in measurement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 07:36:10 +02:00

80 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import client from "prom-client";
// Guard all metric registration — Vite's dev server can re-evaluate
// this module, causing "already registered" errors.
function getOrCreate<T>(name: string, create: () => T): T {
return (client.register.getSingleMetric(name) as T) ?? create();
}
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 plannerActiveSessions = getOrCreate("planner_active_sessions", () =>
new client.Gauge({
name: "planner_active_sessions",
help: "Number of active planner sessions",
}),
);
export const plannerConnectedClients = getOrCreate("planner_connected_clients", () =>
new client.Gauge({
name: "planner_connected_clients",
help: "Number of connected WebSocket clients",
}),
);
export const brouterRequestDuration = getOrCreate("brouter_request_duration_seconds", () =>
new client.Histogram({
name: "brouter_request_duration_seconds",
help: "Duration of BRouter API requests in seconds",
buckets: [0.1, 0.25, 0.5, 1, 2, 5, 10],
}),
);
export const overpassCacheEvents = getOrCreate("overpass_cache_events_total", () =>
new client.Counter({
name: "overpass_cache_events_total",
help: "Overpass proxy cache events",
labelNames: ["result"] as const, // hit | miss | coalesced
}),
);
export const overpassCacheSize = getOrCreate("overpass_cache_size", () =>
new client.Gauge({
name: "overpass_cache_size",
help: "Current number of entries in the Overpass proxy cache",
}),
);
export const overpassUpstreamDuration = getOrCreate("overpass_upstream_duration_seconds", () =>
new client.Histogram({
name: "overpass_upstream_duration_seconds",
help: "Duration of upstream Overpass API requests in seconds",
labelNames: ["upstream"] as const,
// Buckets go to 30s because our per-upstream timeout is 10s; a bit
// of headroom catches slow-but-not-timed-out tails without overly
// coarse resolution at the fast end where lz4 typically lands
// (~100500ms).
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 15, 30],
}),
);
export const overpassUpstreamRequests = getOrCreate("overpass_upstream_requests_total", () =>
new client.Counter({
name: "overpass_upstream_requests_total",
help: "Upstream Overpass API requests by upstream host and status",
labelNames: ["upstream", "status"] as const,
}),
);
export const registry = client.register;