Skip to content

Commit c7e6c6b

Browse files
authored
feat(nextjs): Enhanced automatic Vercel Cron monitoring (#19192)
This PR introduces a new experimental approach for automatic Vercel cron job monitoring that leverages span lifecycle events instead of wrapping route handlers at build time. **Summary** The current `automaticVercelMonitors` option works by wrapping cron route handlers during webpack compilation. This forced a couple of limitations, we couldn't do Turbopack and we couldn't do App Router. This new approach (`_experimental.vercelCronsMonitoring`) instead hooks into the span start/end events to detect cron requests at runtime and send check-ins accordingly. This is less invasive and works without modifying user code. **How it works** When a request comes in, we check if it's a Vercel cron request by looking for the `vercel-cron` user agent header we already collected. If it matches a configured cron path from `vercel.json`, we start an "in_progress" check-in and store the check-in ID on the span as attributes. When the span ends, we complete the check-in with either "ok" or "error" status based on the span's final status and delete the attributes we added earlier. ```mermaid sequenceDiagram participant Vercel participant NextJS as Next.js App participant SDK as Sentry SDK participant Sentry Vercel->>NextJS: GET /api/my-cron (User-Agent: vercel-cron/1.0) NextJS->>SDK: Span starts SDK->>SDK: Detect vercel-cron user agent SDK->>SDK: Match route to vercel.json crons config SDK->>Sentry: Check-in (status: in_progress) SDK->>SDK: Store check-in ID as span attribute NextJS->>NextJS: Execute route handler NextJS->>SDK: Span ends SDK->>SDK: Read check-in ID from span SDK->>SDK: Determine status from span (ok/error) SDK->>Sentry: Check-in (status: ok or error) ``` **Backward Compatibility** The existing `automaticVercelMonitors` option continues to work unchanged. When both options are enabled, the SDK prefers the new span-based approach and logs a warning suggesting to remove the legacy option. | Configuration | Behavior | |---------------|----------| | Only `webpack.automaticVercelMonitors: true` | Uses existing wrapper approach | | Only `_experimental.vercelCronsMonitoring: true` | Uses new span-based approach | | Both enabled | Uses span-based approach + warning | **Usage** ```javascript // next.config.js module.exports = withSentryConfig(nextConfig, { _experimental: { vercelCronsMonitoring: true, }, }); ``` --- **Why is it experimental?** I need to test this out in production with a few scenarios, it works locally and it works when I half-assed a prod vercel app via a tunnel to my local Verdaccio. Also I think it is a bit flimsy, it all depends if the header is there. If Vercel changes the implementation or we stop applying the headers on the spans then this easily breaks. I'm thinking we can dogfood this initially and see how well it works for us before trying something else. Closes #11637
1 parent ef7f7e4 commit c7e6c6b

File tree

22 files changed

+652
-25
lines changed

22 files changed

+652
-25
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@ Work in this release was contributed by @limbonaut. Thank you for your contribut
1010

1111
The `sentryTanstackStart` Vite plugin now automatically instruments middleware in `createServerFn().middleware([...])` calls. This captures performance data without requiring manual wrapping with `wrapMiddlewaresWithSentry()`.
1212

13+
- **feat(nextjs): New experimental automatic vercel cron monitoring ([#19066](https://github.com/getsentry/sentry-javascript/pull/19192))**
14+
15+
Setting `_experimental.vercelCronMonitoring` to `true` in your Sentry configuration will automatically create Sentry cron monitors for your Vercel Cron Jobs.
16+
17+
Please note that this is an experimental unstable feature and subject to change.
18+
19+
```ts
20+
// next.config.ts
21+
export default withSentryConfig(nextConfig, {
22+
_experimental: {
23+
vercelCronMonitoring: true,
24+
},
25+
});
26+
```
27+
1328
## 10.38.0
1429

1530
### Important Changes
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const dynamic = 'force-dynamic';
2+
3+
export async function GET() {
4+
throw new Error('Cron job error');
5+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { NextResponse } from 'next/server';
2+
3+
export const dynamic = 'force-dynamic';
4+
5+
export async function GET() {
6+
// Simulate some work
7+
await new Promise(resolve => setTimeout(resolve, 100));
8+
return NextResponse.json({ message: 'Cron job executed successfully' });
9+
}

dev-packages/e2e-tests/test-applications/nextjs-15/next.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
const { withSentryConfig } = require('@sentry/nextjs');
22

3+
// Simulate Vercel environment for cron monitoring tests
4+
process.env.VERCEL = '1';
5+
36
/** @type {import('next').NextConfig} */
47
const nextConfig = {};
58

@@ -8,4 +11,7 @@ module.exports = withSentryConfig(nextConfig, {
811
release: {
912
name: 'foobar123',
1013
},
14+
_experimental: {
15+
vercelCronsMonitoring: true,
16+
},
1117
});
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForEnvelopeItem } from '@sentry-internal/test-utils';
3+
4+
test('Sends cron check-in envelope for successful cron job', async ({ request }) => {
5+
const inProgressEnvelopePromise = waitForEnvelopeItem('nextjs-15', envelope => {
6+
return (
7+
envelope[0].type === 'check_in' &&
8+
// @ts-expect-error envelope[1] is untyped
9+
envelope[1]['monitor_slug'] === '/api/cron-test' &&
10+
// @ts-expect-error envelope[1] is untyped
11+
envelope[1]['status'] === 'in_progress'
12+
);
13+
});
14+
15+
const okEnvelopePromise = waitForEnvelopeItem('nextjs-15', envelope => {
16+
return (
17+
envelope[0].type === 'check_in' &&
18+
// @ts-expect-error envelope[1] is untyped
19+
envelope[1]['monitor_slug'] === '/api/cron-test' &&
20+
// @ts-expect-error envelope[1] is untyped
21+
envelope[1]['status'] === 'ok'
22+
);
23+
});
24+
25+
const response = await request.get('/api/cron-test', {
26+
headers: {
27+
'User-Agent': 'vercel-cron/1.0',
28+
},
29+
});
30+
31+
expect(response.status()).toBe(200);
32+
expect(await response.json()).toStrictEqual({ message: 'Cron job executed successfully' });
33+
34+
const inProgressEnvelope = await inProgressEnvelopePromise;
35+
const okEnvelope = await okEnvelopePromise;
36+
37+
expect(inProgressEnvelope[1]).toEqual(
38+
expect.objectContaining({
39+
check_in_id: expect.any(String),
40+
monitor_slug: '/api/cron-test',
41+
status: 'in_progress',
42+
monitor_config: {
43+
schedule: {
44+
type: 'crontab',
45+
value: '0 * * * *',
46+
},
47+
max_runtime: 720,
48+
},
49+
}),
50+
);
51+
52+
expect(okEnvelope[1]).toEqual(
53+
expect.objectContaining({
54+
check_in_id: expect.any(String),
55+
monitor_slug: '/api/cron-test',
56+
status: 'ok',
57+
duration: expect.any(Number),
58+
}),
59+
);
60+
// @ts-expect-error envelope[1] is untyped
61+
expect(okEnvelope[1]['check_in_id']).toBe(inProgressEnvelope[1]['check_in_id']);
62+
});
63+
64+
test('Sends cron check-in envelope with error status for failed cron job', async ({ request }) => {
65+
const inProgressEnvelopePromise = waitForEnvelopeItem('nextjs-15', envelope => {
66+
return (
67+
envelope[0].type === 'check_in' &&
68+
// @ts-expect-error envelope[1] is untyped
69+
envelope[1]['monitor_slug'] === '/api/cron-test-error' &&
70+
// @ts-expect-error envelope[1] is untyped
71+
envelope[1]['status'] === 'in_progress'
72+
);
73+
});
74+
75+
const errorEnvelopePromise = waitForEnvelopeItem('nextjs-15', envelope => {
76+
return (
77+
envelope[0].type === 'check_in' &&
78+
// @ts-expect-error envelope[1] is untyped
79+
envelope[1]['monitor_slug'] === '/api/cron-test-error' &&
80+
// @ts-expect-error envelope[1] is untyped
81+
envelope[1]['status'] === 'error'
82+
);
83+
});
84+
85+
await request.get('/api/cron-test-error', {
86+
headers: {
87+
'User-Agent': 'vercel-cron/1.0',
88+
},
89+
});
90+
91+
const inProgressEnvelope = await inProgressEnvelopePromise;
92+
const errorEnvelope = await errorEnvelopePromise;
93+
94+
expect(inProgressEnvelope[1]).toEqual(
95+
expect.objectContaining({
96+
check_in_id: expect.any(String),
97+
monitor_slug: '/api/cron-test-error',
98+
status: 'in_progress',
99+
monitor_config: {
100+
schedule: {
101+
type: 'crontab',
102+
value: '30 * * * *',
103+
},
104+
max_runtime: 720,
105+
},
106+
}),
107+
);
108+
109+
expect(errorEnvelope[1]).toEqual(
110+
expect.objectContaining({
111+
check_in_id: expect.any(String),
112+
monitor_slug: '/api/cron-test-error',
113+
status: 'error',
114+
duration: expect.any(Number),
115+
}),
116+
);
117+
118+
// @ts-expect-error envelope[1] is untyped
119+
expect(errorEnvelope[1]['check_in_id']).toBe(inProgressEnvelope[1]['check_in_id']);
120+
});
121+
122+
test('Does not send cron check-in envelope for regular requests without vercel-cron user agent', async ({
123+
request,
124+
}) => {
125+
let checkInReceived = false;
126+
127+
waitForEnvelopeItem('nextjs-15', envelope => {
128+
if (
129+
envelope[0].type === 'check_in' && // @ts-expect-error envelope[1] is untyped
130+
envelope[1]['monitor_slug'] === '/api/cron-test'
131+
) {
132+
checkInReceived = true;
133+
return true;
134+
}
135+
return false;
136+
});
137+
138+
const response = await request.get('/api/cron-test');
139+
140+
expect(response.status()).toBe(200);
141+
expect(await response.json()).toStrictEqual({ message: 'Cron job executed successfully' });
142+
143+
await new Promise(resolve => setTimeout(resolve, 2000));
144+
145+
expect(checkInReceived).toBe(false);
146+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"crons": [
3+
{
4+
"path": "/api/cron-test",
5+
"schedule": "0 * * * *"
6+
},
7+
{
8+
"path": "/api/cron-test-error",
9+
"schedule": "30 * * * *"
10+
}
11+
]
12+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const dynamic = 'force-dynamic';
2+
3+
export async function GET() {
4+
throw new Error('Cron job error');
5+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { NextResponse } from 'next/server';
2+
3+
export const dynamic = 'force-dynamic';
4+
5+
export async function GET() {
6+
// Simulate some work
7+
await new Promise(resolve => setTimeout(resolve, 100));
8+
return NextResponse.json({ message: 'Cron job executed successfully' });
9+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { withSentryConfig } from '@sentry/nextjs';
22
import type { NextConfig } from 'next';
33

4+
// Simulate Vercel environment for cron monitoring tests
5+
process.env.VERCEL = '1';
6+
47
const nextConfig: NextConfig = {};
58

69
export default withSentryConfig(nextConfig, {
710
silent: true,
11+
_experimental: {
12+
vercelCronsMonitoring: true,
13+
},
814
});
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForEnvelopeItem } from '@sentry-internal/test-utils';
3+
4+
test('Sends cron check-in envelope for successful cron job', async ({ request }) => {
5+
const inProgressEnvelopePromise = waitForEnvelopeItem('nextjs-16', envelope => {
6+
return (
7+
envelope[0].type === 'check_in' &&
8+
// @ts-expect-error envelope[1] is untyped
9+
envelope[1]['monitor_slug'] === '/api/cron-test' &&
10+
// @ts-expect-error envelope[1] is untyped
11+
envelope[1]['status'] === 'in_progress'
12+
);
13+
});
14+
15+
const okEnvelopePromise = waitForEnvelopeItem('nextjs-16', envelope => {
16+
return (
17+
envelope[0].type === 'check_in' &&
18+
// @ts-expect-error envelope[1] is untyped
19+
envelope[1]['monitor_slug'] === '/api/cron-test' &&
20+
// @ts-expect-error envelope[1] is untyped
21+
envelope[1]['status'] === 'ok'
22+
);
23+
});
24+
25+
const response = await request.get('/api/cron-test', {
26+
headers: {
27+
'User-Agent': 'vercel-cron/1.0',
28+
},
29+
});
30+
31+
expect(response.status()).toBe(200);
32+
expect(await response.json()).toStrictEqual({ message: 'Cron job executed successfully' });
33+
34+
const inProgressEnvelope = await inProgressEnvelopePromise;
35+
const okEnvelope = await okEnvelopePromise;
36+
37+
expect(inProgressEnvelope[1]).toEqual(
38+
expect.objectContaining({
39+
check_in_id: expect.any(String),
40+
monitor_slug: '/api/cron-test',
41+
status: 'in_progress',
42+
monitor_config: {
43+
schedule: {
44+
type: 'crontab',
45+
value: '0 * * * *',
46+
},
47+
max_runtime: 720,
48+
},
49+
}),
50+
);
51+
52+
expect(okEnvelope[1]).toEqual(
53+
expect.objectContaining({
54+
check_in_id: expect.any(String),
55+
monitor_slug: '/api/cron-test',
56+
status: 'ok',
57+
duration: expect.any(Number),
58+
}),
59+
);
60+
61+
// @ts-expect-error envelope[1] is untyped
62+
expect(okEnvelope[1]['check_in_id']).toBe(inProgressEnvelope[1]['check_in_id']);
63+
});
64+
65+
test('Sends cron check-in envelope with error status for failed cron job', async ({ request }) => {
66+
const inProgressEnvelopePromise = waitForEnvelopeItem('nextjs-16', envelope => {
67+
return (
68+
envelope[0].type === 'check_in' &&
69+
// @ts-expect-error envelope[1] is untyped
70+
envelope[1]['monitor_slug'] === '/api/cron-test-error' &&
71+
// @ts-expect-error envelope[1] is untyped
72+
envelope[1]['status'] === 'in_progress'
73+
);
74+
});
75+
76+
const errorEnvelopePromise = waitForEnvelopeItem('nextjs-16', envelope => {
77+
return (
78+
envelope[0].type === 'check_in' &&
79+
// @ts-expect-error envelope[1] is untyped
80+
envelope[1]['monitor_slug'] === '/api/cron-test-error' &&
81+
// @ts-expect-error envelope[1] is untyped
82+
envelope[1]['status'] === 'error'
83+
);
84+
});
85+
86+
await request.get('/api/cron-test-error', {
87+
headers: {
88+
'User-Agent': 'vercel-cron/1.0',
89+
},
90+
});
91+
92+
const inProgressEnvelope = await inProgressEnvelopePromise;
93+
const errorEnvelope = await errorEnvelopePromise;
94+
95+
expect(inProgressEnvelope[1]).toEqual(
96+
expect.objectContaining({
97+
check_in_id: expect.any(String),
98+
monitor_slug: '/api/cron-test-error',
99+
status: 'in_progress',
100+
monitor_config: {
101+
schedule: {
102+
type: 'crontab',
103+
value: '30 * * * *',
104+
},
105+
max_runtime: 720,
106+
},
107+
}),
108+
);
109+
110+
expect(errorEnvelope[1]).toEqual(
111+
expect.objectContaining({
112+
check_in_id: expect.any(String),
113+
monitor_slug: '/api/cron-test-error',
114+
status: 'error',
115+
duration: expect.any(Number),
116+
}),
117+
);
118+
119+
// @ts-expect-error envelope[1] is untyped
120+
expect(errorEnvelope[1]['check_in_id']).toBe(inProgressEnvelope[1]['check_in_id']);
121+
});
122+
123+
test('Does not send cron check-in envelope for regular requests without vercel-cron user agent', async ({
124+
request,
125+
}) => {
126+
let checkInReceived = false;
127+
128+
waitForEnvelopeItem('nextjs-16', envelope => {
129+
if (
130+
envelope[0].type === 'check_in' && // @ts-expect-error envelope[1] is untyped
131+
envelope[1]['monitor_slug'] === '/api/cron-test'
132+
) {
133+
checkInReceived = true;
134+
return true;
135+
}
136+
return false;
137+
});
138+
139+
const response = await request.get('/api/cron-test');
140+
141+
expect(response.status()).toBe(200);
142+
expect(await response.json()).toStrictEqual({ message: 'Cron job executed successfully' });
143+
144+
await new Promise(resolve => setTimeout(resolve, 2000));
145+
146+
expect(checkInReceived).toBe(false);
147+
});

0 commit comments

Comments
 (0)