From 0fe74443409207a5a934832fc85f6a8ed4102266 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 27 Apr 2026 15:51:09 +0200 Subject: [PATCH] move to shared util --- .../server/enhanceHandleRequestRootSpan.ts | 78 ++++++++ packages/nextjs/src/server/index.ts | 185 +++++------------- .../enhanceHandleRequestRootSpan.test.ts | 179 +++++++++++++++++ packages/nextjs/test/serverSdk.test.ts | 35 ++++ 4 files changed, 344 insertions(+), 133 deletions(-) create mode 100644 packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts create mode 100644 packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts diff --git a/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts b/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts new file mode 100644 index 000000000000..a934380492dc --- /dev/null +++ b/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts @@ -0,0 +1,78 @@ +import { + ATTR_HTTP_REQUEST_METHOD, + ATTR_HTTP_ROUTE, + SEMATTRS_HTTP_METHOD, + SEMATTRS_HTTP_TARGET, +} from '@opentelemetry/semantic-conventions'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, stripUrlQueryAndFragment } from '@sentry/core'; +import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; +import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../common/span-attributes-with-logic-attached'; + +export interface MutableRootSpan { + attributes: Record; + getName(): string | undefined; + setName(name: string): void; + setOp(op: string): void; +} + +/** + * Normalizes name, op and source for the root span of a Next.js `BaseServer.handleRequest` request. + * + * Called from two places that operate on different shapes of the same underlying root span: + * - Legacy mode: from `preprocessEvent`, adapted around a transaction `Event` whose `contexts.trace.data` + * holds the root span's attributes and whose `event.transaction` is the root span's name. + * - Streamed mode: from `processSegmentSpan`, adapted around a `StreamedSpanJSON` (the streamed + * counterpart of the legacy transaction root) directly. + * + * The `MutableRootSpan` adapter hides those differences so the enhancement logic can be shared. + */ +export function enhanceHandleRequestRootSpan(span: MutableRootSpan): void { + const { attributes } = span; + + if (attributes[ATTR_NEXT_SPAN_TYPE] !== 'BaseServer.handleRequest') { + return; + } + + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; + span.setOp('http.server'); + + const currentName = span.getName(); + if (currentName) { + span.setName(stripUrlQueryAndFragment(currentName)); + } + + // eslint-disable-next-line deprecation/deprecation + const method = attributes[SEMATTRS_HTTP_METHOD] ?? attributes[ATTR_HTTP_REQUEST_METHOD]; + // eslint-disable-next-line deprecation/deprecation + const target = attributes[SEMATTRS_HTTP_TARGET]; + const route = attributes[ATTR_HTTP_ROUTE] || attributes[ATTR_NEXT_ROUTE]; + const spanName = attributes[ATTR_NEXT_SPAN_NAME]; + + if (typeof method === 'string' && typeof route === 'string' && !route.startsWith('middleware')) { + const cleanRoute = route.replace(/\/route$/, ''); + span.setName(`${method} ${cleanRoute}`); + attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; + // Preserve next.route in case it did not get hoisted + attributes[ATTR_NEXT_ROUTE] = cleanRoute; + } + + // backfill transaction name for pages that would otherwise contain unparameterized routes + const routeBackfill = attributes[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]; + if (typeof routeBackfill === 'string' && span.getName() !== 'GET /_app') { + span.setName(`${typeof method === 'string' ? method : 'GET'} ${routeBackfill}`); + } + + const middlewareMatch = + typeof spanName === 'string' && spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); + + if (middlewareMatch) { + span.setName(`middleware ${middlewareMatch[1]}`); + span.setOp('http.server.middleware'); + } + + // Next.js overrides transaction names for page loads that throw an error + // but we want to keep the original target name + if (span.getName() === 'GET /_error' && typeof target === 'string') { + span.setName(`${typeof method === 'string' ? `${method} ` : ''}${target}`); + } +} diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 0483ab6448ff..1008601a3318 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -1,39 +1,27 @@ // import/export got a false positive, and affects most of our index barrel files // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ -import { - ATTR_HTTP_ROUTE, - ATTR_URL_QUERY, - SEMATTRS_HTTP_METHOD, - SEMATTRS_HTTP_TARGET, -} from '@opentelemetry/semantic-conventions'; +import { ATTR_URL_QUERY, SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; import type { EventProcessor } from '@sentry/core'; import { applySdkMetadata, debug, - extractTraceparentData, getClient, getGlobalScope, GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - stripUrlQueryAndFragment, } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; -import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; -import { - TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL, - TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL, - TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, -} from '../common/span-attributes-with-logic-attached'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached'; import { isBuild } from '../common/utils/isBuild'; import { isCloudflareWaitUntilAvailable } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; +import { enhanceHandleRequestRootSpan } from './enhanceHandleRequestRootSpan'; import { handleOnSpanStart } from './handleOnSpanStart'; import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; import { maybeCompleteCronCheckIn } from './vercelCronsMonitoring'; @@ -155,6 +143,23 @@ export function init(options: NodeOptions): NodeClient | undefined { ...cloudflareConfig, }; + const nextjsIgnoreSpans: NonNullable = [ + // Static assets (matches `_next/static` anywhere in the name to handle custom basePath) + /^GET (\/.*)?\/_next\/static\//, + // Dev source-map fetch endpoints + /\/__nextjs_original-stack-frame/, + // Pages router /404 + /^\/404$/, + // App router /404 and /_not-found segments (any HTTP method) + /^(GET|HEAD|POST|PUT|DELETE|CONNECT|OPTIONS|TRACE|PATCH) \/(404|_not-found)$/, + // Next.js 13 root transactions named "NextServer.getRequestHandler" containing useless tracing + /^NextServer\.getRequestHandler$/, + // Spans flagged via TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION + // (set in `dropMiddlewareTunnelRequests` during `spanStart`) + { attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } }, + ]; + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; + if (DEBUG_BUILD && opts.debug) { debug.enable(); } @@ -195,62 +200,6 @@ export function init(options: NodeOptions): NodeClient | undefined { client?.on('spanEnd', maybeCompleteCronCheckIn); client?.on('spanEnd', maybeCleanupQueueSpan); - getGlobalScope().addEventProcessor( - Object.assign( - (event => { - if (event.type === 'transaction') { - // Filter out transactions for static assets - // This regex matches the default path to the static assets (`_next/static`) and could potentially filter out too many transactions. - // We match `/_next/static/` anywhere in the transaction name because its location may change with the basePath setting. - if (event.transaction?.match(/^GET (\/.*)?\/_next\/static\//)) { - return null; - } - - // Filter out requests to resolve source maps for stack frames in dev mode - if (event.transaction?.match(/\/__nextjs_original-stack-frame/)) { - return null; - } - - // Filter out /404 transactions which seem to be created excessively - if ( - // Pages router - event.transaction === '/404' || - // App router (could be "GET /404", "POST /404", ...) - event.transaction?.match(/^(GET|HEAD|POST|PUT|DELETE|CONNECT|OPTIONS|TRACE|PATCH) \/(404|_not-found)$/) - ) { - return null; - } - - // Filter transactions that we explicitly want to drop. - if (event.contexts?.trace?.data?.[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]) { - return null; - } - - // Next.js 13 sometimes names the root transactions like this containing useless tracing. - if (event.transaction === 'NextServer.getRequestHandler') { - return null; - } - - // Next.js 13 is not correctly picking up tracing data for trace propagation so we use a back-fill strategy - if (typeof event.contexts?.trace?.data?.[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL] === 'string') { - const traceparentData = extractTraceparentData( - event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL], - ); - - if (traceparentData?.parentSampled === false) { - return null; - } - } - - return event; - } else { - return event; - } - }) satisfies EventProcessor, - { id: 'NextLowQualityTransactionsFilter' }, - ), - ); - getGlobalScope().addEventProcessor( Object.assign( ((event, hint) => { @@ -289,74 +238,44 @@ export function init(options: NodeOptions): NodeClient | undefined { // Use the preprocessEvent hook instead of an event processor, so that the users event processors receive the most // up-to-date value, but also so that the logic that detects changes to the transaction names to set the source to // "custom", doesn't trigger. + // This handles the legacy (non-streamed) path where the segment span is emitted as a transaction event; + // `enhanceHandleRequestRootSpan` is adapted to operate on the event's trace context, which is the segment span's data. + // Span streaming bypasses event processors entirely - see the `processSegmentSpan` hook below for that path. client?.on('preprocessEvent', event => { - // Enhance route handler transactions - if ( - event.type === 'transaction' && - event.contexts?.trace?.data?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' - ) { - event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; - event.contexts.trace.op = 'http.server'; - - if (event.transaction) { - event.transaction = stripUrlQueryAndFragment(event.transaction); - } - - // eslint-disable-next-line deprecation/deprecation - const method = event.contexts.trace.data[SEMATTRS_HTTP_METHOD]; - // eslint-disable-next-line deprecation/deprecation - const target = event.contexts?.trace?.data?.[SEMATTRS_HTTP_TARGET]; - const route = event.contexts.trace.data[ATTR_HTTP_ROUTE] || event.contexts.trace.data[ATTR_NEXT_ROUTE]; - const spanName = event.contexts.trace.data[ATTR_NEXT_SPAN_NAME]; - - if (typeof method === 'string' && typeof route === 'string' && !route.startsWith('middleware')) { - const cleanRoute = route.replace(/\/route$/, ''); - event.transaction = `${method} ${cleanRoute}`; - event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; - // Preserve next.route in case it did not get hoisted - event.contexts.trace.data[ATTR_NEXT_ROUTE] = cleanRoute; - } - - // backfill transaction name for pages that would otherwise contain unparameterized routes - if (event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL] && event.transaction !== 'GET /_app') { - event.transaction = `${method} ${event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]}`; - } - - const middlewareMatch = - typeof spanName === 'string' && spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); - - if (middlewareMatch) { - const normalizedName = `middleware ${middlewareMatch[1]}`; - event.transaction = normalizedName; - event.contexts.trace.op = 'http.server.middleware'; - } - - // Next.js overrides transaction names for page loads that throw an error - // but we want to keep the original target name - if (event.transaction === 'GET /_error' && target) { - event.transaction = `${method ? `${method} ` : ''}${target}`; - } - } - - // Next.js 13 is not correctly picking up tracing data for trace propagation so we use a back-fill strategy - if ( - event.type === 'transaction' && - typeof event.contexts?.trace?.data?.[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL] === 'string' - ) { - const traceparentData = extractTraceparentData(event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL]); - - if (traceparentData?.traceId) { - event.contexts.trace.trace_id = traceparentData.traceId; - } - - if (traceparentData?.parentSpanId) { - event.contexts.trace.parent_span_id = traceparentData.parentSpanId; - } + if (event.type === 'transaction' && event.contexts?.trace?.data) { + enhanceHandleRequestRootSpan({ + attributes: event.contexts.trace.data, + getName: () => event.transaction, + setName: name => { + event.transaction = name; + }, + setOp: op => { + event.contexts!.trace!.op = op; + }, + }); } setUrlProcessingMetadata(event); }); + // Streamed-span counterpart of the `preprocessEvent` hook above. Streamed segment spans never become + // transaction events, so the same enhancement has to be applied here directly on the span JSON. + client?.on('processSegmentSpan', span => { + const attributes = (span.attributes ??= {}); + enhanceHandleRequestRootSpan({ + attributes, + getName: () => span.name, + setName: name => { + span.name = name; + }, + // For streamed spans, op lives in `attributes['sentry.op']` - mirror it there so middleware + // overrides land somewhere readable (the legacy path uses a separate `event.contexts.trace.op`). + setOp: op => { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; + }, + }); + }); + if (process.env.NODE_ENV === 'development') { getGlobalScope().addEventProcessor(devErrorSymbolicationEventProcessor); } diff --git a/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts b/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts new file mode 100644 index 000000000000..8373c3a6e744 --- /dev/null +++ b/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts @@ -0,0 +1,179 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; +import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../../src/common/span-attributes-with-logic-attached'; +import { enhanceHandleRequestRootSpan } from '../../src/server/enhanceHandleRequestRootSpan'; + +function makeSpan(attributes: Record, name?: string) { + let currentName = name; + let op: string | undefined; + return { + span: { + attributes, + getName: () => currentName, + setName: (n: string) => { + currentName = n; + }, + setOp: (o: string) => { + op = o; + }, + }, + getName: () => currentName, + getOp: () => op, + }; +} + +describe('enhanceHandleRequestRootSpan', () => { + it('does nothing for non-BaseServer.handleRequest spans', () => { + const { span, getName, getOp } = makeSpan({ [ATTR_NEXT_SPAN_TYPE]: 'Render.getServerSideProps' }, 'GET /api/foo'); + enhanceHandleRequestRootSpan(span); + expect(getName()).toBe('GET /api/foo'); + expect(getOp()).toBeUndefined(); + expect(span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBeUndefined(); + }); + + it('sets http.server op and source=route for parameterized routes', () => { + const { span, getName, getOp } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + 'next.route': '/api/users/[id]', + }, + 'GET /api/users/123', + ); + enhanceHandleRequestRootSpan(span); + + expect(getOp()).toBe('http.server'); + expect(span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('http.server'); + expect(getName()).toBe('GET /api/users/[id]'); + expect(span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]).toBe('route'); + expect(span.attributes[ATTR_NEXT_ROUTE]).toBe('/api/users/[id]'); + }); + + it('strips trailing /route from app router route handler routes', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'POST', + 'next.route': '/api/widgets/route', + }, + 'POST /api/widgets/route', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('POST /api/widgets'); + expect(span.attributes[ATTR_NEXT_ROUTE]).toBe('/api/widgets'); + }); + + it('strips URL query and fragment from the segment name', () => { + const { span, getName } = makeSpan( + { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest' }, + 'GET /search?q=foo#section', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /search'); + }); + + it('does not rename middleware-prefixed routes via the route attribute', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + 'next.route': 'middleware GET', + }, + 'GET /foo', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /foo'); + }); + + it('uses the route backfill attribute when present', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + [TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]: '/posts/[slug]', + }, + 'GET /posts/hello-world', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /posts/[slug]'); + }); + + it('does not apply the backfill for the special GET /_app transaction', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + [TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]: '/posts/[slug]', + }, + 'GET /_app', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /_app'); + }); + + it('normalizes middleware span names and sets http.server.middleware op', () => { + const { span, getName, getOp } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + [ATTR_NEXT_SPAN_NAME]: 'middleware POST /api/protected', + }, + 'middleware POST /api/protected?token=abc', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('middleware POST'); + expect(getOp()).toBe('http.server.middleware'); + }); + + it('writes the middleware op into attributes when the adapter mirrors op writes (streamed shape)', () => { + // Mirrors the `processSegmentSpan` adapter in src/server/index.ts where `setOp` writes back + // into `attributes['sentry.op']` because that is the only op storage for streamed segment spans. + const attributes: Record = { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + [ATTR_NEXT_SPAN_NAME]: 'middleware GET /api', + }; + let name: string | undefined = 'middleware GET /api'; + const span = { + attributes, + getName: () => name, + setName: (n: string) => { + name = n; + }, + setOp: (op: string) => { + attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; + }, + }; + + enhanceHandleRequestRootSpan(span); + + expect(name).toBe('middleware GET'); + expect(attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('http.server.middleware'); + }); + + it('rewrites GET /_error using the http.target attribute', () => { + const { span, getName } = makeSpan( + { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'GET', + 'http.target': '/api/broken', + }, + 'GET /_error', + ); + + enhanceHandleRequestRootSpan(span); + + expect(getName()).toBe('GET /api/broken'); + }); +}); diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 26a73aa676d3..5ef92ae2d890 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -3,6 +3,7 @@ import { GLOBAL_OBJ } from '@sentry/core'; import { getCurrentScope } from '@sentry/node'; import * as SentryNode from '@sentry/node'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/server'; // normally this is set as part of the build process, so mock it here @@ -116,6 +117,40 @@ describe('Server init()', () => { expect(init({})).not.toBeUndefined(); }); + describe('ignoreSpans', () => { + function getIgnoreSpans(): NonNullable { + const callArgs = nodeInit.mock.calls[0]?.[0] as SentryNode.NodeOptions; + return callArgs.ignoreSpans ?? []; + } + + function regexSources(patterns: NonNullable): string[] { + return patterns.filter((p): p is RegExp => p instanceof RegExp).map(p => p.source); + } + + it('appends the Next.js name patterns and attribute filter', () => { + init({}); + const patterns = getIgnoreSpans(); + const sources = regexSources(patterns); + + expect(sources).toContain('^GET (\\/.*)?\\/_next\\/static\\/'); + expect(sources).toContain('\\/__nextjs_original-stack-frame'); + expect(sources).toContain('^\\/404$'); + expect(sources).toContain('^(GET|HEAD|POST|PUT|DELETE|CONNECT|OPTIONS|TRACE|PATCH) \\/(404|_not-found)$'); + expect(sources).toContain('^NextServer\\.getRequestHandler$'); + expect(patterns).toContainEqual({ + attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true }, + }); + }); + + it('preserves user-provided ignoreSpans entries', () => { + init({ ignoreSpans: ['user-pattern', /custom-regex/] }); + const patterns = getIgnoreSpans(); + + expect(patterns).toContain('user-pattern'); + expect(regexSources(patterns)).toContain('custom-regex'); + }); + }); + describe('OpenNext/Cloudflare runtime detection', () => { const cloudflareContextSymbol = Symbol.for('__cloudflare-context__');