Skip to content

Commit 83f7cce

Browse files
authored
feat(node): Update scope transactionName in http and express instrumentations (#11434)
Concrete changes: 1. `addRequestDataToEvent` no longer applies its transaction name to _non-transaction_ events 2. Http instrumentation sets the isolation scope's `transactionName` in the Otel `requestHook` to the unparameterized request URL path (w/o query params or fragments). 3. Express instrumentation sets the isolation scope's `transactionName` in the Otel `spanName` hook to a parameterized route, once a request handler span (e.g. `app.get(...)`) is created. 4. As a consequence of the above, non-transaction events now get assigned `event.transaction` correctly from the scopes.
1 parent 9c8f0d3 commit 83f7cce

File tree

6 files changed

+139
-50
lines changed

6 files changed

+139
-50
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const { loggingTransport } = require('@sentry-internal/node-integration-tests');
2+
const Sentry = require('@sentry/node');
3+
4+
Sentry.init({
5+
dsn: 'https://[email protected]/1337',
6+
release: '1.0',
7+
// disable attaching headers to /test/* endpoints
8+
tracePropagationTargets: [/^(?!.*test).*$/],
9+
tracesSampleRate: 1.0,
10+
transport: loggingTransport,
11+
});
12+
13+
// express must be required after Sentry is initialized
14+
const express = require('express');
15+
const cors = require('cors');
16+
const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests');
17+
18+
const app = express();
19+
20+
app.use(cors());
21+
22+
app.get('/test/:id1/:id2', (_req, res) => {
23+
Sentry.captureMessage(new Error('error_1'));
24+
res.send('Success');
25+
});
26+
27+
Sentry.setupExpressErrorHandler(app);
28+
29+
startExpressServerAndSendPortToRunner(app);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
2+
3+
describe('express tracing experimental', () => {
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
describe('CJS', () => {
9+
test('should apply the scope transactionName to error events', done => {
10+
createRunner(__dirname, 'server.js')
11+
.ignore('session', 'sessions', 'transaction')
12+
.expect({
13+
event: {
14+
exception: {
15+
values: [
16+
{
17+
value: 'error_1',
18+
},
19+
],
20+
},
21+
transaction: 'GET /test/:id1/:id2',
22+
},
23+
})
24+
.start(done)
25+
.makeRequest('get', '/test/123/abc?q=1');
26+
});
27+
});
28+
});

packages/node/src/integrations/http.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import { SpanKind } from '@opentelemetry/api';
44
import { registerInstrumentations } from '@opentelemetry/instrumentation';
55
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
66

7-
import { addBreadcrumb, defineIntegration, getIsolationScope, isSentryRequestUrl } from '@sentry/core';
7+
import { addBreadcrumb, defineIntegration, getIsolationScope, isSentryRequestUrl, spanToJSON } from '@sentry/core';
88
import { _INTERNAL, getClient, getSpanKind } from '@sentry/opentelemetry';
99
import type { IntegrationFn } from '@sentry/types';
1010

11+
import { stripUrlQueryAndFragment } from '@sentry/utils';
1112
import type { NodeClient } from '../sdk/client';
1213
import { setIsolationScope } from '../sdk/scope';
1314
import type { HTTPModuleRequestIncomingMessage } from '../transports/http-module';
@@ -81,19 +82,35 @@ const _httpIntegration = ((options: HttpOptions = {}) => {
8182
requireParentforOutgoingSpans: true,
8283
requireParentforIncomingSpans: false,
8384
requestHook: (span, req) => {
84-
_updateSpan(span);
85+
addOriginToSpan(span, 'auto.http.otel.http');
86+
87+
if (getSpanKind(span) !== SpanKind.SERVER) {
88+
return;
89+
}
8590

8691
// Update the isolation scope, isolate this request
87-
if (getSpanKind(span) === SpanKind.SERVER) {
88-
const isolationScope = getIsolationScope().clone();
89-
isolationScope.setSDKProcessingMetadata({ request: req });
90-
91-
const client = getClient<NodeClient>();
92-
if (client && client.getOptions().autoSessionTracking) {
93-
isolationScope.setRequestSession({ status: 'ok' });
94-
}
95-
setIsolationScope(isolationScope);
92+
const isolationScope = getIsolationScope().clone();
93+
isolationScope.setSDKProcessingMetadata({ request: req });
94+
95+
const client = getClient<NodeClient>();
96+
if (client && client.getOptions().autoSessionTracking) {
97+
isolationScope.setRequestSession({ status: 'ok' });
9698
}
99+
setIsolationScope(isolationScope);
100+
101+
// attempt to update the scope's `transactionName` based on the request URL
102+
// Ideally, framework instrumentations coming after the HttpInstrumentation
103+
// update the transactionName once we get a parameterized route.
104+
const attributes = spanToJSON(span).data;
105+
if (!attributes) {
106+
return;
107+
}
108+
109+
const httpMethod = String(attributes['http.method']).toUpperCase() || 'GET';
110+
const httpTarget = stripUrlQueryAndFragment(String(attributes['http.target'])) || '/';
111+
const bestEffortTransactionName = `${httpMethod} ${httpTarget}`;
112+
113+
isolationScope.setTransactionName(bestEffortTransactionName);
97114
},
98115
responseHook: (span, res) => {
99116
if (_breadcrumbs) {
@@ -123,11 +140,6 @@ const _httpIntegration = ((options: HttpOptions = {}) => {
123140
*/
124141
export const httpIntegration = defineIntegration(_httpIntegration);
125142

126-
/** Update the span with data we need. */
127-
function _updateSpan(span: Span): void {
128-
addOriginToSpan(span, 'auto.http.otel.http');
129-
}
130-
131143
/** Add a breadcrumb for outgoing requests. */
132144
function _addRequestBreadcrumb(span: Span, response: HTTPModuleRequestIncomingMessage | ServerResponse): void {
133145
if (getSpanKind(span) !== SpanKind.CLIENT) {

packages/node/src/integrations/tracing/express.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ const _expressIntegration = (() => {
1818
requestHook(span) {
1919
addOriginToSpan(span, 'auto.http.otel.express');
2020
},
21+
spanNameHook(info, defaultName) {
22+
if (info.layerType === 'request_handler') {
23+
// type cast b/c Otel unfortunately types info.request as any :(
24+
const req = info.request as { method?: string };
25+
const method = req.method ? req.method.toUpperCase() : 'GET';
26+
getIsolationScope().setTransactionName(`${method} ${info.route}`);
27+
}
28+
return defaultName;
29+
},
2130
}),
2231
],
2332
});

packages/utils/src/requestdata.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ export function addRequestDataToEvent(
298298
}
299299
}
300300

301-
if (include.transaction && !event.transaction) {
301+
if (include.transaction && !event.transaction && event.type === 'transaction') {
302302
// TODO do we even need this anymore?
303303
// TODO make this work for nextjs
304304
event.transaction = extractTransaction(req, include.transaction);

packages/utils/test/requestdata.test.ts

Lines changed: 44 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -149,52 +149,63 @@ describe('addRequestDataToEvent', () => {
149149
});
150150

151151
describe('transaction property', () => {
152-
test('extracts method and full route path by default`', () => {
153-
const parsedRequest: Event = addRequestDataToEvent(mockEvent, mockReq);
152+
describe('for transaction events', () => {
153+
beforeEach(() => {
154+
mockEvent.type = 'transaction';
155+
});
154156

155-
expect(parsedRequest.transaction).toEqual('POST /routerMountPath/subpath/:parameterName');
156-
});
157+
test('extracts method and full route path by default`', () => {
158+
const parsedRequest: Event = addRequestDataToEvent(mockEvent, mockReq);
157159

158-
test('extracts method and full path by default when mountpoint is `/`', () => {
159-
mockReq.originalUrl = mockReq.originalUrl.replace('/routerMountpath', '');
160-
mockReq.baseUrl = '';
160+
expect(parsedRequest.transaction).toEqual('POST /routerMountPath/subpath/:parameterName');
161+
});
161162

162-
const parsedRequest: Event = addRequestDataToEvent(mockEvent, mockReq);
163+
test('extracts method and full path by default when mountpoint is `/`', () => {
164+
mockReq.originalUrl = mockReq.originalUrl.replace('/routerMountpath', '');
165+
mockReq.baseUrl = '';
163166

164-
// `subpath/` is the full path here, because there's no router mount path
165-
expect(parsedRequest.transaction).toEqual('POST /subpath/:parameterName');
166-
});
167+
const parsedRequest: Event = addRequestDataToEvent(mockEvent, mockReq);
167168

168-
test('fallback to method and `originalUrl` if route is missing', () => {
169-
delete mockReq.route;
169+
// `subpath/` is the full path here, because there's no router mount path
170+
expect(parsedRequest.transaction).toEqual('POST /subpath/:parameterName');
171+
});
170172

171-
const parsedRequest: Event = addRequestDataToEvent(mockEvent, mockReq);
173+
test('fallback to method and `originalUrl` if route is missing', () => {
174+
delete mockReq.route;
172175

173-
expect(parsedRequest.transaction).toEqual('POST /routerMountPath/subpath/specificValue');
174-
});
176+
const parsedRequest: Event = addRequestDataToEvent(mockEvent, mockReq);
175177

176-
test('can extract path only instead if configured', () => {
177-
const optionsWithPathTransaction = {
178-
include: {
179-
transaction: 'path',
180-
},
181-
} as const;
178+
expect(parsedRequest.transaction).toEqual('POST /routerMountPath/subpath/specificValue');
179+
});
182180

183-
const parsedRequest: Event = addRequestDataToEvent(mockEvent, mockReq, optionsWithPathTransaction);
181+
test('can extract path only instead if configured', () => {
182+
const optionsWithPathTransaction = {
183+
include: {
184+
transaction: 'path',
185+
},
186+
} as const;
184187

185-
expect(parsedRequest.transaction).toEqual('/routerMountPath/subpath/:parameterName');
186-
});
188+
const parsedRequest: Event = addRequestDataToEvent(mockEvent, mockReq, optionsWithPathTransaction);
187189

188-
test('can extract handler name instead if configured', () => {
189-
const optionsWithHandlerTransaction = {
190-
include: {
191-
transaction: 'handler',
192-
},
193-
} as const;
190+
expect(parsedRequest.transaction).toEqual('/routerMountPath/subpath/:parameterName');
191+
});
194192

195-
const parsedRequest: Event = addRequestDataToEvent(mockEvent, mockReq, optionsWithHandlerTransaction);
193+
test('can extract handler name instead if configured', () => {
194+
const optionsWithHandlerTransaction = {
195+
include: {
196+
transaction: 'handler',
197+
},
198+
} as const;
199+
200+
const parsedRequest: Event = addRequestDataToEvent(mockEvent, mockReq, optionsWithHandlerTransaction);
201+
202+
expect(parsedRequest.transaction).toEqual('parameterNameRouteHandler');
203+
});
204+
});
205+
it('transaction is not applied to non-transaction events', () => {
206+
const parsedRequest: Event = addRequestDataToEvent(mockEvent, mockReq);
196207

197-
expect(parsedRequest.transaction).toEqual('parameterNameRouteHandler');
208+
expect(parsedRequest.transaction).toBeUndefined();
198209
});
199210
});
200211
});

0 commit comments

Comments
 (0)