From 29b76e06f3c2708c29ebf1e56955b046b55320d0 Mon Sep 17 00:00:00 2001 From: thelastbackspace <23155651+thelastbackspace@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:14:16 +0100 Subject: [PATCH] feat: label Fastify requests by route --- CHANGELOG.md | 3 +++ src/__tests__/instrument.test.ts | 25 ++++++++++++++++++++ src/auto.ts | 20 +++++++++++++++- src/instrument.ts | 39 +++++++++++++++++++++++++++++++- 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a59aeb2..1427dc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Fastify route labels:** with `traceperf/auto` or `traceperf run`, Fastify (3–5) requests are labelled by route, `GET /api/articles/:slug`, instead of one label per URL. `traceperf/auto` wraps the `fastify()` factory to add an `onRequest` hook (`wrapFastifyFactory` for doing it by hand). Any framework can do the same by setting the `ROUTE` symbol on the raw request. + ## [1.7.0] - 2026-11-06 ### Added diff --git a/src/__tests__/instrument.test.ts b/src/__tests__/instrument.test.ts index 36873fc..279eafc 100644 --- a/src/__tests__/instrument.test.ts +++ b/src/__tests__/instrument.test.ts @@ -8,8 +8,10 @@ import { ExecutionRecord, instrumentFetch, instrumentHttp, + instrumentHttpServer, instrumentMethods, prismaExtension, + wrapFastifyFactory, } from '../index'; let server: http.Server; @@ -158,3 +160,26 @@ describe('prismaExtension', () => { expect(roots[0].label).toBe('prisma.User.findMany'); }); }); + +describe('Fastify route labels (wrapFastifyFactory)', () => { + test('requests are labelled by route, prefixes included, not by URL', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fastify = wrapFastifyFactory(require('fastify')); + const { tp, roots } = setup(); + const uninstall = instrumentHttpServer(tp); + const app = fastify(); + app.get('/articles/:slug', async () => ({ ok: true })); + app.register(async (api: any) => { api.get('/profiles/:name', async () => ({ ok: true })); }, { prefix: '/api' }); + try { + await app.listen({ port: 0, host: '127.0.0.1' }); + const port = (app.server.address() as AddressInfo).port; + for (const p of ['/articles/post-3', '/articles/post-7', '/api/profiles/bob', '/nope']) await get(`http://127.0.0.1:${port}${p}`); + } finally { + await app.close(); + uninstall(); + } + expect(roots.map(r => r.label)).toEqual(['GET /articles/:slug', 'GET /articles/:slug', 'GET /api/profiles/:name', 'GET /nope']); + expect(wrapFastifyFactory(fastify)).toBe(fastify); // idempotent + expect(fastify.fastify).toBe(fastify); + }); +}); diff --git a/src/auto.ts b/src/auto.ts index d12cdbf..be67185 100644 --- a/src/auto.ts +++ b/src/auto.ts @@ -11,7 +11,7 @@ */ import type { OptimizedPerformanceMonitor } from './core/optimized-performance-monitor'; import { loadNodeBuiltin } from './core/env'; -import { HttpServerInstrumentOptions, instrumentFetch, instrumentHttp, instrumentHttpServer, Uninstall } from './instrument'; +import { HttpServerInstrumentOptions, instrumentFetch, instrumentHttp, instrumentHttpServer, Uninstall, wrapFastifyFactory } from './instrument'; import { instrumentBetterSqlite3, instrumentMongo, instrumentMysql, instrumentMysql2, instrumentPg, instrumentPrisma, instrumentRedis } from './drivers'; export type AutoTarget = 'server' | 'http' | 'fetch' | 'pg' | 'mysql2' | 'mongodb' | 'ioredis' | 'prisma' | 'better-sqlite3' | 'mysql'; @@ -92,6 +92,24 @@ export function autoInstrument(tp: OptimizedPerformanceMonitor, options: AutoIns if (deps) { const appRequire = moduleApi.createRequire(path.join(deps.dir, 'package.json')); const load = (name: string) => appRequire(name); + // Fastify: label requests by route. The app gets the wrapped factory + // (require and import both read the module cache, filled here first). + if (deps.names.has('fastify') && !disabled.has('server')) { + try { + const file = appRequire.resolve('fastify'); + const original = appRequire('fastify'); + const cached = appRequire.cache && appRequire.cache[file]; + const wrapped = wrapFastifyFactory(original); + if (cached && wrapped !== original) { + cached.exports = wrapped; + restores.push(() => { + if (cached.exports === wrapped) cached.exports = original; + }); + } + } catch { + // labels stay URL-based + } + } if (deps.names.has('pg')) add('pg', () => instrumentPg(tp, load('pg'))); if (deps.names.has('mysql2')) add('mysql2', () => instrumentMysql2(tp, load('mysql2'))); if (deps.names.has('mysql')) add('mysql', () => instrumentMysql(tp, load('mysql'))); diff --git a/src/instrument.ts b/src/instrument.ts index 5b14fac..b5fd7a3 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -247,6 +247,40 @@ export interface HttpServerInstrumentOptions extends InstrumentOptions { * functions) nests under it, with no framework middleware. Responses with * status >= 500 and connections closed before a response count as failed. */ +/** + * Set on an incoming request (the raw http.IncomingMessage) to the matched + * route, e.g. "/api/articles/:slug", to label it by route instead of URL. + */ +export const ROUTE = Symbol.for('traceperf.route'); + +/** + * Fastify (3, 4, 5): label requests by their route ("GET /api/articles/:slug"). + * Wraps the fastify() factory so every instance gets an onRequest hook; returns + * the wrapper, to be used in place of the module's export. + */ +export function wrapFastifyFactory(fastify: any): any { + if (typeof fastify !== 'function' || fastify[PATCHED]) return fastify; + const wrapped = function (this: unknown, ...args: unknown[]) { + const app = fastify.apply(this, args); + try { + app.addHook('onRequest', (request: any, _reply: unknown, done: () => void) => { + const url = (request.routeOptions && request.routeOptions.url) || request.routerPath; + if (typeof url === 'string' && request.raw) request.raw[ROUTE] = url; + done(); + }); + } catch { + // never break the app + } + return app; + }; + Object.assign(wrapped, fastify); + (wrapped as any)[PATCHED] = fastify; + // `import fastify from 'fastify'`, `require('fastify').fastify`, `.default` + (wrapped as any).fastify = wrapped; + (wrapped as any).default = wrapped; + return wrapped; +} + export function instrumentHttpServer(tp: OptimizedPerformanceMonitor, options: HttpServerInstrumentOptions = {}): Uninstall { const { errorStatus = 500, labelFor, ignore, ...trackOptions } = options; const restores: Uninstall[] = []; @@ -269,8 +303,11 @@ export function instrumentHttpServer(tp: OptimizedPerformanceMonitor, options: H const end = (error?: unknown) => { if (ended) return; ended = true; + // A framework set the matched route (traceperf/auto does it for Fastify) + const marked = !labelFor ? req[ROUTE] : undefined; + if (typeof marked === 'string' && marked) span.setLabel(`${req.method || 'GET'} ${marked}`); // Express (4, 5, and Nest on it) knows the route: "/api/articles/:slug", not the slug itself - const route = !labelFor && req.route && typeof req.route.path === 'string' ? req.route.path : undefined; + const route = !labelFor && !marked && req.route && typeof req.route.path === 'string' ? req.route.path : undefined; if (route) { const base = typeof req.baseUrl === 'string' ? req.baseUrl : ''; // router.route('/') under app.use('/v1/users') is "/v1/users", not "/v1/users/"