Skip to content
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ Work in this release was contributed by @limbonaut. Thank you for your contribut

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

- **feat(nextjs): New experimental automatic vercel cron monitoring ([#19066](https://github.com/getsentry/sentry-javascript/pull/19192))**

Setting `_experimental.vercelCronMonitoring` to `true` in your Sentry configuration will automatically create Sentry cron monitors for your Vercel Cron Jobs.

Please note that this is an experimental unstable feature and subject to change.

```ts
// next.config.ts
export default withSentryConfig(nextConfig, {
_experimental: {
vercelCronMonitoring: true,
},
});
```

## 10.38.0

### Important Changes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const dynamic = 'force-dynamic';

export async function GET() {
throw new Error('Cron job error');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';

export const dynamic = 'force-dynamic';

export async function GET() {
// Simulate some work
await new Promise(resolve => setTimeout(resolve, 100));
return NextResponse.json({ message: 'Cron job executed successfully' });
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
const { withSentryConfig } = require('@sentry/nextjs');

// Simulate Vercel environment for cron monitoring tests
process.env.VERCEL = '1';

/** @type {import('next').NextConfig} */
const nextConfig = {};

Expand All @@ -8,4 +11,7 @@ module.exports = withSentryConfig(nextConfig, {
release: {
name: 'foobar123',
},
_experimental: {
vercelCronsMonitoring: true,
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { expect, test } from '@playwright/test';
import { waitForEnvelopeItem } from '@sentry-internal/test-utils';

test('Sends cron check-in envelope for successful cron job', async ({ request }) => {
const inProgressEnvelopePromise = waitForEnvelopeItem('nextjs-15', envelope => {
return (
envelope[0].type === 'check_in' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['monitor_slug'] === '/api/cron-test' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['status'] === 'in_progress'
);
});

const okEnvelopePromise = waitForEnvelopeItem('nextjs-15', envelope => {
return (
envelope[0].type === 'check_in' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['monitor_slug'] === '/api/cron-test' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['status'] === 'ok'
);
});

const response = await request.get('/api/cron-test', {
headers: {
'User-Agent': 'vercel-cron/1.0',
},
});

expect(response.status()).toBe(200);
expect(await response.json()).toStrictEqual({ message: 'Cron job executed successfully' });

const inProgressEnvelope = await inProgressEnvelopePromise;
const okEnvelope = await okEnvelopePromise;

expect(inProgressEnvelope[1]).toEqual(
expect.objectContaining({
check_in_id: expect.any(String),
monitor_slug: '/api/cron-test',
status: 'in_progress',
monitor_config: {
schedule: {
type: 'crontab',
value: '0 * * * *',
},
max_runtime: 720,
},
}),
);

expect(okEnvelope[1]).toEqual(
expect.objectContaining({
check_in_id: expect.any(String),
monitor_slug: '/api/cron-test',
status: 'ok',
duration: expect.any(Number),
}),
);
// @ts-expect-error envelope[1] is untyped
expect(okEnvelope[1]['check_in_id']).toBe(inProgressEnvelope[1]['check_in_id']);
});

test('Sends cron check-in envelope with error status for failed cron job', async ({ request }) => {
const inProgressEnvelopePromise = waitForEnvelopeItem('nextjs-15', envelope => {
return (
envelope[0].type === 'check_in' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['monitor_slug'] === '/api/cron-test-error' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['status'] === 'in_progress'
);
});

const errorEnvelopePromise = waitForEnvelopeItem('nextjs-15', envelope => {
return (
envelope[0].type === 'check_in' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['monitor_slug'] === '/api/cron-test-error' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['status'] === 'error'
);
});

await request.get('/api/cron-test-error', {
headers: {
'User-Agent': 'vercel-cron/1.0',
},
});

const inProgressEnvelope = await inProgressEnvelopePromise;
const errorEnvelope = await errorEnvelopePromise;

expect(inProgressEnvelope[1]).toEqual(
expect.objectContaining({
check_in_id: expect.any(String),
monitor_slug: '/api/cron-test-error',
status: 'in_progress',
monitor_config: {
schedule: {
type: 'crontab',
value: '30 * * * *',
},
max_runtime: 720,
},
}),
);

expect(errorEnvelope[1]).toEqual(
expect.objectContaining({
check_in_id: expect.any(String),
monitor_slug: '/api/cron-test-error',
status: 'error',
duration: expect.any(Number),
}),
);

// @ts-expect-error envelope[1] is untyped
expect(errorEnvelope[1]['check_in_id']).toBe(inProgressEnvelope[1]['check_in_id']);
});

test('Does not send cron check-in envelope for regular requests without vercel-cron user agent', async ({
request,
}) => {
let checkInReceived = false;

waitForEnvelopeItem('nextjs-15', envelope => {
if (
envelope[0].type === 'check_in' && // @ts-expect-error envelope[1] is untyped
envelope[1]['monitor_slug'] === '/api/cron-test'
) {
checkInReceived = true;
return true;
}
return false;
});

const response = await request.get('/api/cron-test');

expect(response.status()).toBe(200);
expect(await response.json()).toStrictEqual({ message: 'Cron job executed successfully' });

await new Promise(resolve => setTimeout(resolve, 2000));

expect(checkInReceived).toBe(false);
});
12 changes: 12 additions & 0 deletions dev-packages/e2e-tests/test-applications/nextjs-15/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"crons": [
{
"path": "/api/cron-test",
"schedule": "0 * * * *"
},
{
"path": "/api/cron-test-error",
"schedule": "30 * * * *"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const dynamic = 'force-dynamic';

export async function GET() {
throw new Error('Cron job error');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';

export const dynamic = 'force-dynamic';

export async function GET() {
// Simulate some work
await new Promise(resolve => setTimeout(resolve, 100));
return NextResponse.json({ message: 'Cron job executed successfully' });
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { withSentryConfig } from '@sentry/nextjs';
import type { NextConfig } from 'next';

// Simulate Vercel environment for cron monitoring tests
process.env.VERCEL = '1';

const nextConfig: NextConfig = {};

export default withSentryConfig(nextConfig, {
silent: true,
_experimental: {
vercelCronsMonitoring: true,
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { expect, test } from '@playwright/test';
import { waitForEnvelopeItem } from '@sentry-internal/test-utils';

test('Sends cron check-in envelope for successful cron job', async ({ request }) => {
const inProgressEnvelopePromise = waitForEnvelopeItem('nextjs-16', envelope => {
return (
envelope[0].type === 'check_in' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['monitor_slug'] === '/api/cron-test' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['status'] === 'in_progress'
);
});

const okEnvelopePromise = waitForEnvelopeItem('nextjs-16', envelope => {
return (
envelope[0].type === 'check_in' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['monitor_slug'] === '/api/cron-test' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['status'] === 'ok'
);
});

const response = await request.get('/api/cron-test', {
headers: {
'User-Agent': 'vercel-cron/1.0',
},
});

expect(response.status()).toBe(200);
expect(await response.json()).toStrictEqual({ message: 'Cron job executed successfully' });

const inProgressEnvelope = await inProgressEnvelopePromise;
const okEnvelope = await okEnvelopePromise;

expect(inProgressEnvelope[1]).toEqual(
expect.objectContaining({
check_in_id: expect.any(String),
monitor_slug: '/api/cron-test',
status: 'in_progress',
monitor_config: {
schedule: {
type: 'crontab',
value: '0 * * * *',
},
max_runtime: 720,
},
}),
);

expect(okEnvelope[1]).toEqual(
expect.objectContaining({
check_in_id: expect.any(String),
monitor_slug: '/api/cron-test',
status: 'ok',
duration: expect.any(Number),
}),
);

// @ts-expect-error envelope[1] is untyped
expect(okEnvelope[1]['check_in_id']).toBe(inProgressEnvelope[1]['check_in_id']);
});

test('Sends cron check-in envelope with error status for failed cron job', async ({ request }) => {
const inProgressEnvelopePromise = waitForEnvelopeItem('nextjs-16', envelope => {
return (
envelope[0].type === 'check_in' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['monitor_slug'] === '/api/cron-test-error' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['status'] === 'in_progress'
);
});

const errorEnvelopePromise = waitForEnvelopeItem('nextjs-16', envelope => {
return (
envelope[0].type === 'check_in' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['monitor_slug'] === '/api/cron-test-error' &&
// @ts-expect-error envelope[1] is untyped
envelope[1]['status'] === 'error'
);
});

await request.get('/api/cron-test-error', {
headers: {
'User-Agent': 'vercel-cron/1.0',
},
});

const inProgressEnvelope = await inProgressEnvelopePromise;
const errorEnvelope = await errorEnvelopePromise;

expect(inProgressEnvelope[1]).toEqual(
expect.objectContaining({
check_in_id: expect.any(String),
monitor_slug: '/api/cron-test-error',
status: 'in_progress',
monitor_config: {
schedule: {
type: 'crontab',
value: '30 * * * *',
},
max_runtime: 720,
},
}),
);

expect(errorEnvelope[1]).toEqual(
expect.objectContaining({
check_in_id: expect.any(String),
monitor_slug: '/api/cron-test-error',
status: 'error',
duration: expect.any(Number),
}),
);

// @ts-expect-error envelope[1] is untyped
expect(errorEnvelope[1]['check_in_id']).toBe(inProgressEnvelope[1]['check_in_id']);
});

test('Does not send cron check-in envelope for regular requests without vercel-cron user agent', async ({
request,
}) => {
let checkInReceived = false;

waitForEnvelopeItem('nextjs-16', envelope => {
if (
envelope[0].type === 'check_in' && // @ts-expect-error envelope[1] is untyped
envelope[1]['monitor_slug'] === '/api/cron-test'
) {
checkInReceived = true;
return true;
}
return false;
});

const response = await request.get('/api/cron-test');

expect(response.status()).toBe(200);
expect(await response.json()).toStrictEqual({ message: 'Cron job executed successfully' });

await new Promise(resolve => setTimeout(resolve, 2000));

expect(checkInReceived).toBe(false);
});
Loading
Loading