diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index e8cde6e94baf..29e2ee55e45e 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -1,6 +1,12 @@ import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; -import { GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, type Span, type SpanAttributes } from '@sentry/core'; -import { isSentryRequestSpan } from '@sentry/opentelemetry'; +import { + getClient, + GLOBAL_OBJ, + isSentryRequestUrl, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + type Span, + type SpanAttributes, +} from '@sentry/core'; import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; @@ -36,6 +42,36 @@ export function dropMiddlewareTunnelRequests(span: Span, attrs: SpanAttributes | } } +/** + * Local copy of `@sentry/opentelemetry`'s `isSentryRequestSpan`, to avoid pulling the whole package into Edge bundles. + */ +function isSentryRequestSpan(span: Span): boolean { + const attributes = spanToAttributes(span); + if (!attributes) { + return false; + } + + const httpUrl = attributes['http.url'] || attributes['url.full']; + if (!httpUrl) { + return false; + } + + return isSentryRequestUrl(httpUrl.toString(), getClient()); +} + +function spanToAttributes(span: Span): Record | undefined { + // OTEL spans expose attributes in different shapes depending on implementation. + // We only need best-effort read access. + type MaybeSpanAttributes = { + attributes?: Record; + _attributes?: Record; + }; + + const maybeSpan = span as unknown as MaybeSpanAttributes; + const attrs = maybeSpan.attributes || maybeSpan._attributes; + return attrs; +} + /** * Checks if a span's HTTP target matches the tunnel route. */ diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 9fa05c94e978..94c71a52c483 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,7 +1,7 @@ // 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 { context } from '@opentelemetry/api'; +import { context, createContextKey } from '@opentelemetry/api'; import { applySdkMetadata, type EventProcessor, @@ -12,6 +12,7 @@ import { getRootSpan, GLOBAL_OBJ, registerSpanErrorInstrumentation, + type Scope, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -19,7 +20,6 @@ import { spanToJSON, stripUrlQueryAndFragment, } from '@sentry/core'; -import { getScopesFromContext } from '@sentry/opentelemetry'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -42,6 +42,32 @@ export { wrapApiHandlerWithSentry } from './wrapApiHandlerWithSentry'; export type EdgeOptions = VercelEdgeOptions; +type CurrentScopes = { + scope: Scope; + isolationScope: Scope; +}; + +// This key must match `@sentry/opentelemetry`'s `SENTRY_SCOPES_CONTEXT_KEY`. +// We duplicate it here so the Edge bundle does not need to import the full `@sentry/opentelemetry` package. +const SENTRY_SCOPES_CONTEXT_KEY = createContextKey('sentry_scopes'); + +type ContextWithGetValue = { + getValue(key: unknown): unknown; +}; + +function getScopesFromContext(otelContext: unknown): CurrentScopes | undefined { + if (!otelContext || typeof otelContext !== 'object') { + return undefined; + } + + const maybeContext = otelContext as Partial; + if (typeof maybeContext.getValue !== 'function') { + return undefined; + } + + return maybeContext.getValue(SENTRY_SCOPES_CONTEXT_KEY) as CurrentScopes | undefined; +} + const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; _sentryRelease?: string; diff --git a/packages/opentelemetry/src/constants.ts b/packages/opentelemetry/src/constants.ts index 375e42dfdd00..3500ad6c4782 100644 --- a/packages/opentelemetry/src/constants.ts +++ b/packages/opentelemetry/src/constants.ts @@ -9,6 +9,10 @@ export const SENTRY_TRACE_STATE_URL = 'sentry.url'; export const SENTRY_TRACE_STATE_SAMPLE_RAND = 'sentry.sample_rand'; export const SENTRY_TRACE_STATE_SAMPLE_RATE = 'sentry.sample_rate'; +// NOTE: `@sentry/nextjs` has a local copy of this context key for Edge bundles: +// - `packages/nextjs/src/edge/index.ts` (`SENTRY_SCOPES_CONTEXT_KEY`) +// +// If you change the key name passed to `createContextKey(...)`, update that file too. export const SENTRY_SCOPES_CONTEXT_KEY = createContextKey('sentry_scopes'); export const SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY = createContextKey('sentry_fork_isolation_scope'); diff --git a/packages/opentelemetry/src/utils/contextData.ts b/packages/opentelemetry/src/utils/contextData.ts index 468b377f9ccd..78577131d0c7 100644 --- a/packages/opentelemetry/src/utils/contextData.ts +++ b/packages/opentelemetry/src/utils/contextData.ts @@ -11,6 +11,10 @@ const SCOPE_CONTEXT_FIELD = '_scopeContext'; * This requires a Context Manager that was wrapped with getWrappedContextManager. */ export function getScopesFromContext(context: Context): CurrentScopes | undefined { + // NOTE: `@sentry/nextjs` has a local copy of this helper for Edge bundles: + // - `packages/nextjs/src/edge/index.ts` (`getScopesFromContext`) + // + // If you change how scopes are stored/read (key or retrieval), update that file too. return context.getValue(SENTRY_SCOPES_CONTEXT_KEY) as CurrentScopes | undefined; } diff --git a/packages/opentelemetry/src/utils/isSentryRequest.ts b/packages/opentelemetry/src/utils/isSentryRequest.ts index d6b59880137b..6e06bcf5ab2e 100644 --- a/packages/opentelemetry/src/utils/isSentryRequest.ts +++ b/packages/opentelemetry/src/utils/isSentryRequest.ts @@ -9,6 +9,10 @@ import { spanHasAttributes } from './spanTypes'; * @returns boolean */ export function isSentryRequestSpan(span: AbstractSpan): boolean { + // NOTE: `@sentry/nextjs` has a local copy of this helper for Edge bundles: + // - `packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts` (`isSentryRequestSpan`) + // + // If you change supported OTEL attribute keys or request detection logic, update that file too. if (!spanHasAttributes(span)) { return false; } diff --git a/packages/vercel-edge/rollup.npm.config.mjs b/packages/vercel-edge/rollup.npm.config.mjs index ae01f43703d0..d8f1704e2f8a 100644 --- a/packages/vercel-edge/rollup.npm.config.mjs +++ b/packages/vercel-edge/rollup.npm.config.mjs @@ -1,79 +1,138 @@ import replace from '@rollup/plugin-replace'; import { makeBaseNPMConfig, makeNPMConfigVariants, plugins } from '@sentry-internal/rollup-utils'; -export default makeNPMConfigVariants( - makeBaseNPMConfig({ - entrypoints: ['src/index.ts'], - bundledBuiltins: ['perf_hooks', 'util'], - packageSpecificConfig: { - context: 'globalThis', - output: { - preserveModules: false, - }, - plugins: [ - plugins.makeCommonJSPlugin({ transformMixedEsModules: true }), // Needed because various modules in the OTEL toolchain use CJS (require-in-the-middle, shimmer, etc..) - plugins.makeJsonPlugin(), // Needed because `require-in-the-middle` imports json via require - replace({ - preventAssignment: true, - values: { - 'process.argv0': JSON.stringify(''), // needed because otel relies on process.argv0 for the default service name, but that api is not available in the edge runtime. - }, - }), - { - // This plugin is needed because otel imports `performance` from `perf_hooks` and also uses it via the `performance` global. - // It also imports `inspect` and `promisify` from node's `util` which are not available in the edge runtime so we need to define a polyfill. - // Both of these APIs are not available in the edge runtime so we need to define a polyfill. - // Vercel does something similar in the `@vercel/otel` package: https://github.com/vercel/otel/blob/087601ae585cb116bb2b46c211d014520de76c71/packages/otel/build.ts#L62 - name: 'edge-runtime-polyfills', - banner: ` - { - if (globalThis.performance === undefined) { - globalThis.performance = { - timeOrigin: 0, - now: () => Date.now() - }; - } - } - `, - resolveId: source => { - if (source === 'perf_hooks') { - return '\0perf_hooks_sentry_shim'; - } else if (source === 'util') { - return '\0util_sentry_shim'; - } else { - return null; +const downlevelLogicalAssignmentsPlugin = { + name: 'downlevel-logical-assignments', + renderChunk(code) { + // ES2021 logical assignment operators (`||=`, `&&=`, `??=`) are not allowed by our ES2020 compatibility check. + // OTEL currently ships some of these, so we downlevel them in the final output. + // + // Note: This is intentionally conservative (only matches property access-like LHS) to avoid duplicating side effects. + // IMPORTANT: Use regex literals (not `String.raw` + `RegExp(...)`) to avoid accidental double-escaping. + let out = code; + + // ??= + out = out.replace(/([A-Za-z_$][\w$]*(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)+)\s*\?\?=\s*([^;]+);/g, (_m, left, right) => { + return `${left} = ${left} ?? ${right};`; + }); + + // ||= + out = out.replace(/([A-Za-z_$][\w$]*(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)+)\s*\|\|=\s*([^;]+);/g, (_m, left, right) => { + return `${left} = ${left} || ${right};`; + }); + + // &&= + out = out.replace(/([A-Za-z_$][\w$]*(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)+)\s*&&=\s*([^;]+);/g, (_m, left, right) => { + return `${left} = ${left} && ${right};`; + }); + + return { code: out, map: null }; + }, +}; + +const baseConfig = makeBaseNPMConfig({ + entrypoints: ['src/index.ts'], + bundledBuiltins: ['perf_hooks', 'util'], + packageSpecificConfig: { + context: 'globalThis', + output: { + preserveModules: false, + }, + plugins: [ + plugins.makeCommonJSPlugin({ transformMixedEsModules: true }), // Needed because various modules in the OTEL toolchain use CJS (require-in-the-middle, shimmer, etc..) + plugins.makeJsonPlugin(), // Needed because `require-in-the-middle` imports json via require + replace({ + preventAssignment: true, + values: { + 'process.argv0': JSON.stringify(''), // needed because otel relies on process.argv0 for the default service name, but that api is not available in the edge runtime. + }, + }), + { + // This plugin is needed because otel imports `performance` from `perf_hooks` and also uses it via the `performance` global. + // It also imports `inspect` and `promisify` from node's `util` which are not available in the edge runtime so we need to define a polyfill. + // Both of these APIs are not available in the edge runtime so we need to define a polyfill. + // Vercel does something similar in the `@vercel/otel` package: https://github.com/vercel/otel/blob/087601ae585cb116bb2b46c211d014520de76c71/packages/otel/build.ts#L62 + name: 'edge-runtime-polyfills', + banner: ` + { + if (globalThis.performance === undefined) { + globalThis.performance = { + timeOrigin: 0, + now: () => Date.now() + }; } - }, - load: id => { - if (id === '\0perf_hooks_sentry_shim') { - return ` - export const performance = { - timeOrigin: 0, - now: () => Date.now() - } - `; - } else if (id === '\0util_sentry_shim') { - return ` - export const inspect = (object) => - JSON.stringify(object, null, 2); + } + `, + resolveId: source => { + if (source === 'perf_hooks') { + return '\0perf_hooks_sentry_shim'; + } else if (source === 'util') { + return '\0util_sentry_shim'; + } else { + return null; + } + }, + load: id => { + if (id === '\0perf_hooks_sentry_shim') { + return ` + export const performance = { + timeOrigin: 0, + now: () => Date.now() + } + `; + } else if (id === '\0util_sentry_shim') { + return ` + export const inspect = (object) => + JSON.stringify(object, null, 2); - export const promisify = (fn) => { - return (...args) => { - return new Promise((resolve, reject) => { - fn(...args, (err, result) => { - if (err) reject(err); - else resolve(result); - }); + export const promisify = (fn) => { + return (...args) => { + return new Promise((resolve, reject) => { + fn(...args, (err, result) => { + if (err) reject(err); + else resolve(result); }); - }; + }); }; - `; - } else { - return null; - } - }, + }; + `; + } else { + return null; + } }, - ], - }, - }), -); + }, + downlevelLogicalAssignmentsPlugin, + ], + }, +}); + +// `makeBaseNPMConfig` marks dependencies/peers as external by default. +// For Edge, we must ensure the OTEL SDK bits which reference `process.argv0` are bundled so our replace() plugin applies. +const baseExternal = baseConfig.external; +baseConfig.external = (source, importer, isResolved) => { + // Never treat these as external - they need to be inlined so `process.argv0` can be replaced. + if ( + source === '@opentelemetry/resources' || + source.startsWith('@opentelemetry/resources/') || + source === '@opentelemetry/sdk-trace-base' || + source.startsWith('@opentelemetry/sdk-trace-base/') + ) { + return false; + } + + if (typeof baseExternal === 'function') { + return baseExternal(source, importer, isResolved); + } + + if (Array.isArray(baseExternal)) { + return baseExternal.includes(source); + } + + if (baseExternal instanceof RegExp) { + return baseExternal.test(source); + } + + return false; +}; + +export default makeNPMConfigVariants(baseConfig); diff --git a/packages/vercel-edge/test/build-artifacts.test.ts b/packages/vercel-edge/test/build-artifacts.test.ts new file mode 100644 index 000000000000..c4994f4f8b29 --- /dev/null +++ b/packages/vercel-edge/test/build-artifacts.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; + +function readBuildFile(relativePathFromPackageRoot: string): string { + const filePath = join(process.cwd(), relativePathFromPackageRoot); + return readFileSync(filePath, 'utf8'); +} + +describe('build artifacts', () => { + it('does not contain Node-only `process.argv0` usage (Edge compatibility)', () => { + const cjs = readBuildFile('build/cjs/index.js'); + const esm = readBuildFile('build/esm/index.js'); + + expect(cjs).not.toContain('process.argv0'); + expect(esm).not.toContain('process.argv0'); + }); + + it('does not contain ES2021 logical assignment operators (ES2020 compatibility)', () => { + const cjs = readBuildFile('build/cjs/index.js'); + const esm = readBuildFile('build/esm/index.js'); + + // ES2021 operators which `es-check es2020` rejects + expect(cjs).not.toContain('??='); + expect(cjs).not.toContain('||='); + expect(cjs).not.toContain('&&='); + + expect(esm).not.toContain('??='); + expect(esm).not.toContain('||='); + expect(esm).not.toContain('&&='); + }); +});