Skip to content

feat(nextjs): Support edge runtime #6730

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
},
"main": "build/cjs/index.js",
"module": "build/esm/index.js",
"browser": "build/esm/client/index.js",
"types": "build/types/index.types.d.ts",
"publishConfig": {
"access": "public"
Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/rollup.npm.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default [
makeBaseNPMConfig({
// We need to include `instrumentServer.ts` separately because it's only conditionally required, and so rollup
// doesn't automatically include it when calculating the module dependency tree.
entrypoints: ['src/index.ts', 'src/client/index.ts', 'src/config/webpack.ts'],
entrypoints: ['src/index.ts', 'src/client/index.ts', 'src/edge/index.ts', 'src/config/webpack.ts'],

// prevent this internal nextjs code from ending up in our built package (this doesn't happen automatially because
// the name doesn't match an SDK dependency)
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/src/config/loaders/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as valueInjectionLoader } from './valueInjectionLoader';
export { default as prefixLoader } from './prefixLoader';
export { default as wrappingLoader } from './wrappingLoader';
export { default as sdkMultiplexerLoader } from './sdkMultiplexerLoader';
24 changes: 24 additions & 0 deletions packages/nextjs/src/config/loaders/sdkMultiplexerLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { LoaderThis } from './types';

type LoaderOptions = {
importTarget: string;
};

/**
* This loader allows us to multiplex SDKs depending on what is passed to the `importTarget` loader option.
* If this loader encounters a file that contains the string "__SENTRY_SDK_MULTIPLEXER__" it will replace it's entire
* content with an "export all"-statement that points to `importTarget`.
*
* In our case we use this to multiplex different SDKs depending on whether we're bundling browser code, server code,
* or edge-runtime code.
*/
export default function sdkMultiplexerLoader(this: LoaderThis<LoaderOptions>, userCode: string): string {
if (!userCode.includes('__SENTRY_SDK_MULTIPLEXER__')) {
return userCode;
}

// We know one or the other will be defined, depending on the version of webpack being used
const { importTarget } = 'getOptions' in this ? this.getOptions() : this.query;

return `export * from "${importTarget}";`;
}
68 changes: 34 additions & 34 deletions packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,17 @@ export function constructWebpackConfigFunction(
// Add a loader which will inject code that sets global values
addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions);

if (buildContext.nextRuntime === 'edge') {
// eslint-disable-next-line no-console
console.warn(
'[@sentry/nextjs] You are using edge functions or middleware. Please note that Sentry does not yet support error monitoring for these features.',
);
}
newConfig.module.rules.push({
test: /node_modules\/@sentry\/nextjs/,
use: [
{
loader: path.resolve(__dirname, 'loaders/sdkMultiplexerLoader.js'),
options: {
importTarget: buildContext.nextRuntime === 'edge' ? './edge' : './client',
},
},
],
});

if (isServer) {
if (userSentryOptions.autoInstrumentServerFunctions !== false) {
Expand Down Expand Up @@ -301,28 +306,25 @@ async function addSentryToEntryProperty(
// we know is that it won't have gotten *simpler* in form, so we only need to worry about the object and function
// options. See https://webpack.js.org/configuration/entry-context/#entry.

const { isServer, dir: projectDir, dev: isDev } = buildContext;
const { isServer, dir: projectDir, dev: isDev, nextRuntime } = buildContext;

const newEntryProperty =
typeof currentEntryProperty === 'function' ? await currentEntryProperty() : { ...currentEntryProperty };

// `sentry.server.config.js` or `sentry.client.config.js` (or their TS equivalents)
const userConfigFile = isServer ? getUserConfigFile(projectDir, 'server') : getUserConfigFile(projectDir, 'client');
const userConfigFile =
nextRuntime === 'edge'
? getUserConfigFile(projectDir, 'edge')
: isServer
? getUserConfigFile(projectDir, 'server')
: getUserConfigFile(projectDir, 'client');

// we need to turn the filename into a path so webpack can find it
const filesToInject = [`./${userConfigFile}`];
const filesToInject = userConfigFile ? [`./${userConfigFile}`] : [];

// inject into all entry points which might contain user's code
for (const entryPointName in newEntryProperty) {
if (
shouldAddSentryToEntryPoint(
entryPointName,
isServer,
userSentryOptions.excludeServerRoutes,
isDev,
buildContext.nextRuntime === 'edge',
)
) {
if (shouldAddSentryToEntryPoint(entryPointName, isServer, userSentryOptions.excludeServerRoutes, isDev)) {
addFilesToExistingEntryPoint(newEntryProperty, entryPointName, filesToInject);
} else {
if (
Expand All @@ -348,7 +350,7 @@ async function addSentryToEntryProperty(
* @param platform Either "server" or "client", so that we know which file to look for
* @returns The name of the relevant file. If no file is found, this method throws an error.
*/
export function getUserConfigFile(projectDir: string, platform: 'server' | 'client'): string {
export function getUserConfigFile(projectDir: string, platform: 'server' | 'client' | 'edge'): string | undefined {
const possibilities = [`sentry.${platform}.config.ts`, `sentry.${platform}.config.js`];

for (const filename of possibilities) {
Expand All @@ -357,7 +359,16 @@ export function getUserConfigFile(projectDir: string, platform: 'server' | 'clie
}
}

throw new Error(`Cannot find '${possibilities[0]}' or '${possibilities[1]}' in '${projectDir}'.`);
// Edge config file is optional
if (platform === 'edge') {
// eslint-disable-next-line no-console
console.warn(
'[@sentry/nextjs] You are using Next.js features that run on the Edge Runtime. Please add a "sentry.edge.config.js" or a "sentry.edge.config.ts" file to your project root in which you initialize the Sentry SDK with "Sentry.init()".',
);
return;
} else {
throw new Error(`Cannot find '${possibilities[0]}' or '${possibilities[1]}' in '${projectDir}'.`);
}
}

/**
Expand Down Expand Up @@ -449,11 +460,9 @@ function shouldAddSentryToEntryPoint(
isServer: boolean,
excludeServerRoutes: Array<string | RegExp> = [],
isDev: boolean,
isEdgeRuntime: boolean,
): boolean {
// We don't support the Edge runtime yet
if (isEdgeRuntime) {
return false;
if (entryPointName === 'middleware') {
return true;
}

// On the server side, by default we inject the `Sentry.init()` code into every page (with a few exceptions).
Expand All @@ -479,9 +488,6 @@ function shouldAddSentryToEntryPoint(
// versions.)
entryPointRoute === '/_app' ||
entryPointRoute === '/_document' ||
// While middleware was in beta, it could be anywhere (at any level) in the `pages` directory, and would be called
// `_middleware.js`. Until the SDK runs successfully in the lambda edge environment, we have to exclude these.
entryPointName.includes('_middleware') ||
// Newer versions of nextjs are starting to introduce things outside the `pages/` folder (middleware, an `app/`
// directory, etc), but until those features are stable and we know how we want to support them, the safest bet is
// not to inject anywhere but inside `pages/`.
Expand Down Expand Up @@ -552,13 +558,7 @@ export function getWebpackPluginOptions(
stripPrefix: ['webpack://_N_E/'],
urlPrefix,
entries: (entryPointName: string) =>
shouldAddSentryToEntryPoint(
entryPointName,
isServer,
userSentryOptions.excludeServerRoutes,
isDev,
buildContext.nextRuntime === 'edge',
),
shouldAddSentryToEntryPoint(entryPointName, isServer, userSentryOptions.excludeServerRoutes, isDev),
release: getSentryRelease(buildId),
dryRun: isDev,
});
Expand Down
69 changes: 69 additions & 0 deletions packages/nextjs/src/edge/edgeclient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { Scope } from '@sentry/core';
import { BaseClient, SDK_VERSION } from '@sentry/core';
import type { ClientOptions, Event, EventHint, Severity, SeverityLevel } from '@sentry/types';

import { eventFromMessage, eventFromUnknownInput } from './eventbuilder';
import type { EdgeTransportOptions } from './transport';

export type EdgeClientOptions = ClientOptions<EdgeTransportOptions>;

/**
* The Sentry Edge SDK Client.
*/
export class EdgeClient extends BaseClient<EdgeClientOptions> {
/**
* Creates a new Edge SDK instance.
* @param options Configuration options for this SDK.
*/
public constructor(options: EdgeClientOptions) {
options._metadata = options._metadata || {};
options._metadata.sdk = options._metadata.sdk || {
name: 'sentry.javascript.nextjs',
packages: [
{
name: 'npm:@sentry/nextjs',
version: SDK_VERSION,
},
],
version: SDK_VERSION,
};

super(options);
}

/**
* @inheritDoc
*/
public eventFromException(exception: unknown, hint?: EventHint): PromiseLike<Event> {
return Promise.resolve(eventFromUnknownInput(this._options.stackParser, exception, hint));
}

/**
* @inheritDoc
*/
public eventFromMessage(
message: string,
// eslint-disable-next-line deprecation/deprecation
level: Severity | SeverityLevel = 'info',
hint?: EventHint,
): PromiseLike<Event> {
return Promise.resolve(
eventFromMessage(this._options.stackParser, message, level, hint, this._options.attachStacktrace),
);
}

/**
* @inheritDoc
*/
protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike<Event | null> {
event.platform = event.platform || 'edge';
event.contexts = {
...event.contexts,
runtime: event.contexts?.runtime || {
name: 'edge',
},
};
event.server_name = event.server_name || process.env.SENTRY_NAME;
return super._prepareEvent(event, hint, scope);
}
}
130 changes: 130 additions & 0 deletions packages/nextjs/src/edge/eventbuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { getCurrentHub } from '@sentry/core';
import type {
Event,
EventHint,
Exception,
Mechanism,
Severity,
SeverityLevel,
StackFrame,
StackParser,
} from '@sentry/types';
import {
addExceptionMechanism,
addExceptionTypeValue,
extractExceptionKeysForMessage,
isError,
isPlainObject,
normalizeToSize,
} from '@sentry/utils';

/**
* Extracts stack frames from the error.stack string
*/
export function parseStackFrames(stackParser: StackParser, error: Error): StackFrame[] {
return stackParser(error.stack || '', 1);
}

/**
* Extracts stack frames from the error and builds a Sentry Exception
*/
export function exceptionFromError(stackParser: StackParser, error: Error): Exception {
const exception: Exception = {
type: error.name || error.constructor.name,
value: error.message,
};

const frames = parseStackFrames(stackParser, error);
if (frames.length) {
exception.stacktrace = { frames };
}

return exception;
}

/**
* Builds and Event from a Exception
* @hidden
*/
export function eventFromUnknownInput(stackParser: StackParser, exception: unknown, hint?: EventHint): Event {
let ex: unknown = exception;
const providedMechanism: Mechanism | undefined =
hint && hint.data && (hint.data as { mechanism: Mechanism }).mechanism;
const mechanism: Mechanism = providedMechanism || {
handled: true,
type: 'generic',
};

if (!isError(exception)) {
if (isPlainObject(exception)) {
// This will allow us to group events based on top-level keys
// which is much better than creating new group when any key/value change
const message = `Non-Error exception captured with keys: ${extractExceptionKeysForMessage(exception)}`;

const hub = getCurrentHub();
const client = hub.getClient();
const normalizeDepth = client && client.getOptions().normalizeDepth;
hub.configureScope(scope => {
scope.setExtra('__serialized__', normalizeToSize(exception, normalizeDepth));
});

ex = (hint && hint.syntheticException) || new Error(message);
(ex as Error).message = message;
} else {
// This handles when someone does: `throw "something awesome";`
// We use synthesized Error here so we can extract a (rough) stack trace.
ex = (hint && hint.syntheticException) || new Error(exception as string);
(ex as Error).message = exception as string;
}
mechanism.synthetic = true;
}

const event = {
exception: {
values: [exceptionFromError(stackParser, ex as Error)],
},
};

addExceptionTypeValue(event, undefined, undefined);
addExceptionMechanism(event, mechanism);

return {
...event,
event_id: hint && hint.event_id,
};
}

/**
* Builds and Event from a Message
* @hidden
*/
export function eventFromMessage(
stackParser: StackParser,
message: string,
// eslint-disable-next-line deprecation/deprecation
level: Severity | SeverityLevel = 'info',
hint?: EventHint,
attachStacktrace?: boolean,
): Event {
const event: Event = {
event_id: hint && hint.event_id,
level,
message,
};

if (attachStacktrace && hint && hint.syntheticException) {
const frames = parseStackFrames(stackParser, hint.syntheticException);
if (frames.length) {
event.exception = {
values: [
{
value: message,
stacktrace: { frames },
},
],
};
}
}

return event;
}
Loading