Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/otel-get-route-callback.md
Original file line number Diff line number Diff line change
@@ -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).
96 changes: 96 additions & 0 deletions packages/otel/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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<Histogram>[]
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)
})
})
22 changes: 19 additions & 3 deletions packages/otel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,32 @@ 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
const duration = (performance.now() - monotonicStartTime) / 1000

requestDuration.record(duration, {
...stableAttrs,
[ATTR_HTTP_ROUTE]: routePath(c),
[ATTR_HTTP_ROUTE]: finalRoute,
[ATTR_HTTP_RESPONSE_STATUS_CODE]: c.res.status,
})
}
Expand Down
14 changes: 14 additions & 0 deletions packages/otel/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down