Skip to content
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

feat(nextjs): Add Edge Runtime SDK #6752

Merged
merged 4 commits into from
Jan 12, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
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;
}
148 changes: 148 additions & 0 deletions packages/nextjs/src/edge/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import '@sentry/tracing'; // Allow people to call tracing API methods without explicitly importing the tracing package.

import { getCurrentHub, getIntegrationsToSetup, initAndBind, Integrations as CoreIntegrations } from '@sentry/core';
import type { Options } from '@sentry/types';
import {
createStackParser,
GLOBAL_OBJ,
logger,
nodeStackLineParser,
stackParserFromStackParserOptions,
} from '@sentry/utils';

import { EdgeClient } from './edgeclient';
import { makeEdgeTransport } from './transport';

const nodeStackParser = createStackParser(nodeStackLineParser());

export const defaultIntegrations = [new CoreIntegrations.InboundFilters(), new CoreIntegrations.FunctionToString()];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future we can include RequestData and LinkedErrors here since they are agnostic to node, but that requires some more refactoring.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LinkedErrors could probably even live in @sentry/core. We currently duplicate it in browser & node. As for RequestData I am a bit unsure. That integration has become a bit of a behemoth and I don't like it becoming even more generic.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We currently duplicate it in browser & node

There is slightly different behaviour for node/browser 😅

RequestData being overloaded is fair, we can probably re-examine this.


export type EdgeOptions = Options;

/** Inits the Sentry NextJS SDK on the Edge Runtime. */
export function init(options: EdgeOptions = {}): void {
if (options.defaultIntegrations === undefined) {
options.defaultIntegrations = defaultIntegrations;
}

if (options.dsn === undefined && process.env.SENTRY_DSN) {
options.dsn = process.env.SENTRY_DSN;
}

if (options.tracesSampleRate === undefined && process.env.SENTRY_TRACES_SAMPLE_RATE) {
const tracesSampleRate = parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE);
if (isFinite(tracesSampleRate)) {
options.tracesSampleRate = tracesSampleRate;
}
}

if (options.release === undefined) {
const detectedRelease = getSentryRelease();
if (detectedRelease !== undefined) {
options.release = detectedRelease;
} else {
// If release is not provided, then we should disable autoSessionTracking
options.autoSessionTracking = false;
}
}

if (options.environment === undefined && process.env.SENTRY_ENVIRONMENT) {
options.environment = process.env.SENTRY_ENVIRONMENT;
}

if (options.autoSessionTracking === undefined && options.dsn !== undefined) {
options.autoSessionTracking = true;
}

if (options.instrumenter === undefined) {
options.instrumenter = 'sentry';
}

const clientOptions = {
...options,
stackParser: stackParserFromStackParserOptions(options.stackParser || nodeStackParser),
integrations: getIntegrationsToSetup(options),
transport: options.transport || makeEdgeTransport,
};

initAndBind(EdgeClient, clientOptions);

// TODO?: Sessiontracking
}

/**
* Returns a release dynamically from environment variables.
*/
export function getSentryRelease(fallback?: string): string | undefined {
// Always read first as Sentry takes this as precedence
if (process.env.SENTRY_RELEASE) {
return process.env.SENTRY_RELEASE;
}

// This supports the variable that sentry-webpack-plugin injects
if (GLOBAL_OBJ.SENTRY_RELEASE && GLOBAL_OBJ.SENTRY_RELEASE.id) {
return GLOBAL_OBJ.SENTRY_RELEASE.id;
}

return (
// GitHub Actions - https://help.github.com/en/actions/configuring-and-managing-workflows/using-environment-variables#default-environment-variables
process.env.GITHUB_SHA ||
// Netlify - https://docs.netlify.com/configure-builds/environment-variables/#build-metadata
process.env.COMMIT_REF ||
// Vercel - https://vercel.com/docs/v2/build-step#system-environment-variables
process.env.VERCEL_GIT_COMMIT_SHA ||
process.env.VERCEL_GITHUB_COMMIT_SHA ||
process.env.VERCEL_GITLAB_COMMIT_SHA ||
process.env.VERCEL_BITBUCKET_COMMIT_SHA ||
// Zeit (now known as Vercel)
process.env.ZEIT_GITHUB_COMMIT_SHA ||
process.env.ZEIT_GITLAB_COMMIT_SHA ||
process.env.ZEIT_BITBUCKET_COMMIT_SHA ||
fallback
);
}

/**
* Call `close()` on the current client, if there is one. See {@link Client.close}.
*
* @param timeout Maximum time in ms the client should wait to flush its event queue before shutting down. Omitting this
* parameter will cause the client to wait until all events are sent before disabling itself.
* @returns A promise which resolves to `true` if the queue successfully drains before the timeout, or `false` if it
* doesn't (or if there's no client defined).
*/
export async function close(timeout?: number): Promise<boolean> {
const client = getCurrentHub().getClient<EdgeClient>();
if (client) {
return client.close(timeout);
}
__DEBUG_BUILD__ && logger.warn('Cannot flush events and disable SDK. No client defined.');
return Promise.resolve(false);
}

/**
* Call `flush()` on the current client, if there is one. See {@link Client.flush}.
*
* @param timeout Maximum time in ms the client should wait to flush its event queue. Omitting this parameter will cause
* the client to wait until all events are sent before resolving the promise.
* @returns A promise which resolves to `true` if the queue successfully drains before the timeout, or `false` if it
* doesn't (or if there's no client defined).
*/
export async function flush(timeout?: number): Promise<boolean> {
const client = getCurrentHub().getClient<EdgeClient>();
if (client) {
return client.flush(timeout);
}
__DEBUG_BUILD__ && logger.warn('Cannot flush events. No client defined.');
return Promise.resolve(false);
}

/**
* This is the getter for lastEventId.
*
* @returns The last event id of a captured event.
*/
export function lastEventId(): string | undefined {
return getCurrentHub().lastEventId();
}

export * from '@sentry/core';
38 changes: 38 additions & 0 deletions packages/nextjs/src/edge/transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createTransport } from '@sentry/core';
import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types';

export interface EdgeTransportOptions extends BaseTransportOptions {
/** Fetch API init parameters. Used by the FetchTransport */
fetchOptions?: RequestInit;
/** Custom headers for the transport. Used by the XHRTransport and FetchTransport */
headers?: { [key: string]: string };
}

/**
* Creates a Transport that uses the Edge Runtimes native fetch API to send events to Sentry.
*/
export function makeEdgeTransport(options: EdgeTransportOptions): Transport {
function makeRequest(request: TransportRequest): PromiseLike<TransportMakeRequestResponse> {
const requestOptions: RequestInit = {
body: request.body,
method: 'POST',
referrerPolicy: 'origin',
headers: options.headers,
...options.fetchOptions,
};

try {
return fetch(options.url, requestOptions).then(response => ({
statusCode: response.status,
headers: {
'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'),
'retry-after': response.headers.get('Retry-After'),
},
}));
} catch (e) {
return Promise.reject(e);
}
}

return createTransport(options, makeRequest);
}