pagesRouterRoutingInstrumentation.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import { getClient, getCurrentScope } from '@sentry/core';
  2. import { WINDOW } from '@sentry/react';
  3. import { tracingContextFromHeaders, browserPerformanceTimeOrigin, stripUrlQueryAndFragment, logger } from '@sentry/utils';
  4. import Router from 'next/router';
  5. import { DEBUG_BUILD } from '../../common/debug-build.js';
  6. const globalObject = WINDOW
  7. ;
  8. /**
  9. * Every Next.js page (static and dynamic ones) comes with a script tag with the id "__NEXT_DATA__". This script tag
  10. * contains a JSON object with data that was either generated at build time for static pages (`getStaticProps`), or at
  11. * runtime with data fetchers like `getServerSideProps.`.
  12. *
  13. * We can use this information to:
  14. * - Always get the parameterized route we're in when loading a page.
  15. * - Send trace information (trace-id, baggage) from the server to the client.
  16. *
  17. * This function extracts this information.
  18. */
  19. function extractNextDataTagInformation() {
  20. let nextData;
  21. // Let's be on the safe side and actually check first if there is really a __NEXT_DATA__ script tag on the page.
  22. // Theoretically this should always be the case though.
  23. const nextDataTag = globalObject.document.getElementById('__NEXT_DATA__');
  24. if (nextDataTag && nextDataTag.innerHTML) {
  25. try {
  26. nextData = JSON.parse(nextDataTag.innerHTML);
  27. } catch (e) {
  28. DEBUG_BUILD && logger.warn('Could not extract __NEXT_DATA__');
  29. }
  30. }
  31. if (!nextData) {
  32. return {};
  33. }
  34. const nextDataTagInfo = {};
  35. const { page, query, props } = nextData;
  36. // `nextData.page` always contains the parameterized route - except for when an error occurs in a data fetching
  37. // function, then it is "/_error", but that isn't a problem since users know which route threw by looking at the
  38. // parent transaction
  39. // TODO: Actually this is a problem (even though it is not that big), because the DSC and the transaction payload will contain
  40. // a different transaction name. Maybe we can fix this. Idea: Also send transaction name via pageProps when available.
  41. nextDataTagInfo.route = page;
  42. nextDataTagInfo.params = query;
  43. if (props && props.pageProps) {
  44. nextDataTagInfo.sentryTrace = props.pageProps._sentryTraceData;
  45. nextDataTagInfo.baggage = props.pageProps._sentryBaggage;
  46. }
  47. return nextDataTagInfo;
  48. }
  49. const DEFAULT_TAGS = {
  50. 'routing.instrumentation': 'next-pages-router',
  51. } ;
  52. // We keep track of the active transaction so we can finish it when we start a navigation transaction.
  53. let activeTransaction = undefined;
  54. // We keep track of the previous location name so we can set the `from` field on navigation transactions.
  55. // This is either a route or a pathname.
  56. let prevLocationName = undefined;
  57. const client = getClient();
  58. /**
  59. * Instruments the Next.js pages router. Only supported for
  60. * client side routing. Works for Next >= 10.
  61. *
  62. * Leverages the SingletonRouter from the `next/router` to
  63. * generate pageload/navigation transactions and parameterize
  64. * transaction names.
  65. */
  66. function pagesRouterInstrumentation(
  67. startTransactionCb,
  68. startTransactionOnPageLoad = true,
  69. startTransactionOnLocationChange = true,
  70. startPageloadSpanCallback,
  71. startNavigationSpanCallback,
  72. ) {
  73. const { route, params, sentryTrace, baggage } = extractNextDataTagInformation();
  74. // eslint-disable-next-line deprecation/deprecation
  75. const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders(
  76. sentryTrace,
  77. baggage,
  78. );
  79. getCurrentScope().setPropagationContext(propagationContext);
  80. prevLocationName = route || globalObject.location.pathname;
  81. if (startTransactionOnPageLoad) {
  82. const source = route ? 'route' : 'url';
  83. const transactionContext = {
  84. name: prevLocationName,
  85. op: 'pageload',
  86. origin: 'auto.pageload.nextjs.pages_router_instrumentation',
  87. tags: DEFAULT_TAGS,
  88. // pageload should always start at timeOrigin (and needs to be in s, not ms)
  89. startTimestamp: browserPerformanceTimeOrigin ? browserPerformanceTimeOrigin / 1000 : undefined,
  90. ...(params && client && client.getOptions().sendDefaultPii && { data: params }),
  91. ...traceparentData,
  92. metadata: {
  93. source,
  94. dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
  95. },
  96. } ;
  97. activeTransaction = startTransactionCb(transactionContext);
  98. startPageloadSpanCallback(transactionContext);
  99. }
  100. if (startTransactionOnLocationChange) {
  101. Router.events.on('routeChangeStart', (navigationTarget) => {
  102. const strippedNavigationTarget = stripUrlQueryAndFragment(navigationTarget);
  103. const matchedRoute = getNextRouteFromPathname(strippedNavigationTarget);
  104. let transactionName;
  105. let transactionSource;
  106. if (matchedRoute) {
  107. transactionName = matchedRoute;
  108. transactionSource = 'route';
  109. } else {
  110. transactionName = strippedNavigationTarget;
  111. transactionSource = 'url';
  112. }
  113. const tags = {
  114. ...DEFAULT_TAGS,
  115. from: prevLocationName,
  116. };
  117. prevLocationName = transactionName;
  118. if (activeTransaction) {
  119. activeTransaction.end();
  120. }
  121. const transactionContext = {
  122. name: transactionName,
  123. op: 'navigation',
  124. origin: 'auto.navigation.nextjs.pages_router_instrumentation',
  125. tags,
  126. metadata: { source: transactionSource },
  127. } ;
  128. const navigationTransaction = startTransactionCb(transactionContext);
  129. startNavigationSpanCallback(transactionContext);
  130. if (navigationTransaction) {
  131. // In addition to the navigation transaction we're also starting a span to mark Next.js's `routeChangeStart`
  132. // and `routeChangeComplete` events.
  133. // We don't want to finish the navigation transaction on `routeChangeComplete`, since users might want to attach
  134. // spans to that transaction even after `routeChangeComplete` is fired (eg. HTTP requests in some useEffect
  135. // hooks). Instead, we'll simply let the navigation transaction finish itself (it's an `IdleTransaction`).
  136. // eslint-disable-next-line deprecation/deprecation
  137. const nextRouteChangeSpan = navigationTransaction.startChild({
  138. op: 'ui.nextjs.route-change',
  139. origin: 'auto.ui.nextjs.pages_router_instrumentation',
  140. description: 'Next.js Route Change',
  141. });
  142. const finishRouteChangeSpan = () => {
  143. nextRouteChangeSpan.end();
  144. Router.events.off('routeChangeComplete', finishRouteChangeSpan);
  145. };
  146. Router.events.on('routeChangeComplete', finishRouteChangeSpan);
  147. }
  148. });
  149. }
  150. }
  151. function getNextRouteFromPathname(pathname) {
  152. const pageRoutes = (globalObject.__BUILD_MANIFEST || {}).sortedPages;
  153. // Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here
  154. if (!pageRoutes) {
  155. return;
  156. }
  157. return pageRoutes.find(route => {
  158. const routeRegExp = convertNextRouteToRegExp(route);
  159. return pathname.match(routeRegExp);
  160. });
  161. }
  162. /**
  163. * Converts a Next.js style route to a regular expression that matches on pathnames (no query params or URL fragments).
  164. *
  165. * In general this involves replacing any instances of square brackets in a route with a wildcard:
  166. * e.g. "/users/[id]/info" becomes /\/users\/([^/]+?)\/info/
  167. *
  168. * Some additional edgecases need to be considered:
  169. * - All routes have an optional slash at the end, meaning users can navigate to "/users/[id]/info" or
  170. * "/users/[id]/info/" - both will be resolved to "/users/[id]/info".
  171. * - Non-optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[...params]").
  172. * - Optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[[...params]]").
  173. *
  174. * @param route A Next.js style route as it is found in `global.__BUILD_MANIFEST.sortedPages`
  175. */
  176. function convertNextRouteToRegExp(route) {
  177. // We can assume a route is at least "/".
  178. const routeParts = route.split('/');
  179. let optionalCatchallWildcardRegex = '';
  180. if (routeParts[routeParts.length - 1].match(/^\[\[\.\.\..+\]\]$/)) {
  181. // If last route part has pattern "[[...xyz]]" we pop the latest route part to get rid of the required trailing
  182. // slash that would come before it if we didn't pop it.
  183. routeParts.pop();
  184. optionalCatchallWildcardRegex = '(?:/(.+?))?';
  185. }
  186. const rejoinedRouteParts = routeParts
  187. .map(
  188. routePart =>
  189. routePart
  190. .replace(/^\[\.\.\..+\]$/, '(.+?)') // Replace catch all wildcard with regex wildcard
  191. .replace(/^\[.*\]$/, '([^/]+?)'), // Replace route wildcards with lazy regex wildcards
  192. )
  193. .join('/');
  194. // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- routeParts are from the build manifest, so no raw user input
  195. return new RegExp(
  196. `^${rejoinedRouteParts}${optionalCatchallWildcardRegex}(?:/)?$`, // optional slash at the end
  197. );
  198. }
  199. export { pagesRouterInstrumentation };
  200. //# sourceMappingURL=pagesRouterRoutingInstrumentation.js.map