Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nextjs): Add API method to wrap API routes with crons instrumentation #8084

Merged
merged 4 commits into from
May 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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