Skip to content

Commit

Permalink
feat(nextjs): Add API method to wrap API routes with crons instrument…
Browse files Browse the repository at this point in the history
…ation (#8084)
  • Loading branch information
lforst committed May 9, 2023
1 parent fd7a092 commit cbe8a57
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 3 deletions.
2 changes: 2 additions & 0 deletions packages/nextjs/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export type ServerComponentContext = {
sentryTraceHeader?: string;
baggageHeader?: string;
};

export type VercelCronsConfig = { path?: string; schedule?: string }[] | undefined;
102 changes: 102 additions & 0 deletions packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts
Original file line number Diff line number Diff line change
@@ -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<F extends (...args: any[]) => 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;
}
});
},
});
}
57 changes: 55 additions & 2 deletions packages/nextjs/src/edge/edgeclient.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -55,6 +65,49 @@ export class EdgeClient extends BaseClient<EdgeClientOptions> {
);
}

/**
* 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
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/nextjs/src/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ export {
wrapApiHandlerWithSentry,
} from './wrapApiHandlerWithSentry';

export { wrapApiHandlerWithSentryVercelCrons } from '../common/wrapApiHandlerWithSentryVercelCrons';

export { wrapMiddlewareWithSentry } from './wrapMiddlewareWithSentry';

export { wrapServerComponentWithSentry } from './wrapServerComponentWithSentry';
10 changes: 9 additions & 1 deletion packages/nextjs/src/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -178,3 +178,11 @@ export declare function wrapServerComponentWithSentry<F extends (...args: any[])
WrappingTarget: F,
context: ServerComponentContext,
): F;

/**
* Wraps an `app` directory server component with Sentry error and performance instrumentation.
*/
export declare function wrapApiHandlerWithSentryVercelCrons<F extends (...args: any[]) => any>(
WrappingTarget: F,
vercelCronsConfig: VercelCronsConfig,
): F;
2 changes: 2 additions & 0 deletions packages/nextjs/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit cbe8a57

Please sign in to comment.