diff --git a/packages/nextjs/src/common/types.ts b/packages/nextjs/src/common/types.ts index cfac0c460a84..6f1c9e5b2c4b 100644 --- a/packages/nextjs/src/common/types.ts +++ b/packages/nextjs/src/common/types.ts @@ -4,3 +4,5 @@ export type ServerComponentContext = { sentryTraceHeader?: string; baggageHeader?: string; }; + +export type VercelCronsConfig = { path?: string; schedule?: string }[] | undefined; diff --git a/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts b/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts new file mode 100644 index 000000000000..abf707dd5f2c --- /dev/null +++ b/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts @@ -0,0 +1,102 @@ +import { captureCheckIn, runWithAsyncContext } from '@sentry/core'; +import type { NextApiRequest } from 'next'; + +import type { VercelCronsConfig } from './types'; + +/** + * Wraps a function with Sentry crons instrumentation by automaticaly sending check-ins for the given Vercel crons config. + */ +export function wrapApiHandlerWithSentryVercelCrons any>( + handler: F, + vercelCronsConfig: VercelCronsConfig, +): F { + return new Proxy(handler, { + apply: (originalFunction, thisArg, args: [NextApiRequest | undefined] | undefined) => { + return runWithAsyncContext(() => { + if (!args || !args[0]) { + return originalFunction.apply(thisArg, args); + } + const [req] = args; + + let maybePromiseResult; + const cronsKey = req.url; + + if ( + !vercelCronsConfig || // do nothing if vercel crons config is missing + !req.headers['user-agent']?.includes('vercel-cron') // do nothing if endpoint is not called from vercel crons + ) { + return originalFunction.apply(thisArg, args); + } + + const vercelCron = vercelCronsConfig.find(vercelCron => vercelCron.path === cronsKey); + + if (!vercelCron || !vercelCron.path || !vercelCron.schedule) { + return originalFunction.apply(thisArg, args); + } + + const monitorSlug = vercelCron.path; + + const checkInId = captureCheckIn( + { + monitorSlug, + status: 'in_progress', + }, + { + checkinMargin: 2, // two minutes - in case Vercel has a blip + maxRuntime: 60 * 12, // (minutes) so 12 hours - just a very high arbitrary number since we don't know the actual duration of the users cron job + schedule: { + type: 'crontab', + value: vercelCron.schedule, + }, + }, + ); + + const startTime = Date.now() / 1000; + + const handleErrorCase = (): void => { + captureCheckIn({ + checkInId, + monitorSlug, + status: 'error', + duration: Date.now() / 1000 - startTime, + }); + }; + + try { + maybePromiseResult = originalFunction.apply(thisArg, args); + } catch (e) { + handleErrorCase(); + throw e; + } + + if (typeof maybePromiseResult === 'object' && maybePromiseResult !== null && 'then' in maybePromiseResult) { + Promise.resolve(maybePromiseResult).then( + () => { + captureCheckIn({ + checkInId, + monitorSlug, + status: 'ok', + duration: Date.now() / 1000 - startTime, + }); + }, + () => { + handleErrorCase(); + }, + ); + + // It is very important that we return the original promise here, because Next.js attaches various properties + // to that promise and will throw if they are not on the returned value. + return maybePromiseResult; + } else { + captureCheckIn({ + checkInId, + monitorSlug, + status: 'ok', + duration: Date.now() / 1000 - startTime, + }); + return maybePromiseResult; + } + }); + }, + }); +} diff --git a/packages/nextjs/src/edge/edgeclient.ts b/packages/nextjs/src/edge/edgeclient.ts index 16aed66d1ca4..64e3d526061c 100644 --- a/packages/nextjs/src/edge/edgeclient.ts +++ b/packages/nextjs/src/edge/edgeclient.ts @@ -1,6 +1,16 @@ import type { Scope } from '@sentry/core'; -import { addTracingExtensions, BaseClient, SDK_VERSION } from '@sentry/core'; -import type { ClientOptions, Event, EventHint, Severity, SeverityLevel } from '@sentry/types'; +import { addTracingExtensions, BaseClient, createCheckInEnvelope, SDK_VERSION } from '@sentry/core'; +import type { + CheckIn, + ClientOptions, + Event, + EventHint, + MonitorConfig, + SerializedCheckIn, + Severity, + SeverityLevel, +} from '@sentry/types'; +import { logger, uuid4 } from '@sentry/utils'; import { eventFromMessage, eventFromUnknownInput } from './eventbuilder'; import type { EdgeTransportOptions } from './transport'; @@ -55,6 +65,49 @@ export class EdgeClient extends BaseClient { ); } + /** + * 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): 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 envelope = createCheckInEnvelope(serializedCheckIn, this.getSdkMetadata(), tunnel, this.getDsn()); + void this._sendEnvelope(envelope); + return id; + } + /** * @inheritDoc */ diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index f7785aaa06d6..ca56ce3facbe 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -136,6 +136,8 @@ export { wrapApiHandlerWithSentry, } from './wrapApiHandlerWithSentry'; +export { wrapApiHandlerWithSentryVercelCrons } from '../common/wrapApiHandlerWithSentryVercelCrons'; + export { wrapMiddlewareWithSentry } from './wrapMiddlewareWithSentry'; export { wrapServerComponentWithSentry } from './wrapServerComponentWithSentry'; diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index 97e48b6d8314..a75b61e02ea3 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -10,7 +10,7 @@ export * from './edge'; import type { Integration, Options, StackParser } from '@sentry/types'; import type * as clientSdk from './client'; -import type { ServerComponentContext } from './common/types'; +import type { ServerComponentContext, VercelCronsConfig } from './common/types'; import type * as edgeSdk from './edge'; import type * as serverSdk from './server'; @@ -178,3 +178,11 @@ export declare function wrapServerComponentWithSentry any>( + WrappingTarget: F, + vercelCronsConfig: VercelCronsConfig, +): F; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index dc036921e436..d92e406cda3f 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -157,6 +157,8 @@ const deprecatedIsBuild = (): boolean => isBuild(); // eslint-disable-next-line deprecation/deprecation export { deprecatedIsBuild as isBuild }; +export { wrapApiHandlerWithSentryVercelCrons } from '../common/wrapApiHandlerWithSentryVercelCrons'; + export { // eslint-disable-next-line deprecation/deprecation withSentryGetStaticProps,