diff --git a/apps/server/src/integrations/metrics/http-metrics.hook.ts b/apps/server/src/integrations/metrics/http-metrics.hook.ts index a01a7d1a..c72fb4d0 100644 --- a/apps/server/src/integrations/metrics/http-metrics.hook.ts +++ b/apps/server/src/integrations/metrics/http-metrics.hook.ts @@ -2,15 +2,36 @@ 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). `/assets/` holds the content-hashed bundle (index-*.js, +// chunk-*.js) — a NEW set of names every deploy, i.e. an UNBOUNDED label set +// (#362); the others (/vad/, /brand/, /locales/, /icons/ — copied verbatim from +// public/) have stable names, so they are merely repetitive per-file labels +// rather than unbounded. Either way none of these belong in the API-route +// histogram: 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/', + '/icons/', +]; + /** * 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..311ad43d 100644 --- a/apps/server/src/integrations/metrics/metrics.spec.ts +++ b/apps/server/src/integrations/metrics/metrics.spec.ts @@ -25,6 +25,61 @@ 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', + '/icons/app-icon-192x192.png', + ])('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'); + }); + + it.each([ + // The TRAILING SLASH on the prefix is the anti-false-collapse guard: a path + // that is the prefix WITHOUT its slash, or merely shares the prefix as a + // substring of a longer segment, must NOT collapse. These would collapse + // under a buggy `includes('/assets/')` / slashless-prefix impl. + '/assets', + '/assetsx/foo.js', + '/iconset/x.png', + ])('does NOT collapse the prefix-boundary case %p', (url) => { + const req = { + url, + routeOptions: { url: '/some/:route' }, + } as unknown as FastifyRequest; + expect(resolveRouteLabel(req)).not.toBe('static'); + expect(resolveRouteLabel(req)).toBe('/some/:route'); + }); }); describe('isStreamingResponse (SSE exclusion)', () => {