Skip to content

Commit 84a1b46

Browse files
committed
feat(sveltekit): Add instrumentation for client-side fetch
1 parent 79ca4a7 commit 84a1b46

File tree

4 files changed

+282
-84
lines changed

4 files changed

+282
-84
lines changed

packages/sveltekit/src/client/load.ts

Lines changed: 196 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
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+
getFetchMethod,
10+
getFetchUrl,
11+
objectify,
12+
stringMatchesSomePattern,
13+
stripUrlQueryAndFragment,
14+
} from '@sentry/utils';
415
import type { LoadEvent } from '@sveltejs/kit';
516

617
function sendErrorToSentry(e: unknown): unknown {
@@ -27,7 +38,17 @@ function sendErrorToSentry(e: unknown): unknown {
2738
}
2839

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

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

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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ function instrumentFetch(): void {
183183

184184
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
185185
/** Extract `method` from fetch call arguments */
186-
function getFetchMethod(fetchArgs: any[] = []): string {
186+
export function getFetchMethod(fetchArgs: any[] = []): string {
187187
if ('Request' in WINDOW && isInstanceOf(fetchArgs[0], Request) && fetchArgs[0].method) {
188188
return String(fetchArgs[0].method).toUpperCase();
189189
}
@@ -194,7 +194,7 @@ function getFetchMethod(fetchArgs: any[] = []): string {
194194
}
195195

196196
/** Extract `url` from fetch call arguments */
197-
function getFetchUrl(fetchArgs: any[] = []): string {
197+
export function getFetchUrl(fetchArgs: any[] = []): string {
198198
if (typeof fetchArgs[0] === 'string') {
199199
return fetchArgs[0];
200200
}

0 commit comments

Comments
 (0)