Skip to content

Commit 92b9373

Browse files
authored
feat(nextjs): Add Edge Runtime SDK (#6752)
1 parent b921631 commit 92b9373

File tree

8 files changed

+562
-11
lines changed

8 files changed

+562
-11
lines changed

packages/nextjs/rollup.npm.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export default [
55
makeBaseNPMConfig({
66
// We need to include `instrumentServer.ts` separately because it's only conditionally required, and so rollup
77
// doesn't automatically include it when calculating the module dependency tree.
8-
entrypoints: ['src/index.ts', 'src/client/index.ts', 'src/config/webpack.ts'],
8+
entrypoints: ['src/index.ts', 'src/client/index.ts', 'src/edge/index.ts', 'src/config/webpack.ts'],
99

1010
// prevent this internal nextjs code from ending up in our built package (this doesn't happen automatially because
1111
// the name doesn't match an SDK dependency)
+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { Scope } from '@sentry/core';
2+
import { BaseClient, SDK_VERSION } from '@sentry/core';
3+
import type { ClientOptions, Event, EventHint, Severity, SeverityLevel } from '@sentry/types';
4+
5+
import { eventFromMessage, eventFromUnknownInput } from './eventbuilder';
6+
import type { EdgeTransportOptions } from './transport';
7+
8+
export type EdgeClientOptions = ClientOptions<EdgeTransportOptions>;
9+
10+
/**
11+
* The Sentry Edge SDK Client.
12+
*/
13+
export class EdgeClient extends BaseClient<EdgeClientOptions> {
14+
/**
15+
* Creates a new Edge SDK instance.
16+
* @param options Configuration options for this SDK.
17+
*/
18+
public constructor(options: EdgeClientOptions) {
19+
options._metadata = options._metadata || {};
20+
options._metadata.sdk = options._metadata.sdk || {
21+
name: 'sentry.javascript.nextjs',
22+
packages: [
23+
{
24+
name: 'npm:@sentry/nextjs',
25+
version: SDK_VERSION,
26+
},
27+
],
28+
version: SDK_VERSION,
29+
};
30+
31+
super(options);
32+
}
33+
34+
/**
35+
* @inheritDoc
36+
*/
37+
public eventFromException(exception: unknown, hint?: EventHint): PromiseLike<Event> {
38+
return Promise.resolve(eventFromUnknownInput(this._options.stackParser, exception, hint));
39+
}
40+
41+
/**
42+
* @inheritDoc
43+
*/
44+
public eventFromMessage(
45+
message: string,
46+
// eslint-disable-next-line deprecation/deprecation
47+
level: Severity | SeverityLevel = 'info',
48+
hint?: EventHint,
49+
): PromiseLike<Event> {
50+
return Promise.resolve(
51+
eventFromMessage(this._options.stackParser, message, level, hint, this._options.attachStacktrace),
52+
);
53+
}
54+
55+
/**
56+
* @inheritDoc
57+
*/
58+
protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike<Event | null> {
59+
event.platform = event.platform || 'edge';
60+
event.contexts = {
61+
...event.contexts,
62+
runtime: event.contexts?.runtime || {
63+
name: 'edge',
64+
},
65+
};
66+
event.server_name = event.server_name || process.env.SENTRY_NAME;
67+
return super._prepareEvent(event, hint, scope);
68+
}
69+
}
+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { getCurrentHub } from '@sentry/core';
2+
import type {
3+
Event,
4+
EventHint,
5+
Exception,
6+
Mechanism,
7+
Severity,
8+
SeverityLevel,
9+
StackFrame,
10+
StackParser,
11+
} from '@sentry/types';
12+
import {
13+
addExceptionMechanism,
14+
addExceptionTypeValue,
15+
extractExceptionKeysForMessage,
16+
isError,
17+
isPlainObject,
18+
normalizeToSize,
19+
} from '@sentry/utils';
20+
21+
/**
22+
* Extracts stack frames from the error.stack string
23+
*/
24+
export function parseStackFrames(stackParser: StackParser, error: Error): StackFrame[] {
25+
return stackParser(error.stack || '', 1);
26+
}
27+
28+
/**
29+
* Extracts stack frames from the error and builds a Sentry Exception
30+
*/
31+
export function exceptionFromError(stackParser: StackParser, error: Error): Exception {
32+
const exception: Exception = {
33+
type: error.name || error.constructor.name,
34+
value: error.message,
35+
};
36+
37+
const frames = parseStackFrames(stackParser, error);
38+
if (frames.length) {
39+
exception.stacktrace = { frames };
40+
}
41+
42+
return exception;
43+
}
44+
45+
/**
46+
* Builds and Event from a Exception
47+
* @hidden
48+
*/
49+
export function eventFromUnknownInput(stackParser: StackParser, exception: unknown, hint?: EventHint): Event {
50+
let ex: unknown = exception;
51+
const providedMechanism: Mechanism | undefined =
52+
hint && hint.data && (hint.data as { mechanism: Mechanism }).mechanism;
53+
const mechanism: Mechanism = providedMechanism || {
54+
handled: true,
55+
type: 'generic',
56+
};
57+
58+
if (!isError(exception)) {
59+
if (isPlainObject(exception)) {
60+
// This will allow us to group events based on top-level keys
61+
// which is much better than creating new group when any key/value change
62+
const message = `Non-Error exception captured with keys: ${extractExceptionKeysForMessage(exception)}`;
63+
64+
const hub = getCurrentHub();
65+
const client = hub.getClient();
66+
const normalizeDepth = client && client.getOptions().normalizeDepth;
67+
hub.configureScope(scope => {
68+
scope.setExtra('__serialized__', normalizeToSize(exception, normalizeDepth));
69+
});
70+
71+
ex = (hint && hint.syntheticException) || new Error(message);
72+
(ex as Error).message = message;
73+
} else {
74+
// This handles when someone does: `throw "something awesome";`
75+
// We use synthesized Error here so we can extract a (rough) stack trace.
76+
ex = (hint && hint.syntheticException) || new Error(exception as string);
77+
(ex as Error).message = exception as string;
78+
}
79+
mechanism.synthetic = true;
80+
}
81+
82+
const event = {
83+
exception: {
84+
values: [exceptionFromError(stackParser, ex as Error)],
85+
},
86+
};
87+
88+
addExceptionTypeValue(event, undefined, undefined);
89+
addExceptionMechanism(event, mechanism);
90+
91+
return {
92+
...event,
93+
event_id: hint && hint.event_id,
94+
};
95+
}
96+
97+
/**
98+
* Builds and Event from a Message
99+
* @hidden
100+
*/
101+
export function eventFromMessage(
102+
stackParser: StackParser,
103+
message: string,
104+
// eslint-disable-next-line deprecation/deprecation
105+
level: Severity | SeverityLevel = 'info',
106+
hint?: EventHint,
107+
attachStacktrace?: boolean,
108+
): Event {
109+
const event: Event = {
110+
event_id: hint && hint.event_id,
111+
level,
112+
message,
113+
};
114+
115+
if (attachStacktrace && hint && hint.syntheticException) {
116+
const frames = parseStackFrames(stackParser, hint.syntheticException);
117+
if (frames.length) {
118+
event.exception = {
119+
values: [
120+
{
121+
value: message,
122+
stacktrace: { frames },
123+
},
124+
],
125+
};
126+
}
127+
}
128+
129+
return event;
130+
}

packages/nextjs/src/edge/index.ts

+148
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import '@sentry/tracing'; // Allow people to call tracing API methods without explicitly importing the tracing package.
2+
3+
import { getCurrentHub, getIntegrationsToSetup, initAndBind, Integrations as CoreIntegrations } from '@sentry/core';
4+
import type { Options } from '@sentry/types';
5+
import {
6+
createStackParser,
7+
GLOBAL_OBJ,
8+
logger,
9+
nodeStackLineParser,
10+
stackParserFromStackParserOptions,
11+
} from '@sentry/utils';
12+
13+
import { EdgeClient } from './edgeclient';
14+
import { makeEdgeTransport } from './transport';
15+
16+
const nodeStackParser = createStackParser(nodeStackLineParser());
17+
18+
export const defaultIntegrations = [new CoreIntegrations.InboundFilters(), new CoreIntegrations.FunctionToString()];
19+
20+
export type EdgeOptions = Options;
21+
22+
/** Inits the Sentry NextJS SDK on the Edge Runtime. */
23+
export function init(options: EdgeOptions = {}): void {
24+
if (options.defaultIntegrations === undefined) {
25+
options.defaultIntegrations = defaultIntegrations;
26+
}
27+
28+
if (options.dsn === undefined && process.env.SENTRY_DSN) {
29+
options.dsn = process.env.SENTRY_DSN;
30+
}
31+
32+
if (options.tracesSampleRate === undefined && process.env.SENTRY_TRACES_SAMPLE_RATE) {
33+
const tracesSampleRate = parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE);
34+
if (isFinite(tracesSampleRate)) {
35+
options.tracesSampleRate = tracesSampleRate;
36+
}
37+
}
38+
39+
if (options.release === undefined) {
40+
const detectedRelease = getSentryRelease();
41+
if (detectedRelease !== undefined) {
42+
options.release = detectedRelease;
43+
} else {
44+
// If release is not provided, then we should disable autoSessionTracking
45+
options.autoSessionTracking = false;
46+
}
47+
}
48+
49+
if (options.environment === undefined && process.env.SENTRY_ENVIRONMENT) {
50+
options.environment = process.env.SENTRY_ENVIRONMENT;
51+
}
52+
53+
if (options.autoSessionTracking === undefined && options.dsn !== undefined) {
54+
options.autoSessionTracking = true;
55+
}
56+
57+
if (options.instrumenter === undefined) {
58+
options.instrumenter = 'sentry';
59+
}
60+
61+
const clientOptions = {
62+
...options,
63+
stackParser: stackParserFromStackParserOptions(options.stackParser || nodeStackParser),
64+
integrations: getIntegrationsToSetup(options),
65+
transport: options.transport || makeEdgeTransport,
66+
};
67+
68+
initAndBind(EdgeClient, clientOptions);
69+
70+
// TODO?: Sessiontracking
71+
}
72+
73+
/**
74+
* Returns a release dynamically from environment variables.
75+
*/
76+
export function getSentryRelease(fallback?: string): string | undefined {
77+
// Always read first as Sentry takes this as precedence
78+
if (process.env.SENTRY_RELEASE) {
79+
return process.env.SENTRY_RELEASE;
80+
}
81+
82+
// This supports the variable that sentry-webpack-plugin injects
83+
if (GLOBAL_OBJ.SENTRY_RELEASE && GLOBAL_OBJ.SENTRY_RELEASE.id) {
84+
return GLOBAL_OBJ.SENTRY_RELEASE.id;
85+
}
86+
87+
return (
88+
// GitHub Actions - https://help.github.com/en/actions/configuring-and-managing-workflows/using-environment-variables#default-environment-variables
89+
process.env.GITHUB_SHA ||
90+
// Netlify - https://docs.netlify.com/configure-builds/environment-variables/#build-metadata
91+
process.env.COMMIT_REF ||
92+
// Vercel - https://vercel.com/docs/v2/build-step#system-environment-variables
93+
process.env.VERCEL_GIT_COMMIT_SHA ||
94+
process.env.VERCEL_GITHUB_COMMIT_SHA ||
95+
process.env.VERCEL_GITLAB_COMMIT_SHA ||
96+
process.env.VERCEL_BITBUCKET_COMMIT_SHA ||
97+
// Zeit (now known as Vercel)
98+
process.env.ZEIT_GITHUB_COMMIT_SHA ||
99+
process.env.ZEIT_GITLAB_COMMIT_SHA ||
100+
process.env.ZEIT_BITBUCKET_COMMIT_SHA ||
101+
fallback
102+
);
103+
}
104+
105+
/**
106+
* Call `close()` on the current client, if there is one. See {@link Client.close}.
107+
*
108+
* @param timeout Maximum time in ms the client should wait to flush its event queue before shutting down. Omitting this
109+
* parameter will cause the client to wait until all events are sent before disabling itself.
110+
* @returns A promise which resolves to `true` if the queue successfully drains before the timeout, or `false` if it
111+
* doesn't (or if there's no client defined).
112+
*/
113+
export async function close(timeout?: number): Promise<boolean> {
114+
const client = getCurrentHub().getClient<EdgeClient>();
115+
if (client) {
116+
return client.close(timeout);
117+
}
118+
__DEBUG_BUILD__ && logger.warn('Cannot flush events and disable SDK. No client defined.');
119+
return Promise.resolve(false);
120+
}
121+
122+
/**
123+
* Call `flush()` on the current client, if there is one. See {@link Client.flush}.
124+
*
125+
* @param timeout Maximum time in ms the client should wait to flush its event queue. Omitting this parameter will cause
126+
* the client to wait until all events are sent before resolving the promise.
127+
* @returns A promise which resolves to `true` if the queue successfully drains before the timeout, or `false` if it
128+
* doesn't (or if there's no client defined).
129+
*/
130+
export async function flush(timeout?: number): Promise<boolean> {
131+
const client = getCurrentHub().getClient<EdgeClient>();
132+
if (client) {
133+
return client.flush(timeout);
134+
}
135+
__DEBUG_BUILD__ && logger.warn('Cannot flush events. No client defined.');
136+
return Promise.resolve(false);
137+
}
138+
139+
/**
140+
* This is the getter for lastEventId.
141+
*
142+
* @returns The last event id of a captured event.
143+
*/
144+
export function lastEventId(): string | undefined {
145+
return getCurrentHub().lastEventId();
146+
}
147+
148+
export * from '@sentry/core';

packages/nextjs/src/edge/transport.ts

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { createTransport } from '@sentry/core';
2+
import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types';
3+
4+
export interface EdgeTransportOptions extends BaseTransportOptions {
5+
/** Fetch API init parameters. Used by the FetchTransport */
6+
fetchOptions?: RequestInit;
7+
/** Custom headers for the transport. Used by the XHRTransport and FetchTransport */
8+
headers?: { [key: string]: string };
9+
}
10+
11+
/**
12+
* Creates a Transport that uses the Edge Runtimes native fetch API to send events to Sentry.
13+
*/
14+
export function makeEdgeTransport(options: EdgeTransportOptions): Transport {
15+
function makeRequest(request: TransportRequest): PromiseLike<TransportMakeRequestResponse> {
16+
const requestOptions: RequestInit = {
17+
body: request.body,
18+
method: 'POST',
19+
referrerPolicy: 'origin',
20+
headers: options.headers,
21+
...options.fetchOptions,
22+
};
23+
24+
try {
25+
return fetch(options.url, requestOptions).then(response => ({
26+
statusCode: response.status,
27+
headers: {
28+
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
29+
'retry-after': response.headers.get('Retry-After'),
30+
},
31+
}));
32+
} catch (e) {
33+
return Promise.reject(e);
34+
}
35+
}
36+
37+
return createTransport(options, makeRequest);
38+
}

0 commit comments

Comments
 (0)