From d9d51fc7598f379bf846d22f2919caceb9d00d96 Mon Sep 17 00:00:00 2001 From: thelastbackspace <23155651+thelastbackspace@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:17:56 +0100 Subject: [PATCH] feat: label Koa requests by route --- CHANGELOG.md | 1 + package-lock.json | 36 ++++++++++++++++++++++++++++++++ package.json | 1 + src/__tests__/instrument.test.ts | 29 +++++++++++++++++++++++++ src/auto.ts | 10 ++++++++- src/instrument.ts | 33 ++++++++++++++++++++++++++++- 6 files changed, 108 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1427dc9..5440056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. +- **Koa route labels:** with `@koa/router` or `koa-router`, Koa requests are labelled by the matched route (`GET /api/users/:id`), auto-detected; `labelKoaRoutes(require('koa'))` by hand. ## [1.7.0] - 2026-11-06 diff --git a/package-lock.json b/package-lock.json index df5ac29..c2e66fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", "@aws-sdk/client-s3": "^3.1153.0", + "@koa/router": "^12.0.2", "@nestjs/common": "^12.2.0", "@nestjs/core": "^12.2.0", "@nestjs/platform-express": "^12.2.0", @@ -2228,6 +2229,31 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@koa/router": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-12.0.2.tgz", + "integrity": "sha512-sYcHglGKTxGF+hQ6x67xDfkE9o+NhVlRHBqq6gLywaMc6CojK/5vFZByphdonKinYlMLkEkacm+HEse9HzwgTA==", + "deprecated": "Please upgrade to v15 or higher. All reported bugs in this version are fixed in newer releases, dependencies have been updated, and security has been improved.", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "methods": "^1.1.2", + "path-to-regexp": "^6.3.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@koa/router/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@loaderkit/resolve": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@loaderkit/resolve/-/resolve-1.0.6.tgz", @@ -7162,6 +7188,16 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", diff --git a/package.json b/package.json index 86ea605..9b7f8a5 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", "@aws-sdk/client-s3": "^3.1153.0", + "@koa/router": "^12.0.2", "@nestjs/common": "^12.2.0", "@nestjs/core": "^12.2.0", "@nestjs/platform-express": "^12.2.0", diff --git a/src/__tests__/instrument.test.ts b/src/__tests__/instrument.test.ts index 279eafc..eb2aae8 100644 --- a/src/__tests__/instrument.test.ts +++ b/src/__tests__/instrument.test.ts @@ -10,6 +10,7 @@ import { instrumentHttp, instrumentHttpServer, instrumentMethods, + labelKoaRoutes, prismaExtension, wrapFastifyFactory, } from '../index'; @@ -183,3 +184,31 @@ describe('Fastify route labels (wrapFastifyFactory)', () => { expect(fastify.fastify).toBe(fastify); }); }); + +describe('Koa route labels (labelKoaRoutes)', () => { + test('requests are labelled by the route @koa/router matched, prefixes included', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Koa = require('koa'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Router = require('@koa/router'); + const { tp, roots } = setup(); + const uninstall = instrumentHttpServer(tp); + const unlabel = labelKoaRoutes(Koa); + expect(labelKoaRoutes(Koa)()).toBeUndefined(); // idempotent: second call is a no-op + const app = new Koa(); + const api = new Router({ prefix: '/api' }); + api.get('/users/:id', (ctx: any) => { ctx.body = 'ok'; }); + app.use(api.routes()); + const server = app.listen(0, '127.0.0.1'); + try { + await new Promise(r => server.once('listening', r)); + const port = (server.address() as AddressInfo).port; + for (const p of ['/api/users/1', '/api/users/2', '/other']) await get(`http://127.0.0.1:${port}${p}`); + } finally { + await new Promise(r => server.close(r)); + unlabel(); + uninstall(); + } + expect(roots.map(r => r.label)).toEqual(['GET /api/users/:id', 'GET /api/users/:id', 'GET /other']); + }); +}); diff --git a/src/auto.ts b/src/auto.ts index be67185..9b13f10 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, wrapFastifyFactory } from './instrument'; +import { HttpServerInstrumentOptions, instrumentFetch, instrumentHttp, instrumentHttpServer, labelKoaRoutes, 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,14 @@ 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); + // Koa: label requests by the route @koa/router / koa-router matched + if (deps.names.has('koa') && !disabled.has('server')) { + try { + restores.push(labelKoaRoutes(appRequire('koa'))); + } catch { + // labels stay URL-based + } + } // 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')) { diff --git a/src/instrument.ts b/src/instrument.ts index b5fd7a3..46948fa 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -253,6 +253,35 @@ export interface HttpServerInstrumentOptions extends InstrumentOptions { */ export const ROUTE = Symbol.for('traceperf.route'); +const KOA_CONTEXT = Symbol.for('traceperf.koa.context'); + +/** + * Koa with @koa/router or koa-router: label requests by their route + * ("GET /users/:id"). Links each request to its Koa context, where the + * router records the matched route. + * + * labelKoaRoutes(require('koa')); + */ +export function labelKoaRoutes(Koa: any): Uninstall { + const proto = Koa && (Koa.default || Koa).prototype; + const original = proto && proto.createContext; + if (typeof original !== 'function' || original[PATCHED]) return () => undefined; + const patched = function (this: unknown, req: any, res: unknown) { + const ctx = original.call(this, req, res); + try { + if (req && typeof req === 'object') req[KOA_CONTEXT] = ctx; + } catch { + // never break the app + } + return ctx; + }; + (patched as any)[PATCHED] = original; + proto.createContext = patched; + return () => { + if (proto.createContext === patched) proto.createContext = original; + }; +} + /** * 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 @@ -304,7 +333,9 @@ export function instrumentHttpServer(tp: OptimizedPerformanceMonitor, options: H if (ended) return; ended = true; // A framework set the matched route (traceperf/auto does it for Fastify) - const marked = !labelFor ? req[ROUTE] : undefined; + // ... or the Koa context, where @koa/router puts it + const koa = req[KOA_CONTEXT]; + const marked = labelFor ? undefined : req[ROUTE] || (koa && typeof koa._matchedRoute === 'string' ? koa._matchedRoute : 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 && !marked && req.route && typeof req.route.path === 'string' ? req.route.path : undefined;