Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/__tests__/instrument.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import {
ExecutionRecord,
instrumentFetch,
instrumentHttp,
instrumentHttpServer,
instrumentMethods,
prismaExtension,
wrapFastifyFactory,
} from '../index';

let server: http.Server;
Expand Down Expand Up @@ -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);
});
});
20 changes: 19 additions & 1 deletion src/auto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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')));
Expand Down
39 changes: 38 additions & 1 deletion src/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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/"
Expand Down
Loading