From 4cc351b538b8417ac59c2dd1bbca03c2a695e802 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Sat, 30 May 2026 00:27:03 +0100 Subject: [PATCH] feat(otel): add getRoute config callback for http.route at finalize The middleware overwrote the http.route attribute at finalize time with the Hono route pattern, even when a downstream adapter (such as an RPC layer) had already set a more specific value on the active span. The same pattern was also written to the http.server.request.duration metric, so per-operation latency collapsed into the pattern bucket (see issue #1914). Add an optional getRoute(c) config callback. When it returns a non-empty string the value is used for both the span attribute and the metric attribute at finalize. When it returns undefined, an empty string, or throws, the middleware falls back to routePath(c). This is fully backward compatible. Closes #1914 --- .changeset/otel-get-route-callback.md | 9 +++ packages/otel/src/index.test.ts | 96 +++++++++++++++++++++++++++ packages/otel/src/index.ts | 22 +++++- packages/otel/src/types.ts | 14 ++++ 4 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 .changeset/otel-get-route-callback.md diff --git a/.changeset/otel-get-route-callback.md b/.changeset/otel-get-route-callback.md new file mode 100644 index 000000000..4f62a437f --- /dev/null +++ b/.changeset/otel-get-route-callback.md @@ -0,0 +1,9 @@ +--- +'@hono/otel': minor +--- + +feat(otel): add `getRoute` config callback so downstream adapters can resolve `http.route` + +The middleware previously overwrote the `http.route` attribute on the active span with the matched Hono route pattern at finalize time, even when a downstream adapter (such as an RPC layer) had already set a more specific value on the span. The same pattern was also reported as the `http.route` attribute of the `http.server.request.duration` metric, so per-operation latency collapsed into the pattern bucket. + +`getRoute(c)` is now an optional config callback. When it returns a non-empty string it is used for both the span attribute and the metric attribute; when it returns `undefined`, an empty string, or throws, the middleware falls back to the existing `routePath(c)` behavior. This is fully backward compatible and resolves [issue #1914](https://github.com/honojs/middleware/issues/1914). diff --git a/packages/otel/src/index.test.ts b/packages/otel/src/index.test.ts index d2870e89e..b1d6b6a67 100644 --- a/packages/otel/src/index.test.ts +++ b/packages/otel/src/index.test.ts @@ -313,6 +313,71 @@ describe('OpenTelemetry middleware - Spans (combined)', () => { const spans = memoryExporter.getFinishedSpans() assert.strictEqual(spans.length, 0) }) + + // Regression tests for https://github.com/honojs/middleware/issues/1914 - a downstream + // adapter (such as an RPC layer) can now expose a resolved operation name via the + // `getRoute` config callback, and the span's `http.route` attribute reflects that + // value at finalize time instead of being overwritten with the Hono route pattern. + it('Should use getRoute return value for the span http.route attribute', async () => { + const app2 = new Hono() + app2.use( + httpInstrumentationMiddleware({ + tracerProvider, + getRoute: (c: Context) => c.get('rpc.route' as never) as string | undefined, + }) + ) + app2.get('/rpc/*', (c) => { + c.set('rpc.route' as never, '/rpc/user/getProfile' as never) + return c.text('ok') + }) + await app2.request('http://localhost/rpc/user/getProfile') + const [span] = memoryExporter.getFinishedSpans() + assert.strictEqual(span.attributes[ATTR_HTTP_ROUTE], '/rpc/user/getProfile') + }) + + it('Should fall back to routePath(c) when getRoute returns undefined', async () => { + const app2 = new Hono() + app2.use( + httpInstrumentationMiddleware({ + tracerProvider, + getRoute: () => undefined, + }) + ) + app2.get('/rpc/*', (c) => c.text('ok')) + await app2.request('http://localhost/rpc/user/getProfile') + const [span] = memoryExporter.getFinishedSpans() + assert.strictEqual(span.attributes[ATTR_HTTP_ROUTE], '/rpc/*') + }) + + it('Should fall back to routePath(c) when getRoute returns an empty string', async () => { + const app2 = new Hono() + app2.use( + httpInstrumentationMiddleware({ + tracerProvider, + getRoute: () => '', + }) + ) + app2.get('/rpc/*', (c) => c.text('ok')) + await app2.request('http://localhost/rpc/user/getProfile') + const [span] = memoryExporter.getFinishedSpans() + assert.strictEqual(span.attributes[ATTR_HTTP_ROUTE], '/rpc/*') + }) + + it('Should fall back to routePath(c) when getRoute throws', async () => { + const app2 = new Hono() + app2.use( + httpInstrumentationMiddleware({ + tracerProvider, + getRoute: () => { + throw new Error('boom') + }, + }) + ) + app2.get('/rpc/*', (c) => c.text('ok')) + await app2.request('http://localhost/rpc/user/getProfile') + const [span] = memoryExporter.getFinishedSpans() + assert.strictEqual(span.attributes[ATTR_HTTP_ROUTE], '/rpc/*') + }) }) describe('OpenTelemetry middleware - Metrics (combined)', () => { @@ -558,4 +623,35 @@ describe('OpenTelemetry middleware - Metrics (combined)', () => { assert.strictEqual(adds.length, 0) }) + + // Regression test for https://github.com/honojs/middleware/issues/1914 - the resolved + // route from `getRoute` must also be applied to the `http.server.request.duration` + // histogram, so per-operation latency is not collapsed into the Hono pattern bucket. + it('Should use getRoute return value for the request duration metric http.route attribute', async () => { + const app = new Hono() + app.use( + httpInstrumentationMiddleware({ + meterProvider: meterProvider as unknown as MeterProvider, + getRoute: (c: Context) => c.get('rpc.route' as never) as string | undefined, + }) + ) + app.get('/rpc/*', (c) => { + c.set('rpc.route' as never, '/rpc/user/getProfile' as never) + return c.text('ok') + }) + await app.request('http://localhost/rpc/user/getProfile') + await metricReader.forceFlush() + + const resourceMetrics = memoryMetricExporter.getMetrics() + assert.ok(resourceMetrics.length > 0) + const metrics = resourceMetrics[0].scopeMetrics[0].metrics + const durationMetric = metrics.find((m) => m.descriptor.name === 'http.server.request.duration') + assert.ok(durationMetric) + const dps = durationMetric.dataPoints as DataPoint[] + const dp = dps.find((d) => d.attributes['http.route'] === '/rpc/user/getProfile') + assert.ok(dp, 'expected a data point keyed by the resolved RPC route') + assert.strictEqual(dp.attributes['http.route'], '/rpc/user/getProfile') + assert.strictEqual(dp.attributes['http.request.method'], 'GET') + assert.strictEqual(dp.attributes['http.response.status_code'], 200) + }) }) diff --git a/packages/otel/src/index.ts b/packages/otel/src/index.ts index b9b43d45e..874d50f8e 100644 --- a/packages/otel/src/index.ts +++ b/packages/otel/src/index.ts @@ -116,8 +116,24 @@ export const httpInstrumentationMiddleware = ( } } finally { activeReqs?.decrement(stableAttrs) - // Update route and name since they may have changed after routing finished - span?.setAttribute(ATTR_HTTP_ROUTE, routePath(c)) + // Update route and name since they may have changed after routing finished. + // Prefer a value resolved by the user via `getRoute` (e.g. an RPC operation + // name that a downstream adapter has set on the context) over the matched + // Hono pattern, so adapters can surface the real operation in both the + // span attribute and the request duration metric. + let finalRoute = routePath(c) + if (config.getRoute) { + try { + const resolved = config.getRoute(c) + if (typeof resolved === 'string' && resolved.length > 0) { + finalRoute = resolved + } + } catch { + // Ignore errors from the user-supplied route resolver and fall back to + // the default pattern so the request still produces a clean span. + } + } + span?.setAttribute(ATTR_HTTP_ROUTE, finalRoute) span?.updateName(spanName(c)) // Convert duration to seconds as the time unit from performance.now() is in milliseconds @@ -125,7 +141,7 @@ export const httpInstrumentationMiddleware = ( requestDuration.record(duration, { ...stableAttrs, - [ATTR_HTTP_ROUTE]: routePath(c), + [ATTR_HTTP_ROUTE]: finalRoute, [ATTR_HTTP_RESPONSE_STATUS_CODE]: c.res.status, }) } diff --git a/packages/otel/src/types.ts b/packages/otel/src/types.ts index ae95937ca..f8667bfa8 100644 --- a/packages/otel/src/types.ts +++ b/packages/otel/src/types.ts @@ -11,6 +11,20 @@ export type HttpInstrumentationConfig = { getTime?(): TimeInput disableTracing?: boolean spanNameFactory?: (c: HonoContext) => string + /** + * Resolves the value of the `http.route` attribute used at finalize time + * for both the active span and the `http.server.request.duration` metric. + * + * Returning a non-empty string overrides the default, which is the Hono + * route pattern (for example `/rpc/*`). This is useful when a downstream + * adapter resolves a more specific route name during request handling + * (such as an RPC operation name) and you want both the span and the + * metric to carry that resolved value instead of the pattern. + * + * Returning `undefined` or throwing falls back to the default pattern, so + * the existing behavior is preserved when this hook is not provided. + */ + getRoute?: (c: HonoContext) => string | undefined serviceName?: string serviceVersion?: string }