Skip to content

Commit fae3a77

Browse files
authored
fix(nextjs): Avoid Edge build warning from OpenTelemetry process.argv0 (#18759)
Fix Next.js Edge build warnings caused by OpenTelemetry’s process.argv0 usage by ensuring OTEL deps are bundled/rewritten in @sentry/vercel-edge and by removing unnecessary @sentry/opentelemetry imports from @sentry/nextjs Edge/shared utilities. closes #18755
1 parent 1535106 commit fae3a77

File tree

7 files changed

+240
-75
lines changed

7 files changed

+240
-75
lines changed

packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions';
2-
import { GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, type Span, type SpanAttributes } from '@sentry/core';
3-
import { isSentryRequestSpan } from '@sentry/opentelemetry';
2+
import {
3+
getClient,
4+
GLOBAL_OBJ,
5+
isSentryRequestUrl,
6+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
7+
type Span,
8+
type SpanAttributes,
9+
} from '@sentry/core';
410
import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes';
511
import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached';
612

@@ -36,6 +42,36 @@ export function dropMiddlewareTunnelRequests(span: Span, attrs: SpanAttributes |
3642
}
3743
}
3844

45+
/**
46+
* Local copy of `@sentry/opentelemetry`'s `isSentryRequestSpan`, to avoid pulling the whole package into Edge bundles.
47+
*/
48+
function isSentryRequestSpan(span: Span): boolean {
49+
const attributes = spanToAttributes(span);
50+
if (!attributes) {
51+
return false;
52+
}
53+
54+
const httpUrl = attributes['http.url'] || attributes['url.full'];
55+
if (!httpUrl) {
56+
return false;
57+
}
58+
59+
return isSentryRequestUrl(httpUrl.toString(), getClient());
60+
}
61+
62+
function spanToAttributes(span: Span): Record<string, unknown> | undefined {
63+
// OTEL spans expose attributes in different shapes depending on implementation.
64+
// We only need best-effort read access.
65+
type MaybeSpanAttributes = {
66+
attributes?: Record<string, unknown>;
67+
_attributes?: Record<string, unknown>;
68+
};
69+
70+
const maybeSpan = span as unknown as MaybeSpanAttributes;
71+
const attrs = maybeSpan.attributes || maybeSpan._attributes;
72+
return attrs;
73+
}
74+
3975
/**
4076
* Checks if a span's HTTP target matches the tunnel route.
4177
*/

packages/nextjs/src/edge/index.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// import/export got a false positive, and affects most of our index barrel files
22
// can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703
33
/* eslint-disable import/export */
4-
import { context } from '@opentelemetry/api';
4+
import { context, createContextKey } from '@opentelemetry/api';
55
import {
66
applySdkMetadata,
77
type EventProcessor,
@@ -12,14 +12,14 @@ import {
1212
getRootSpan,
1313
GLOBAL_OBJ,
1414
registerSpanErrorInstrumentation,
15+
type Scope,
1516
SEMANTIC_ATTRIBUTE_SENTRY_OP,
1617
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1718
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
1819
setCapturedScopesOnSpan,
1920
spanToJSON,
2021
stripUrlQueryAndFragment,
2122
} from '@sentry/core';
22-
import { getScopesFromContext } from '@sentry/opentelemetry';
2323
import type { VercelEdgeOptions } from '@sentry/vercel-edge';
2424
import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge';
2525
import { DEBUG_BUILD } from '../common/debug-build';
@@ -42,6 +42,32 @@ export { wrapApiHandlerWithSentry } from './wrapApiHandlerWithSentry';
4242

4343
export type EdgeOptions = VercelEdgeOptions;
4444

45+
type CurrentScopes = {
46+
scope: Scope;
47+
isolationScope: Scope;
48+
};
49+
50+
// This key must match `@sentry/opentelemetry`'s `SENTRY_SCOPES_CONTEXT_KEY`.
51+
// We duplicate it here so the Edge bundle does not need to import the full `@sentry/opentelemetry` package.
52+
const SENTRY_SCOPES_CONTEXT_KEY = createContextKey('sentry_scopes');
53+
54+
type ContextWithGetValue = {
55+
getValue(key: unknown): unknown;
56+
};
57+
58+
function getScopesFromContext(otelContext: unknown): CurrentScopes | undefined {
59+
if (!otelContext || typeof otelContext !== 'object') {
60+
return undefined;
61+
}
62+
63+
const maybeContext = otelContext as Partial<ContextWithGetValue>;
64+
if (typeof maybeContext.getValue !== 'function') {
65+
return undefined;
66+
}
67+
68+
return maybeContext.getValue(SENTRY_SCOPES_CONTEXT_KEY) as CurrentScopes | undefined;
69+
}
70+
4571
const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
4672
_sentryRewriteFramesDistDir?: string;
4773
_sentryRelease?: string;

packages/opentelemetry/src/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export const SENTRY_TRACE_STATE_URL = 'sentry.url';
99
export const SENTRY_TRACE_STATE_SAMPLE_RAND = 'sentry.sample_rand';
1010
export const SENTRY_TRACE_STATE_SAMPLE_RATE = 'sentry.sample_rate';
1111

12+
// NOTE: `@sentry/nextjs` has a local copy of this context key for Edge bundles:
13+
// - `packages/nextjs/src/edge/index.ts` (`SENTRY_SCOPES_CONTEXT_KEY`)
14+
//
15+
// If you change the key name passed to `createContextKey(...)`, update that file too.
1216
export const SENTRY_SCOPES_CONTEXT_KEY = createContextKey('sentry_scopes');
1317

1418
export const SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY = createContextKey('sentry_fork_isolation_scope');

packages/opentelemetry/src/utils/contextData.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ const SCOPE_CONTEXT_FIELD = '_scopeContext';
1111
* This requires a Context Manager that was wrapped with getWrappedContextManager.
1212
*/
1313
export function getScopesFromContext(context: Context): CurrentScopes | undefined {
14+
// NOTE: `@sentry/nextjs` has a local copy of this helper for Edge bundles:
15+
// - `packages/nextjs/src/edge/index.ts` (`getScopesFromContext`)
16+
//
17+
// If you change how scopes are stored/read (key or retrieval), update that file too.
1418
return context.getValue(SENTRY_SCOPES_CONTEXT_KEY) as CurrentScopes | undefined;
1519
}
1620

packages/opentelemetry/src/utils/isSentryRequest.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { spanHasAttributes } from './spanTypes';
99
* @returns boolean
1010
*/
1111
export function isSentryRequestSpan(span: AbstractSpan): boolean {
12+
// NOTE: `@sentry/nextjs` has a local copy of this helper for Edge bundles:
13+
// - `packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts` (`isSentryRequestSpan`)
14+
//
15+
// If you change supported OTEL attribute keys or request detection logic, update that file too.
1216
if (!spanHasAttributes(span)) {
1317
return false;
1418
}
Lines changed: 130 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,138 @@
11
import replace from '@rollup/plugin-replace';
22
import { makeBaseNPMConfig, makeNPMConfigVariants, plugins } from '@sentry-internal/rollup-utils';
33

4-
export default makeNPMConfigVariants(
5-
makeBaseNPMConfig({
6-
entrypoints: ['src/index.ts'],
7-
bundledBuiltins: ['perf_hooks', 'util'],
8-
packageSpecificConfig: {
9-
context: 'globalThis',
10-
output: {
11-
preserveModules: false,
12-
},
13-
plugins: [
14-
plugins.makeCommonJSPlugin({ transformMixedEsModules: true }), // Needed because various modules in the OTEL toolchain use CJS (require-in-the-middle, shimmer, etc..)
15-
plugins.makeJsonPlugin(), // Needed because `require-in-the-middle` imports json via require
16-
replace({
17-
preventAssignment: true,
18-
values: {
19-
'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.
20-
},
21-
}),
22-
{
23-
// This plugin is needed because otel imports `performance` from `perf_hooks` and also uses it via the `performance` global.
24-
// 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.
25-
// Both of these APIs are not available in the edge runtime so we need to define a polyfill.
26-
// Vercel does something similar in the `@vercel/otel` package: https://github.com/vercel/otel/blob/087601ae585cb116bb2b46c211d014520de76c71/packages/otel/build.ts#L62
27-
name: 'edge-runtime-polyfills',
28-
banner: `
29-
{
30-
if (globalThis.performance === undefined) {
31-
globalThis.performance = {
32-
timeOrigin: 0,
33-
now: () => Date.now()
34-
};
35-
}
36-
}
37-
`,
38-
resolveId: source => {
39-
if (source === 'perf_hooks') {
40-
return '\0perf_hooks_sentry_shim';
41-
} else if (source === 'util') {
42-
return '\0util_sentry_shim';
43-
} else {
44-
return null;
4+
const downlevelLogicalAssignmentsPlugin = {
5+
name: 'downlevel-logical-assignments',
6+
renderChunk(code) {
7+
// ES2021 logical assignment operators (`||=`, `&&=`, `??=`) are not allowed by our ES2020 compatibility check.
8+
// OTEL currently ships some of these, so we downlevel them in the final output.
9+
//
10+
// Note: This is intentionally conservative (only matches property access-like LHS) to avoid duplicating side effects.
11+
// IMPORTANT: Use regex literals (not `String.raw` + `RegExp(...)`) to avoid accidental double-escaping.
12+
let out = code;
13+
14+
// ??=
15+
out = out.replace(/([A-Za-z_$][\w$]*(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)+)\s*\?\?=\s*([^;]+);/g, (_m, left, right) => {
16+
return `${left} = ${left} ?? ${right};`;
17+
});
18+
19+
// ||=
20+
out = out.replace(/([A-Za-z_$][\w$]*(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)+)\s*\|\|=\s*([^;]+);/g, (_m, left, right) => {
21+
return `${left} = ${left} || ${right};`;
22+
});
23+
24+
// &&=
25+
out = out.replace(/([A-Za-z_$][\w$]*(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)+)\s*&&=\s*([^;]+);/g, (_m, left, right) => {
26+
return `${left} = ${left} && ${right};`;
27+
});
28+
29+
return { code: out, map: null };
30+
},
31+
};
32+
33+
const baseConfig = makeBaseNPMConfig({
34+
entrypoints: ['src/index.ts'],
35+
bundledBuiltins: ['perf_hooks', 'util'],
36+
packageSpecificConfig: {
37+
context: 'globalThis',
38+
output: {
39+
preserveModules: false,
40+
},
41+
plugins: [
42+
plugins.makeCommonJSPlugin({ transformMixedEsModules: true }), // Needed because various modules in the OTEL toolchain use CJS (require-in-the-middle, shimmer, etc..)
43+
plugins.makeJsonPlugin(), // Needed because `require-in-the-middle` imports json via require
44+
replace({
45+
preventAssignment: true,
46+
values: {
47+
'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.
48+
},
49+
}),
50+
{
51+
// This plugin is needed because otel imports `performance` from `perf_hooks` and also uses it via the `performance` global.
52+
// 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.
53+
// Both of these APIs are not available in the edge runtime so we need to define a polyfill.
54+
// Vercel does something similar in the `@vercel/otel` package: https://github.com/vercel/otel/blob/087601ae585cb116bb2b46c211d014520de76c71/packages/otel/build.ts#L62
55+
name: 'edge-runtime-polyfills',
56+
banner: `
57+
{
58+
if (globalThis.performance === undefined) {
59+
globalThis.performance = {
60+
timeOrigin: 0,
61+
now: () => Date.now()
62+
};
4563
}
46-
},
47-
load: id => {
48-
if (id === '\0perf_hooks_sentry_shim') {
49-
return `
50-
export const performance = {
51-
timeOrigin: 0,
52-
now: () => Date.now()
53-
}
54-
`;
55-
} else if (id === '\0util_sentry_shim') {
56-
return `
57-
export const inspect = (object) =>
58-
JSON.stringify(object, null, 2);
64+
}
65+
`,
66+
resolveId: source => {
67+
if (source === 'perf_hooks') {
68+
return '\0perf_hooks_sentry_shim';
69+
} else if (source === 'util') {
70+
return '\0util_sentry_shim';
71+
} else {
72+
return null;
73+
}
74+
},
75+
load: id => {
76+
if (id === '\0perf_hooks_sentry_shim') {
77+
return `
78+
export const performance = {
79+
timeOrigin: 0,
80+
now: () => Date.now()
81+
}
82+
`;
83+
} else if (id === '\0util_sentry_shim') {
84+
return `
85+
export const inspect = (object) =>
86+
JSON.stringify(object, null, 2);
5987
60-
export const promisify = (fn) => {
61-
return (...args) => {
62-
return new Promise((resolve, reject) => {
63-
fn(...args, (err, result) => {
64-
if (err) reject(err);
65-
else resolve(result);
66-
});
88+
export const promisify = (fn) => {
89+
return (...args) => {
90+
return new Promise((resolve, reject) => {
91+
fn(...args, (err, result) => {
92+
if (err) reject(err);
93+
else resolve(result);
6794
});
68-
};
95+
});
6996
};
70-
`;
71-
} else {
72-
return null;
73-
}
74-
},
97+
};
98+
`;
99+
} else {
100+
return null;
101+
}
75102
},
76-
],
77-
},
78-
}),
79-
);
103+
},
104+
downlevelLogicalAssignmentsPlugin,
105+
],
106+
},
107+
});
108+
109+
// `makeBaseNPMConfig` marks dependencies/peers as external by default.
110+
// For Edge, we must ensure the OTEL SDK bits which reference `process.argv0` are bundled so our replace() plugin applies.
111+
const baseExternal = baseConfig.external;
112+
baseConfig.external = (source, importer, isResolved) => {
113+
// Never treat these as external - they need to be inlined so `process.argv0` can be replaced.
114+
if (
115+
source === '@opentelemetry/resources' ||
116+
source.startsWith('@opentelemetry/resources/') ||
117+
source === '@opentelemetry/sdk-trace-base' ||
118+
source.startsWith('@opentelemetry/sdk-trace-base/')
119+
) {
120+
return false;
121+
}
122+
123+
if (typeof baseExternal === 'function') {
124+
return baseExternal(source, importer, isResolved);
125+
}
126+
127+
if (Array.isArray(baseExternal)) {
128+
return baseExternal.includes(source);
129+
}
130+
131+
if (baseExternal instanceof RegExp) {
132+
return baseExternal.test(source);
133+
}
134+
135+
return false;
136+
};
137+
138+
export default makeNPMConfigVariants(baseConfig);
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { readFileSync } from 'fs';
2+
import { join } from 'path';
3+
import { describe, expect, it } from 'vitest';
4+
5+
function readBuildFile(relativePathFromPackageRoot: string): string {
6+
const filePath = join(process.cwd(), relativePathFromPackageRoot);
7+
return readFileSync(filePath, 'utf8');
8+
}
9+
10+
describe('build artifacts', () => {
11+
it('does not contain Node-only `process.argv0` usage (Edge compatibility)', () => {
12+
const cjs = readBuildFile('build/cjs/index.js');
13+
const esm = readBuildFile('build/esm/index.js');
14+
15+
expect(cjs).not.toContain('process.argv0');
16+
expect(esm).not.toContain('process.argv0');
17+
});
18+
19+
it('does not contain ES2021 logical assignment operators (ES2020 compatibility)', () => {
20+
const cjs = readBuildFile('build/cjs/index.js');
21+
const esm = readBuildFile('build/esm/index.js');
22+
23+
// ES2021 operators which `es-check es2020` rejects
24+
expect(cjs).not.toContain('??=');
25+
expect(cjs).not.toContain('||=');
26+
expect(cjs).not.toContain('&&=');
27+
28+
expect(esm).not.toContain('??=');
29+
expect(esm).not.toContain('||=');
30+
expect(esm).not.toContain('&&=');
31+
});
32+
});

0 commit comments

Comments
 (0)