diff --git a/package.json b/package.json index 849d79b3628a..04d6c0256860 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "packages/types", "packages/typescript", "packages/utils", + "packages/vercel-edge", "packages/vue", "packages/wasm" ], diff --git a/packages/vercel-edge/.eslintrc.js b/packages/vercel-edge/.eslintrc.js new file mode 100644 index 000000000000..99fcba0976da --- /dev/null +++ b/packages/vercel-edge/.eslintrc.js @@ -0,0 +1,23 @@ +module.exports = { + env: { + node: true, + }, + extends: ['../../.eslintrc.js'], + rules: { + '@sentry-internal/sdk/no-optional-chaining': 'off', + }, + overrides: [ + { + files: ['scripts/**/*.ts'], + parserOptions: { + project: ['../../tsconfig.dev.json'], + }, + }, + { + files: ['test/**'], + parserOptions: { + sourceType: 'module', + }, + }, + ], +}; diff --git a/packages/vercel-edge/LICENSE b/packages/vercel-edge/LICENSE new file mode 100644 index 000000000000..5113ccb2ac3d --- /dev/null +++ b/packages/vercel-edge/LICENSE @@ -0,0 +1,14 @@ +Copyright (c) 2020 Sentry (https://sentry.io) and individual contributors. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/vercel-edge/README.md b/packages/vercel-edge/README.md new file mode 100644 index 000000000000..aec2e5e27872 --- /dev/null +++ b/packages/vercel-edge/README.md @@ -0,0 +1,96 @@ +

+ + Sentry + +

+ +# Official Sentry SDK for Serverless environments + +## Links + +- [Official SDK Docs](https://docs.sentry.io/) +- [TypeDoc](http://getsentry.github.io/sentry-javascript/) + +## General + +This package is a wrapper around `@sentry/node`, with added functionality related to various Serverless solutions. All +methods available in `@sentry/node` can be imported from `@sentry/serverless`. + +Currently supported environment: + +### AWS Lambda + +To use this SDK, call `Sentry.AWSLambda.init(options)` at the very beginning of your JavaScript file. + +```javascript +import * as Sentry from '@sentry/serverless'; + +Sentry.AWSLambda.init({ + dsn: '__DSN__', + // ... +}); + +// async (recommended) +exports.handler = Sentry.AWSLambda.wrapHandler(async (event, context) => { + throw new Error('oh, hello there!'); +}); + +// sync +exports.handler = Sentry.AWSLambda.wrapHandler((event, context, callback) => { + throw new Error('oh, hello there!'); +}); +``` + +If you also want to trace performance of all the incoming requests and also outgoing AWS service requests, just set the `tracesSampleRate` option. + +```javascript +import * as Sentry from '@sentry/serverless'; + +Sentry.AWSLambda.init({ + dsn: '__DSN__', + tracesSampleRate: 1.0, +}); +``` + +#### Integrate Sentry using internal extension + +Another and much simpler way to integrate Sentry to your AWS Lambda function is to add an official layer. + +1. Choose Layers -> Add Layer. +2. Specify an ARN: `arn:aws:lambda:us-west-1:TODO:layer:TODO:VERSION`. +3. Go to Environment variables and add: + - `NODE_OPTIONS`: `-r @sentry/serverless/build/npm/cjs/awslambda-auto`. + - `SENTRY_DSN`: `your dsn`. + - `SENTRY_TRACES_SAMPLE_RATE`: a number between 0 and 1 representing the chance a transaction is sent to Sentry. For more information, see [docs](https://docs.sentry.io/platforms/node/guides/aws-lambda/configuration/options/#tracesSampleRate). + +### Google Cloud Functions + +To use this SDK, call `Sentry.GCPFunction.init(options)` at the very beginning of your JavaScript file. + +```javascript +import * as Sentry from '@sentry/serverless'; + +Sentry.GCPFunction.init({ + dsn: '__DSN__', + tracesSampleRate: 1.0, + // ... +}); + +// For HTTP Functions: + +exports.helloHttp = Sentry.GCPFunction.wrapHttpFunction((req, res) => { + throw new Error('oh, hello there!'); +}); + +// For Background Functions: + +exports.helloEvents = Sentry.GCPFunction.wrapEventFunction((data, context, callback) => { + throw new Error('oh, hello there!'); +}); + +// For CloudEvents: + +exports.helloEvents = Sentry.GCPFunction.wrapCloudEventFunction((context, callback) => { + throw new Error('oh, hello there!'); +}); +``` diff --git a/packages/vercel-edge/jest.config.js b/packages/vercel-edge/jest.config.js new file mode 100644 index 000000000000..24f49ab59a4c --- /dev/null +++ b/packages/vercel-edge/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../jest/jest.config.js'); diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json new file mode 100644 index 000000000000..94b7b213f9d2 --- /dev/null +++ b/packages/vercel-edge/package.json @@ -0,0 +1,80 @@ +{ + "name": "@sentry/vercel-edge", + "version": "7.61.0", + "description": "Official Sentry SDK for the Vercel edge runtime", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vercel-edge", + "author": "Sentry", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "main": "build/npm/cjs/index.js", + "module": "build/npm/esm/index.js", + "types": "build/npm/types/index.d.ts", + "typesVersions": { + "<4.9": { + "build/npm/types/index.d.ts": [ + "build/npm/types-ts3.8/index.d.ts" + ] + } + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@sentry/core": "7.61.0", + "@sentry/node": "7.61.0", + "@sentry/types": "7.61.0", + "@sentry/utils": "7.61.0", + "@types/express": "^4.17.14", + "tslib": "^2.4.1 || ^1.9.3" + }, + "devDependencies": { + "@types/node": "^14.6.4", + "find-up": "^5.0.0", + "nock": "^13.0.4", + "npm-packlist": "^2.1.4" + }, + "scripts": { + "build": "run-p build:transpile build:types build:bundle", + "build:dev": "run-p build:transpile build:types", + "build:transpile": "rollup -c rollup.npm.config.js", + "build:types": "run-s build:types:core build:types:downlevel", + "build:types:core": "tsc -p tsconfig.types.json", + "build:types:downlevel": "yarn downlevel-dts build/npm/types build/npm/types-ts3.8 --to ts3.8", + "build:watch": "run-p build:transpile:watch build:types:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", + "build:types:watch": "tsc -p tsconfig.types.json --watch", + "build:tarball": "ts-node ../../scripts/prepack.ts --bundles && npm pack ./build/npm", + "circularDepCheck": "madge --circular src/index.ts", + "clean": "rimraf build coverage sentry-vercel-edge-*.tgz", + "fix": "run-s fix:eslint fix:prettier", + "fix:eslint": "eslint . --format stylish --fix", + "fix:prettier": "prettier --write \"{src,test,scripts}/**/**.ts\"", + "lint": "run-s lint:prettier lint:eslint", + "lint:eslint": "eslint . --format stylish", + "lint:prettier": "prettier --check \"{src,test,scripts}/**/**.ts\"", + "test": "jest", + "test:watch": "jest --watch", + "yalc:publish": "ts-node ../../scripts/prepack.ts --bundles && yalc publish ./build/npm --push" + }, + "volta": { + "extends": "../../package.json" + }, + "sideEffects": false, + "nx": { + "targets": { + "build:bundle": { + "dependsOn": [ + "build:transpile", + "build:types" + ], + "outputs": [ + "{projectRoot}/build/aws" + ] + } + } + } +} diff --git a/packages/vercel-edge/rollup.npm.config.js b/packages/vercel-edge/rollup.npm.config.js new file mode 100644 index 000000000000..11f2b66e96ca --- /dev/null +++ b/packages/vercel-edge/rollup.npm.config.js @@ -0,0 +1,9 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index.js'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts'], + // packages with bundles have a different build directory structure + hasBundles: true, + }), +); diff --git a/packages/vercel-edge/src/edgeclient.ts b/packages/vercel-edge/src/edgeclient.ts new file mode 100644 index 000000000000..a5ecca1181b6 --- /dev/null +++ b/packages/vercel-edge/src/edgeclient.ts @@ -0,0 +1,175 @@ +import type { Scope } from '@sentry/core'; +import { + addTracingExtensions, + BaseClient, + createCheckInEnvelope, + getDynamicSamplingContextFromClient, + SDK_VERSION, +} from '@sentry/core'; +import type { + CheckIn, + ClientOptions, + DynamicSamplingContext, + Event, + EventHint, + MonitorConfig, + SerializedCheckIn, + Severity, + SeverityLevel, + TraceContext, +} from '@sentry/types'; +import { logger, uuid4 } from '@sentry/utils'; + +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 || {}; + // TODO (isaacharrisholt) figure out how to get a default SDK name + options._metadata.sdk = options._metadata.sdk || { + name: 'sentry.javascript.nextjs', + packages: [ + { + name: 'npm:@sentry/nextjs', + version: SDK_VERSION, + }, + ], + version: SDK_VERSION, + }; + + // The Edge client always supports tracing + addTracingExtensions(); + + 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), + ); + } + + /** + * Create a cron monitor check in and send it to Sentry. + * + * @param checkIn An object that describes a check in. + * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want + * to create a monitor automatically when sending a check in. + */ + public captureCheckIn(checkIn: CheckIn, monitorConfig?: MonitorConfig, scope?: Scope): string { + const id = checkIn.status !== 'in_progress' && checkIn.checkInId ? checkIn.checkInId : uuid4(); + if (!this._isEnabled()) { + __DEBUG_BUILD__ && logger.warn('SDK not enabled, will not capture checkin.'); + return id; + } + + const options = this.getOptions(); + const { release, environment, tunnel } = options; + + const serializedCheckIn: SerializedCheckIn = { + check_in_id: id, + monitor_slug: checkIn.monitorSlug, + status: checkIn.status, + release, + environment, + }; + + if (checkIn.status !== 'in_progress') { + serializedCheckIn.duration = checkIn.duration; + } + + if (monitorConfig) { + serializedCheckIn.monitor_config = { + schedule: monitorConfig.schedule, + checkin_margin: monitorConfig.checkinMargin, + max_runtime: monitorConfig.maxRuntime, + timezone: monitorConfig.timezone, + }; + } + + const [dynamicSamplingContext, traceContext] = this._getTraceInfoFromScope(scope); + if (traceContext) { + serializedCheckIn.contexts = { + trace: traceContext, + }; + } + + const envelope = createCheckInEnvelope( + serializedCheckIn, + dynamicSamplingContext, + this.getSdkMetadata(), + tunnel, + this.getDsn(), + ); + + __DEBUG_BUILD__ && logger.info('Sending checkin:', checkIn.monitorSlug, checkIn.status); + void this._sendEnvelope(envelope); + return id; + } + + /** + * @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); + } + + /** Extract trace information from scope */ + private _getTraceInfoFromScope( + scope: Scope | undefined, + ): [dynamicSamplingContext: Partial | undefined, traceContext: TraceContext | undefined] { + if (!scope) { + return [undefined, undefined]; + } + + const span = scope.getSpan(); + if (span) { + return [span?.transaction?.getDynamicSamplingContext(), span?.getTraceContext()]; + } + + const { traceId, spanId, parentSpanId, dsc } = scope.getPropagationContext(); + const traceContext: TraceContext = { + trace_id: traceId, + span_id: spanId, + parent_span_id: parentSpanId, + }; + if (dsc) { + return [dsc, traceContext]; + } + + return [getDynamicSamplingContextFromClient(traceId, this, scope), traceContext]; + } +} diff --git a/packages/vercel-edge/src/eventbuilder.ts b/packages/vercel-edge/src/eventbuilder.ts new file mode 100644 index 000000000000..4e483fce3ff7 --- /dev/null +++ b/packages/vercel-edge/src/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/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts new file mode 100644 index 000000000000..e087c2305097 --- /dev/null +++ b/packages/vercel-edge/src/index.ts @@ -0,0 +1,137 @@ +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'; +import { getVercelEnv } from './utils/getVercelEnv'; + +const nodeStackParser = createStackParser(nodeStackLineParser()); + +export const defaultIntegrations = [new CoreIntegrations.InboundFilters(), new CoreIntegrations.FunctionToString()]; + +export type EdgeOptions = Options; + +/** Inits the Sentry Vercel Edge 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; + } + } + + options.environment = options.environment || process.env.SENTRY_ENVIRONMENT || getVercelEnv() || process.env.NODE_ENV; + + 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); +} + +/** + * This is the getter for lastEventId. + * + * @returns The last event id of a captured event. + */ +export function lastEventId(): string | undefined { + return getCurrentHub().lastEventId(); +} + +/** + * Just a passthrough in case this is imported from the client. + */ +export function withSentryConfig(exportedUserNextConfig: T): T { + return exportedUserNextConfig; +} + +export { flush } from './utils/flush'; + +export * from '@sentry/core'; diff --git a/packages/vercel-edge/src/transport.ts b/packages/vercel-edge/src/transport.ts new file mode 100644 index 000000000000..de8c20af20f9 --- /dev/null +++ b/packages/vercel-edge/src/transport.ts @@ -0,0 +1,97 @@ +import { createTransport } from '@sentry/core'; +import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types'; +import { SentryError } from '@sentry/utils'; + +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 }; +} + +const DEFAULT_TRANSPORT_BUFFER_SIZE = 30; + +/** + * This is a modified promise buffer that collects tasks until drain is called. + * We need this in the edge runtime because edge function invocations may not share I/O objects, like fetch requests + * and responses, and the normal PromiseBuffer inherently buffers stuff inbetween incoming requests. + * + * A limitation we need to be aware of is that DEFAULT_TRANSPORT_BUFFER_SIZE is the maximum amount of payloads the + * SDK can send for a given edge function invocation. + */ +export class IsolatedPromiseBuffer { + // We just have this field because the promise buffer interface requires it. + // If we ever remove it from the interface we should also remove it here. + public $: Array> = []; + + private _taskProducers: (() => PromiseLike)[] = []; + + public constructor(private readonly _bufferSize: number = DEFAULT_TRANSPORT_BUFFER_SIZE) {} + + /** + * @inheritdoc + */ + public add(taskProducer: () => PromiseLike): PromiseLike { + if (this._taskProducers.length >= this._bufferSize) { + return Promise.reject(new SentryError('Not adding Promise because buffer limit was reached.')); + } + + this._taskProducers.push(taskProducer); + return Promise.resolve(); + } + + /** + * @inheritdoc + */ + public drain(timeout?: number): PromiseLike { + const oldTaskProducers = [...this._taskProducers]; + this._taskProducers = []; + + return new Promise(resolve => { + const timer = setTimeout(() => { + if (timeout && timeout > 0) { + resolve(false); + } + }, timeout); + + void Promise.all( + oldTaskProducers.map(taskProducer => + taskProducer().then(null, () => { + // catch all failed requests + }), + ), + ).then(() => { + // resolve to true if all fetch requests settled + clearTimeout(timer); + resolve(true); + }); + }); + } +} + +/** + * 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, + }; + + return fetch(options.url, requestOptions).then(response => { + return { + statusCode: response.status, + headers: { + 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), + 'retry-after': response.headers.get('Retry-After'), + }, + }; + }); + } + + return createTransport(options, makeRequest, new IsolatedPromiseBuffer(options.bufferSize)); +} diff --git a/packages/vercel-edge/src/types.ts b/packages/vercel-edge/src/types.ts new file mode 100644 index 000000000000..9a7bd7553719 --- /dev/null +++ b/packages/vercel-edge/src/types.ts @@ -0,0 +1,7 @@ +// We cannot make any assumptions about what users define as their handler except maybe that it is a function +export interface EdgeRouteHandler { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (...args: any[]): any; +} + +export type EdgeCompatibleSDK = 'nextjs'; diff --git a/packages/vercel-edge/src/utils/flush.ts b/packages/vercel-edge/src/utils/flush.ts new file mode 100644 index 000000000000..5daa52936391 --- /dev/null +++ b/packages/vercel-edge/src/utils/flush.ts @@ -0,0 +1,20 @@ +import { getCurrentHub } from '@sentry/core'; +import type { Client } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +/** + * 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); +} diff --git a/packages/vercel-edge/src/utils/getVercelEnv.ts b/packages/vercel-edge/src/utils/getVercelEnv.ts new file mode 100644 index 000000000000..b485d2c51ddb --- /dev/null +++ b/packages/vercel-edge/src/utils/getVercelEnv.ts @@ -0,0 +1,9 @@ +/** + * Returns an environment setting value determined by Vercel's `VERCEL_ENV` environment variable. + * + * @param envVarPrefix Prefix to use for the VERCEL_ENV environment variable (e.g. NEXT_PUBLIC_). + */ +export function getVercelEnv(envVarPrefix?: string): string | undefined { + const vercelEnvVar = process.env[`${envVarPrefix ? envVarPrefix : ''}VERCEL_ENV`]; + return vercelEnvVar ? `vercel-${vercelEnvVar}` : undefined; +} diff --git a/packages/vercel-edge/test/__mocks__/@sentry/node.ts b/packages/vercel-edge/test/__mocks__/@sentry/node.ts new file mode 100644 index 000000000000..6da20c091780 --- /dev/null +++ b/packages/vercel-edge/test/__mocks__/@sentry/node.ts @@ -0,0 +1,69 @@ +const origSentry = jest.requireActual('@sentry/node'); +export const defaultIntegrations = origSentry.defaultIntegrations; // eslint-disable-line @typescript-eslint/no-unsafe-member-access +export const Handlers = origSentry.Handlers; // eslint-disable-line @typescript-eslint/no-unsafe-member-access +export const Integrations = origSentry.Integrations; +export const addRequestDataToEvent = origSentry.addRequestDataToEvent; +export const SDK_VERSION = '6.6.6'; +export const Severity = { + Warning: 'warning', +}; +export const fakeHub = { + configureScope: jest.fn((fn: (arg: any) => any) => fn(fakeScope)), + pushScope: jest.fn(() => fakeScope), + popScope: jest.fn(), + getScope: jest.fn(() => fakeScope), + startTransaction: jest.fn(context => ({ ...fakeTransaction, ...context })), +}; +export const fakeScope = { + addEventProcessor: jest.fn(), + setTransactionName: jest.fn(), + setTag: jest.fn(), + setContext: jest.fn(), + setSpan: jest.fn(), + getTransaction: jest.fn(() => fakeTransaction), + setSDKProcessingMetadata: jest.fn(), + setPropagationContext: jest.fn(), +}; +export const fakeSpan = { + finish: jest.fn(), +}; +export const fakeTransaction = { + finish: jest.fn(), + setHttpStatus: jest.fn(), + startChild: jest.fn(() => fakeSpan), +}; +export const init = jest.fn(); +export const addGlobalEventProcessor = jest.fn(); +export const getCurrentHub = jest.fn(() => fakeHub); +export const startTransaction = jest.fn(_ => fakeTransaction); +export const captureException = jest.fn(); +export const captureMessage = jest.fn(); +export const withScope = jest.fn(cb => cb(fakeScope)); +export const flush = jest.fn(() => Promise.resolve()); + +export const resetMocks = (): void => { + fakeTransaction.setHttpStatus.mockClear(); + fakeTransaction.finish.mockClear(); + fakeTransaction.startChild.mockClear(); + fakeSpan.finish.mockClear(); + fakeHub.configureScope.mockClear(); + fakeHub.pushScope.mockClear(); + fakeHub.popScope.mockClear(); + fakeHub.getScope.mockClear(); + + fakeScope.addEventProcessor.mockClear(); + fakeScope.setTransactionName.mockClear(); + fakeScope.setTag.mockClear(); + fakeScope.setContext.mockClear(); + fakeScope.setSpan.mockClear(); + fakeScope.getTransaction.mockClear(); + + init.mockClear(); + addGlobalEventProcessor.mockClear(); + getCurrentHub.mockClear(); + startTransaction.mockClear(); + captureException.mockClear(); + captureMessage.mockClear(); + withScope.mockClear(); + flush.mockClear(); +}; diff --git a/packages/vercel-edge/test/__mocks__/dns.ts b/packages/vercel-edge/test/__mocks__/dns.ts new file mode 100644 index 000000000000..d03aa8d3f84b --- /dev/null +++ b/packages/vercel-edge/test/__mocks__/dns.ts @@ -0,0 +1,2 @@ +export const lookup = jest.fn(); +export const resolveTxt = jest.fn(); diff --git a/packages/vercel-edge/tsconfig.json b/packages/vercel-edge/tsconfig.json new file mode 100644 index 000000000000..a2731860dfa0 --- /dev/null +++ b/packages/vercel-edge/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["src/**/*"], + + "compilerOptions": { + // package-specific options + "target": "ES2018", + "resolveJsonModule": true + } +} diff --git a/packages/vercel-edge/tsconfig.test.json b/packages/vercel-edge/tsconfig.test.json new file mode 100644 index 000000000000..87f6afa06b86 --- /dev/null +++ b/packages/vercel-edge/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*"], + + "compilerOptions": { + // should include all types from `./tsconfig.json` plus types for all test frameworks used + "types": ["node", "jest"] + + // other package-specific, test-specific options + } +} diff --git a/packages/vercel-edge/tsconfig.types.json b/packages/vercel-edge/tsconfig.types.json new file mode 100644 index 000000000000..374fd9bc9364 --- /dev/null +++ b/packages/vercel-edge/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/npm/types" + } +}