Skip to content

Commit d84f341

Browse files
author
Luca Forstner
committed
ref: Hoist performance fetch instrumentation into core
1 parent 86c8d2a commit d84f341

File tree

3 files changed

+208
-2
lines changed

3 files changed

+208
-2
lines changed

packages/core/src/fetch.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import type { Client, HandlerDataFetch, Scope, Span, SpanOrigin } from '@sentry/types';
2+
import {
3+
BAGGAGE_HEADER_NAME,
4+
dynamicSamplingContextToSentryBaggageHeader,
5+
generateSentryTraceHeader,
6+
isInstanceOf,
7+
} from '@sentry/utils';
8+
9+
import { getCurrentHub } from './hub';
10+
import { getDynamicSamplingContextFromClient } from './tracing';
11+
import { hasTracingEnabled } from './utils/hasTracingEnabled';
12+
13+
type PolymorphicRequestHeaders =
14+
| Record<string, string | undefined>
15+
| Array<[string, string]>
16+
// the below is not preicsely the Header type used in Request, but it'll pass duck-typing
17+
| {
18+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
19+
[key: string]: any;
20+
append: (key: string, value: string) => void;
21+
get: (key: string) => string | null | undefined;
22+
};
23+
24+
/**
25+
* Create and track fetch request spans for usage in combination with `addInstrumentationHandler`.
26+
*
27+
* @returns Span if a span was created, otherwise void.
28+
*/
29+
export function instrumentFetchRequest(
30+
handlerData: HandlerDataFetch,
31+
shouldCreateSpan: (url: string) => boolean,
32+
shouldAttachHeaders: (url: string) => boolean,
33+
spans: Record<string, Span>,
34+
spanOrigin: SpanOrigin = 'auto.http.browser',
35+
): Span | undefined {
36+
if (!hasTracingEnabled() || !handlerData.fetchData) {
37+
return undefined;
38+
}
39+
40+
const shouldCreateSpanResult = shouldCreateSpan(handlerData.fetchData.url);
41+
42+
if (handlerData.endTimestamp && shouldCreateSpanResult) {
43+
const spanId = handlerData.fetchData.__span;
44+
if (!spanId) return;
45+
46+
const span = spans[spanId];
47+
if (span) {
48+
if (handlerData.response) {
49+
span.setHttpStatus(handlerData.response.status);
50+
51+
const contentLength =
52+
handlerData.response && handlerData.response.headers && handlerData.response.headers.get('content-length');
53+
54+
if (contentLength) {
55+
const contentLengthNum = parseInt(contentLength);
56+
if (contentLengthNum > 0) {
57+
span.setData('http.response_content_length', contentLengthNum);
58+
}
59+
}
60+
} else if (handlerData.error) {
61+
span.setStatus('internal_error');
62+
}
63+
span.finish();
64+
65+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
66+
delete spans[spanId];
67+
}
68+
return undefined;
69+
}
70+
71+
const hub = getCurrentHub();
72+
const scope = hub.getScope();
73+
const client = hub.getClient();
74+
const parentSpan = scope.getSpan();
75+
76+
const { method, url } = handlerData.fetchData;
77+
78+
const span =
79+
shouldCreateSpanResult && parentSpan
80+
? parentSpan.startChild({
81+
data: {
82+
url,
83+
type: 'fetch',
84+
'http.method': method,
85+
},
86+
description: `${method} ${url}`,
87+
op: 'http.client',
88+
origin: spanOrigin,
89+
})
90+
: undefined;
91+
92+
if (span) {
93+
handlerData.fetchData.__span = span.spanId;
94+
spans[span.spanId] = span;
95+
}
96+
97+
if (shouldAttachHeaders(handlerData.fetchData.url) && client) {
98+
const request: string | Request = handlerData.args[0];
99+
100+
// In case the user hasn't set the second argument of a fetch call we default it to `{}`.
101+
handlerData.args[1] = handlerData.args[1] || {};
102+
103+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
104+
const options: { [key: string]: any } = handlerData.args[1];
105+
106+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
107+
options.headers = addTracingHeadersToFetchRequest(request, client, scope, options, span);
108+
}
109+
110+
return span;
111+
}
112+
113+
/**
114+
* Adds sentry-trace and baggage headers to the various forms of fetch headers
115+
*/
116+
export function addTracingHeadersToFetchRequest(
117+
request: string | unknown, // unknown is actually type Request but we can't export DOM types from this package,
118+
client: Client,
119+
scope: Scope,
120+
options: {
121+
headers?:
122+
| {
123+
[key: string]: string[] | string | undefined;
124+
}
125+
| PolymorphicRequestHeaders;
126+
},
127+
requestSpan?: Span,
128+
): PolymorphicRequestHeaders | undefined {
129+
const span = requestSpan || scope.getSpan();
130+
131+
const transaction = span && span.transaction;
132+
133+
const { traceId, sampled, dsc } = scope.getPropagationContext();
134+
135+
const sentryTraceHeader = span ? span.toTraceparent() : generateSentryTraceHeader(traceId, undefined, sampled);
136+
const dynamicSamplingContext = transaction
137+
? transaction.getDynamicSamplingContext()
138+
: dsc
139+
? dsc
140+
: getDynamicSamplingContextFromClient(traceId, client, scope);
141+
142+
const sentryBaggageHeader = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext);
143+
144+
const headers =
145+
typeof Request !== 'undefined' && isInstanceOf(request, Request) ? (request as Request).headers : options.headers;
146+
147+
if (!headers) {
148+
return { 'sentry-trace': sentryTraceHeader, baggage: sentryBaggageHeader };
149+
} else if (typeof Headers !== 'undefined' && isInstanceOf(headers, Headers)) {
150+
const newHeaders = new Headers(headers as Headers);
151+
152+
newHeaders.append('sentry-trace', sentryTraceHeader);
153+
154+
if (sentryBaggageHeader) {
155+
// If the same header is appended multiple times the browser will merge the values into a single request header.
156+
// Its therefore safe to simply push a "baggage" entry, even though there might already be another baggage header.
157+
newHeaders.append(BAGGAGE_HEADER_NAME, sentryBaggageHeader);
158+
}
159+
160+
return newHeaders as PolymorphicRequestHeaders;
161+
} else if (Array.isArray(headers)) {
162+
const newHeaders = [...headers, ['sentry-trace', sentryTraceHeader]];
163+
164+
if (sentryBaggageHeader) {
165+
// If there are multiple entries with the same key, the browser will merge the values into a single request header.
166+
// Its therefore safe to simply push a "baggage" entry, even though there might already be another baggage header.
167+
newHeaders.push([BAGGAGE_HEADER_NAME, sentryBaggageHeader]);
168+
}
169+
170+
return newHeaders as PolymorphicRequestHeaders;
171+
} else {
172+
const existingBaggageHeader = 'baggage' in headers ? headers.baggage : undefined;
173+
const newBaggageHeaders: string[] = [];
174+
175+
if (Array.isArray(existingBaggageHeader)) {
176+
newBaggageHeaders.push(...existingBaggageHeader);
177+
} else if (existingBaggageHeader) {
178+
newBaggageHeaders.push(existingBaggageHeader);
179+
}
180+
181+
if (sentryBaggageHeader) {
182+
newBaggageHeaders.push(sentryBaggageHeader);
183+
}
184+
185+
return {
186+
...(headers as Exclude<typeof headers, Headers>),
187+
'sentry-trace': sentryTraceHeader,
188+
baggage: newBaggageHeaders.length > 0 ? newBaggageHeaders.join(',') : undefined,
189+
};
190+
}
191+
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export { hasTracingEnabled } from './utils/hasTracingEnabled';
5555
export { isSentryRequestUrl } from './utils/isSentryRequestUrl';
5656
export { DEFAULT_ENVIRONMENT } from './constants';
5757
export { ModuleMetadata } from './integrations/metadata';
58+
export { instrumentFetchRequest } from './fetch';
5859
import * as Integrations from './integrations';
5960

6061
export { Integrations };

packages/types/src/instrument.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,27 @@ interface SentryFetchData {
3131
url: string;
3232
request_body_size?: number;
3333
response_body_size?: number;
34+
// span_id for the fetch request
35+
__span?: string;
3436
}
3537

3638
export interface HandlerDataFetch {
3739
args: any[];
3840
fetchData: SentryFetchData;
3941
startTimestamp: number;
4042
endTimestamp?: number;
41-
// This is actually `Response`, make sure to cast this where needed (not available in Node)
42-
response?: unknown;
43+
// This is actually `Response` - Note: this type is not complete. Add to it if necessary.
44+
response?: {
45+
readonly ok: boolean;
46+
readonly status: number;
47+
readonly url: string;
48+
headers: {
49+
append(name: string, value: string): void;
50+
delete(name: string): void;
51+
get(name: string): string | null;
52+
has(name: string): boolean;
53+
set(name: string, value: string): void;
54+
};
55+
};
56+
error?: unknown;
4357
}

0 commit comments

Comments
 (0)