Skip to content

Commit 5b67fc9

Browse files
authored
fix(tracing): Add sentry-trace header to outgoing http(s) requests in node (#3053)
This brings `@sentry/node` in line with `@sentry/browser`, in that any outgoing request recorded as a span will now correctly include the `sentry-trace` header. Main Changes: - Split `integrations/http` utils off into their own file (things were getting unwieldy). `urlToOptions` and `normalizeRequestArgs` are the only actual additions there. - Handle all allowable inputs to `http(s).request()` and `http(s).get` (we were missing the option of passing a `URL` object) by normalizing those inputs to one of two options, down from ten. - Fix the types in various places where we had them backwards (requests typed as responses and vice-versa). - Move our node types to version 10 as a) until two days ago, that version included `method` in the `http.ClientRequest` type whereas version 11 didn't for some reason, and b) even now that that's moot, if we supposedly support node versions back to 6, we shouldn't be using types which didn't exist back then. `v10` is the earliest node version supported by `@types/node`, and is still being actively maintained, so it seemed like a good compromise. - (The whole point) Add `sentry-trace` header to outgoing requests.
1 parent eaad783 commit 5b67fc9

File tree

8 files changed

+322
-121
lines changed

8 files changed

+322
-121
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"@types/chai": "^4.1.3",
4646
"@types/jest": "^24.0.11",
4747
"@types/mocha": "^5.2.0",
48-
"@types/node": "^11.13.7",
48+
"@types/node": "~10.17.0",
4949
"@types/sinon": "^7.0.11",
5050
"chai": "^4.1.2",
5151
"codecov": "^3.6.5",

packages/node/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@
3131
"@types/cookie": "0.3.2",
3232
"@types/express": "^4.17.2",
3333
"@types/lru-cache": "^5.1.0",
34-
"@types/node": "^11.13.7",
34+
"@types/node": "~10.17.0",
3535
"eslint": "7.6.0",
3636
"express": "^4.17.1",
3737
"jest": "^24.7.1",
38+
"nock": "^13.0.5",
3839
"npm-run-all": "^4.1.2",
3940
"prettier": "1.19.0",
4041
"rimraf": "^2.6.3",
Lines changed: 52 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import { getCurrentHub } from '@sentry/core';
22
import { Integration, Span, Transaction } from '@sentry/types';
3-
import { fill, parseSemver } from '@sentry/utils';
3+
import { fill, logger, parseSemver } from '@sentry/utils';
44
import * as http from 'http';
55
import * as https from 'https';
66

7+
import {
8+
cleanSpanDescription,
9+
extractUrl,
10+
isSentryRequest,
11+
normalizeRequestArgs,
12+
RequestMethod,
13+
RequestMethodArgs,
14+
} from './utils/http';
15+
716
const NODE_VERSION = parseSemver(process.versions.node);
817

918
/** http module integration */
@@ -45,7 +54,7 @@ export class Http implements Integration {
4554
return;
4655
}
4756

48-
const wrappedHandlerMaker = _createWrappedHandlerMaker(this._breadcrumbs, this._tracing);
57+
const wrappedHandlerMaker = _createWrappedRequestMethodFactory(this._breadcrumbs, this._tracing);
4958

5059
const httpModule = require('http');
5160
fill(httpModule, 'get', wrappedHandlerMaker);
@@ -62,9 +71,10 @@ export class Http implements Integration {
6271
}
6372
}
6473

65-
type OriginalHandler = () => http.ClientRequest;
66-
type WrappedHandler = (options: string | http.ClientRequestArgs) => http.ClientRequest;
67-
type WrappedHandlerMaker = (originalHandler: OriginalHandler) => WrappedHandler;
74+
// for ease of reading below
75+
type OriginalRequestMethod = RequestMethod;
76+
type WrappedRequestMethod = RequestMethod;
77+
type WrappedRequestMethodFactory = (original: OriginalRequestMethod) => WrappedRequestMethod;
6878

6979
/**
7080
* Function which creates a function which creates wrapped versions of internal `request` and `get` calls within `http`
@@ -75,17 +85,22 @@ type WrappedHandlerMaker = (originalHandler: OriginalHandler) => WrappedHandler;
7585
*
7686
* @returns A function which accepts the exiting handler and returns a wrapped handler
7787
*/
78-
function _createWrappedHandlerMaker(breadcrumbsEnabled: boolean, tracingEnabled: boolean): WrappedHandlerMaker {
79-
return function wrappedHandlerMaker(originalHandler: OriginalHandler): WrappedHandler {
80-
return function wrappedHandler(
81-
this: typeof http | typeof https,
82-
options: string | http.ClientRequestArgs,
83-
): http.ClientRequest {
84-
const requestUrl = extractUrl(options);
85-
86-
// we don't want to record requests to Sentry as either breadcrumbs or spans, so just use the original handler
88+
function _createWrappedRequestMethodFactory(
89+
breadcrumbsEnabled: boolean,
90+
tracingEnabled: boolean,
91+
): WrappedRequestMethodFactory {
92+
return function wrappedRequestMethodFactory(originalRequestMethod: OriginalRequestMethod): WrappedRequestMethod {
93+
return function wrappedMethod(this: typeof http | typeof https, ...args: RequestMethodArgs): http.ClientRequest {
94+
// eslint-disable-next-line @typescript-eslint/no-this-alias
95+
const httpModule = this;
96+
97+
const requestArgs = normalizeRequestArgs(args);
98+
const requestOptions = requestArgs[0];
99+
const requestUrl = extractUrl(requestOptions);
100+
101+
// we don't want to record requests to Sentry as either breadcrumbs or spans, so just use the original method
87102
if (isSentryRequest(requestUrl)) {
88-
return originalHandler.apply(this, arguments);
103+
return originalRequestMethod.apply(httpModule, requestArgs);
89104
}
90105

91106
let span: Span | undefined;
@@ -96,32 +111,43 @@ function _createWrappedHandlerMaker(breadcrumbsEnabled: boolean, tracingEnabled:
96111
transaction = scope.getTransaction();
97112
if (transaction) {
98113
span = transaction.startChild({
99-
description: `${typeof options === 'string' || !options.method ? 'GET' : options.method} ${requestUrl}`,
114+
description: `${requestOptions.method || 'GET'} ${requestUrl}`,
100115
op: 'request',
101116
});
117+
118+
const sentryTraceHeader = span.toTraceparent();
119+
logger.log(`[Tracing] Adding sentry-trace header to outgoing request: ${sentryTraceHeader}`);
120+
requestOptions.headers = { ...requestOptions.headers, 'sentry-trace': sentryTraceHeader };
102121
}
103122
}
104123

105124
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
106-
return originalHandler
107-
.apply(this, arguments)
108-
.once('response', function(this: http.IncomingMessage, res: http.ServerResponse): void {
125+
return originalRequestMethod
126+
.apply(httpModule, requestArgs)
127+
.once('response', function(this: http.ClientRequest, res: http.IncomingMessage): void {
128+
// eslint-disable-next-line @typescript-eslint/no-this-alias
129+
const req = this;
109130
if (breadcrumbsEnabled) {
110-
addRequestBreadcrumb('response', requestUrl, this, res);
131+
addRequestBreadcrumb('response', requestUrl, req, res);
111132
}
112133
if (tracingEnabled && span) {
113-
span.setHttpStatus(res.statusCode);
114-
cleanDescription(options, this, span);
134+
if (res.statusCode) {
135+
span.setHttpStatus(res.statusCode);
136+
}
137+
span.description = cleanSpanDescription(span.description, requestOptions, req);
115138
span.finish();
116139
}
117140
})
118-
.once('error', function(this: http.IncomingMessage): void {
141+
.once('error', function(this: http.ClientRequest): void {
142+
// eslint-disable-next-line @typescript-eslint/no-this-alias
143+
const req = this;
144+
119145
if (breadcrumbsEnabled) {
120-
addRequestBreadcrumb('error', requestUrl, this);
146+
addRequestBreadcrumb('error', requestUrl, req);
121147
}
122148
if (tracingEnabled && span) {
123149
span.setHttpStatus(500);
124-
cleanDescription(options, this, span);
150+
span.description = cleanSpanDescription(span.description, requestOptions, req);
125151
span.finish();
126152
}
127153
});
@@ -132,7 +158,7 @@ function _createWrappedHandlerMaker(breadcrumbsEnabled: boolean, tracingEnabled:
132158
/**
133159
* Captures Breadcrumb based on provided request/response pair
134160
*/
135-
function addRequestBreadcrumb(event: string, url: string, req: http.IncomingMessage, res?: http.ServerResponse): void {
161+
function addRequestBreadcrumb(event: string, url: string, req: http.ClientRequest, res?: http.IncomingMessage): void {
136162
if (!getCurrentHub().getIntegration(Http)) {
137163
return;
138164
}
@@ -154,72 +180,3 @@ function addRequestBreadcrumb(event: string, url: string, req: http.IncomingMess
154180
},
155181
);
156182
}
157-
158-
/**
159-
* Assemble a URL to be used for breadcrumbs and spans.
160-
*
161-
* @param url URL string or object containing the component parts
162-
* @returns Fully-formed URL
163-
*/
164-
export function extractUrl(url: string | http.ClientRequestArgs): string {
165-
if (typeof url === 'string') {
166-
return url;
167-
}
168-
const protocol = url.protocol || '';
169-
const hostname = url.hostname || url.host || '';
170-
// Don't log standard :80 (http) and :443 (https) ports to reduce the noise
171-
const port = !url.port || url.port === 80 || url.port === 443 ? '' : `:${url.port}`;
172-
const path = url.path ? url.path : '/';
173-
174-
// internal routes end up with too many slashes
175-
return `${protocol}//${hostname}${port}${path}`.replace('///', '/');
176-
}
177-
178-
/**
179-
* Handle an edge case with urls in the span description. Runs just before the span closes because it relies on
180-
* data from the response object.
181-
*
182-
* @param requestOptions Configuration data for the request
183-
* @param response Response object
184-
* @param span Span representing the request
185-
*/
186-
function cleanDescription(
187-
requestOptions: string | http.ClientRequestArgs,
188-
response: http.IncomingMessage,
189-
span: Span,
190-
): void {
191-
// There are some libraries which don't pass the request protocol in the options object, so attempt to retrieve it
192-
// from the response and run the URL processing again. We only do this in the presence of a (non-empty) host value,
193-
// 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
194-
// jamming a random `http:` on the front of it.
195-
if (typeof requestOptions !== 'string' && !Object.keys(requestOptions).includes('protocol') && requestOptions.host) {
196-
// Neither http.IncomingMessage nor any of its ancestors have an `agent` property in their type definitions, and
197-
// http.Agent doesn't have a `protocol` property in its type definition. Nonetheless, at least one request library
198-
// (superagent) arranges things that way, so might as well give it a shot.
199-
try {
200-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
201-
requestOptions.protocol = (response as any).agent.protocol;
202-
span.description = `${requestOptions.method || 'GET'} ${extractUrl(requestOptions)}`;
203-
} catch (error) {
204-
// well, we tried
205-
}
206-
}
207-
}
208-
209-
/**
210-
* Checks whether given url points to Sentry server
211-
* @param url url to verify
212-
*/
213-
function isSentryRequest(url: string): boolean {
214-
const client = getCurrentHub().getClient();
215-
if (!url || !client) {
216-
return false;
217-
}
218-
219-
const dsn = client.getDsn();
220-
if (!dsn) {
221-
return false;
222-
}
223-
224-
return url.indexOf(dsn.host) !== -1;
225-
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { getCurrentHub } from '@sentry/core';
2+
import * as http from 'http';
3+
import { URL } from 'url';
4+
5+
/**
6+
* Checks whether given url points to Sentry server
7+
* @param url url to verify
8+
*/
9+
export function isSentryRequest(url: string): boolean {
10+
const dsn = getCurrentHub()
11+
.getClient()
12+
?.getDsn();
13+
return dsn ? url.includes(dsn.host) : false;
14+
}
15+
16+
/**
17+
* Assemble a URL to be used for breadcrumbs and spans.
18+
*
19+
* @param requestOptions RequestOptions object containing the component parts for a URL
20+
* @returns Fully-formed URL
21+
*/
22+
export function extractUrl(requestOptions: RequestOptions): string {
23+
const protocol = requestOptions.protocol || '';
24+
const hostname = requestOptions.hostname || requestOptions.host || '';
25+
// Don't log standard :80 (http) and :443 (https) ports to reduce the noise
26+
const port =
27+
!requestOptions.port || requestOptions.port === 80 || requestOptions.port === 443 ? '' : `:${requestOptions.port}`;
28+
const path = requestOptions.path ? requestOptions.path : '/';
29+
30+
return `${protocol}//${hostname}${port}${path}`;
31+
}
32+
33+
/**
34+
* Handle various edge cases in the span description (for spans representing http(s) requests).
35+
*
36+
* @param description current `description` property of the span representing the request
37+
* @param requestOptions Configuration data for the request
38+
* @param Request Request object
39+
*
40+
* @returns The cleaned description
41+
*/
42+
export function cleanSpanDescription(
43+
description: string | undefined,
44+
requestOptions: RequestOptions,
45+
request: http.ClientRequest,
46+
): string | undefined {
47+
// nothing to clean
48+
if (!description) {
49+
return description;
50+
}
51+
52+
// eslint-disable-next-line prefer-const
53+
let [method, requestUrl] = description.split(' ');
54+
55+
// superagent sticks the protocol in a weird place (we check for host because if both host *and* protocol are missing,
56+
// we're likely dealing with an internal route and this doesn't apply)
57+
if (requestOptions.host && !requestOptions.protocol) {
58+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
59+
requestOptions.protocol = (request as any)?.agent?.protocol; // worst comes to worst, this is undefined and nothing changes
60+
requestUrl = extractUrl(requestOptions);
61+
}
62+
63+
// internal routes can end up starting with a triple slash rather than a single one
64+
if (requestUrl?.startsWith('///')) {
65+
requestUrl = requestUrl.slice(2);
66+
}
67+
68+
return `${method} ${requestUrl}`;
69+
}
70+
71+
// the node types are missing a few properties which node's `urlToOptions` function spits out
72+
export type RequestOptions = http.RequestOptions & { hash?: string; search?: string; pathname?: string; href?: string };
73+
type RequestCallback = (response: http.IncomingMessage) => void;
74+
export type RequestMethodArgs =
75+
| [RequestOptions | string | URL, RequestCallback?]
76+
| [string | URL, RequestOptions, RequestCallback?];
77+
export type RequestMethod = (...args: RequestMethodArgs) => http.ClientRequest;
78+
79+
/**
80+
* Convert a URL object into a RequestOptions object.
81+
*
82+
* Copied from Node's internals (where it's used in http(s).request() and http(s).get()), modified only to use the
83+
* RequestOptions type above.
84+
*
85+
* See https://github.com/nodejs/node/blob/master/lib/internal/url.js.
86+
*/
87+
export function urlToOptions(url: URL): RequestOptions {
88+
const options: RequestOptions = {
89+
protocol: url.protocol,
90+
hostname:
91+
typeof url.hostname === 'string' && url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname,
92+
hash: url.hash,
93+
search: url.search,
94+
pathname: url.pathname,
95+
path: `${url.pathname || ''}${url.search || ''}`,
96+
href: url.href,
97+
};
98+
if (url.port !== '') {
99+
options.port = Number(url.port);
100+
}
101+
if (url.username || url.password) {
102+
options.auth = `${url.username}:${url.password}`;
103+
}
104+
return options;
105+
}
106+
107+
/**
108+
* Normalize inputs to `http(s).request()` and `http(s).get()`.
109+
*
110+
* Legal inputs to `http(s).request()` and `http(s).get()` can take one of ten forms:
111+
* [ RequestOptions | string | URL ],
112+
* [ RequestOptions | string | URL, RequestCallback ],
113+
* [ string | URL, RequestOptions ], and
114+
* [ string | URL, RequestOptions, RequestCallback ].
115+
*
116+
* This standardizes to one of two forms: [ RequestOptions ] and [ RequestOptions, RequestCallback ]. A similar thing is
117+
* done as the first step of `http(s).request()` and `http(s).get()`; this just does it early so that we can interact
118+
* with the args in a standard way.
119+
*
120+
* @param requestArgs The inputs to `http(s).request()` or `http(s).get()`, as an array.
121+
*
122+
* @returns Equivalent args of the form [ RequestOptions ] or [ RequestOptions, RequestCallback ].
123+
*/
124+
export function normalizeRequestArgs(
125+
requestArgs: RequestMethodArgs,
126+
): [RequestOptions] | [RequestOptions, RequestCallback] {
127+
let callback, requestOptions;
128+
129+
// pop off the callback, if there is one
130+
if (typeof requestArgs[requestArgs.length - 1] === 'function') {
131+
callback = requestArgs.pop() as RequestCallback;
132+
}
133+
134+
// create a RequestOptions object of whatever's at index 0
135+
if (typeof requestArgs[0] === 'string') {
136+
requestOptions = urlToOptions(new URL(requestArgs[0]));
137+
} else if (requestArgs[0] instanceof URL) {
138+
requestOptions = urlToOptions(requestArgs[0]);
139+
} else {
140+
requestOptions = requestArgs[0];
141+
}
142+
143+
// if the options were given separately from the URL, fold them in
144+
if (requestArgs.length === 2) {
145+
requestOptions = { ...requestOptions, ...requestArgs[1] };
146+
}
147+
148+
// return args in standardized form
149+
if (callback) {
150+
return [requestOptions, callback];
151+
} else {
152+
return [requestOptions];
153+
}
154+
}

0 commit comments

Comments
 (0)