diff --git a/apps/server/src/integrations/metrics/http-metrics.hook.ts b/apps/server/src/integrations/metrics/http-metrics.hook.ts index a01a7d1a..d1215dfe 100644 --- a/apps/server/src/integrations/metrics/http-metrics.hook.ts +++ b/apps/server/src/integrations/metrics/http-metrics.hook.ts @@ -2,15 +2,28 @@ import { FastifyReply, FastifyRequest } from 'fastify'; import { isStreamingResponse } from './metrics.constants'; import { observeHttp } from './metrics.registry'; +// URL path prefixes served by @fastify/static (client build output under +// client/dist). Their filenames are content-hashed (index-*.js, chunk-*.js), +// so a NEW set of names is minted on every deploy — using them as the `route` +// label would grow the label set without bound (#362). Collapse them all to one +// bounded `static` label. (Edge latency for static is already measured by +// Traefik's traefik_router_request_duration_*.) +const STATIC_PATH_PREFIXES = ['/assets/', '/vad/', '/brand/', '/locales/']; + /** * Resolve the BOUNDED route label for an HTTP response. * - * HARD REQUIREMENT (#355): use the ROUTE TEMPLATE (`/pages/:id`), NEVER the raw - * URL (`/pages/abc-123`), so label cardinality stays finite. Fastify exposes the - * matched template on `req.routeOptions.url`. On 404s (no route matched) that is - * missing → collapse to the literal `unknown`. + * HARD REQUIREMENT (#355): use the ROUTE TEMPLATE (`/pages/:id`), NEVER a raw + * URL (`/pages/abc-123` or `/assets/index-CAbxDtto.js`), so label cardinality + * stays finite. Fastify exposes the matched template on `req.routeOptions.url`, + * BUT @fastify/static serves each file through a route whose matched url is the + * raw (hashed) file path — so for static assets that value is itself unbounded. + * Detect static requests by their path prefix FIRST and collapse to `static`; + * otherwise use the route template; on a 404 (no route matched) → `unknown`. */ export function resolveRouteLabel(req: FastifyRequest): string { + const path = (req.url ?? '').split('?', 1)[0]; + if (STATIC_PATH_PREFIXES.some((p) => path.startsWith(p))) return 'static'; const url = req.routeOptions?.url; return typeof url === 'string' && url.length > 0 ? url : 'unknown'; } diff --git a/apps/server/src/integrations/metrics/metrics.spec.ts b/apps/server/src/integrations/metrics/metrics.spec.ts index ffcc11e6..02a27133 100644 --- a/apps/server/src/integrations/metrics/metrics.spec.ts +++ b/apps/server/src/integrations/metrics/metrics.spec.ts @@ -25,6 +25,43 @@ describe('resolveRouteLabel (histogram route label)', () => { const req = { url: '/x' } as unknown as FastifyRequest; expect(resolveRouteLabel(req)).toBe('unknown'); }); + + it.each([ + '/assets/index-CAbxDtto.js', + '/assets/chunk-3OPIFGDE-CJOt9nr5.js', + '/assets/excalidraw-menu-DpsI0kFW.js', + '/vad/silero_vad_v5.onnx', + '/brand/logo.svg', + '/locales/en.json', + ])('collapses hashed/static asset %p to "static" (#362 cardinality)', (url) => { + // @fastify/static serves each file through a route whose matched url is the + // raw (hashed) file path, so routeOptions.url is itself unbounded here. + const req = { + url, + routeOptions: { url }, + } as unknown as FastifyRequest; + const label = resolveRouteLabel(req); + expect(label).toBe('static'); + expect(label).not.toContain('.js'); + expect(label).not.toContain('index-'); + }); + + it('strips the query string before the static-prefix check', () => { + const req = { + url: '/assets/index-CAbxDtto.js?v=2', + routeOptions: { url: '/assets/index-CAbxDtto.js' }, + } as unknown as FastifyRequest; + expect(resolveRouteLabel(req)).toBe('static'); + }); + + it('does NOT collapse a real API route that merely mentions assets', () => { + // A templated API route is kept as-is; only the static path PREFIXES collapse. + const req = { + url: '/api/pages/assets-guide', + routeOptions: { url: '/api/pages/:id' }, + } as unknown as FastifyRequest; + expect(resolveRouteLabel(req)).toBe('/api/pages/:id'); + }); }); describe('isStreamingResponse (SSE exclusion)', () => {