diff --git a/packages/nextjs/rollup.npm.config.js b/packages/nextjs/rollup.npm.config.js index 2329f9eafed1..db1fa9c1fde1 100644 --- a/packages/nextjs/rollup.npm.config.js +++ b/packages/nextjs/rollup.npm.config.js @@ -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) diff --git a/packages/nextjs/src/edge/edgeclient.ts b/packages/nextjs/src/edge/edgeclient.ts new file mode 100644 index 000000000000..a5b38d651aed --- /dev/null +++ b/packages/nextjs/src/edge/edgeclient.ts @@ -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; + +/** + * The Sentry Edge SDK Client. + */ +export class EdgeClient extends BaseClient { + /** + * 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 { + 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 { + return Promise.resolve( + eventFromMessage(this._options.stackParser, message, level, hint, this._options.attachStacktrace), + ); + } + + /** + * @inheritDoc + */ + protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike { + 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); + } +} diff --git a/packages/nextjs/src/edge/eventbuilder.ts b/packages/nextjs/src/edge/eventbuilder.ts new file mode 100644 index 000000000000..4e483fce3ff7 --- /dev/null +++ b/packages/nextjs/src/edge/eventbuilder.ts @@ -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; +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts new file mode 100644 index 000000000000..42b48094d966 --- /dev/null +++ b/packages/nextjs/src/edge/index.ts @@ -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()]; + +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 { + const client = getCurrentHub().getClient(); + 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 { + const client = getCurrentHub().getClient(); + 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'; diff --git a/packages/nextjs/src/edge/transport.ts b/packages/nextjs/src/edge/transport.ts new file mode 100644 index 000000000000..3fc4b8e101c3 --- /dev/null +++ b/packages/nextjs/src/edge/transport.ts @@ -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 { + 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); +} diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index 5df30dffd580..fcce6708a293 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -5,28 +5,27 @@ export * from './config'; export * from './client'; export * from './server'; +export * from './edge'; import type { Integration, Options, StackParser } from '@sentry/types'; import type { BrowserOptions } from './client'; import * as clientSdk from './client'; +import type { EdgeOptions } from './edge'; +import * as edgeSdk from './edge'; import type { NodeOptions } from './server'; import * as serverSdk from './server'; /** Initializes Sentry Next.js SDK */ -export declare function init(options: Options | BrowserOptions | NodeOptions): void; +export declare function init(options: Options | BrowserOptions | NodeOptions | EdgeOptions): void; // We export a merged Integrations object so that users can (at least typing-wise) use all integrations everywhere. -export const Integrations = { ...clientSdk.Integrations, ...serverSdk.Integrations }; +export const Integrations = { ...clientSdk.Integrations, ...serverSdk.Integrations, ...edgeSdk.Integrations }; export declare const defaultIntegrations: Integration[]; export declare const defaultStackParser: StackParser; -// This variable is not a runtime variable but just a type to tell typescript that the methods below can either come -// from the client SDK or from the server SDK. TypeScript is smart enough to understand that these resolve to the same -// methods from `@sentry/core`. -declare const runtime: 'client' | 'server'; - -export const close = runtime === 'client' ? clientSdk.close : serverSdk.close; -export const flush = runtime === 'client' ? clientSdk.flush : serverSdk.flush; -export const lastEventId = runtime === 'client' ? clientSdk.lastEventId : serverSdk.lastEventId; +export declare function close(timeout?: number | undefined): PromiseLike; +export declare function flush(timeout?: number | undefined): PromiseLike; +export declare function lastEventId(): string | undefined; +export declare function getSentryRelease(fallback?: string): string | undefined; diff --git a/packages/nextjs/test/edge/edgeclient.test.ts b/packages/nextjs/test/edge/edgeclient.test.ts new file mode 100644 index 000000000000..cba4a751c71e --- /dev/null +++ b/packages/nextjs/test/edge/edgeclient.test.ts @@ -0,0 +1,57 @@ +import { createTransport } from '@sentry/core'; +import type { Event, EventHint } from '@sentry/types'; + +import type { EdgeClientOptions } from '../../src/edge/edgeclient'; +import { EdgeClient } from '../../src/edge/edgeclient'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +function getDefaultEdgeClientOptions(options: Partial = {}): EdgeClientOptions { + return { + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + instrumenter: 'sentry', + ...options, + }; +} + +describe('NodeClient', () => { + describe('_prepareEvent', () => { + test('adds platform to event', () => { + const options = getDefaultEdgeClientOptions({ dsn: PUBLIC_DSN }); + const client = new EdgeClient(options); + + const event: Event = {}; + const hint: EventHint = {}; + (client as any)._prepareEvent(event, hint); + + expect(event.platform).toEqual('edge'); + }); + + test('adds runtime context to event', () => { + const options = getDefaultEdgeClientOptions({ dsn: PUBLIC_DSN }); + const client = new EdgeClient(options); + + const event: Event = {}; + const hint: EventHint = {}; + (client as any)._prepareEvent(event, hint); + + expect(event.contexts?.runtime).toEqual({ + name: 'edge', + }); + }); + + test("doesn't clobber existing runtime data", () => { + const options = getDefaultEdgeClientOptions({ dsn: PUBLIC_DSN }); + const client = new EdgeClient(options); + + const event: Event = { contexts: { runtime: { name: 'foo', version: '1.2.3' } } }; + const hint: EventHint = {}; + (client as any)._prepareEvent(event, hint); + + expect(event.contexts?.runtime).toEqual({ name: 'foo', version: '1.2.3' }); + expect(event.contexts?.runtime).not.toEqual({ name: 'edge' }); + }); + }); +}); diff --git a/packages/nextjs/test/edge/transport.test.ts b/packages/nextjs/test/edge/transport.test.ts new file mode 100644 index 000000000000..abb925184685 --- /dev/null +++ b/packages/nextjs/test/edge/transport.test.ts @@ -0,0 +1,110 @@ +import type { EventEnvelope, EventItem } from '@sentry/types'; +import { createEnvelope, serializeEnvelope } from '@sentry/utils'; +import { TextEncoder } from 'util'; + +import type { EdgeTransportOptions } from '../../src/edge/transport'; +import { makeEdgeTransport } from '../../src/edge/transport'; + +const DEFAULT_EDGE_TRANSPORT_OPTIONS: EdgeTransportOptions = { + url: 'https://sentry.io/api/42/store/?sentry_key=123&sentry_version=7', + recordDroppedEvent: () => undefined, + textEncoder: new TextEncoder(), +}; + +const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, +]); + +class Headers { + headers: { [key: string]: string } = {}; + get(key: string) { + return this.headers[key] || null; + } + set(key: string, value: string) { + this.headers[key] = value; + } +} + +const mockFetch = jest.fn(); + +// @ts-ignore fetch is not on global +const oldFetch = global.fetch; +// @ts-ignore fetch is not on global +global.fetch = mockFetch; + +afterAll(() => { + // @ts-ignore fetch is not on global + global.fetch = oldFetch; +}); + +describe('Edge Transport', () => { + it('calls fetch with the given URL', async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers: new Headers(), + status: 200, + text: () => Promise.resolve({}), + }), + ); + + const transport = makeEdgeTransport(DEFAULT_EDGE_TRANSPORT_OPTIONS); + + expect(mockFetch).toHaveBeenCalledTimes(0); + await transport.send(ERROR_ENVELOPE); + expect(mockFetch).toHaveBeenCalledTimes(1); + + expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_EDGE_TRANSPORT_OPTIONS.url, { + body: serializeEnvelope(ERROR_ENVELOPE, new TextEncoder()), + method: 'POST', + referrerPolicy: 'origin', + }); + }); + + it('sets rate limit headers', async () => { + const headers = { + get: jest.fn(), + }; + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers, + status: 200, + text: () => Promise.resolve({}), + }), + ); + + const transport = makeEdgeTransport(DEFAULT_EDGE_TRANSPORT_OPTIONS); + + expect(headers.get).toHaveBeenCalledTimes(0); + await transport.send(ERROR_ENVELOPE); + + expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(headers.get).toHaveBeenCalledWith('Retry-After'); + }); + + it('allows for custom options to be passed in', async () => { + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers: new Headers(), + status: 200, + text: () => Promise.resolve({}), + }), + ); + + const REQUEST_OPTIONS: RequestInit = { + referrerPolicy: 'strict-origin', + keepalive: false, + referrer: 'http://example.org', + }; + + const transport = makeEdgeTransport({ ...DEFAULT_EDGE_TRANSPORT_OPTIONS, fetchOptions: REQUEST_OPTIONS }); + + await transport.send(ERROR_ENVELOPE); + expect(mockFetch).toHaveBeenLastCalledWith(DEFAULT_EDGE_TRANSPORT_OPTIONS.url, { + body: serializeEnvelope(ERROR_ENVELOPE, new TextEncoder()), + method: 'POST', + ...REQUEST_OPTIONS, + }); + }); +});