-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Expand file tree
/
Copy pathhttpServerSpansIntegration.ts
More file actions
409 lines (354 loc) · 14.9 KB
/
httpServerSpansIntegration.ts
File metadata and controls
409 lines (354 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
import { errorMonitor } from 'node:events';
import type { ClientRequest, IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http';
import { context, SpanKind, trace } from '@opentelemetry/api';
import type { RPCMetadata } from '@opentelemetry/core';
import { getRPCMetadata, isTracingSuppressed, RPCType, setRPCMetadata } from '@opentelemetry/core';
import {
ATTR_HTTP_RESPONSE_STATUS_CODE,
ATTR_HTTP_ROUTE,
SEMATTRS_HTTP_STATUS_CODE,
SEMATTRS_NET_HOST_IP,
SEMATTRS_NET_HOST_PORT,
SEMATTRS_NET_PEER_IP,
} from '@opentelemetry/semantic-conventions';
import type { Event, Integration, IntegrationFn, Span, SpanAttributes, SpanStatus } from '@sentry/core';
import {
debug,
getIsolationScope,
getSpanStatusFromHttpCode,
httpHeadersToSpanAttributes,
parseStringToURLObject,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SPAN_STATUS_ERROR,
stripUrlQueryAndFragment,
} from '@sentry/core';
import { DEBUG_BUILD } from '../../debug-build';
import type { NodeClient } from '../../sdk/client';
import { addStartSpanCallback } from './httpServerIntegration';
const INTEGRATION_NAME = 'Http.ServerSpans';
// Tree-shakable guard to remove all code related to tracing
declare const __SENTRY_TRACING__: boolean;
export interface HttpServerSpansIntegrationOptions {
/**
* Do not capture spans for incoming HTTP requests to URLs where the given callback returns `true`.
* Spans will be non recording if tracing is disabled.
*
* The `urlPath` param consists of the URL path and query string (if any) of the incoming request.
* For example: `'/users/details?id=123'`
*
* The `request` param contains the original {@type IncomingMessage} object of the incoming request.
* You can use it to filter on additional properties like method, headers, etc.
*/
ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean;
/**
* Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc.
* This helps reduce noise in your transactions.
*
* @default `true`
*/
ignoreStaticAssets?: boolean;
/**
* Do not capture spans for incoming HTTP requests with the given status codes.
* By default, spans with some 3xx and 4xx status codes are ignored (see @default).
* Expects an array of status codes or a range of status codes, e.g. [[300,399], 404] would ignore 3xx and 404 status codes.
*
* @default `[[401, 404], [301, 303], [305, 399]]`
*/
ignoreStatusCodes?: (number | [number, number])[];
/**
* @deprecated This is deprecated in favor of `incomingRequestSpanHook`.
*/
instrumentation?: {
requestHook?: (span: Span, req: ClientRequest | IncomingMessage) => void;
responseHook?: (span: Span, response: IncomingMessage | ServerResponse) => void;
applyCustomAttributesOnSpan?: (
span: Span,
request: ClientRequest | IncomingMessage,
response: IncomingMessage | ServerResponse,
) => void;
};
/**
* A hook that can be used to mutate the span for incoming requests.
* This is triggered after the span is created, but before it is recorded.
*/
onSpanCreated?: (span: Span, request: IncomingMessage, response: ServerResponse) => void;
}
const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions = {}) => {
const ignoreStaticAssets = options.ignoreStaticAssets ?? true;
const ignoreIncomingRequests = options.ignoreIncomingRequests;
const ignoreStatusCodes = options.ignoreStatusCodes ?? [
[401, 404],
// 300 and 304 are possibly valid status codes we do not want to filter
[301, 303],
[305, 399],
];
const { onSpanCreated } = options;
// eslint-disable-next-line deprecation/deprecation
const { requestHook, responseHook, applyCustomAttributesOnSpan } = options.instrumentation ?? {};
return {
name: INTEGRATION_NAME,
setup(client: NodeClient) {
// If no tracing, we can just skip everything here
if (typeof __SENTRY_TRACING__ !== 'undefined' && !__SENTRY_TRACING__) {
return;
}
client.on('httpServerRequest', (_request, _response, normalizedRequest) => {
// Type-casting this here because we do not want to put the node types into core
const request = _request as IncomingMessage;
const response = _response as ServerResponse;
const startSpan = (next: () => boolean): boolean => {
if (
shouldIgnoreSpansForIncomingRequest(request, {
ignoreStaticAssets,
ignoreIncomingRequests,
})
) {
DEBUG_BUILD && debug.log(INTEGRATION_NAME, 'Skipping span creation for incoming request', request.url);
return next();
}
const fullUrl = normalizedRequest.url || request.url || '/';
const urlObj = parseStringToURLObject(fullUrl);
const headers = request.headers;
const userAgent = headers['user-agent'];
const ips = headers['x-forwarded-for'];
const httpVersion = request.httpVersion;
const host = headers.host;
const hostname = host?.replace(/^(.*)(:[0-9]{1,5})/, '$1') || 'localhost';
const tracer = client.tracer;
const scheme = fullUrl.startsWith('https') ? 'https' : 'http';
const method = normalizedRequest.method || request.method?.toUpperCase() || 'GET';
const httpTargetWithoutQueryFragment = urlObj ? urlObj.pathname : stripUrlQueryAndFragment(fullUrl);
const bestEffortTransactionName = `${method} ${httpTargetWithoutQueryFragment}`;
// We use the plain tracer.startSpan here so we can pass the span kind
const span = tracer.startSpan(bestEffortTransactionName, {
kind: SpanKind.SERVER,
attributes: {
// Sentry specific attributes
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.http',
'sentry.http.prefetch': isKnownPrefetchRequest(request) || undefined,
// Old Semantic Conventions attributes - added for compatibility with what `@opentelemetry/instrumentation-http` output before
'http.url': fullUrl,
'http.method': normalizedRequest.method,
'http.target': urlObj ? `${urlObj.pathname}${urlObj.search}` : httpTargetWithoutQueryFragment,
'http.host': host,
'net.host.name': hostname,
'http.client_ip': typeof ips === 'string' ? ips.split(',')[0] : undefined,
'http.user_agent': userAgent,
'http.scheme': scheme,
'http.flavor': httpVersion,
'net.transport': httpVersion?.toUpperCase() === 'QUIC' ? 'ip_udp' : 'ip_tcp',
...getRequestContentLengthAttribute(request),
...httpHeadersToSpanAttributes(
normalizedRequest.headers || {},
client.getOptions().sendDefaultPii ?? false,
),
},
});
// TODO v11: Remove the following three hooks, only onSpanCreated should remain
requestHook?.(span, request);
responseHook?.(span, response);
applyCustomAttributesOnSpan?.(span, request, response);
onSpanCreated?.(span, request, response);
const rpcMetadata: RPCMetadata = {
type: RPCType.HTTP,
span,
};
return context.with(setRPCMetadata(trace.setSpan(context.active(), span), rpcMetadata), () => {
context.bind(context.active(), request);
context.bind(context.active(), response);
// Ensure we only end the span once
// E.g. error can be emitted before close is emitted
let isEnded = false;
function endSpan(status: SpanStatus): void {
if (isEnded) {
return;
}
isEnded = true;
const newAttributes = getIncomingRequestAttributesOnResponse(request, response);
span.setAttributes(newAttributes);
span.setStatus(status);
span.end();
// Update the transaction name if the route has changed
const route = newAttributes['http.route'];
if (route) {
getIsolationScope().setTransactionName(`${request.method?.toUpperCase() || 'GET'} ${route}`);
}
}
response.on('close', () => {
endSpan(getSpanStatusFromHttpCode(response.statusCode));
});
response.on(errorMonitor, () => {
const httpStatus = getSpanStatusFromHttpCode(response.statusCode);
// Ensure we def. have an error status here
endSpan(httpStatus.code === SPAN_STATUS_ERROR ? httpStatus : { code: SPAN_STATUS_ERROR });
});
return next();
});
};
addStartSpanCallback(request, startSpan);
});
},
processEvent(event) {
// Drop transaction if it has a status code that should be ignored
if (event.type === 'transaction') {
const statusCode = event.contexts?.trace?.data?.['http.response.status_code'];
if (typeof statusCode === 'number') {
const shouldDrop = shouldFilterStatusCode(statusCode, ignoreStatusCodes);
if (shouldDrop) {
DEBUG_BUILD && debug.log('Dropping transaction due to status code', statusCode);
return null;
}
}
}
return event;
},
afterAllSetup(client) {
if (!DEBUG_BUILD) {
return;
}
if (client.getIntegrationByName('Http')) {
debug.warn(
'It seems that you have manually added `httpServerSpansIntergation` while `httpIntegration` is also present. Make sure to remove `httpIntegration` when adding `httpServerSpansIntegration`.',
);
}
if (!client.getIntegrationByName('Http.Server')) {
debug.error(
'It seems that you have manually added `httpServerSpansIntergation` without adding `httpServerIntegration`. This is a requiement for spans to be created - please add the `httpServerIntegration` integration.',
);
}
},
};
}) satisfies IntegrationFn;
/**
* This integration emits spans for incoming requests handled via the node `http` module.
* It requires the `httpServerIntegration` to be present.
*/
export const httpServerSpansIntegration = _httpServerSpansIntegration as (
options?: HttpServerSpansIntegrationOptions,
) => Integration & {
name: 'HttpServerSpans';
setup: (client: NodeClient) => void;
processEvent: (event: Event) => Event | null;
};
function isKnownPrefetchRequest(req: IncomingMessage): boolean {
// Currently only handles Next.js prefetch requests but may check other frameworks in the future.
return req.headers['next-router-prefetch'] === '1';
}
/**
* Check if a request is for a common static asset that should be ignored by default.
*
* Only exported for tests.
*/
export function isStaticAssetRequest(urlPath: string): boolean {
const path = stripUrlQueryAndFragment(urlPath);
// Common static file extensions
if (path.match(/\.(ico|png|jpg|jpeg|gif|svg|css|js|woff|woff2|ttf|eot|webp|avif)$/)) {
return true;
}
// Common metadata files
if (path.match(/^\/(robots\.txt|sitemap\.xml|manifest\.json|browserconfig\.xml)$/)) {
return true;
}
return false;
}
function shouldIgnoreSpansForIncomingRequest(
request: IncomingMessage,
{
ignoreStaticAssets,
ignoreIncomingRequests,
}: {
ignoreStaticAssets?: boolean;
ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean;
},
): boolean {
if (isTracingSuppressed(context.active())) {
return true;
}
// request.url is the only property that holds any information about the url
// it only consists of the URL path and query string (if any)
const urlPath = request.url;
const method = request.method?.toUpperCase();
// We do not capture OPTIONS/HEAD requests as spans
if (method === 'OPTIONS' || method === 'HEAD' || !urlPath) {
return true;
}
// Default static asset filtering
if (ignoreStaticAssets && method === 'GET' && isStaticAssetRequest(urlPath)) {
return true;
}
if (ignoreIncomingRequests?.(urlPath, request)) {
return true;
}
return false;
}
function getRequestContentLengthAttribute(request: IncomingMessage): SpanAttributes {
const length = getContentLength(request.headers);
if (length == null) {
return {};
}
if (isCompressed(request.headers)) {
return {
['http.request_content_length']: length,
};
} else {
return {
['http.request_content_length_uncompressed']: length,
};
}
}
function getContentLength(headers: IncomingHttpHeaders): number | null {
const contentLengthHeader = headers['content-length'];
if (contentLengthHeader === undefined) return null;
const contentLength = parseInt(contentLengthHeader, 10);
if (isNaN(contentLength)) return null;
return contentLength;
}
function isCompressed(headers: IncomingHttpHeaders): boolean {
const encoding = headers['content-encoding'];
return !!encoding && encoding !== 'identity';
}
function getIncomingRequestAttributesOnResponse(request: IncomingMessage, response: ServerResponse): SpanAttributes {
// take socket from the request,
// since it may be detached from the response object in keep-alive mode
const { socket } = request;
const { statusCode, statusMessage } = response;
const newAttributes: SpanAttributes = {
[ATTR_HTTP_RESPONSE_STATUS_CODE]: statusCode,
// eslint-disable-next-line deprecation/deprecation
[SEMATTRS_HTTP_STATUS_CODE]: statusCode,
'http.status_text': statusMessage?.toUpperCase(),
};
const rpcMetadata = getRPCMetadata(context.active());
if (socket) {
const { localAddress, localPort, remoteAddress, remotePort } = socket;
// eslint-disable-next-line deprecation/deprecation
newAttributes[SEMATTRS_NET_HOST_IP] = localAddress;
// eslint-disable-next-line deprecation/deprecation
newAttributes[SEMATTRS_NET_HOST_PORT] = localPort;
// eslint-disable-next-line deprecation/deprecation
newAttributes[SEMATTRS_NET_PEER_IP] = remoteAddress;
newAttributes['net.peer.port'] = remotePort;
}
// eslint-disable-next-line deprecation/deprecation
newAttributes[SEMATTRS_HTTP_STATUS_CODE] = statusCode;
newAttributes['http.status_text'] = (statusMessage || '').toUpperCase();
if (rpcMetadata?.type === RPCType.HTTP && rpcMetadata.route !== undefined) {
const routeName = rpcMetadata.route;
newAttributes[ATTR_HTTP_ROUTE] = routeName;
}
return newAttributes;
}
/**
* If the given status code should be filtered for the given list of status codes/ranges.
*/
function shouldFilterStatusCode(statusCode: number, dropForStatusCodes: (number | [number, number])[]): boolean {
return dropForStatusCodes.some(code => {
if (typeof code === 'number') {
return code === statusCode;
}
const [min, max] = code;
return statusCode >= min && statusCode <= max;
});
}