Skip to content

feat(sveltekit): Add partial instrumentation for client-side fetch #7626

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 3 additions & 14 deletions packages/node/src/integrations/http.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Hub } from '@sentry/core';
import { getCurrentHub } from '@sentry/core';
import type { EventProcessor, Integration, Span, TracePropagationTargets } from '@sentry/types';
import type { EventProcessor, Integration, SanitizedRequestData, Span, TracePropagationTargets } from '@sentry/types';
import { dynamicSamplingContextToSentryBaggageHeader, fill, logger, stringMatchesSomePattern } from '@sentry/utils';
import type * as http from 'http';
import type * as https from 'https';
Expand Down Expand Up @@ -122,16 +122,6 @@ type OriginalRequestMethod = RequestMethod;
type WrappedRequestMethod = RequestMethod;
type WrappedRequestMethodFactory = (original: OriginalRequestMethod) => WrappedRequestMethod;

/**
* See https://develop.sentry.dev/sdk/data-handling/#structuring-data
*/
type RequestSpanData = {
url: string;
method: string;
'http.fragment'?: string;
'http.query'?: string;
};

/**
* Function which creates a function which creates wrapped versions of internal `request` and `get` calls within `http`
* and `https` modules. (NB: Not a typo - this is a creator^2!)
Expand Down Expand Up @@ -197,7 +187,7 @@ function _createWrappedRequestMethodFactory(

const scope = getCurrentHub().getScope();

const requestSpanData: RequestSpanData = {
const requestSpanData: SanitizedRequestData = {
url: requestUrl,
method: requestOptions.method || 'GET',
};
Expand Down Expand Up @@ -304,7 +294,7 @@ function _createWrappedRequestMethodFactory(
*/
function addRequestBreadcrumb(
event: string,
requestSpanData: RequestSpanData,
requestSpanData: SanitizedRequestData,
req: http.ClientRequest,
res?: http.IncomingMessage,
): void {
Expand All @@ -316,7 +306,6 @@ function addRequestBreadcrumb(
{
category: 'http',
data: {
method: req.method,
status_code: res && res.statusCode,
...requestSpanData,
},
Expand Down
204 changes: 200 additions & 4 deletions packages/sveltekit/src/client/load.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { trace } from '@sentry/core';
import { addTracingHeadersToFetchRequest } from '@sentry-internal/tracing';
import type { BaseClient } from '@sentry/core';
import { getCurrentHub, trace } from '@sentry/core';
import type { Breadcrumbs, BrowserTracing } from '@sentry/svelte';
import { captureException } from '@sentry/svelte';
import { addExceptionMechanism, objectify } from '@sentry/utils';
import type { ClientOptions, SanitizedRequestData } from '@sentry/types';
import {
addExceptionMechanism,
getSanitizedUrlString,
objectify,
parseFetchArgs,
parseUrl,
stringMatchesSomePattern,
} from '@sentry/utils';
import type { LoadEvent } from '@sveltejs/kit';

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

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

const patchedEvent = {
...event,
fetch: instrumentSvelteKitFetch(event.fetch),
};

const routeId = event.route.id;
return trace(
{
Expand All @@ -57,9 +83,179 @@ export function wrapLoadWithSentry<T extends (...args: any) => any>(origLoad: T)
source: routeId ? 'route' : 'url',
},
},
() => wrappingTarget.apply(thisArg, args),
() => wrappingTarget.apply(thisArg, [patchedEvent]),
sendErrorToSentry,
);
},
});
}

type SvelteKitFetch = LoadEvent['fetch'];

/**
* Instruments SvelteKit's client `fetch` implementation which is passed to the client-side universal `load` functions.
*
* We need to instrument this in addition to the native fetch we instrument in BrowserTracing because SvelteKit
* stores the native fetch implementation before our SDK is initialized.
*
* see: https://github.com/sveltejs/kit/blob/master/packages/kit/src/runtime/client/fetcher.js
*
* This instrumentation takes the fetch-related options from `BrowserTracing` to determine if we should
* instrument fetch for perfomance monitoring, create a span for or attach our tracing headers to the given request.
*
* To dertermine if breadcrumbs should be recorded, this instrumentation relies on the availability of and the options
* set in the `BreadCrumbs` integration.
*
* @param originalFetch SvelteKit's original fetch implemenetation
*
* @returns a proxy of SvelteKit's fetch implementation
*/
function instrumentSvelteKitFetch(originalFetch: SvelteKitFetch): SvelteKitFetch {
const client = getCurrentHub().getClient() as BaseClient<ClientOptions>;

const browserTracingIntegration =
client.getIntegrationById && (client.getIntegrationById('BrowserTracing') as BrowserTracing | undefined);
const breadcrumbsIntegration =
client.getIntegrationById && (client.getIntegrationById('Breadcrumbs') as Breadcrumbs | undefined);

const browserTracingOptions = browserTracingIntegration && browserTracingIntegration.options;

const shouldTraceFetch = browserTracingOptions && browserTracingOptions.traceFetch;
const shouldAddFetchBreadcrumb = breadcrumbsIntegration && breadcrumbsIntegration.options.fetch;

/* Identical check as in BrowserTracing, just that we also need to verify that BrowserTracing is actually installed */
const shouldCreateSpan =
browserTracingOptions && typeof browserTracingOptions.shouldCreateSpanForRequest === 'function'
? browserTracingOptions.shouldCreateSpanForRequest
: (_: string) => shouldTraceFetch;

/* Identical check as in BrowserTracing, just that we also need to verify that BrowserTracing is actually installed */
const shouldAttachHeaders: (url: string) => boolean = url => {
return (
!!shouldTraceFetch &&
stringMatchesSomePattern(url, browserTracingOptions.tracePropagationTargets || ['localhost', /^\//])
);
};

return new Proxy(originalFetch, {
apply: (wrappingTarget, thisArg, args: Parameters<LoadEvent['fetch']>) => {
const [input, init] = args;
const { url: rawUrl, method } = parseFetchArgs(args);

// TODO: extract this to a util function (and use it in breadcrumbs integration as well)
if (rawUrl.match(/sentry_key/)) {
// We don't create spans or breadcrumbs for fetch requests that contain `sentry_key` (internal sentry requests)
return wrappingTarget.apply(thisArg, args);
}

const urlObject = parseUrl(rawUrl);

const requestData: SanitizedRequestData = {
url: getSanitizedUrlString(urlObject),
method,
...(urlObject.search && { 'http.query': urlObject.search.substring(1) }),
...(urlObject.hash && { 'http.hash': urlObject.hash.substring(1) }),
};

const patchedInit: RequestInit = { ...init };
const activeSpan = getCurrentHub().getScope().getSpan();
const activeTransaction = activeSpan && activeSpan.transaction;

const createSpan = activeTransaction && shouldCreateSpan(rawUrl);
const attachHeaders = createSpan && activeTransaction && shouldAttachHeaders(rawUrl);

// only attach headers if we should create a span
if (attachHeaders) {
const dsc = activeTransaction.getDynamicSamplingContext();

const headers = addTracingHeadersToFetchRequest(
input as string | Request,
dsc,
activeSpan,
patchedInit as {
headers:
| {
[key: string]: string[] | string | undefined;
}
| Request['headers'];
},
) as HeadersInit;

patchedInit.headers = headers;
}
let fetchPromise: Promise<Response>;

const patchedFetchArgs = [input, patchedInit];

if (createSpan) {
fetchPromise = trace(
{
name: `${requestData.method} ${requestData.url}`, // this will become the description of the span
op: 'http.client',
data: requestData,
},
span => {
const promise: Promise<Response> = wrappingTarget.apply(thisArg, patchedFetchArgs);
if (span) {
promise.then(res => span.setHttpStatus(res.status)).catch(_ => span.setStatus('internal_error'));
}
return promise;
},
);
} else {
fetchPromise = wrappingTarget.apply(thisArg, patchedFetchArgs);
}

if (shouldAddFetchBreadcrumb) {
addFetchBreadcrumb(fetchPromise, requestData, args);
}

return fetchPromise;
},
});
}

/* Adds a breadcrumb for the given fetch result */
function addFetchBreadcrumb(
fetchResult: Promise<Response>,
requestData: SanitizedRequestData,
args: Parameters<SvelteKitFetch>,
): void {
const breadcrumbStartTimestamp = Date.now();
fetchResult.then(
response => {
getCurrentHub().addBreadcrumb(
{
type: 'http',
category: 'fetch',
data: {
...requestData,
status_code: response.status,
},
},
{
input: args,
response,
startTimestamp: breadcrumbStartTimestamp,
endTimestamp: Date.now(),
},
);
},
error => {
getCurrentHub().addBreadcrumb(
{
type: 'http',
category: 'fetch',
level: 'error',
data: requestData,
},
{
input: args,
data: error,
startTimestamp: breadcrumbStartTimestamp,
endTimestamp: Date.now(),
},
);
},
);
}
Loading