Skip to content

Commit ac1c608

Browse files
logaretmclaude
andcommitted
fix: update root span with parameterized route name on Node.js
On Node.js, the root HTTP span is created by Node's HTTP instrumentation with only the raw URL. The Elysia OTel plugin creates a child span with route info but doesn't propagate it up. This updates the root span and isolation scope with the parameterized route name in onAfterHandle (for successful requests on Node.js) and onError (for all runtimes). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 116043a commit ac1c608

4 files changed

Lines changed: 39 additions & 10 deletions

File tree

dev-packages/e2e-tests/test-applications/elysia-bun/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
"type": "module",
66
"scripts": {
77
"start": "bun src/app.ts",
8-
"start:node": "node src/app.ts",
98
"test": "playwright test",
109
"clean": "npx rimraf node_modules pnpm-lock.yaml",
1110
"test:build": "pnpm install",

dev-packages/e2e-tests/test-applications/elysia-node/package.json

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,21 @@
44
"private": true,
55
"type": "module",
66
"scripts": {
7-
"start": "bun src/app.ts",
8-
"start:node": "node src/app.ts",
7+
"start": "node src/app.ts",
98
"test": "playwright test",
109
"clean": "npx rimraf node_modules pnpm-lock.yaml",
1110
"test:build": "pnpm install",
1211
"test:assert": "pnpm test"
1312
},
1413
"dependencies": {
15-
"@elysiajs/opentelemetry": "^1.4.0",
14+
"@elysiajs/opentelemetry": "^1.4.10",
1615
"@sentry/elysia": "latest || *",
17-
"elysia": "^1.4.0",
16+
"elysia": "latest",
1817
"@elysiajs/node": "^1.4.5"
1918
},
2019
"devDependencies": {
2120
"@playwright/test": "~1.56.0",
22-
"@sentry-internal/test-utils": "link:../../../test-utils",
23-
"bun-types": "^1.2.9"
21+
"@sentry-internal/test-utils": "link:../../../test-utils"
2422
},
2523
"volta": {
2624
"extends": "../../package.json"

dev-packages/e2e-tests/test-applications/elysia-node/src/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ app.listen(3030, () => {
126126
});
127127

128128
// Second app for external propagation tests
129-
const app2 = new Elysia();
129+
const app2 = new Elysia({ adapter: node() });
130130

131131
app2.get('/external-allowed', ({ request }) => {
132132
const headers = Object.fromEntries(request.headers.entries());

packages/elysia/src/withElysia.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { opentelemetry } from '@elysiajs/opentelemetry';
22
import {
33
captureException,
4+
getActiveSpan,
45
getClient,
56
getIsolationScope,
7+
getRootSpan,
68
getTraceData,
9+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
10+
updateSpanName,
711
winterCGRequestToRequestData,
812
} from '@sentry/core';
913
import type { Elysia, ErrorContext } from 'elysia';
@@ -13,9 +17,30 @@ interface ElysiaHandlerOptions {
1317
shouldHandleError?: (context: ErrorContext) => boolean;
1418
}
1519

20+
function isBun(): boolean {
21+
return typeof Bun !== 'undefined';
22+
}
23+
1624
let isClientHooksSetup = false;
1725
const instrumentedApps = new WeakSet<Elysia>();
1826

27+
/**
28+
* Updates the root span and isolation scope with the parameterized route name.
29+
* Only needed on Node.js where the root span comes from HTTP instrumentation.
30+
*/
31+
function updateRouteTransactionName(method: string, route: string): void {
32+
const transactionName = `${method} ${route}`;
33+
34+
const activeSpan = getActiveSpan();
35+
if (activeSpan) {
36+
const rootSpan = getRootSpan(activeSpan);
37+
updateSpanName(rootSpan, transactionName);
38+
rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
39+
}
40+
41+
getIsolationScope().setTransactionName(transactionName);
42+
}
43+
1944
function defaultShouldHandleError(context: ErrorContext): boolean {
2045
const status = context.set.status;
2146
if (status === undefined) {
@@ -75,8 +100,15 @@ export function withElysia<T extends Elysia>(app: T, options: ElysiaHandlerOptio
75100
});
76101
});
77102

78-
// Propagate trace data to all response headers
103+
// Propagate trace data to all response headers and update transaction name
79104
app.onAfterHandle({ as: 'global' }, context => {
105+
// On Node.js, the root span is created by the HTTP instrumentation and only has the raw URL.
106+
// The Elysia OTel plugin creates a child span with route info, but we need to propagate it up.
107+
// On Bun, the Elysia OTel plugin already handles the root span correctly.
108+
if (!isBun() && context.route) {
109+
updateRouteTransactionName(context.request.method, context.route);
110+
}
111+
80112
const traceData = getTraceData();
81113
if (traceData['sentry-trace']) {
82114
context.set.headers['sentry-trace'] = traceData['sentry-trace'];
@@ -89,7 +121,7 @@ export function withElysia<T extends Elysia>(app: T, options: ElysiaHandlerOptio
89121
// Register the error handler for all routes
90122
app.onError({ as: 'global' }, context => {
91123
if (context.route) {
92-
getIsolationScope().setTransactionName(`${context.request.method} ${context.route}`);
124+
updateRouteTransactionName(context.request.method, context.route);
93125
}
94126

95127
const shouldHandleError = options?.shouldHandleError || defaultShouldHandleError;

0 commit comments

Comments
 (0)