Skip to content

Commit cbe8a57

Browse files
author
Luca Forstner
authored
feat(nextjs): Add API method to wrap API routes with crons instrumentation (#8084)
1 parent fd7a092 commit cbe8a57

File tree

6 files changed

+172
-3
lines changed

6 files changed

+172
-3
lines changed

packages/nextjs/src/common/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ export type ServerComponentContext = {
44
sentryTraceHeader?: string;
55
baggageHeader?: string;
66
};
7+
8+
export type VercelCronsConfig = { path?: string; schedule?: string }[] | undefined;
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { captureCheckIn, runWithAsyncContext } from '@sentry/core';
2+
import type { NextApiRequest } from 'next';
3+
4+
import type { VercelCronsConfig } from './types';
5+
6+
/**
7+
* Wraps a function with Sentry crons instrumentation by automaticaly sending check-ins for the given Vercel crons config.
8+
*/
9+
export function wrapApiHandlerWithSentryVercelCrons<F extends (...args: any[]) => any>(
10+
handler: F,
11+
vercelCronsConfig: VercelCronsConfig,
12+
): F {
13+
return new Proxy(handler, {
14+
apply: (originalFunction, thisArg, args: [NextApiRequest | undefined] | undefined) => {
15+
return runWithAsyncContext(() => {
16+
if (!args || !args[0]) {
17+
return originalFunction.apply(thisArg, args);
18+
}
19+
const [req] = args;
20+
21+
let maybePromiseResult;
22+
const cronsKey = req.url;
23+
24+
if (
25+
!vercelCronsConfig || // do nothing if vercel crons config is missing
26+
!req.headers['user-agent']?.includes('vercel-cron') // do nothing if endpoint is not called from vercel crons
27+
) {
28+
return originalFunction.apply(thisArg, args);
29+
}
30+
31+
const vercelCron = vercelCronsConfig.find(vercelCron => vercelCron.path === cronsKey);
32+
33+
if (!vercelCron || !vercelCron.path || !vercelCron.schedule) {
34+
return originalFunction.apply(thisArg, args);
35+
}
36+
37+
const monitorSlug = vercelCron.path;
38+
39+
const checkInId = captureCheckIn(
40+
{
41+
monitorSlug,
42+
status: 'in_progress',
43+
},
44+
{
45+
checkinMargin: 2, // two minutes - in case Vercel has a blip
46+
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
47+
schedule: {
48+
type: 'crontab',
49+
value: vercelCron.schedule,
50+
},
51+
},
52+
);
53+
54+
const startTime = Date.now() / 1000;
55+
56+
const handleErrorCase = (): void => {
57+
captureCheckIn({
58+
checkInId,
59+
monitorSlug,
60+
status: 'error',
61+
duration: Date.now() / 1000 - startTime,
62+
});
63+
};
64+
65+
try {
66+
maybePromiseResult = originalFunction.apply(thisArg, args);
67+
} catch (e) {
68+
handleErrorCase();
69+
throw e;
70+
}
71+
72+
if (typeof maybePromiseResult === 'object' && maybePromiseResult !== null && 'then' in maybePromiseResult) {
73+
Promise.resolve(maybePromiseResult).then(
74+
() => {
75+
captureCheckIn({
76+
checkInId,
77+
monitorSlug,
78+
status: 'ok',
79+
duration: Date.now() / 1000 - startTime,
80+
});
81+
},
82+
() => {
83+
handleErrorCase();
84+
},
85+
);
86+
87+
// It is very important that we return the original promise here, because Next.js attaches various properties
88+
// to that promise and will throw if they are not on the returned value.
89+
return maybePromiseResult;
90+
} else {
91+
captureCheckIn({
92+
checkInId,
93+
monitorSlug,
94+
status: 'ok',
95+
duration: Date.now() / 1000 - startTime,
96+
});
97+
return maybePromiseResult;
98+
}
99+
});
100+
},
101+
});
102+
}

packages/nextjs/src/edge/edgeclient.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import type { Scope } from '@sentry/core';
2-
import { addTracingExtensions, BaseClient, SDK_VERSION } from '@sentry/core';
3-
import type { ClientOptions, Event, EventHint, Severity, SeverityLevel } from '@sentry/types';
2+
import { addTracingExtensions, BaseClient, createCheckInEnvelope, SDK_VERSION } from '@sentry/core';
3+
import type {
4+
CheckIn,
5+
ClientOptions,
6+
Event,
7+
EventHint,
8+
MonitorConfig,
9+
SerializedCheckIn,
10+
Severity,
11+
SeverityLevel,
12+
} from '@sentry/types';
13+
import { logger, uuid4 } from '@sentry/utils';
414

515
import { eventFromMessage, eventFromUnknownInput } from './eventbuilder';
616
import type { EdgeTransportOptions } from './transport';
@@ -55,6 +65,49 @@ export class EdgeClient extends BaseClient<EdgeClientOptions> {
5565
);
5666
}
5767

68+
/**
69+
* Create a cron monitor check in and send it to Sentry.
70+
*
71+
* @param checkIn An object that describes a check in.
72+
* @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want
73+
* to create a monitor automatically when sending a check in.
74+
*/
75+
public captureCheckIn(checkIn: CheckIn, monitorConfig?: MonitorConfig): string {
76+
const id = checkIn.status !== 'in_progress' && checkIn.checkInId ? checkIn.checkInId : uuid4();
77+
if (!this._isEnabled()) {
78+
__DEBUG_BUILD__ && logger.warn('SDK not enabled, will not capture checkin.');
79+
return id;
80+
}
81+
82+
const options = this.getOptions();
83+
const { release, environment, tunnel } = options;
84+
85+
const serializedCheckIn: SerializedCheckIn = {
86+
check_in_id: id,
87+
monitor_slug: checkIn.monitorSlug,
88+
status: checkIn.status,
89+
release,
90+
environment,
91+
};
92+
93+
if (checkIn.status !== 'in_progress') {
94+
serializedCheckIn.duration = checkIn.duration;
95+
}
96+
97+
if (monitorConfig) {
98+
serializedCheckIn.monitor_config = {
99+
schedule: monitorConfig.schedule,
100+
checkin_margin: monitorConfig.checkinMargin,
101+
max_runtime: monitorConfig.maxRuntime,
102+
timezone: monitorConfig.timezone,
103+
};
104+
}
105+
106+
const envelope = createCheckInEnvelope(serializedCheckIn, this.getSdkMetadata(), tunnel, this.getDsn());
107+
void this._sendEnvelope(envelope);
108+
return id;
109+
}
110+
58111
/**
59112
* @inheritDoc
60113
*/

packages/nextjs/src/edge/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ export {
136136
wrapApiHandlerWithSentry,
137137
} from './wrapApiHandlerWithSentry';
138138

139+
export { wrapApiHandlerWithSentryVercelCrons } from '../common/wrapApiHandlerWithSentryVercelCrons';
140+
139141
export { wrapMiddlewareWithSentry } from './wrapMiddlewareWithSentry';
140142

141143
export { wrapServerComponentWithSentry } from './wrapServerComponentWithSentry';

packages/nextjs/src/index.types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export * from './edge';
1010
import type { Integration, Options, StackParser } from '@sentry/types';
1111

1212
import type * as clientSdk from './client';
13-
import type { ServerComponentContext } from './common/types';
13+
import type { ServerComponentContext, VercelCronsConfig } from './common/types';
1414
import type * as edgeSdk from './edge';
1515
import type * as serverSdk from './server';
1616

@@ -178,3 +178,11 @@ export declare function wrapServerComponentWithSentry<F extends (...args: any[])
178178
WrappingTarget: F,
179179
context: ServerComponentContext,
180180
): F;
181+
182+
/**
183+
* Wraps an `app` directory server component with Sentry error and performance instrumentation.
184+
*/
185+
export declare function wrapApiHandlerWithSentryVercelCrons<F extends (...args: any[]) => any>(
186+
WrappingTarget: F,
187+
vercelCronsConfig: VercelCronsConfig,
188+
): F;

packages/nextjs/src/server/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ const deprecatedIsBuild = (): boolean => isBuild();
157157
// eslint-disable-next-line deprecation/deprecation
158158
export { deprecatedIsBuild as isBuild };
159159

160+
export { wrapApiHandlerWithSentryVercelCrons } from '../common/wrapApiHandlerWithSentryVercelCrons';
161+
160162
export {
161163
// eslint-disable-next-line deprecation/deprecation
162164
withSentryGetStaticProps,

0 commit comments

Comments
 (0)