Skip to content

Commit 4d8abee

Browse files
ref(node): Remove query strings from transaction and span names (#2857)
* add stripQueryString function and use it in extractUrl * add tests for extractUrl * strip query string from handlers urls in span names * add method to handle url edge cases * clarify description and variable names * strip fragments also * move internal route cleaning to extractUrl * simplify url trimming function * add tests for tracing handler * move stripUrlPath to utils * change name of url-stripping method * fix missing method when cleaning description * escape ? in regex Co-authored-by: Kamil Ogórek <[email protected]>
1 parent def5490 commit 4d8abee

File tree

5 files changed

+253
-17
lines changed

5 files changed

+253
-17
lines changed

packages/node/src/handlers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { captureException, getCurrentHub, startTransaction, withScope } from '@sentry/core';
44
import { Span } from '@sentry/tracing';
55
import { Event } from '@sentry/types';
6-
import { forget, isPlainObject, isString, logger, normalize } from '@sentry/utils';
6+
import { forget, isPlainObject, isString, logger, normalize, stripUrlQueryAndFragment } from '@sentry/utils';
77
import * as cookie from 'cookie';
88
import * as domain from 'domain';
99
import * as http from 'http';
@@ -32,7 +32,7 @@ export function tracingHandler(): (
3232
// TODO: At this point `req.route.path` (which we use in `extractTransaction`) is not available
3333
// but `req.path` or `req.url` should do the job as well. We could unify this here.
3434
const reqMethod = (req.method || '').toUpperCase();
35-
const reqUrl = req.url;
35+
const reqUrl = req.url && stripUrlQueryAndFragment(req.url);
3636

3737
let traceId;
3838
let parentSpanId;

packages/node/src/integrations/http.ts

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getCurrentHub } from '@sentry/core';
22
import { Integration, Span, Transaction } from '@sentry/types';
3-
import { fill, parseSemver } from '@sentry/utils';
3+
import { fill, parseSemver, stripUrlQueryAndFragment } from '@sentry/utils';
44
import * as http from 'http';
55
import * as https from 'https';
66

@@ -102,6 +102,7 @@ function createHandlerWrapper(
102102
}
103103
if (tracingEnabled && span) {
104104
span.setHttpStatus(res.statusCode);
105+
cleanDescription(options, this, span);
105106
span.finish();
106107
}
107108
})
@@ -111,6 +112,7 @@ function createHandlerWrapper(
111112
}
112113
if (tracingEnabled && span) {
113114
span.setHttpStatus(500);
115+
cleanDescription(options, this, span);
114116
span.finish();
115117
}
116118
});
@@ -145,21 +147,54 @@ function addRequestBreadcrumb(event: string, url: string, req: http.IncomingMess
145147
}
146148

147149
/**
148-
* Function that can combine together a url that'll be used for our breadcrumbs.
150+
* Assemble a URL to be used for breadcrumbs and spans.
149151
*
150-
* @param options url that should be returned or an object containing it's parts.
151-
* @returns constructed url
152+
* @param requestArgs URL string or object containing the component parts
153+
* @returns Fully-formed URL
152154
*/
153-
function extractUrl(options: string | http.ClientRequestArgs): string {
154-
if (typeof options === 'string') {
155-
return options;
155+
export function extractUrl(requestArgs: string | http.ClientRequestArgs): string {
156+
if (typeof requestArgs === 'string') {
157+
return stripUrlQueryAndFragment(requestArgs);
156158
}
157-
const protocol = options.protocol || '';
158-
const hostname = options.hostname || options.host || '';
159+
const protocol = requestArgs.protocol || '';
160+
const hostname = requestArgs.hostname || requestArgs.host || '';
159161
// Don't log standard :80 (http) and :443 (https) ports to reduce the noise
160-
const port = !options.port || options.port === 80 || options.port === 443 ? '' : `:${options.port}`;
161-
const path = options.path || '/';
162-
return `${protocol}//${hostname}${port}${path}`;
162+
const port = !requestArgs.port || requestArgs.port === 80 || requestArgs.port === 443 ? '' : `:${requestArgs.port}`;
163+
const path = requestArgs.path ? stripUrlQueryAndFragment(requestArgs.path) : '/';
164+
165+
// internal routes end up with too many slashes
166+
return `${protocol}//${hostname}${port}${path}`.replace('///', '/');
167+
}
168+
169+
/**
170+
* Handle an edge case with urls in the span description. Runs just before the span closes because it relies on
171+
* data from the response object.
172+
*
173+
* @param requestOptions Configuration data for the request
174+
* @param response Response object
175+
* @param span Span representing the request
176+
*/
177+
function cleanDescription(
178+
requestOptions: string | http.ClientRequestArgs,
179+
response: http.IncomingMessage,
180+
span: Span,
181+
): void {
182+
// There are some libraries which don't pass the request protocol in the options object, so attempt to retrieve it
183+
// from the response and run the URL processing again. We only do this in the presence of a (non-empty) host value,
184+
// because if we're missing both, it's likely we're dealing with an internal route, in which case we don't want to be
185+
// jamming a random `http:` on the front of it.
186+
if (typeof requestOptions !== 'string' && !Object.keys(requestOptions).includes('protocol') && requestOptions.host) {
187+
// Neither http.IncomingMessage nor any of its ancestors have an `agent` property in their type definitions, and
188+
// http.Agent doesn't have a `protocol` property in its type definition. Nonetheless, at least one request library
189+
// (superagent) arranges things that way, so might as well give it a shot.
190+
try {
191+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
192+
requestOptions.protocol = (response as any).agent.protocol;
193+
span.description = `${requestOptions.method || 'GET'} ${extractUrl(requestOptions)}`;
194+
} catch (error) {
195+
// well, we tried
196+
}
197+
}
163198
}
164199

165200
/**

packages/node/test/handlers.test.ts

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
import { Runtime } from '@sentry/types';
1+
import * as sentryCore from '@sentry/core';
2+
import { Hub } from '@sentry/hub';
3+
import * as sentryHub from '@sentry/hub';
4+
import { SpanStatus, Transaction } from '@sentry/tracing';
5+
import { Runtime, Transaction as TransactionType } from '@sentry/types';
6+
import * as http from 'http';
7+
import * as net from 'net';
28

39
import { Event, Request, User } from '../src';
4-
import { parseRequest } from '../src/handlers';
10+
import { NodeClient } from '../src/client';
11+
import { parseRequest, tracingHandler } from '../src/handlers';
512

613
describe('parseRequest', () => {
714
let mockReq: { [key: string]: any };
@@ -164,4 +171,133 @@ describe('parseRequest', () => {
164171
expect(parsedRequest.transaction).toEqual('routeHandler');
165172
});
166173
});
167-
});
174+
}); // end describe('parseRequest()')
175+
176+
describe('tracingHandler', () => {
177+
const urlString = 'http://dogs.are.great:1231/yay/';
178+
const queryString = '?furry=yes&funny=very';
179+
const fragment = '#adoptnotbuy';
180+
181+
const sentryTracingMiddleware = tracingHandler();
182+
183+
let req: http.IncomingMessage, res: http.ServerResponse, next: () => undefined;
184+
185+
function createNoOpSpy() {
186+
const noop = { noop: () => undefined }; // this is wrapped in an object so jest can spy on it
187+
return jest.spyOn(noop, 'noop') as any;
188+
}
189+
190+
beforeEach(() => {
191+
req = new http.IncomingMessage(new net.Socket());
192+
req.url = `${urlString}`;
193+
req.method = 'GET';
194+
res = new http.ServerResponse(req);
195+
next = createNoOpSpy();
196+
});
197+
198+
afterEach(() => {
199+
jest.restoreAllMocks();
200+
});
201+
202+
it('creates a transaction when handling a request', () => {
203+
const startTransaction = jest.spyOn(sentryCore, 'startTransaction');
204+
205+
sentryTracingMiddleware(req, res, next);
206+
207+
expect(startTransaction).toHaveBeenCalled();
208+
});
209+
210+
it("pulls parent's data from tracing header on the request", () => {
211+
req.headers = { 'sentry-trace': '12312012123120121231201212312012-1121201211212012-0' };
212+
213+
sentryTracingMiddleware(req, res, next);
214+
215+
const transaction = (res as any).__sentry_transaction;
216+
217+
expect(transaction.traceId).toEqual('12312012123120121231201212312012');
218+
expect(transaction.parentSpanId).toEqual('1121201211212012');
219+
expect(transaction.sampled).toEqual(false);
220+
});
221+
222+
it('puts its transaction on the scope', () => {
223+
const hub = new Hub(new NodeClient({ tracesSampleRate: 1.0 }));
224+
// we need to mock both of these because the tracing handler relies on `@sentry/core` while the sampler relies on
225+
// `@sentry/hub`, and mocking breaks the link between the two
226+
jest.spyOn(sentryCore, 'getCurrentHub').mockReturnValue(hub);
227+
jest.spyOn(sentryHub, 'getCurrentHub').mockReturnValue(hub);
228+
229+
sentryTracingMiddleware(req, res, next);
230+
231+
const transaction = sentryCore
232+
.getCurrentHub()
233+
.getScope()
234+
?.getTransaction();
235+
236+
expect(transaction).toBeDefined();
237+
expect(transaction).toEqual(expect.objectContaining({ name: `GET ${urlString}`, op: 'http.server' }));
238+
});
239+
240+
it('puts its transaction on the response object', () => {
241+
sentryTracingMiddleware(req, res, next);
242+
243+
const transaction = (res as any).__sentry_transaction;
244+
245+
expect(transaction).toBeDefined();
246+
expect(transaction).toEqual(expect.objectContaining({ name: `GET ${urlString}`, op: 'http.server' }));
247+
});
248+
249+
it('pulls status code from the response', async () => {
250+
const transaction = new Transaction({ name: 'mockTransaction' });
251+
jest.spyOn(sentryCore, 'startTransaction').mockReturnValue(transaction as TransactionType);
252+
const finishTransaction = jest.spyOn(transaction, 'finish');
253+
254+
sentryTracingMiddleware(req, res, next);
255+
res.statusCode = 200;
256+
res.emit('finish');
257+
258+
expect(finishTransaction).toHaveBeenCalled();
259+
expect(transaction.status).toBe(SpanStatus.Ok);
260+
expect(transaction.tags).toEqual(expect.objectContaining({ 'http.status_code': '200' }));
261+
});
262+
263+
it('strips query string from request path', () => {
264+
req.url = `${urlString}${queryString}`;
265+
266+
sentryTracingMiddleware(req, res, next);
267+
268+
const transaction = (res as any).__sentry_transaction;
269+
270+
expect(transaction?.name).toBe(`GET ${urlString}`);
271+
});
272+
273+
it('strips fragment from request path', () => {
274+
req.url = `${urlString}${fragment}`;
275+
276+
sentryTracingMiddleware(req, res, next);
277+
278+
const transaction = (res as any).__sentry_transaction;
279+
280+
expect(transaction?.name).toBe(`GET ${urlString}`);
281+
});
282+
283+
it('strips query string and fragment from request path', () => {
284+
req.url = `${urlString}${queryString}${fragment}`;
285+
286+
sentryTracingMiddleware(req, res, next);
287+
288+
const transaction = (res as any).__sentry_transaction;
289+
290+
expect(transaction?.name).toBe(`GET ${urlString}`);
291+
});
292+
293+
it('closes the transaction when request processing is done', () => {
294+
const transaction = new Transaction({ name: 'mockTransaction' });
295+
jest.spyOn(sentryCore, 'startTransaction').mockReturnValue(transaction as TransactionType);
296+
const finishTransaction = jest.spyOn(transaction, 'finish');
297+
298+
sentryTracingMiddleware(req, res, next);
299+
res.emit('finish');
300+
301+
expect(finishTransaction).toHaveBeenCalled();
302+
});
303+
}); // end describe('tracingHandler')
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { RequestOptions } from 'http';
2+
3+
import { extractUrl } from './../../src/integrations/http';
4+
5+
describe('extractUrl()', () => {
6+
const urlString = 'http://dogs.are.great:1231/yay/';
7+
const urlParts: RequestOptions = {
8+
protocol: 'http:',
9+
host: 'dogs.are.great',
10+
method: 'GET',
11+
path: '/yay/',
12+
port: 1231,
13+
};
14+
const queryString = '?furry=yes&funny=very';
15+
const fragment = '#adoptnotbuy';
16+
17+
it('accepts a url string', () => {
18+
expect(extractUrl(urlString)).toBe(urlString);
19+
});
20+
21+
it('accepts a http.RequestOptions object and returns a string with everything in the right place', () => {
22+
expect(extractUrl(urlParts)).toBe('http://dogs.are.great:1231/yay/');
23+
});
24+
25+
it('strips query string from url string', () => {
26+
const urlWithQueryString = `${urlString}${queryString}`;
27+
expect(extractUrl(urlWithQueryString)).toBe(urlString);
28+
});
29+
30+
it('strips query string from path in http.RequestOptions object', () => {
31+
const urlPartsWithQueryString = { ...urlParts, path: `${urlParts.path}${queryString}` };
32+
expect(extractUrl(urlPartsWithQueryString)).toBe(urlString);
33+
});
34+
35+
it('strips fragment from url string', () => {
36+
const urlWithFragment = `${urlString}${fragment}`;
37+
expect(extractUrl(urlWithFragment)).toBe(urlString);
38+
});
39+
40+
it('strips fragment from path in http.RequestOptions object', () => {
41+
const urlPartsWithFragment = { ...urlParts, path: `${urlParts.path}${fragment}` };
42+
expect(extractUrl(urlPartsWithFragment)).toBe(urlString);
43+
});
44+
45+
it('strips query string and fragment from url string', () => {
46+
const urlWithQueryStringAndFragment = `${urlString}${queryString}${fragment}`;
47+
expect(extractUrl(urlWithQueryStringAndFragment)).toBe(urlString);
48+
});
49+
50+
it('strips query string and fragment from path in http.RequestOptions object', () => {
51+
const urlPartsWithQueryStringAndFragment = { ...urlParts, path: `${urlParts.path}${queryString}${fragment}` };
52+
expect(extractUrl(urlPartsWithQueryStringAndFragment)).toBe(urlString);
53+
});
54+
}); // end describe('extractUrl()')

packages/utils/src/misc.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,3 +510,14 @@ export function addContextToFrame(lines: string[], frame: StackFrame, linesOfCon
510510
.slice(Math.min(sourceLine + 1, maxLines), sourceLine + 1 + linesOfContext)
511511
.map((line: string) => snipLine(line, 0));
512512
}
513+
514+
/**
515+
* Strip the query string and fragment off of a given URL or path (if present)
516+
*
517+
* @param urlPath Full URL or path, including possible query string and/or fragment
518+
* @returns URL or path without query string or fragment
519+
*/
520+
export function stripUrlQueryAndFragment(urlPath: string): string {
521+
// eslint-disable-next-line no-useless-escape
522+
return urlPath.split(/[\?#]/, 1)[0];
523+
}

0 commit comments

Comments
 (0)