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 }