Skip to content

Commit

Permalink
feat(vercel-edge): Add fetch instrumentation (#9504)
Browse files Browse the repository at this point in the history
  • Loading branch information
lforst authored Nov 17, 2023
1 parent 1e2bf6e commit ff416ae
Show file tree
Hide file tree
Showing 13 changed files with 425 additions and 8 deletions.
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

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

if (request.headers.has('x-should-make-request')) {
await fetch('http://localhost:3030/');
}

return NextResponse.next();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,46 @@ test('Records exceptions happening in middleware', async ({ request }) => {

expect(await errorEventPromise).toBeDefined();
});

test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => {
const middlewareTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
return (
transactionEvent?.transaction === 'middleware' &&
!!transactionEvent.spans?.find(span => span.op === 'http.client')
);
});

request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-make-request': '1' } }).catch(() => {
// Noop
});

const middlewareTransaction = await middlewareTransactionPromise;

expect(middlewareTransaction.spans).toEqual(
expect.arrayContaining([
{
data: { 'http.method': 'GET', 'http.response.status_code': 200, type: 'fetch', url: 'http://localhost:3030/' },
description: 'GET http://localhost:3030/',
op: 'http.client',
origin: 'auto.http.wintercg_fetch',
parent_span_id: expect.any(String),
span_id: expect.any(String),
start_timestamp: expect.any(Number),
status: 'ok',
tags: { 'http.status_code': '200' },
timestamp: expect.any(Number),
trace_id: expect.any(String),
},
]),
);
expect(middlewareTransaction.breadcrumbs).toEqual(
expect.arrayContaining([
{
category: 'fetch',
data: { __span: expect.any(String), method: 'GET', status_code: 200, url: 'http://localhost:3030/' },
timestamp: expect.any(Number),
type: 'http',
},
]),
);
});
1 change: 1 addition & 0 deletions packages/nextjs/src/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type EdgeOptions = VercelEdgeOptions;

const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
__rewriteFramesDistDir__?: string;
fetch: (...args: unknown[]) => unknown;
};

/** Inits the Sentry NextJS SDK on the Edge Runtime. */
Expand Down
7 changes: 4 additions & 3 deletions packages/node/src/integrations/undici/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,16 +272,17 @@ function setHeadersOnRequest(
sentryTrace: string,
sentryBaggageHeader: string | undefined,
): void {
if (request.__sentry_has_headers__) {
const headerLines = request.headers.split('\r\n');
const hasSentryHeaders = headerLines.some(headerLine => headerLine.startsWith('sentry-trace:'));

if (hasSentryHeaders) {
return;
}

request.addHeader('sentry-trace', sentryTrace);
if (sentryBaggageHeader) {
request.addHeader('baggage', sentryBaggageHeader);
}

request.__sentry_has_headers__ = true;
}

function createRequestSpan(
Expand Down
1 change: 0 additions & 1 deletion packages/node/src/integrations/undici/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,6 @@ export interface UndiciResponse {

export interface RequestWithSentry extends UndiciRequest {
__sentry_span__?: Span;
__sentry_has_headers__?: boolean;
}

export interface RequestCreateMessage {
Expand Down
4 changes: 3 additions & 1 deletion packages/tracing-internal/src/node/integrations/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Hub, Integration, PolymorphicRequest, Transaction } from '@sentry/
import {
extractPathForTransaction,
getNumberOfUrlSegments,
GLOBAL_OBJ,
isRegExp,
logger,
stripUrlQueryAndFragment,
Expand Down Expand Up @@ -485,7 +486,8 @@ function getLayerRoutePathInfo(layer: Layer): LayerRoutePathInfo {

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

// allow call extractOriginalRoute only if node version support Regex d flag, node 16+
if (major >= 16) {
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ interface SentryFetchData {

export interface HandlerDataFetch {
args: any[];
fetchData: SentryFetchData;
fetchData: SentryFetchData; // This data is among other things dumped directly onto the fetch breadcrumb data
startTimestamp: number;
endTimestamp?: number;
// This is actually `Response` - Note: this type is not complete. Add to it if necessary.
Expand Down
6 changes: 6 additions & 0 deletions packages/utils/src/supports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { getGlobalObject } from './worldwide';
// eslint-disable-next-line deprecation/deprecation
const WINDOW = getGlobalObject<Window>();

declare const EdgeRuntime: string | undefined;

export { supportsHistory } from './vendor/supportsHistory';

/**
Expand Down Expand Up @@ -89,6 +91,10 @@ export function isNativeFetch(func: Function): boolean {
* @returns true if `window.fetch` is natively implemented, false otherwise
*/
export function supportsNativeFetch(): boolean {
if (typeof EdgeRuntime === 'string') {
return true;
}

if (!supportsFetch()) {
return false;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/vercel-edge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"dependencies": {
"@sentry/core": "7.80.1",
"@sentry/types": "7.80.1",
"@sentry/utils": "7.80.1"
"@sentry/utils": "7.80.1",
"@sentry-internal/tracing": "7.80.1"
},
"devDependencies": {
"@edge-runtime/jest-environment": "2.2.3",
Expand Down
3 changes: 3 additions & 0 deletions packages/vercel-edge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,11 @@ export { defaultIntegrations, init } from './sdk';

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

import { WinterCGFetch } from './integrations/wintercg-fetch';

const INTEGRATIONS = {
...CoreIntegrations,
...WinterCGFetch,
};

export { INTEGRATIONS as Integrations };
163 changes: 163 additions & 0 deletions packages/vercel-edge/src/integrations/wintercg-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { instrumentFetchRequest } from '@sentry-internal/tracing';
import { getCurrentHub, isSentryRequestUrl } from '@sentry/core';
import type { FetchBreadcrumbData, FetchBreadcrumbHint, HandlerDataFetch, Integration, Span } from '@sentry/types';
import { addInstrumentationHandler, LRUMap, stringMatchesSomePattern } from '@sentry/utils';

export interface Options {
/**
* Whether breadcrumbs should be recorded for requests
* Defaults to true
*/
breadcrumbs: boolean;
/**
* Function determining whether or not to create spans to track outgoing requests to the given URL.
* By default, spans will be created for all outgoing requests.
*/
shouldCreateSpanForRequest?: (url: string) => boolean;
}

/**
* Creates spans and attaches tracing headers to fetch requests on WinterCG runtimes.
*/
export class WinterCGFetch implements Integration {
/**
* @inheritDoc
*/
public static id: string = 'WinterCGFetch';

/**
* @inheritDoc
*/
public name: string = WinterCGFetch.id;

private readonly _options: Options;

private readonly _createSpanUrlMap: LRUMap<string, boolean> = new LRUMap(100);
private readonly _headersUrlMap: LRUMap<string, boolean> = new LRUMap(100);

public constructor(_options: Partial<Options> = {}) {
this._options = {
breadcrumbs: _options.breadcrumbs === undefined ? true : _options.breadcrumbs,
shouldCreateSpanForRequest: _options.shouldCreateSpanForRequest,
};
}

/**
* @inheritDoc
*/
public setupOnce(): void {
const spans: Record<string, Span> = {};

addInstrumentationHandler('fetch', (handlerData: HandlerDataFetch) => {
const hub = getCurrentHub();
if (!hub.getIntegration(WinterCGFetch)) {
return;
}

if (isSentryRequestUrl(handlerData.fetchData.url, hub)) {
return;
}

instrumentFetchRequest(
handlerData,
this._shouldCreateSpan.bind(this),
this._shouldAttachTraceData.bind(this),
spans,
'auto.http.wintercg_fetch',
);

if (this._options.breadcrumbs) {
createBreadcrumb(handlerData);
}
});
}

/** Decides whether to attach trace data to the outgoing fetch request */
private _shouldAttachTraceData(url: string): boolean {
const hub = getCurrentHub();
const client = hub.getClient();

if (!client) {
return false;
}

const clientOptions = client.getOptions();

if (clientOptions.tracePropagationTargets === undefined) {
return true;
}

const cachedDecision = this._headersUrlMap.get(url);
if (cachedDecision !== undefined) {
return cachedDecision;
}

const decision = stringMatchesSomePattern(url, clientOptions.tracePropagationTargets);
this._headersUrlMap.set(url, decision);
return decision;
}

/** Helper that wraps shouldCreateSpanForRequest option */
private _shouldCreateSpan(url: string): boolean {
if (this._options.shouldCreateSpanForRequest === undefined) {
return true;
}

const cachedDecision = this._createSpanUrlMap.get(url);
if (cachedDecision !== undefined) {
return cachedDecision;
}

const decision = this._options.shouldCreateSpanForRequest(url);
this._createSpanUrlMap.set(url, decision);
return decision;
}
}

function createBreadcrumb(handlerData: HandlerDataFetch): void {
const { startTimestamp, endTimestamp } = handlerData;

// We only capture complete fetch requests
if (!endTimestamp) {
return;
}

if (handlerData.error) {
const data = handlerData.fetchData;
const hint: FetchBreadcrumbHint = {
data: handlerData.error,
input: handlerData.args,
startTimestamp,
endTimestamp,
};

getCurrentHub().addBreadcrumb(
{
category: 'fetch',
data,
level: 'error',
type: 'http',
},
hint,
);
} else {
const data: FetchBreadcrumbData = {
...handlerData.fetchData,
status_code: handlerData.response && handlerData.response.status,
};
const hint: FetchBreadcrumbHint = {
input: handlerData.args,
response: handlerData.response,
startTimestamp,
endTimestamp,
};
getCurrentHub().addBreadcrumb(
{
category: 'fetch',
data,
type: 'http',
},
hint,
);
}
}
2 changes: 2 additions & 0 deletions packages/vercel-edge/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createStackParser, GLOBAL_OBJ, nodeStackLineParser, stackParserFromStac

import { setAsyncLocalStorageAsyncContextStrategy } from './async';
import { VercelEdgeClient } from './client';
import { WinterCGFetch } from './integrations/wintercg-fetch';
import { makeEdgeTransport } from './transports';
import type { VercelEdgeClientOptions, VercelEdgeOptions } from './types';
import { getVercelEnv } from './utils/vercel';
Expand All @@ -17,6 +18,7 @@ export const defaultIntegrations = [
new CoreIntegrations.InboundFilters(),
new CoreIntegrations.FunctionToString(),
new CoreIntegrations.LinkedErrors(),
new WinterCGFetch(),
];

/** Inits the Sentry NextJS SDK on the Edge Runtime. */
Expand Down
Loading

0 comments on commit ff416ae

Please sign in to comment.