@sentry/nextjs
Version:
Official Sentry SDK for Next.js
228 lines (193 loc) • 8.71 kB
JavaScript
import { getClient, getCurrentScope } from '@sentry/core';
import { WINDOW } from '@sentry/react';
import { tracingContextFromHeaders, browserPerformanceTimeOrigin, stripUrlQueryAndFragment, logger } from '@sentry/utils';
import Router from 'next/router';
import { DEBUG_BUILD } from '../../common/debug-build.js';
const globalObject = WINDOW
;
/**
* Every Next.js page (static and dynamic ones) comes with a script tag with the id "__NEXT_DATA__". This script tag
* contains a JSON object with data that was either generated at build time for static pages (`getStaticProps`), or at
* runtime with data fetchers like `getServerSideProps.`.
*
* We can use this information to:
* - Always get the parameterized route we're in when loading a page.
* - Send trace information (trace-id, baggage) from the server to the client.
*
* This function extracts this information.
*/
function extractNextDataTagInformation() {
let nextData;
// Let's be on the safe side and actually check first if there is really a __NEXT_DATA__ script tag on the page.
// Theoretically this should always be the case though.
const nextDataTag = globalObject.document.getElementById('__NEXT_DATA__');
if (nextDataTag && nextDataTag.innerHTML) {
try {
nextData = JSON.parse(nextDataTag.innerHTML);
} catch (e) {
DEBUG_BUILD && logger.warn('Could not extract __NEXT_DATA__');
}
}
if (!nextData) {
return {};
}
const nextDataTagInfo = {};
const { page, query, props } = nextData;
// `nextData.page` always contains the parameterized route - except for when an error occurs in a data fetching
// function, then it is "/_error", but that isn't a problem since users know which route threw by looking at the
// parent transaction
// TODO: Actually this is a problem (even though it is not that big), because the DSC and the transaction payload will contain
// a different transaction name. Maybe we can fix this. Idea: Also send transaction name via pageProps when available.
nextDataTagInfo.route = page;
nextDataTagInfo.params = query;
if (props && props.pageProps) {
nextDataTagInfo.sentryTrace = props.pageProps._sentryTraceData;
nextDataTagInfo.baggage = props.pageProps._sentryBaggage;
}
return nextDataTagInfo;
}
const DEFAULT_TAGS = {
'routing.instrumentation': 'next-pages-router',
} ;
// We keep track of the active transaction so we can finish it when we start a navigation transaction.
let activeTransaction = undefined;
// We keep track of the previous location name so we can set the `from` field on navigation transactions.
// This is either a route or a pathname.
let prevLocationName = undefined;
const client = getClient();
/**
* Instruments the Next.js pages router. Only supported for
* client side routing. Works for Next >= 10.
*
* Leverages the SingletonRouter from the `next/router` to
* generate pageload/navigation transactions and parameterize
* transaction names.
*/
function pagesRouterInstrumentation(
startTransactionCb,
startTransactionOnPageLoad = true,
startTransactionOnLocationChange = true,
) {
const { route, params, sentryTrace, baggage } = extractNextDataTagInformation();
const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders(
sentryTrace,
baggage,
);
getCurrentScope().setPropagationContext(propagationContext);
prevLocationName = route || globalObject.location.pathname;
if (startTransactionOnPageLoad) {
const source = route ? 'route' : 'url';
activeTransaction = startTransactionCb({
name: prevLocationName,
op: 'pageload',
origin: 'auto.pageload.nextjs.pages_router_instrumentation',
tags: DEFAULT_TAGS,
// pageload should always start at timeOrigin (and needs to be in s, not ms)
startTimestamp: browserPerformanceTimeOrigin ? browserPerformanceTimeOrigin / 1000 : undefined,
...(params && client && client.getOptions().sendDefaultPii && { data: params }),
...traceparentData,
metadata: {
source,
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
},
});
}
if (startTransactionOnLocationChange) {
Router.events.on('routeChangeStart', (navigationTarget) => {
const strippedNavigationTarget = stripUrlQueryAndFragment(navigationTarget);
const matchedRoute = getNextRouteFromPathname(strippedNavigationTarget);
let transactionName;
let transactionSource;
if (matchedRoute) {
transactionName = matchedRoute;
transactionSource = 'route';
} else {
transactionName = strippedNavigationTarget;
transactionSource = 'url';
}
const tags = {
...DEFAULT_TAGS,
from: prevLocationName,
};
prevLocationName = transactionName;
if (activeTransaction) {
activeTransaction.end();
}
const navigationTransaction = startTransactionCb({
name: transactionName,
op: 'navigation',
origin: 'auto.navigation.nextjs.pages_router_instrumentation',
tags,
metadata: { source: transactionSource },
});
if (navigationTransaction) {
// In addition to the navigation transaction we're also starting a span to mark Next.js's `routeChangeStart`
// and `routeChangeComplete` events.
// We don't want to finish the navigation transaction on `routeChangeComplete`, since users might want to attach
// spans to that transaction even after `routeChangeComplete` is fired (eg. HTTP requests in some useEffect
// hooks). Instead, we'll simply let the navigation transaction finish itself (it's an `IdleTransaction`).
// eslint-disable-next-line deprecation/deprecation
const nextRouteChangeSpan = navigationTransaction.startChild({
op: 'ui.nextjs.route-change',
origin: 'auto.ui.nextjs.pages_router_instrumentation',
description: 'Next.js Route Change',
});
const finishRouteChangeSpan = () => {
nextRouteChangeSpan.end();
Router.events.off('routeChangeComplete', finishRouteChangeSpan);
};
Router.events.on('routeChangeComplete', finishRouteChangeSpan);
}
});
}
}
function getNextRouteFromPathname(pathname) {
const pageRoutes = (globalObject.__BUILD_MANIFEST || {}).sortedPages;
// Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here
if (!pageRoutes) {
return;
}
return pageRoutes.find(route => {
const routeRegExp = convertNextRouteToRegExp(route);
return pathname.match(routeRegExp);
});
}
/**
* Converts a Next.js style route to a regular expression that matches on pathnames (no query params or URL fragments).
*
* In general this involves replacing any instances of square brackets in a route with a wildcard:
* e.g. "/users/[id]/info" becomes /\/users\/([^/]+?)\/info/
*
* Some additional edgecases need to be considered:
* - All routes have an optional slash at the end, meaning users can navigate to "/users/[id]/info" or
* "/users/[id]/info/" - both will be resolved to "/users/[id]/info".
* - Non-optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[...params]").
* - Optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[[...params]]").
*
* @param route A Next.js style route as it is found in `global.__BUILD_MANIFEST.sortedPages`
*/
function convertNextRouteToRegExp(route) {
// We can assume a route is at least "/".
const routeParts = route.split('/');
let optionalCatchallWildcardRegex = '';
if (routeParts[routeParts.length - 1].match(/^\[\[\.\.\..+\]\]$/)) {
// If last route part has pattern "[[...xyz]]" we pop the latest route part to get rid of the required trailing
// slash that would come before it if we didn't pop it.
routeParts.pop();
optionalCatchallWildcardRegex = '(?:/(.+?))?';
}
const rejoinedRouteParts = routeParts
.map(
routePart =>
routePart
.replace(/^\[\.\.\..+\]$/, '(.+?)') // Replace catch all wildcard with regex wildcard
.replace(/^\[.*\]$/, '([^/]+?)'), // Replace route wildcards with lazy regex wildcards
)
.join('/');
// eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- routeParts are from the build manifest, so no raw user input
return new RegExp(
`^${rejoinedRouteParts}${optionalCatchallWildcardRegex}(?:/)?$`, // optional slash at the end
);
}
export { pagesRouterInstrumentation };
//# sourceMappingURL=pagesRouterRoutingInstrumentation.js.map