8000 feat(vercel-edge): Add fetch instrumentation (#9504) · alexgleason/sentry-javascript@ff416ae · GitHub
[go: up one dir, main page]

Skip to content

Commit ff416ae

Browse files
authored
feat(vercel-edge): Add fetch instrumentation (getsentry#9504)
1 parent 1e2bf6e commit ff416ae

File tree

13 files changed

+425
-8
lines changed

13 files changed

+425
-8
lines changed

packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { NextResponse } from 'next/server';
22
import type { NextRequest } from 'next/server';
33

4-
export function middleware(request: NextRequest) {
4+
export async function middleware(request: NextRequest) {
55
if (request.headers.has('x-should-throw')) {
66
throw new Error('Middleware Error');
77
}
88

9+
if (request.headers.has('x-should-make-request')) {
10+
await fetch('http://localhost:3030/');
11+
}
12+
913
return NextResponse.next();
1014
}
1115

packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,46 @@ test('Records exceptions happening in middleware', async ({ request }) => {
4545

4646
expect(await errorEventPromise).toBeDefined();
4747
});
48+
49+
test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => {
50+
const middlewareTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
51+
return (
52+
transactionEvent?.transaction === 'middleware' &&
53+
!!transactionEvent.spans?.find(span => span.op === 'http.client')
54+
);
55+
});
56+
57+
request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-make-request': '1' } }).catch(() => {
58+
// Noop
59+
});
60+
61+
const middlewareTransaction = await middlewareTransactionPromise;
62+
63+
expect(middlewareTransaction.spans).toEqual(
64+
expect.arrayContaining([
65+
{
66+
data: { 'http.method': 'GET', 'http.response.status_code': 200, type: 'fetch', url: 'http://localhost:3030/' },
67+
description: 'GET http://localhost:3030/',
68+
op: 'http.client',
69+
origin: 'auto.http.wintercg_fetch',
70+
parent_span_id: expect.any(String),
71+
span_id: expect.any(String),
72+
start_timestamp: expect.any(Number),
73+
status: 'ok',
74+
tags: { 'http.status_code': '200' },
75+
timestamp: expect.any(Number),
76+
trace_id: expect.any(String),
77+
},
78+
]),
79+
);
80+
expect(middlewareTransaction.breadcrumbs).toEqual(
81+
expect.arrayContaining([
82+
{
83+
category: 'fetch',
84+
data: { __span: expect.any(String), method: 'GET', status_code: 200, url: 'http://localhost:3030/' },
85+
timestamp: expect.any(Number),
86+
type: 'http',
87+
},
88+
]),
89+
);
90+
});

packages/nextjs/src/edge/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type EdgeOptions = VercelEdgeOptions;
1111

1212
const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
1313
__rewriteFramesDistDir__?: string;
14+
fetch: (...args: unknown[]) => unknown;
1415
};
1516

1617
/** Inits the Sentry NextJS SDK on the Edge Runtime. */

packages/node/src/integrations/undici/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,16 +272,17 @@ function setHeadersOnRequest(
272272
sentryTrace: string,
273273
sentryBaggageHeader: string | undefined,
274274
): void {
275-
if (request.__sentry_has_headers__) {
275+
const headerLines = request.headers.split('\r\n');
276+
const hasSentryHeaders = headerLines.some(headerLine => headerLine.startsWith('sentry-trace:'));
277+
278+
if (hasSentryHeaders) {
276279
return;
277280
}
278281

279282
request.addHeader('sentry-trace', sentryTrace);
280283
if (sentryBaggageHeader) {
281284
request.addHeader('baggage', sentryBaggageHeader);
282285
}
283-
284-
request.__sentry_has_headers__ = true;
285286
}
286287

287288
function createRequestSpan(

packages/node/src/integrations/undici/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,6 @@ export interface UndiciResponse {
236236

237237
export interface RequestWithSentry extends UndiciRequest {
238238
__sentry_span__?: Span;
239-
__sentry_has_headers__?: boolean;
240239
}
241240

242241
export interface RequestCreateMessage {

packages/tracing-internal/src/node/integrations/express.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Hub, Integration, PolymorphicRequest, Transaction } from '@sentry/
33
import {
44
extractPathForTransaction,
55
getNumberOfUrlSegments,
6+
GLOBAL_OBJ,
67
isRegExp,
78
logger,
89
stripUrlQueryAndFragment,
@@ -485,7 +486,8 @@ function getLayerRoutePathInfo(layer: Layer): LayerRoutePathInfo {
485486

486487
if (!lrp) {
487488
// parse node.js major version
488-
const [major] = process.versions.node.split('.').map(Number);
489+
// Next.js will complain if we directly use `proces.versions` here because of edge runtime.
490+
const [major] = (GLOBAL_OBJ as unknown as NodeJS.Global).process.versions.node.split('.').map(Number);
489491

490492
// allow call extractOriginalRoute only if node version support Regex d flag, node 16+
491493
if (major >= 16) {

packages/types/src/instrument.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ interface SentryFetchData {
3737

3838
export interface HandlerDataFetch {
3939
args: any[];
40-
fetchData: SentryFetchData;
40+
fetchData: SentryFetchData; // This data is among other things dumped directly onto the fetch breadcrumb data
4141
startTimestamp: number;
4242
endTimestamp?: number;
4343
// This is actually `Response` - Note: this type is not complete. Add to it if necessary.

packages/utils/src/supports.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { getGlobalObject } from './worldwide';
44
// eslint-disable-next-line deprecation/deprecation
55
const WINDOW = getGlobalObject<Window>();
66

7+
declare const EdgeRuntime: string | undefined;
8+
79
export { supportsHistory } from './vendor/supportsHistory';
810

911
/**
@@ -89,6 +91,10 @@ export function isNativeFetch(func: Function): boolean {
8991
* @returns true if `window.fetch` is natively implemented, false otherwise
9092
*/
9193
export function supportsNativeFetch(): boolean {
94+
if (typeof EdgeRuntime === 'string') {
95+
return true;
96+
}
97+
9298
if (!supportsFetch()) {
9399
return false;
94100
}

packages/vercel-edge/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"dependencies": {
2626
"@sentry/core": "7.80.1",
2727
"@sentry/types": "7.80.1",
28-
"@sentry/utils": "7.80.1"
28+
"@sentry/utils": "7.80.1",
29+
"@sentry-internal/tracing": "7.80.1"
2930
},
3031
"devDependencies": {
3132
"@edge-runtime/jest-environment": "2.2.3",

packages/vercel-edge/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,11 @@ export { defaultIntegrations, init } from './sdk';
7070

7171
import { Integrations as CoreIntegrations } from '@sentry/core';
7272

73+
import { WinterCGFetch } from './integrations/wintercg-fetch';
74+
7375
const INTEGRATIONS = {
7476
...CoreIntegrations,
77+
...WinterCGFetch,
7578
};
7679

7780
export { INTEGRATIONS as Integrations };
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { instrumentFetchRequest } from '@sentry-internal/tracing';
2+
import { getCurrentHub, isSentryRequestUrl } from '@sentry/core';
3+
import type { FetchBreadcrumbData, FetchBreadcrumbHint, HandlerDataFetch, Integration, Span } from '@sentry/types';
4+
import { addInstrumentationHandler, LRUMap, stringMatchesSomePattern } from '@sentry/utils';
5+
6+
export interface Options {
7+
/**
8+
* Whether breadcrumbs should be recorded for requests
9+
* Defaults to true
10+
*/
11+
breadcrumbs: boolean;
12+
/**
13+
* Function determining whether or not to create spans to track outgoing requests to the given URL.
14+
* By default, spans will be created for all outgoing requests.
15+
*/
16+
shouldCreateSpanForRequest?: (url: string) => boolean;
17+
}
18+
19+
/**
20+
* Creates spans and attaches tracing headers to fetch requests on WinterCG runtimes.
21+
*/
22+
export class WinterCGFetch implements Integration {
23+
/**
24+
* @inheritDoc
25+
*/
26+
public static id: string = 'WinterCGFetch';
27+
28+
/**
29+
* @inheritDoc
30+
*/
31+
public name: string = WinterCGFetch.id;
32+
33+
private readonly _options: Options;
34+
35+
private readonly _createSpanUrlMap: LRUMap<string, boolean> = new LRUMap(100);
36+
private readonly _headersUrlMap: LRUMap<string, boolean> = new LRUMap(100);
37+
38+
public constructor(_options: Partial<Options> = {}) {
39+
this._options = {
40+
breadcrumbs: _options.breadcrumbs === undefined ? true : _options.breadcrumbs,
41+
shouldCreateSpanForRequest: _options.shouldCreateSpanForRequest,
42+
};
43+
}
44+
45+
/**
46+
* @inheritDoc
47+
*/
48+
public setupOnce(): void {
49+
const spans: Record<string, Span> = {};
50+
51+
addInstrumentationHandler('fetch', (handlerData: HandlerDataFetch) => {
52+
const hub = getCurrentHub();
53+
if (!hub.getIntegration(WinterCGFetch)) {
54+
return;
55+
}
56+
57+
if (isSentryRequestUrl(handlerData.fetchData.url, hub)) {
58+
return;
59+
}
60+
61+
instrumentFetchRequest(
62+
handlerData,
63+
this._shouldCreateSpan.bind(this),
64+
this._shouldAttachTraceData.bind(this),
65+
spans,
66+
'auto.http.wintercg_fetch',
67+
);
68+
69+
if (this._options.breadcrumbs) {
70+
createBreadcrumb(handlerData);
71+
}
72+
});
73+
}
74+
75+
/** Decides whether to attach trace data to the outgoing fetch request */
76+
private _shouldAttachTraceData(url: string): boolean {
77+
const hub = getCurrentHub();
78+
const client = hub.getClient();
79+
80+
if (!client) {
81+
return false;
82+
}
83+
84+
const clientOptions = client.getOptions();
85+
86+
if (clientOptions.tracePropagationTargets === undefined) {
87+
return true;
88+
}
89+
90+
const cachedDecision = this._headersUrlMap.get(url);
91+
if (cachedDecision !== undefined) {
92+
return cachedDecision;
93+
}
94+
95+
const decision = stringMatchesSomePattern(url, clientOptions.tracePropagationTargets);
96+
this._headersUrlMap.set(url, decision);
97+
return decision;
98+
}
99+
100+
/** Helper that wraps shouldCreateSpanForRequest option */
101+
private _shouldCreateSpan(url: string): boolean {
102+
if (this._options.shouldCreateSpanForRequest === undefined) {
103+
return true;
104+
}
105+
106+
const cachedDecision = this._createSpanUrlMap.get(url);
107+
if (cachedDecision !== undefined) {
108+
return cachedDecision;
109+
}
110+
111+
const decision = this._options.shouldCreateSpanForRequest(url);
112+
this._createSpanUrlMap.set(url, decision);
113+
return decision;
114+
}
115+
}
116+
117+
function createBreadcrumb(handlerData: HandlerDataFetch): void {
118+
const { startTimestamp, endTimestamp } = handlerData;
119+
120+
// We only capture complete fetch requests
121+
if (!endTimestamp) {
122+
return;
123+
}
124+
125+
if (handlerData.error) {
126+
const data = handlerData.fetchData;
127+
const hint: FetchBreadcrumbHint = {
128+
data: handlerData.error,
129+
input: handlerData.args,
130+
startTimestamp,
131+
endTimestamp,
132+
};
133+
134+
getCurrentHub().addBreadcrumb(
135+
{
136+
category: 'fetch',
137+
data,
138+
level: 'error',
139+
type: 'http',
140+
},
141+
hint,
142+
);
143+
} else {
144+
const data: FetchBreadcrumbData = {
145+
...handlerData.fetchData,
146+
status_code: handlerData.response && handlerData.response.status,
147+
};
148+
const hint: FetchBreadcrumbHint = {
149+
input: handlerData.args,
150+
response: handlerData.response,
151+
startTimestamp,
152+
endTimestamp,
153+
};
154+
getCurrentHub().addBreadcrumb(
155+
{
156+
category: 'fetch',
157+
data,
158+
type: 'http',
159+
},
160+
hint,
161+
);
162+
}
163+
}

packages/vercel-edge/src/sdk.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createStackParser, GLOBAL_OBJ, nodeStackLineParser, stackParserFromStac
33

44
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
55
import { VercelEdgeClient } from './client';
6+
import { WinterCGFetch } from './integrations/wintercg-fetch';
67
import { makeEdgeTransport } from './transports';
78
import type { VercelEdgeClientOptions, VercelEdgeOptions } from './types';
89
import { getVercelEnv } from './utils/vercel';
@@ -17,6 +18,7 @@ export const defaultIntegrations = [
1718
new CoreIntegrations.InboundFilters(),
1819
new CoreIntegrations.FunctionToString(),
1920
new CoreIntegrations.LinkedErrors(),
21+
new WinterCGFetch(),
2022
];
2123

2224
/** Inits the Sentry NextJS SDK on the Edge Runtime. */

0 commit comments

Comments
 (0)
0