Skip to content
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

feat(vercel-edge): Add fetch instrumentation #9504

Merged
merged 22 commits into from
Nov 17, 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
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