diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index 2cd83f672fd5..4ef061d8e520 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -44,6 +44,10 @@ type LoaderOptions = { excludeServerRoutes: Array; wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component'; sentryConfigFilePath?: string; + cronsUpsertConfiguration?: { + path?: string; + schedule?: string; + }[]; }; function moduleExists(id: string): boolean { @@ -74,6 +78,7 @@ export default function wrappingLoader( excludeServerRoutes = [], wrappingTargetKind, sentryConfigFilePath, + cronsUpsertConfiguration, } = 'getOptions' in this ? this.getOptions() : this.query; this.async(); @@ -113,6 +118,8 @@ export default function wrappingLoader( throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`); } + templateCode = templateCode.replace(/__CRONS_UPSERT_CONFIGURATION__/g, JSON.stringify(cronsUpsertConfiguration)); + // Inject the route and the path to the file we're wrapping into the template templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\')); } else if (wrappingTargetKind === 'server-component') { diff --git a/packages/nextjs/src/config/templates/apiWrapperTemplate.ts b/packages/nextjs/src/config/templates/apiWrapperTemplate.ts index 91cf5ef1e0c6..e7b3f3a27ce9 100644 --- a/packages/nextjs/src/config/templates/apiWrapperTemplate.ts +++ b/packages/nextjs/src/config/templates/apiWrapperTemplate.ts @@ -54,7 +54,14 @@ export const config = { }, }; -export default userProvidedHandler ? Sentry.wrapApiHandlerWithSentry(userProvidedHandler, '__ROUTE__') : undefined; +declare const __CRONS_UPSERT_CONFIGURATION__: Parameters[1]; + +export default userProvidedHandler + ? Sentry.wrapApiHandlerWithSentry( + Sentry.wrapApiHandlerWithSentryCrons(userProvidedHandler, __CRONS_UPSERT_CONFIGURATION__), + '__ROUTE__', + ) + : undefined; // Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to // not include anything whose name matchs something we've explicitly exported above. diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 73fb60660451..7370031ddea7 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -163,6 +163,17 @@ export function constructWebpackConfigFunction( ], }); + let vercelCronsConfig: { crons?: any } | undefined = undefined; + try { + if (process.env.VERCEL) { + vercelCronsConfig = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'vercel.json'), 'utf8')); + } + } catch (e) { + // eslint-disable-next-line no-console + console.log('Reading failed'); + // noop + } + // Wrap api routes newConfig.module.rules.unshift({ test: resourcePath => { @@ -177,6 +188,7 @@ export function constructWebpackConfigFunction( loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), options: { ...staticWrappingLoaderOptions, + cronsUpsertConfiguration: vercelCronsConfig?.crons, wrappingTargetKind: 'api-route', }, }, diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index dc036921e436..de66fea950e5 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 { wrapApiHandlerWithSentryCrons } from './wrapApiHandlerWithSentryCrons'; + export { // eslint-disable-next-line deprecation/deprecation withSentryGetStaticProps, diff --git a/packages/nextjs/src/server/wrapApiHandlerWithSentryCrons.ts b/packages/nextjs/src/server/wrapApiHandlerWithSentryCrons.ts new file mode 100644 index 000000000000..e9723a73e782 --- /dev/null +++ b/packages/nextjs/src/server/wrapApiHandlerWithSentryCrons.ts @@ -0,0 +1,89 @@ +import { runWithAsyncContext } from '@sentry/core'; +import { captureCheckin } from '@sentry/node'; +import type { NextApiRequest } from 'next'; + +/** + * TODO + */ +export function wrapApiHandlerWithSentryCrons any>( + handler: F, + vercelCronsConfig: { path?: string; schedule?: string }[] | undefined, +): F { + return new Proxy(handler, { + apply: (originalFunction, thisArg, args: [NextApiRequest]) => { + return runWithAsyncContext(() => { + let maybePromiseResult; + const cronsKey = args[0].url; + + if (!vercelCronsConfig) { + 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; + + captureCheckin( + { + monitorSlug, + status: 'in_progress', + }, + { + schedule: { + type: 'crontab', + value: vercelCron.schedule, + }, + }, + ); + + const startTime = Date.now() / 1000; + + const handleErrorCase = (): void => { + captureCheckin({ + 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) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + Promise.resolve(maybePromiseResult).then( + () => { + captureCheckin({ + 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({ + monitorSlug, + status: 'ok', + duration: Date.now() / 1000 - startTime, + }); + return maybePromiseResult; + } + }); + }, + }); +} diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index d0d0ae7424be..8eac8297d25d 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -1,10 +1,11 @@ import type { Scope } from '@sentry/core'; import { addTracingExtensions, BaseClient, SDK_VERSION, SessionFlusher } from '@sentry/core'; -import type { Event, EventHint, Severity, SeverityLevel } from '@sentry/types'; +import type { CheckIn, Event, EventHint, Severity, SeverityLevel } from '@sentry/types'; import { logger, resolvedSyncPromise } from '@sentry/utils'; import * as os from 'os'; import { TextEncoder } from 'util'; +import { createCheckInEnvelope } from './checkin'; import { eventFromMessage, eventFromUnknownInput } from './eventbuilder'; import type { NodeClientOptions } from './types'; @@ -103,6 +104,21 @@ export class NodeClient extends BaseClient { return super.close(timeout); } + /** + * TODO + */ + public captureCheckin(checkin: CheckIn): void { + if (!this._isEnabled()) { + __DEBUG_BUILD__ && logger.warn('SDK not enabled, will not capture checkin.'); + return; + } + + const envelope = createCheckInEnvelope(checkin, this.getSdkMetadata(), this.getOptions().tunnel, this.getDsn()); + + __DEBUG_BUILD__ && logger.warn('Sending checkin: ', checkin); + void this._sendEnvelope(envelope); + } + /** Method that initialises an instance of SessionFlusher on Client */ public initSessionFlusher(): void { const { release, environment } = this._options; diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 6537b16aca70..0d754542877e 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -56,7 +56,16 @@ export { autoDiscoverNodePerformanceMonitoringIntegrations } from './tracing'; export { NodeClient } from './client'; export { makeNodeTransport } from './transports'; -export { defaultIntegrations, init, defaultStackParser, lastEventId, flush, close, getSentryRelease } from './sdk'; +export { + defaultIntegrations, + init, + defaultStackParser, + lastEventId, + flush, + close, + getSentryRelease, + captureCheckin, +} from './sdk'; export { addRequestDataToEvent, DEFAULT_USER_INCLUDES, extractRequestData } from './requestdata'; export { deepReadDirSync } from './utils'; diff --git a/packages/node/src/sdk.ts b/packages/node/src/sdk.ts index d0a02c746247..a46f3d9188ef 100644 --- a/packages/node/src/sdk.ts +++ b/packages/node/src/sdk.ts @@ -6,13 +6,14 @@ import { initAndBind, Integrations as CoreIntegrations, } from '@sentry/core'; -import type { SessionStatus, StackParser } from '@sentry/types'; +import type { CheckIn, SessionStatus, StackParser } from '@sentry/types'; import { createStackParser, GLOBAL_OBJ, logger, nodeStackLineParser, stackParserFromStackParserOptions, + uuid4, } from '@sentry/utils'; import { setNodeAsyncContextStrategy } from './async'; @@ -285,3 +286,61 @@ function startSessionTracking(): void { if (session && !terminalStates.includes(session.status)) hub.endSession(); }); } + +interface CrontabSchedule { + type: 'crontab'; + // The crontab schedule string, e.g. 0 * * * *. + value: string; +} + +interface IntervalSchedule { + type: 'interval'; + value: number; + unit: 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute'; +} + +export interface UserFacingCheckin { + // The distinct slug of the monitor. + monitorSlug: string; + // The status of the check-in. + status: 'in_progress' | 'ok' | 'error'; + // Timing data + duration?: number; +} + +export interface UpdateMonitor { + schedule: CrontabSchedule | IntervalSchedule; + // The allowed allowed margin of minutes after the expected check-in time that + // the monitor will not be considered missed for. + checkinMargin?: number; + // The allowed allowed duration in minutes that the monitor may be `in_progress` + // for before being considered failed due to timeout. + maxRuntime?: number; + // A tz database string representing the timezone which the monitor's execution schedule is in. + // See: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + timezone?: string; +} + +/** + * TODO + */ +export function captureCheckin(checkin: UserFacingCheckin, updateMonitor?: UpdateMonitor): void { + const normalizedCheckin: CheckIn = { + check_in_id: uuid4(), + monitor_slug: checkin.monitorSlug, + status: checkin.status, + duration: checkin.duration, + release: 'dummy release', + environment: 'dummy environment', + monitor_config: updateMonitor + ? { + schedule: updateMonitor?.schedule, + checkin_margin: updateMonitor?.checkinMargin, + max_runtime: updateMonitor?.maxRuntime, + timezone: updateMonitor?.timezone, + } + : undefined, + }; + + return getCurrentHub().getClient()?.captureCheckin(normalizedCheckin); +}