Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ebd108b
feat(node): Add Crons check-ins for BullMQ job schedulers
wedamija Oct 17, 2026
b7b621e
chore(deps): Add bullmq to pnpm-lock.yaml
wedamija Oct 20, 2026
d20f423
chore(deps): Dedupe ioredis in pnpm-lock.yaml
wedamija Oct 20, 2026
3184cca
fix(node): Send UTC for BullMQ schedulers with the utc option
wedamija Oct 20, 2026
c1597cb
Merge remote-tracking branch 'origin/develop' into danf/bullmq-cron-m…
wedamija Oct 26, 2026
b6b9096
chore(deps): Dedupe the lockfile
wedamija Oct 26, 2026
69a87a2
Merge remote-tracking branch 'origin/develop' into wt-bullmq-merge
wedamija Oct 28, 2026
b8c15b6
test(node): Use a separate Redis port for the BullMQ cron suite
wedamija Oct 28, 2026
3e16f88
fix(node): Send the BullMQ in_progress check-in in the job's trace
wedamija Oct 29, 2026
8af9c77
fix(node): Skip jobs with unsupported schedules when monitoring all jobs
wedamija Oct 29, 2026
94cb10f
Merge remote-tracking branch 'origin/develop' into danf/bullmq-cron-m…
wedamija Nov 3, 2026
3adc336
fix(node): Keep one BullMQ check-in across job retries
wedamija Nov 11, 2026
0b3cc08
Merge remote-tracking branch 'origin/develop' into danf/bullmq-cron-m…
wedamija Nov 11, 2026
efe7761
Merge remote-tracking branch 'origin/danf/shared-cron-converter' into…
wedamija Nov 11, 2026
aa89816
ref(node): Use the shared cron converter for BullMQ schedulers
wedamija Nov 11, 2026
267e8f6
Merge remote-tracking branch 'origin/danf/shared-cron-converter' into…
wedamija Nov 11, 2026
bff29e6
feat(node): Replace instrumentBullMQWorker with bullmqJobSchedulerInt…
wedamija Nov 11, 2026
d09ba09
test(bun): Skip the BullMQ cron suite in the Bun build project
wedamija Nov 11, 2026
53aad8f
feat(server-utils): Keep BullMQ external in getInstrumentedModuleNames
wedamija Nov 11, 2026
d4a0056
Merge branch 'danf/shared-cron-converter' into danf/bullmq-cron-monitors
wedamija Nov 11, 2026
ef51a40
fix(bun): Don't export bullmqJobSchedulerIntegration
wedamija Nov 11, 2026
48c5da7
feat(server-utils): Support BullMQ 6 and fix BullMQ schedule edge cases
wedamija Nov 11, 2026
3ffbace
Merge remote-tracking branch 'origin/danf/shared-cron-converter' into…
wedamija Nov 11, 2026
59b5f8e
ref(server-utils): Drop a redundant cast in the BullMQ channel subscr…
wedamija Nov 11, 2026
8d0f02f
ref(server-utils): Drop the unused type argument instead, since subsc…
wedamija Nov 11, 2026
50795d7
fix(server-utils): Log skipped legacy BullMQ repeatable jobs and try …
wedamija Nov 12, 2026
e264c21
fix(server-utils): Cap the schedulers the BullMQ integration remember…
wedamija Nov 12, 2026
077a0f7
test(node): Wait for the BullMQ scenario's jobs instead of a fixed ti…
wedamija Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ Work in this release was contributed by @kunal649. Thank you for your contributi

`Sentry.cron.instrumentCron`, `instrumentNodeCron` and `instrumentNodeSchedule` read schedules the same way as `@SentryCron` and the cron integrations. Without a time zone, they send the server's time zone, where the scheduler runs the job, instead of letting Sentry assume UTC. For expressions Sentry would read differently than the scheduler, like per-second schedules, they send no monitor config, so existing monitors keep their schedule. They send names and nicknames like `MON` or `@daily` as numbers, and drop a fixed seconds field. If the seconds field isn't `0`, a check-in margin of 2 minutes is sent too, since the job checks in up to 59 seconds after the minute Sentry expects. This replaces a margin set in Sentry. Expressions with a day of month that none of their months have, like `0 9 31 2,4 *`, send no monitor config. `instrumentNodeSchedule` also sends `L` and `#`, where Sentry reads them the same way.

- **feat(node): Add `bullmqJobSchedulerIntegration` to monitor BullMQ job schedulers ([#25472](https://github.com/getsentry/sentry-javascript/pull/25472))**

Add `Sentry.bullmqJobSchedulerIntegration()` to send check-ins for every job a BullMQ worker (5.16.0 to 6.x) runs from a job scheduler, with `<queue>-<scheduler id>` as the monitor slug and the scheduler's `pattern` or `every` as the schedule. Each monitored scheduler creates a monitor in Sentry, and monitors are billed. Use `excludeJobSchedulers` to skip schedulers by id. A retried job keeps one check-in.

```js
Sentry.init({
dsn: '__DSN__',
integrations: [Sentry.bullmqJobSchedulerIntegration({ excludeJobSchedulers: ['cache-warmup'] })],
});
```

## 11.12.0

### Important Changes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
// `supports({ bunMin })`. Neither is listed here.

// Node-only features: ANR and native thread watchdogs, child processes, the AWS Lambda Node runtime,
// `node:sqlite`, which `flue` needs, and the Vercel keep-alive, which needs `http.server.response.finish`.
// `node:sqlite`, which `flue` needs, the Vercel keep-alive, which needs `http.server.response.finish`, and
// `bullmqJobSchedulerIntegration`, which `bun run` can't inject and the bundler plugins don't instrument.
const NODE_ONLY = [
'suites/anr/test.ts',
'suites/aws-lambda-web/**',
'suites/aws-serverless/**',
'suites/breadcrumbs/**',
'suites/child-process/test.ts',
'suites/cron/bullmq/test.ts',
'suites/thread-blocked-native/test.ts',
'suites/tracing/flue/test.ts',
'suites/vercel/keep-alive/test.ts',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const DEPENDENTS: Dependent[] = [
'childProcessIntegration',
'workerThreadsIntegration',
'pinoIntegration',
'bullmqJobSchedulerIntegration',
// Bun will get its own runtime metrics integration
'nodeRuntimeMetricsIntegration',
'NodeRuntimeMetricsOptions',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
version: '3.9'

services:
db:
image: redis:latest
restart: always
ports:
- '6385:6379'
healthcheck:
test: ['CMD-SHELL', 'redis-cli ping | grep -q PONG']
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
transport: loggingTransport,
integrations: [Sentry.bullmqJobSchedulerIntegration()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
transport: loggingTransport,
integrations: [Sentry.bullmqJobSchedulerIntegration({ excludeJobSchedulers: ['excluded-scheduler'] })],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = async () => {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as Sentry from '@sentry/node';
import { Queue, Worker } from 'bullmq';

const connection = { host: 'localhost', port: 6385 };
const telemetry = new Sentry.BullMQTelemetry();
const autorun = process.env.AUTORUN === 'true';

async function run() {
const queue = new Queue('crons-telemetry', { connection, telemetry });
await queue.obliterate({ force: true });

await queue.upsertJobScheduler('ok-scheduler', { every: 300_000 }, { name: 'ok-job' });
await queue.upsertJobScheduler(
'failing-scheduler',
{ pattern: '0 0 9 * * 1-5', tz: 'Europe/Vienna', immediately: true },
{ name: 'failing-job' },
);

const worker = new Worker(
'crons-telemetry',
async job => {
await Sentry.startSpan({ name: `work ${job.name}` }, async () => {
await new Promise(resolve => setTimeout(resolve, 50));
});
if (job.name === 'failing-job') {
throw new Error('Error in scheduled job');
}
},
{ connection, telemetry, autorun },
);

const finished = new Promise(resolve => {
let count = 0;
const done = () => {
if (++count === 2) {
resolve();
}
};
worker.on('completed', done);
worker.on('failed', done);
});

if (!autorun) {
void worker.run();
}

await finished;
await worker.close();
await queue.obliterate({ force: true });
await queue.close();
await Sentry.flush();
process.exit();
}

void run();
101 changes: 101 additions & 0 deletions dev-packages/node-integration-tests/suites/cron/bullmq/scenario.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { dirname, join } from 'node:path';
import { Queue, Worker } from 'bullmq';

const connection = { host: 'localhost', port: 6385 };

async function run() {
const queue = new Queue('crons', { connection });
const sandboxedQueue = new Queue('crons-sandboxed', { connection });
await queue.obliterate({ force: true });
await sandboxedQueue.obliterate({ force: true });

const worker = new Worker(
'crons',
async job => {
if (job.name === 'failing' || (job.name === 'flaky' && job.attemptsMade === 0)) {
throw new Error('Error in scheduled job');
}
},
{ connection },
);
const sandboxedWorker = new Worker('crons-sandboxed', join(dirname(process.argv[1]), 'processor.cjs'), {
connection,
});

// Waits for every job to finish, including the ones that send no check-ins.
const pending = new Map([
['plain', 1],
['legacy', 1],
['legacy-key', 1],
['excluded', 1],
['every', 2],
['failing', 1],
['flaky', 1],
['sandboxed', 1],
]);
const finished = new Promise(resolve => {
const done = job => {
const left = pending.get(job?.name);
if (left === undefined) {
return;
}
if (left > 1) {
pending.set(job.name, left - 1);
} else {
pending.delete(job.name);
}
if (!pending.size) {
resolve();
}
};
for (const w of [worker, sandboxedWorker]) {
w.on('completed', done);
w.on('failed', job => job?.finishedOn && done(job));
}
});

// Runs delayed scheduler jobs now. The loop below and the `completed` listener can race to promote the same job.
const promote = job => job?.promote().catch(() => {});

// Runs the iteration the worker created after the first run, instead of waiting 5 minutes.
let promoted = false;
worker.on('completed', async job => {
if (job.name !== 'every' || promoted) {
return;
}
promoted = true;
const delayed = await queue.getDelayed();
await promote(delayed.find(next => next.repeatJobKey === 'every-scheduler'));
});

await queue.add('plain', {});
await queue.add('legacy', {}, { repeat: { every: 300_000, immediately: true } });
await queue.add('legacy-key', {}, { repeat: { every: 300_000, key: 'custom-key', immediately: true } });
await queue.upsertJobScheduler('excluded-scheduler', { every: 300_000 }, { name: 'excluded' });
await queue.upsertJobScheduler('every-scheduler', { every: 300_000 }, { name: 'every' });
await queue.upsertJobScheduler(
'pattern-scheduler',
{ pattern: '0 0 9 * * 1-5', tz: 'Europe/Vienna', immediately: true },
{ name: 'failing' },
);
await queue.upsertJobScheduler('retry-scheduler', { every: 600_000 }, { name: 'flaky', opts: { attempts: 2 } });
await sandboxedQueue.upsertJobScheduler('sandboxed-scheduler', { every: 300_000 }, { name: 'sandboxed' });

// BullMQ 5.16 starts an `every` scheduler at its next full interval, so run the first jobs now.
for (const job of [...(await queue.getDelayed()), ...(await sandboxedQueue.getDelayed())]) {
if (job.opts.repeat?.count === 1) {
await promote(job);
}
}

await finished;
await worker.close();
await sandboxedWorker.close();
await queue.obliterate({ force: true });
await sandboxedQueue.obliterate({ force: true });
await queue.close();
await sandboxedQueue.close();
process.exit();
}

void run();
Loading
Loading