fix(metrics): collapse static-asset routes to bounded "static" label (#362)
Follow-up to #355: http_request_duration_seconds's `route` label captured raw content-hashed asset filenames (route="/assets/index-CAbxDtto.js", "/assets/chunk-*.js"). @fastify/static serves each file through a route whose matched routeOptions.url IS the raw hashed path, so the label was unbounded — a new set of names every deploy, growing the series forever (the exact cardinality leak the API routes were protected against). resolveRouteLabel now detects a static request by its path prefix (/assets/, /vad/, /brand/, /locales/) FIRST and collapses it to a single `static` label (query string stripped before the check); API routes still use the template and 404s still collapse to `unknown`. Static edge latency is already measured by Traefik's traefik_router_request_duration_*. Gate: server tsc 0; metrics.spec passes (added static-collapse + query-strip + "real API route mentioning assets is NOT collapsed" cases). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user