Skip to content

Commit 105dcf1

Browse files
authored
feat(sveltekit): Add partial instrumentation for client-side fetch (#7626)
Add partial instrumentation to the client-side `fetch` passed to the universal `load` functions. It enables distributed traces of fetch calls happening **inside** a `load` function. Limitation: `fetch` requests made by SvelteKit (e.g. to call server-only load functions) are **not** touched by this instrumentation because we cannot access the Kit-internal fetch function at this time
1 parent 8ccb82d commit 105dcf1

File tree

11 files changed

+631
-63
lines changed

11 files changed

+631
-63
lines changed

packages/node/src/integrations/http.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Hub } from '@sentry/core';
22
import { getCurrentHub } from '@sentry/core';
3-
import type { EventProcessor, Integration, Span, TracePropagationTargets } from '@sentry/types';
3+
import type { EventProcessor, Integration, SanitizedRequestData, Span, TracePropagationTargets } from '@sentry/types';
44
import { dynamicSamplingContextToSentryBaggageHeader, fill, logger, stringMatchesSomePattern } from '@sentry/utils';
55
import type * as http from 'http';
66
import type * as https from 'https';
@@ -122,16 +122,6 @@ type OriginalRequestMethod = RequestMethod;
122122
type WrappedRequestMethod = RequestMethod;
123123
type WrappedRequestMethodFactory = (original: OriginalRequestMethod) => WrappedRequestMethod;
124124

125-
/**
126-
* See https://develop.sentry.dev/sdk/data-handling/#structuring-data
127-
*/
128-
type RequestSpanData = {
129-
url: string;
130-
method: string;
131-
'http.fragment'?: string;
132-
'http.query'?: string;
133-
};
134-
135125
/**
136126
* Function which creates a function which creates wrapped versions of internal `request` and `get` calls within `http`
137127
* and `https` modules. (NB: Not a typo - this is a creator^2!)
@@ -197,7 +187,7 @@ function _createWrappedRequestMethodFactory(
197187

198188
const scope = getCurrentHub().getScope();
199189

200-
const requestSpanData: RequestSpanData = {
190+
const requestSpanData: SanitizedRequestData = {
201191
url: requestUrl,
202192
method: requestOptions.method || 'GET',
203193
};
@@ -304,7 +294,7 @@ function _createWrappedRequestMethodFactory(
304294
*/
305295
function addRequestBreadcrumb(
306296
event: string,
307-
requestSpanData: RequestSpanData,
297+
requestSpanData: SanitizedRequestData,
308298
req: http.ClientRequest,
309299
res?: http.IncomingMessage,
310300
): void {
@@ -316,7 +306,6 @@ function addRequestBreadcrumb(
316306
{
317307
category: 'http',
318308
data: {
319-
method: req.method,
320309
status_code: res && res.statusCode,
321310
...requestSpanData,
322311
},

packages/sveltekit/src/client/load.ts

Lines changed: 200 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
1-
import { trace } from '@sentry/core';
1+
import { addTracingHeadersToFetchRequest } from '@sentry-internal/tracing';
2+
import type { BaseClient } from '@sentry/core';
3+
import { getCurrentHub, trace } from '@sentry/core';
4+
import type { Breadcrumbs, BrowserTracing } from '@sentry/svelte';
25
import { captureException } from '@sentry/svelte';
3-
import { addExceptionMechanism, objectify } from '@sentry/utils';
6+
import type { ClientOptions, SanitizedRequestData } from '@sentry/types';
7+
import {
8+
addExceptionMechanism,
9+
getSanitizedUrlString,
10+
objectify,
11+
parseFetchArgs,
12+
parseUrl,
13+
stringMatchesSomePattern,
14+
} from '@sentry/utils';
415
import type { LoadEvent } from '@sveltejs/kit';
516

617
import { isRedirect } from '../common/utils';
@@ -34,7 +45,17 @@ function sendErrorToSentry(e: unknown): unknown {
3445
}
3546

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

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

0 commit comments

Comments
 (0)