Skip to content

Commit 93e3131

Browse files
committed
feat(sveltekit): Add instrumentation for client-side fetch
1 parent 5cab9e1 commit 93e3131

File tree

4 files changed

+280
-85
lines changed

4 files changed

+280
-85
lines changed

packages/sveltekit/src/client/load.ts

Lines changed: 194 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1-
import { trace } from '@sentry/core';
1+
import type { BaseClient } from '@sentry/core';
2+
import { getCurrentHub, trace } from '@sentry/core';
3+
import type { Breadcrumbs, BrowserTracing } from '@sentry/svelte';
24
import { captureException } from '@sentry/svelte';
3-
import { addExceptionMechanism, objectify } from '@sentry/utils';
5+
import type { ClientOptions } from '@sentry/types';
6+
import {
7+
addExceptionMechanism,
8+
addTracingHeadersToFetchRequest,
9+
objectify,
10+
parseFetchArgs,
11+
stringMatchesSomePattern,
12+
stripUrlQueryAndFragment,
13+
} from '@sentry/utils';
414
import type { LoadEvent } from '@sveltejs/kit';
515

616
function sendErrorToSentry(e: unknown): unknown {
@@ -27,7 +37,17 @@ function sendErrorToSentry(e: unknown): unknown {
2737
}
2838

2939
/**
30-
* @inheritdoc
40+
* Wrap load function with Sentry. This wrapper will
41+
*
42+
* - catch errors happening during the execution of `load`
43+
* - create a load span if performance monitoring is enabled
44+
* - attach tracing Http headers to `fech` requests if performance monitoring is enabled to get connected traces.
45+
* - add a fetch breadcrumb for every `fetch` request
46+
*
47+
* Note that tracing Http headers are only attached if the url matches the specified `tracePropagationTargets`
48+
* entries to avoid CORS errors.
49+
*
50+
* @param origLoad SvelteKit user defined load function
3151
*/
3252
// The liberal generic typing of `T` is necessary because we cannot let T extend `Load`.
3353
// This function needs to tell TS that it returns exactly the type that it was called with
@@ -40,6 +60,11 @@ export function wrapLoadWithSentry<T extends (...args: any) => any>(origLoad: T)
4060
// Type casting here because `T` cannot extend `Load` (see comment above function signature)
4161
const event = args[0] as LoadEvent;
4262

63+
const patchedEvent = {
64+
...event,
65+
fetch: instrumentSvelteKitFetch(event.fetch),
66+
};
67+
4368
const routeId = event.route.id;
4469
return trace(
4570
{
@@ -50,9 +75,174 @@ export function wrapLoadWithSentry<T extends (...args: any) => any>(origLoad: T)
5075
source: routeId ? 'route' : 'url',
5176
},
5277
},
53-
() => wrappingTarget.apply(thisArg, args),
78+
() => wrappingTarget.apply(thisArg, [patchedEvent]),
5479
sendErrorToSentry,
5580
);
5681
},
5782
});
5883
}
84+
85+
type SvelteKitFetch = LoadEvent['fetch'];
86+
87+
/**
88+
* Instruments SvelteKit's client `fetch` implementation which is passed to the client-side universal `load` functions.
89+
*
90+
* We need to instrument this in addition to the native fetch we instrument in BrowserTracing because SvelteKit
91+
* stores the native fetch implementation before our SDK is initialized.
92+
*
93+
* see: https://github.com/sveltejs/kit/blob/master/packages/kit/src/runtime/client/fetcher.js
94+
*
95+
* This instrumentation takes the fetch-related options from `BrowserTracing` to determine if we should
96+
* instrument fetch for perfomance monitoring, create a span for or attach our tracing headers to the given request.
97+
*
98+
* To dertermine if breadcrumbs should be recorded, this instrumentation relies on the availability of and the options
99+
* set in the `BreadCrumbs` integration.
100+
*
101+
* @param originalFetch SvelteKit's original fetch implemenetation
102+
*
103+
* @returns a proxy of SvelteKit's fetch implementation
104+
*/
105+
function instrumentSvelteKitFetch(originalFetch: SvelteKitFetch): SvelteKitFetch {
106+
const client = getCurrentHub().getClient() as BaseClient<ClientOptions>;
107+
108+
const browserTracingIntegration =
109+
client.getIntegrationById && (client.getIntegrationById('BrowserTracing') as BrowserTracing | undefined);
110+
const breadcrumbsIntegration = client.getIntegrationById('BreadCrumbs') as Breadcrumbs | undefined;
111+
112+
const browserTracingOptions = browserTracingIntegration && browserTracingIntegration.options;
113+
114+
const shouldTraceFetch = browserTracingOptions && browserTracingOptions.traceFetch;
115+
const shouldAddFetchBreadcrumbs = breadcrumbsIntegration && breadcrumbsIntegration.options.fetch;
116+
117+
/* Identical check as in BrowserTracing, just that we also need to verify that BrowserTracing is actually installed */
118+
const shouldCreateSpan =
119+
browserTracingOptions && typeof browserTracingOptions.shouldCreateSpanForRequest === 'function'
120+
? browserTracingOptions.shouldCreateSpanForRequest
121+
: (_: string) => shouldTraceFetch;
122+
123+
/* Identical check as in BrowserTracing, just that we also need to verify that BrowserTracing is actually installed */
124+
const shouldAttachHeaders: (url: string) => boolean = url => {
125+
return (
126+
!!shouldTraceFetch &&
127+
stringMatchesSomePattern(url, browserTracingOptions.tracePropagationTargets || ['localhost', /^\//])
128+
);
129+
};
130+
131+
return new Proxy(originalFetch, {
132+
apply: (wrappingTarget, thisArg, args: Parameters<LoadEvent['fetch']>) => {
133+
const [input, init] = args;
134+
const { url: rawUrl, method } = parseFetchArgs(args);
135+
const sanitizedUrl = stripUrlQueryAndFragment(rawUrl);
136+
137+
// TODO: extract this to a util function (and use it in breadcrumbs integration as well)
138+
if (rawUrl.match(/sentry_key/) && method === 'POST') {
139+
// We will not create breadcrumbs for fetch requests that contain `sentry_key` (internal sentry requests)
140+
return wrappingTarget.apply(thisArg, args);
141+
}
142+
143+
const patchedInit: RequestInit = { ...init } || {};
144+
const activeSpan = getCurrentHub().getScope().getSpan();
145+
const activeTransaction = activeSpan && activeSpan.transaction;
146+
147+
const attachHeaders = shouldAttachHeaders(rawUrl);
148+
const attachSpan = shouldCreateSpan(rawUrl);
149+
150+
if (attachHeaders && attachSpan && activeTransaction) {
151+
const dsc = activeTransaction.getDynamicSamplingContext();
152+
const headers = addTracingHeadersToFetchRequest(
153+
input as string | Request,
154+
dsc,
155+
activeSpan,
156+
patchedInit as {
157+
headers:
158+
| {
159+
[key: string]: string[] | string | undefined;
160+
}
161+
| Request['headers'];
162+
},
163+
) as HeadersInit;
164+
patchedInit.headers = headers;
165+
}
166+
167+
let fetchPromise: Promise<Response>;
168+
169+
if (attachSpan) {
170+
fetchPromise = trace(
171+
{
172+
name: `${method} ${sanitizedUrl}`, // this will become the description of the span
173+
op: 'http.client',
174+
data: {
175+
/* TODO: extract query data (we might actually only do this once we tackle sanitization on the browser-side) */
176+
},
177+
parentSpanId: activeSpan && activeSpan.spanId,
178+
},
179+
async span => {
180+
const fetchResult: Response = await wrappingTarget.apply(thisArg, [input, patchedInit]);
181+
if (span) {
182+
span.setHttpStatus(fetchResult.status);
183+
}
184+
return fetchResult;
185+
},
186+
);
187+
} else {
188+
fetchPromise = wrappingTarget.apply(thisArg, [input, patchedInit]);
189+
}
190+
191+
if (shouldAddFetchBreadcrumbs) {
192+
addFetchBreadcrumbs(fetchPromise, method, sanitizedUrl, args);
193+
}
194+
195+
return fetchPromise;
196+
},
197+
});
198+
}
199+
200+
/* Adds breadcrumbs for the given fetch result */
201+
function addFetchBreadcrumbs(
202+
fetchResult: Promise<Response>,
203+
method: string,
204+
sanitizedUrl: string,
205+
args: Parameters<SvelteKitFetch>,
206+
): void {
207+
const breadcrumbStartTimestamp = Date.now();
208+
fetchResult.then(
209+
response => {
210+
getCurrentHub().addBreadcrumb(
211+
{
212+
type: 'http',
213+
category: 'fetch',
214+
data: {
215+
method: method,
216+
url: sanitizedUrl,
217+
status_code: response.status,
218+
},
219+
},
220+
{
221+
input: args,
222+
response,
223+
startTimestamp: breadcrumbStartTimestamp,
224+
endTimestamp: Date.now(),
225+
},
226+
);
227+
},
228+
error => {
229+
getCurrentHub().addBreadcrumb(
230+
{
231+
type: 'http',
232+
category: 'fetch',
233+
level: 'error',
234+
data: {
235+
method: method,
236+
url: sanitizedUrl,
237+
},
238+
},
239+
{
240+
input: args,
241+
data: error,
242+
startTimestamp: breadcrumbStartTimestamp,
243+
endTimestamp: Date.now(),
244+
},
245+
);
246+
},
247+
);
248+
}

packages/tracing-internal/src/browser/request.ts

Lines changed: 2 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
/* eslint-disable max-lines */
22
import { getCurrentHub, hasTracingEnabled } from '@sentry/core';
3-
import type { DynamicSamplingContext, Span } from '@sentry/types';
3+
import type { Span } from '@sentry/types';
44
import {
55
addInstrumentationHandler,
6+
addTracingHeadersToFetchRequest,
67
BAGGAGE_HEADER_NAME,
78
dynamicSamplingContextToSentryBaggageHeader,
8-
isInstanceOf,
99
stringMatchesSomePattern,
1010
} from '@sentry/utils';
1111

@@ -90,17 +90,6 @@ export interface XHRData {
9090
endTimestamp?: number;
9191
}
9292

93-
type PolymorphicRequestHeaders =
94-
| Record<string, string | undefined>
95-
| Array<[string, string]>
96-
// the below is not preicsely the Header type used in Request, but it'll pass duck-typing
97-
| {
98-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
99-
[key: string]: any;
100-
append: (key: string, value: string) => void;
101-
get: (key: string) => string;
102-
};
103-
10493
export const defaultRequestInstrumentationOptions: RequestInstrumentationOptions = {
10594
traceFetch: true,
10695
traceXHR: true,
@@ -221,70 +210,6 @@ export function fetchCallback(
221210
}
222211
}
223212

224-
function addTracingHeadersToFetchRequest(
225-
request: string | Request,
226-
dynamicSamplingContext: Partial<DynamicSamplingContext>,
227-
span: Span,
228-
options: {
229-
headers?:
230-
| {
231-
[key: string]: string[] | string | undefined;
232-
}
233-
| Request['headers'];
234-
},
235-
): PolymorphicRequestHeaders {
236-
const sentryBaggageHeader = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext);
237-
const sentryTraceHeader = span.toTraceparent();
238-
239-
const headers =
240-
typeof Request !== 'undefined' && isInstanceOf(request, Request) ? (request as Request).headers : options.headers;
241-
242-
if (!headers) {
243-
return { 'sentry-trace': sentryTraceHeader, baggage: sentryBaggageHeader };
244-
} else if (typeof Headers !== 'undefined' && isInstanceOf(headers, Headers)) {
245-
const newHeaders = new Headers(headers as Headers);
246-
247-
newHeaders.append('sentry-trace', sentryTraceHeader);
248-
249-
if (sentryBaggageHeader) {
250-
// If the same header is appended miultiple times the browser will merge the values into a single request header.
251-
// Its therefore safe to simply push a "baggage" entry, even though there might already be another baggage header.
252-
newHeaders.append(BAGGAGE_HEADER_NAME, sentryBaggageHeader);
253-
}
254-
255-
return newHeaders as PolymorphicRequestHeaders;
256-
} else if (Array.isArray(headers)) {
257-
const newHeaders = [...headers, ['sentry-trace', sentryTraceHeader]];
258-
259-
if (sentryBaggageHeader) {
260-
// If there are multiple entries with the same key, the browser will merge the values into a single request header.
261-
// Its therefore safe to simply push a "baggage" entry, even though there might already be another baggage header.
262-
newHeaders.push([BAGGAGE_HEADER_NAME, sentryBaggageHeader]);
263-
}
264-
265-
return newHeaders;
266-
} else {
267-
const existingBaggageHeader = 'baggage' in headers ? headers.baggage : undefined;
268-
const newBaggageHeaders: string[] = [];
269-
270-
if (Array.isArray(existingBaggageHeader)) {
271-
newBaggageHeaders.push(...existingBaggageHeader);
272-
} else if (existingBaggageHeader) {
273-
newBaggageHeaders.push(existingBaggageHeader);
274-
}
275-
276-
if (sentryBaggageHeader) {
277-
newBaggageHeaders.push(sentryBaggageHeader);
278-
}
279-
280-
return {
281-
...(headers as Exclude<typeof headers, Headers>),
282-
'sentry-trace': sentryTraceHeader,
283-
baggage: newBaggageHeaders.length > 0 ? newBaggageHeaders.join(',') : undefined,
284-
};
285-
}
286-
}
287-
288213
/**
289214
* Create and track xhr request spans
290215
*/

packages/utils/src/instrument.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,8 @@ function getUrlFromResource(resource: FetchResource): string {
210210
}
211211

212212
/**
213-
* Exported only for tests.
214-
* @hidden
215-
* */
213+
* Parses the fetch arguments to find the used Http method and the url of the request
214+
*/
216215
export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: string } {
217216
if (fetchArgs.length === 0) {
218217
return { method: 'GET', url: '' };

0 commit comments

Comments
 (0)