diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/docker-compose.yml b/dev-packages/e2e-tests/test-applications/node-bullmq/docker-compose.yml new file mode 100644 index 000000000000..15a423ffbc07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/docker-compose.yml @@ -0,0 +1,7 @@ +services: + redis: + image: redis:8 + restart: always + container_name: e2e-tests-node-bullmq-redis + ports: + - '6379:6379' diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/global-setup.mjs b/dev-packages/e2e-tests/test-applications/node-bullmq/global-setup.mjs new file mode 100644 index 000000000000..438b88b61794 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/global-setup.mjs @@ -0,0 +1,13 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start Redis via Docker Compose + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/node-bullmq/global-teardown.mjs new file mode 100644 index 000000000000..35ce41179193 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/global-teardown.mjs @@ -0,0 +1,13 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + // Stop Redis and remove containers + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/package.json b/dev-packages/e2e-tests/test-applications/node-bullmq/package.json new file mode 100644 index 000000000000..cbd47311eec6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/package.json @@ -0,0 +1,25 @@ +{ + "name": "node-bullmq", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "start": "node src/app.mjs", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test": "playwright test", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "bullmq": "^5.0.0", + "express": "^4.21.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-bullmq/playwright.config.mjs new file mode 100644 index 000000000000..d5fd0b394f15 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/playwright.config.mjs @@ -0,0 +1,11 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', +}; diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-bullmq/src/app.mjs new file mode 100644 index 000000000000..af2023ab3af9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/src/app.mjs @@ -0,0 +1,64 @@ +import './instrument.mjs'; + +import * as Sentry from '@sentry/node'; +import { Queue, Worker } from 'bullmq'; +import express from 'express'; + +const app = express(); +const port = 3030; + +const connection = { host: '127.0.0.1', port: 6379 }; +const telemetry = new Sentry.BullMQTelemetry({ enableMetrics: true }); + +const testQueue = new Queue('test-queue', { connection, telemetry }); + +const worker = new Worker( + 'test-queue', + async job => { + if (job.name === 'fail-job') { + throw new Error('Test error from BullMQ processor'); + } + + if (job.name === 'breadcrumb-job') { + Sentry.addBreadcrumb({ message: 'breadcrumb-from-bullmq-processor' }); + } + + return { success: true }; + }, + { connection, telemetry }, +); + +worker.on('error', err => { + console.error('Worker error:', err); +}); + +app.get('/enqueue/success', async (req, res) => { + await testQueue.add('success-job', { data: 'test' }); + res.send('Job enqueued'); +}); + +app.get('/enqueue/fail', async (req, res) => { + await testQueue.add('fail-job', { data: 'test' }); + res.send('Job enqueued'); +}); + +app.get('/enqueue/breadcrumb-test', async (req, res) => { + await testQueue.add('breadcrumb-job', { data: 'test' }); + res.send('Job enqueued'); +}); + +app.get('/enqueue/link-test', async (req, res) => { + await testQueue.add('link-job', { data: 'test' }); + res.send('Job enqueued'); +}); + +app.get('/check-isolation', async (req, res) => { + Sentry.captureException(new Error('Isolation check')); + res.send('Isolation check'); +}); + +Sentry.setupExpressErrorHandler(app); + +app.listen(port, () => { + console.log(`App listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/src/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-bullmq/src/instrument.mjs new file mode 100644 index 000000000000..fcf7d3c23b04 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/src/instrument.mjs @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-bullmq/start-event-proxy.mjs new file mode 100644 index 000000000000..2426471fe487 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-bullmq', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/tests/bullmq.test.ts b/dev-packages/e2e-tests/test-applications/node-bullmq/tests/bullmq.test.ts new file mode 100644 index 000000000000..bc76586b8e27 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/tests/bullmq.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForMetric, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('Creates a queue.publish span when adding a job', async ({ baseURL }) => { + const publishSpanPromise = waitForStreamedSpan('node-bullmq', span => { + return ( + getSpanOp(span) === 'queue.publish' && span.attributes['sentry.segment.name']?.value === 'GET /enqueue/success' + ); + }); + + await fetch(`${baseURL}/enqueue/success`); + + const publishSpan = await publishSpanPromise; + + expect(publishSpan.is_segment).toBe(false); + expect(publishSpan.attributes['sentry.origin']?.value).toBe('auto.queue.bullmq.producer'); + expect(publishSpan.attributes['messaging.system']?.value).toBe('bullmq'); +}); + +test('Creates a segment span for queue.process when processing a job', async ({ baseURL }) => { + const processSpanPromise = waitForStreamedSpan('node-bullmq', span => { + return span.is_segment && getSpanOp(span) === 'queue.process'; + }); + + await fetch(`${baseURL}/enqueue/success`); + + const processSpan = await processSpanPromise; + + expect(processSpan.attributes['sentry.origin']?.value).toBe('auto.queue.bullmq.consumer'); + expect(processSpan.attributes['messaging.system']?.value).toBe('bullmq'); +}); + +test('Sends exception to Sentry on error in job processor', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-bullmq', event => { + return ( + !event.type && + event.exception?.values?.[0]?.value === 'Test error from BullMQ processor' && + event.exception?.values?.[0]?.mechanism?.type === 'auto.queue.bullmq' + ); + }); + + await fetch(`${baseURL}/enqueue/fail`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.queue.bullmq', + }); +}); + +test('BullMQ processor breadcrumbs do not leak into subsequent HTTP requests', async ({ baseURL }) => { + const processSpanPromise = waitForStreamedSpan('node-bullmq', span => { + return ( + span.is_segment && + getSpanOp(span) === 'queue.process' && + span.attributes['bullmq.job.name']?.value === 'breadcrumb-job' + ); + }); + + await fetch(`${baseURL}/enqueue/breadcrumb-test`); + + await processSpanPromise; + + const errorEventPromise = waitForError('node-bullmq', event => { + return event.exception?.values?.[0]?.value === 'Isolation check'; + }); + + await fetch(`${baseURL}/check-isolation`); + + const errorEvent = await errorEventPromise; + + const leakedBreadcrumb = (errorEvent.breadcrumbs || []).find( + (b: { message?: string }) => b.message === 'breadcrumb-from-bullmq-processor', + ); + expect(leakedBreadcrumb).toBeUndefined(); +}); + +test('Links the queue.process segment span to its producer span', async ({ baseURL }) => { + const producerSpanPromise = waitForStreamedSpan('node-bullmq', span => { + return getSpanOp(span) === 'queue.publish' && span.attributes['bullmq.job.name']?.value === 'link-job'; + }); + + const consumerSpanPromise = waitForStreamedSpan('node-bullmq', span => { + return ( + span.is_segment && getSpanOp(span) === 'queue.process' && span.attributes['bullmq.job.name']?.value === 'link-job' + ); + }); + + await fetch(`${baseURL}/enqueue/link-test`); + + const producerSpan = await producerSpanPromise; + const consumerSpan = await consumerSpanPromise; + + expect(producerSpan.attributes['sentry.segment.name']?.value).toBe('GET /enqueue/link-test'); + expect(consumerSpan.attributes['sentry.previous_trace']).toBeUndefined(); + expect(consumerSpan.links).toEqual([ + { + trace_id: producerSpan.trace_id, + span_id: producerSpan.span_id, + sampled: true, + attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } }, + }, + ]); +}); + +test('Emits bullmq.jobs.completed counter metric on successful job', async ({ baseURL }) => { + const metricPromise = waitForMetric('node-bullmq', metric => { + return metric.name === 'bullmq.jobs.completed' && metric.type === 'counter'; + }); + + await fetch(`${baseURL}/enqueue/success`); + + const metric = await metricPromise; + + expect(metric.name).toBe('bullmq.jobs.completed'); + expect(metric.type).toBe('counter'); + expect(metric.value).toEqual(expect.any(Number)); +}); + +test('Emits bullmq.job.duration histogram metric on job completion', async ({ baseURL }) => { + const metricPromise = waitForMetric('node-bullmq', metric => { + return metric.name === 'bullmq.job.duration' && metric.type === 'distribution'; + }); + + await fetch(`${baseURL}/enqueue/success`); + + const metric = await metricPromise; + + expect(metric.name).toBe('bullmq.job.duration'); + expect(metric.type).toBe('distribution'); + expect(metric.value).toEqual(expect.any(Number)); +}); diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 18f95052d740..63759f7af96a 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -62,6 +62,7 @@ "@types/pg": "^8.6.5", "ai": "^4.3.16", "amqplib": "^0.10.9", + "bullmq": "^5.79.1", "body-parser": "^2.3.0", "consola": "^3.2.3", "cors": "^2.8.5", diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/bullmq/docker-compose.yml new file mode 100644 index 000000000000..deac6f3496a4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/docker-compose.yml @@ -0,0 +1,12 @@ +services: + db: + image: redis:latest + restart: always + ports: + - '6384:6379' + healthcheck: + test: ['CMD-SHELL', 'redis-cli ping | grep -q PONG'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/bullmq/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/instrument.mjs @@ -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', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario-attributes.mjs b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario-attributes.mjs new file mode 100644 index 000000000000..1d3b1e445320 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario-attributes.mjs @@ -0,0 +1,68 @@ +import * as Sentry from '@sentry/node'; +import { FlowProducer, Queue, Worker } from 'bullmq'; + +const telemetry = new Sentry.BullMQTelemetry({ enableMetrics: true }); +const connection = { host: '127.0.0.1', port: 6384 }; + +async function run() { + const opsQueue = new Queue('attributes-ops-queue', { connection, telemetry }); + const opsWorker = new Worker('attributes-ops-queue', null, { connection, telemetry, name: 'attributes-ops-worker' }); + const flowProducer = new FlowProducer({ connection, telemetry }); + const processQueue = new Queue('attributes-process-queue', { connection, telemetry }); + const processWorker = new Worker('attributes-process-queue', async () => {}, { + connection, + telemetry, + name: 'attributes-worker', + }); + + const jobProcessed = new Promise(resolve => { + processWorker.once('completed', () => resolve()); + }); + + await Sentry.startSpan({ name: 'bullmq attributes' }, async () => { + await processQueue.add('process-job', { data: 'test-data' }); + + await opsQueue.addBulk([ + { name: 'bulk-job-1', data: {} }, + { name: 'bulk-job-2', data: {} }, + ]); + await flowProducer.add({ + name: 'flow-root', + queueName: 'attributes-ops-queue', + children: [{ name: 'flow-child', queueName: 'attributes-ops-queue' }], + }); + await opsQueue.upsertJobScheduler('attributes-scheduler', { every: 60_000 }, { name: 'scheduled-job' }); + + const progressJob = await opsQueue.add('progress-job', {}); + await opsQueue.updateJobProgress(progressJob.id, { percentage: 50 }); + await opsQueue.remove(progressJob.id, { removeChildren: true }); + await opsQueue.removeDeduplicationKey('attributes-deduplication-id'); + await opsQueue.removeDebounceKey('attributes-debounce-id'); + await opsQueue.retryJobs({ count: 10 }); + await opsQueue.promoteJobs({ count: 10 }); + await opsQueue.trimEvents(100); + await opsQueue.clean(0, 100, 'completed'); + + await opsWorker.getNextJob('attributes-token', { block: false }); + // BullMQ runs these two on timers. Calling them directly runs them once, inside this span. + await opsWorker['moveStalledJobsToWait'](); + await opsWorker['lockManager'].extendLocks([]); + await opsWorker.rateLimit(1); + await opsWorker.pause(true); + await opsWorker.close(true); + + await opsQueue.rateLimit(1); + await opsQueue.drain(true); + }); + + await jobProcessed; + await processQueue.recordJobCountsMetric('waiting', 'completed'); + await processWorker.close(); + + await opsQueue.obliterate({ force: true }); + await processQueue.obliterate({ force: true }); + await Promise.all([flowProducer.close(), opsQueue.close(), processQueue.close()]); + await Sentry.flush(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario-failed-job.mjs b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario-failed-job.mjs new file mode 100644 index 000000000000..90eea3bb176e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario-failed-job.mjs @@ -0,0 +1,30 @@ +import * as Sentry from '@sentry/node'; +import { Queue, Worker } from 'bullmq'; + +const telemetry = new Sentry.BullMQTelemetry(); +const connection = { host: '127.0.0.1', port: 6384 }; + +async function run() { + const queue = new Queue('test-failed-queue', { connection, telemetry }); + + const worker = new Worker( + 'test-failed-queue', + async () => { + throw new Error('Job failed on purpose'); + }, + { connection, telemetry }, + ); + + const jobFailed = new Promise(resolve => { + worker.on('failed', () => resolve()); + }); + + await queue.add('failing-job', { data: 'test-data' }); + + await jobFailed; + await worker.close(); + await queue.close(); + await Sentry.flush(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs new file mode 100644 index 000000000000..ffdcead133e6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/node'; +import { Queue, Worker } from 'bullmq'; + +const telemetry = new Sentry.BullMQTelemetry({ enableMetrics: true }); +const connection = { host: '127.0.0.1', port: 6384 }; + +async function run() { + const queue = new Queue('test-queue', { connection, telemetry }); + + const worker = new Worker( + 'test-queue', + async () => { + // job processed + }, + { connection, telemetry }, + ); + + const jobsProcessed = new Promise(resolve => { + let completed = 0; + worker.on('completed', () => { + completed++; + if (completed === 2) { + resolve(); + } + }); + }); + + await Sentry.startSpan({ name: 'enqueue test-jobs' }, async () => { + await queue.add('test-job-1', { data: 'test-data' }); + await queue.add('test-job-2', { data: 'test-data' }); + }); + + await jobsProcessed; + await worker.close(); + await queue.close(); + await Sentry.flush(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts new file mode 100644 index 000000000000..07d89c7adbe9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -0,0 +1,262 @@ +import type { SerializedMetricContainer } from '@sentry/core'; +import { afterAll, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; + +describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('traces producer and consumer operations with queue attributes', { timeout: 90_000 }, async () => { + const producerSpans: Record = {}; + const consumerSpans: Record = {}; + + await createRunner() + .ignore('trace_metric') + .unordered() + .expect({ + span: container => { + const producerSegment = container.items.find(item => item.is_segment && item.name === 'enqueue test-jobs'); + const publishSpans = container.items.filter( + item => item.attributes['sentry.origin']?.value === 'auto.queue.bullmq.producer', + ); + + expect(producerSegment).toBeDefined(); + expect(publishSpans).toHaveLength(2); + + for (const publishSpan of publishSpans) { + expect(publishSpan).toEqual( + expect.objectContaining({ + parent_span_id: producerSegment!.span_id, + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'test-queue' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.operation.name': { type: 'string', value: 'add' }, + }), + }), + ); + + producerSpans[publishSpan.attributes['bullmq.job.name']!.value as string] = { + trace_id: publishSpan.trace_id, + span_id: publishSpan.span_id, + messageId: publishSpan.attributes['messaging.message.id']!.value, + }; + } + }, + }) + .expect({ + span: container => { + const consumerSegment = container.items.find( + item => item.is_segment && item.attributes['bullmq.job.name']?.value === 'test-job-1', + ); + + expect(consumerSegment).toEqual( + expect.objectContaining({ + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'queue.process' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.consumer' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'test-queue' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.message.retry.count': { type: 'integer', value: 0 }, + 'messaging.operation.name': { type: 'string', value: 'process' }, + }), + }), + ); + expect(consumerSegment!.attributes['sentry.previous_trace']).toBeUndefined(); + expect(consumerSegment!.attributes['bullmq.queue.name']).toBeUndefined(); + + consumerSpans['test-job-1'] = { + trace_id: consumerSegment!.trace_id, + link: consumerSegment!.links?.[0], + messageId: consumerSegment!.attributes['messaging.message.id']!.value, + }; + }, + }) + .expect({ + span: container => { + const consumerSegment = container.items.find( + item => item.is_segment && item.attributes['bullmq.job.name']?.value === 'test-job-2', + ); + + expect(consumerSegment).toEqual( + expect.objectContaining({ + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'queue.process' }, + 'messaging.destination.name': { type: 'string', value: 'test-queue' }, + }), + }), + ); + + consumerSpans['test-job-2'] = { + trace_id: consumerSegment!.trace_id, + link: consumerSegment!.links?.[0], + messageId: consumerSegment!.attributes['messaging.message.id']!.value, + }; + }, + }) + .start() + .completed(); + + for (const jobName of ['test-job-1', 'test-job-2']) { + const producer = producerSpans[jobName]!; + const consumer = consumerSpans[jobName]!; + + expect(consumer.trace_id).not.toBe(producer.trace_id); + expect(consumer.messageId).toBe(producer.messageId); + expect(consumer.link).toEqual({ + trace_id: producer.trace_id, + span_id: producer.span_id, + sampled: true, + attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } }, + }); + } + + expect(consumerSpans['test-job-1']!.trace_id).not.toBe(consumerSpans['test-job-2']!.trace_id); + }); + + test('emits completion counter and duration histogram for processed jobs', { timeout: 90_000 }, async () => { + await createRunner() + .ignore('span') + .expect({ + trace_metric: (metrics: SerializedMetricContainer) => { + const items = metrics.items || []; + + expect(items).toHaveLength(4); + expect(items.filter(item => item.name === 'bullmq.jobs.completed')).toEqual([ + expect.objectContaining({ type: 'counter', value: expect.any(Number) }), + expect.objectContaining({ type: 'counter', value: expect.any(Number) }), + ]); + expect(items.filter(item => item.name === 'bullmq.job.duration')).toEqual([ + expect.objectContaining({ type: 'distribution', unit: 'ms', value: expect.any(Number) }), + expect.objectContaining({ type: 'distribution', unit: 'ms', value: expect.any(Number) }), + ]); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-failed-job.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures an error for a failed job in the trace of the job', { timeout: 90_000 }, async () => { + let consumerTraceId: string | undefined; + let errorTraceId: string | undefined; + + await createRunner() + .ignore('trace_metric') + .unordered() + .expect({ + event: event => { + expect(event.exception?.values).toEqual([ + expect.objectContaining({ + type: 'Error', + value: 'Job failed on purpose', + mechanism: { type: 'auto.queue.bullmq', handled: false }, + }), + ]); + + errorTraceId = event.contexts?.trace?.trace_id; + }, + }) + .expect({ + span: container => { + const consumerSegment = container.items.find( + item => item.is_segment && item.attributes['sentry.op']?.value === 'queue.process', + ); + + expect(consumerSegment).toBeDefined(); + + consumerTraceId = consumerSegment!.trace_id; + }, + }) + .start() + .completed(); + + expect(errorTraceId).toBe(consumerTraceId); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-attributes.mjs', 'instrument.mjs', (createRunner, test) => { + test('sends the BullMQ attributes that are registered in the conventions', { timeout: 90_000 }, async () => { + const attributeKeys = new Set(); + + await createRunner() + .unordered() + .expect({ + span: container => { + expect(container.items.find(item => item.is_segment && item.name === 'bullmq attributes')).toBeDefined(); + + for (const item of container.items) { + Object.keys(item.attributes).forEach(key => attributeKeys.add(key)); + } + }, + }) + .expect({ + span: container => { + expect( + container.items.find(item => item.is_segment && item.attributes['sentry.op']?.value === 'queue.process'), + ).toBeDefined(); + + for (const item of container.items) { + Object.keys(item.attributes).forEach(key => attributeKeys.add(key)); + } + }, + }) + .expect({ + trace_metric: (metrics: SerializedMetricContainer) => { + expect(metrics.items.find(item => item.name === 'bullmq.jobs.completed')).toBeDefined(); + expect(metrics.items.find(item => item.name === 'bullmq.queue.jobs')).toBeDefined(); + + for (const item of metrics.items) { + Object.keys(item.attributes || {}).forEach(key => attributeKeys.add(key)); + } + }, + }) + .start() + .completed(); + + // `bullmq.job.state` and `bullmq.queue.clean.count` are only set by BullMQ 6. + expect([...attributeKeys]).toEqual( + expect.arrayContaining([ + 'bullmq.flow.name', + 'bullmq.job.attempt_finished_timestamp', + 'bullmq.job.bulk.names', + 'bullmq.job.deduplication.key', + 'bullmq.job.finished.timestamp', + 'bullmq.job.ids', + 'bullmq.job.key', + 'bullmq.job.name', + 'bullmq.job.options', + 'bullmq.job.processed.timestamp', + 'bullmq.job.progress', + 'bullmq.job.scheduler.id', + 'bullmq.job.status', + 'bullmq.job.type', + 'bullmq.queue.clean.limit', + 'bullmq.queue.drain.delay', + 'bullmq.queue.event.max.length', + 'bullmq.queue.grace', + 'bullmq.queue.jobs.state', + 'bullmq.queue.options', + 'bullmq.queue.rate.limit', + 'bullmq.worker.do.not.wait.active', + 'bullmq.worker.force.close', + 'bullmq.worker.id', + 'bullmq.worker.jobs.to.extend.locks', + 'bullmq.worker.name', + 'bullmq.worker.options', + 'bullmq.worker.rate.limit', + 'bullmq.worker.stalled.jobs', + ]), + ); + }); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index ed0b1b540bb4..93b2474e7417 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -18,6 +18,7 @@ export { googleGenAIIntegration, // eslint-disable-next-line typescript/no-deprecated disableAnrDetectionForCallback, + BullMQTelemetry, captureCheckIn, captureConsoleIntegration, captureEvent, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 6f17bcc02c0c..855daa9e6811 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -159,6 +159,7 @@ export { zodErrorsIntegration, profiler, amqplibIntegration, + BullMQTelemetry, anthropicAIIntegration, googleGenAIIntegration, vercelAIIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index e96e052656cb..6d716b92c430 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -176,6 +176,7 @@ export { zodErrorsIntegration, profiler, amqplibIntegration, + BullMQTelemetry, anthropicAIIntegration, googleGenAIIntegration, vercelAIIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index eb4fa0495540..59307f2fe42e 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -156,6 +156,7 @@ export { zodErrorsIntegration, profiler, amqplibIntegration, + BullMQTelemetry, anthropicAIIntegration, googleGenAIIntegration, childProcessIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 1a1105557d9f..623ef4a048be 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -62,6 +62,7 @@ export { export { setupHapiErrorHandler } from './integrations/tracing/hapi'; // oxlint-disable-next-line typescript/no-deprecated -- deprecated but still re-exported for backwards compatibility export { setupKoaErrorHandler } from './integrations/tracing/koa'; +export { BullMQTelemetry } from './integrations/tracing/bullmq'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler, diff --git a/packages/node/src/integrations/tracing/bullmq/contextManager.ts b/packages/node/src/integrations/tracing/bullmq/contextManager.ts new file mode 100644 index 000000000000..ad32e5689cdb --- /dev/null +++ b/packages/node/src/integrations/tracing/bullmq/contextManager.ts @@ -0,0 +1,62 @@ +import type { Span } from '@opentelemetry/api'; +import { + extractTraceparentData, + getActiveSpan, + getCurrentScope, + spanToTraceHeader, + withActiveSpan, + withIsolationScope, +} from '@sentry/core'; +import type { ContextManager, SentryContext } from './types'; + +export class SentryBullMQContextManager implements ContextManager { + public active(): SentryContext { + return { + span: getActiveSpan(), + scope: getCurrentScope(), + }; + } + + public with unknown>(context: SentryContext, fn: A): ReturnType { + if (context.span) { + return withIsolationScope(() => { + return withActiveSpan(context.span as Span, fn) as ReturnType; + }); + } + return withIsolationScope(() => withActiveSpan(null, fn)) as ReturnType; + } + + /** + * Returns a context without a span. BullMQ 6.3.11+ runs the stalled jobs checker in it, so the + * checker does not stay attached to the span that was active when the worker started. + */ + public root(): SentryContext { + return { + span: undefined, + scope: getCurrentScope(), + }; + } + + public getMetadata(context: SentryContext): string { + if (context.span) { + return spanToTraceHeader(context.span); + } + return ''; + } + + public fromMetadata(activeContext: SentryContext, metadata: string): SentryContext { + const traceparent = extractTraceparentData(metadata); + if (!traceparent?.traceId || !traceparent?.parentSpanId) { + return activeContext; + } + + return { + ...activeContext, + producerSpanContext: { + traceId: traceparent.traceId, + spanId: traceparent.parentSpanId, + sampled: traceparent.parentSampled === true, + }, + }; + } +} diff --git a/packages/node/src/integrations/tracing/bullmq/index.ts b/packages/node/src/integrations/tracing/bullmq/index.ts new file mode 100644 index 000000000000..ee27f875fe27 --- /dev/null +++ b/packages/node/src/integrations/tracing/bullmq/index.ts @@ -0,0 +1,56 @@ +import { SentryBullMQContextManager } from './contextManager'; +import { SentryBullMQMeter } from './meter'; +import { SentryBullMQTracer } from './tracer'; +import type { ContextManager, Meter, Telemetry, Tracer, SentryContext } from './types'; + +/** + * A Sentry-specific telemetry implementation for BullMQ. + * + * This implements BullMQ's Telemetry interface to provide automatic tracing + * for queue operations with proper Queue Insights attributes. + * + * @example + * ```javascript + * import * as Sentry from '@sentry/node'; + * import { Queue, Worker } from 'bullmq'; + * + * const telemetry = new Sentry.BullMQTelemetry(); + * + * const queue = new Queue('myQueue', { + * connection: { host: '127.0.0.1', port: 6379 }, + * telemetry, + * }); + * + * const worker = new Worker('myQueue', async (job) => { + * // Process job + * }, { + * connection: { host: '127.0.0.1', port: 6379 }, + * telemetry, + * }); + * ``` + * + * To also send BullMQ's job metrics to Sentry, use `new Sentry.BullMQTelemetry({ enableMetrics: true })`. + * + * @see https://docs.bullmq.io/guide/telemetry + */ +export class BullMQTelemetry implements Telemetry { + public tracer: Tracer; + public contextManager: ContextManager; + public meter?: Meter; + + public constructor( + options: { + /** + * Sends BullMQ's job metrics, such as `bullmq.jobs.completed` and `bullmq.job.duration`, to Sentry. Defaults to `false`. + */ + enableMetrics?: boolean; + } = {}, + ) { + this.tracer = new SentryBullMQTracer(); + this.contextManager = new SentryBullMQContextManager(); + + if (options.enableMetrics) { + this.meter = new SentryBullMQMeter(); + } + } +} diff --git a/packages/node/src/integrations/tracing/bullmq/meter.ts b/packages/node/src/integrations/tracing/bullmq/meter.ts new file mode 100644 index 000000000000..e8c96bcf750b --- /dev/null +++ b/packages/node/src/integrations/tracing/bullmq/meter.ts @@ -0,0 +1,83 @@ +import { metrics } from '@sentry/core'; +import { toSentryAttributes } from './span'; +import type { AttributeValue, Counter, Gauge, Histogram, Meter, MetricOptions } from './types'; + +function toMetricAttributes( + attributes?: Record, +): Record | undefined { + if (!attributes) { + return undefined; + } + const result: Record = {}; + for (const [key, value] of Object.entries(toSentryAttributes(attributes))) { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + result[key] = value; + } + } + return result; +} + +class SentryBullMQCounter implements Counter { + private _name: string; + private _unit?: string; + + public constructor(name: string, unit?: string) { + this._name = name; + this._unit = unit; + } + + public add(value: number, attributes?: Record): void { + metrics.count(this._name, value, { + unit: this._unit, + attributes: toMetricAttributes(attributes), + }); + } +} + +class SentryBullMQHistogram implements Histogram { + private _name: string; + private _unit?: string; + + public constructor(name: string, unit?: string) { + this._name = name; + this._unit = unit; + } + + public record(value: number, attributes?: Record): void { + metrics.distribution(this._name, value, { + unit: this._unit, + attributes: toMetricAttributes(attributes), + }); + } +} + +class SentryBullMQGauge implements Gauge { + private _name: string; + private _unit?: string; + + public constructor(name: string, unit?: string) { + this._name = name; + this._unit = unit; + } + + public record(value: number, attributes?: Record): void { + metrics.gauge(this._name, value, { + unit: this._unit, + attributes: toMetricAttributes(attributes), + }); + } +} + +export class SentryBullMQMeter implements Meter { + public createCounter(name: string, options?: MetricOptions): Counter { + return new SentryBullMQCounter(name, options?.unit); + } + + public createHistogram(name: string, options?: MetricOptions): Histogram { + return new SentryBullMQHistogram(name, options?.unit); + } + + public createGauge(name: string, options?: MetricOptions): Gauge { + return new SentryBullMQGauge(name, options?.unit); + } +} diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts new file mode 100644 index 000000000000..b2bc84ae6cca --- /dev/null +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -0,0 +1,112 @@ +import type { Attributes, Span } from '@opentelemetry/api'; +import { + MESSAGING_BATCH_MESSAGE_COUNT, + MESSAGING_DESTINATION_NAME, + MESSAGING_MESSAGE_ID, + MESSAGING_MESSAGE_RETRY_COUNT, + MESSAGING_OPERATION_NAME, +} from '@sentry/conventions/attributes'; +import type { Scope } from '@sentry/core'; +import { captureException } from '@sentry/core'; +import type { AttributeValue, TelemetrySpan } from './types'; + +function toOtelAttributes(attributes: Record): Attributes { + return attributes as Attributes; +} + +/** + * Returns the BullMQ attributes, with each attribute that has a `messaging.*` convention replaced by that convention. + */ +export function toSentryAttributes(attributes: Record): Attributes { + const { + 'bullmq.queue.name': queueName, + 'bullmq.queue.operation': operation, + 'bullmq.job.id': jobId, + 'bullmq.job.attempts.made': attemptsMade, + 'bullmq.job.bulk.count': bulkCount, + ...rest + } = attributes; + const result = toOtelAttributes(rest); + + if (typeof queueName === 'string') { + result[MESSAGING_DESTINATION_NAME] = queueName; + } + + if (typeof operation === 'string') { + result[MESSAGING_OPERATION_NAME] = operation; + } + + if (jobId !== undefined) { + result[MESSAGING_MESSAGE_ID] = String(jobId); + } + + // BullMQ counts the attempt that just finished in `attemptsMade`. + if (typeof attemptsMade === 'number') { + result[MESSAGING_MESSAGE_RETRY_COUNT] = Math.max(attemptsMade - 1, 0); + } + + if (typeof bulkCount === 'number') { + result[MESSAGING_BATCH_MESSAGE_COUNT] = bulkCount; + } + + return result; +} + +export class SentryBullMQSpan implements TelemetrySpan { + private _span: Span; + private _scope: Scope; + + public constructor(span: Span, scope: Scope) { + this._span = span; + this._scope = scope; + } + + public setAttribute(key: string, value: AttributeValue): void { + this.setAttributes({ [key]: value }); + } + + public setAttributes(attributes: Record): void { + this._span.setAttributes(toSentryAttributes(attributes)); + } + + public addEvent(name: string, attributes?: Record): void { + this._span.addEvent(name, attributes ? toOtelAttributes(attributes) : undefined); + + // BullMQ sends this event from `Worker.handleFailed`, and the event name is not an exported constant: + // https://github.com/taskforcesh/bullmq/blob/01b8b14a973984845dc0bbcefccf35b3ed30782d/src/classes/worker.ts#L1223-L1225 + if (name === 'job failed') { + const reason = attributes?.['bullmq.job.failed.reason']; + captureException(new Error(String(reason || 'Unknown error')), { + mechanism: { + handled: false, + type: 'auto.queue.bullmq', + }, + }); + } + } + + /** + * Does not capture to Sentry. BullMQ calls this for Redis, lock and serialization errors that it + * retries itself, often several times for one fault. Job failures are captured in `addEvent`. + */ + public recordException(exception: Error | string | { code?: number; message?: string; name?: string }): void { + const error = + exception instanceof Error + ? exception + : new Error(typeof exception === 'string' ? exception : exception.message || 'Unknown error'); + + this._span.recordException(error); + } + + public setSpanOnContext(context: unknown): unknown { + return { + ...(context as object), + span: this._span, + scope: this._scope, + }; + } + + public end(): void { + this._span.end(); + } +} diff --git a/packages/node/src/integrations/tracing/bullmq/tracer.ts b/packages/node/src/integrations/tracing/bullmq/tracer.ts new file mode 100644 index 000000000000..ff9648e390ab --- /dev/null +++ b/packages/node/src/integrations/tracing/bullmq/tracer.ts @@ -0,0 +1,80 @@ +import { MESSAGING_SYSTEM, SENTRY_LINK_TYPE, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { QUEUE, QUEUE_PROCESS, QUEUE_PUBLISH } from '@sentry/conventions/op'; +import type { SpanAttributes } from '@sentry/core'; +import { getCurrentScope, startInactiveSpan, startNewTrace } from '@sentry/core'; +import { SentryBullMQSpan, toSentryAttributes } from './span'; +import type { SpanOptions, TelemetrySpan, Tracer, SentryContext } from './types'; + +// BullMQ span names follow OTel messaging semconv: "{operation} {destination}" +// e.g. "add myQueue", "addBulk myQueue", "process myQueue", "addFlow myQueue" +const PRODUCER_OPERATIONS = new Set(['add', 'addbulk', 'addflow', 'addbulkflows']); +const CONSUMER_OPERATIONS = new Set(['process']); + +function getOperation(name: string): string { + return name.split(' ')[0]!.toLowerCase(); +} + +function getOpFromSpanName(name: string): string { + const operation = getOperation(name); + + if (CONSUMER_OPERATIONS.has(operation)) { + return QUEUE_PROCESS; + } + + if (PRODUCER_OPERATIONS.has(operation)) { + return QUEUE_PUBLISH; + } + + return QUEUE; +} + +function getOriginFromSpanName(name: string): string { + const operation = getOperation(name); + + if (CONSUMER_OPERATIONS.has(operation)) { + return 'auto.queue.bullmq.consumer'; + } + + if (PRODUCER_OPERATIONS.has(operation)) { + return 'auto.queue.bullmq.producer'; + } + + return 'auto.queue.bullmq'; +} + +export class SentryBullMQTracer implements Tracer { + public startSpan(name: string, options?: SpanOptions, context?: SentryContext): TelemetrySpan { + const op = getOpFromSpanName(name); + const origin = getOriginFromSpanName(name); + + const attributes: SpanAttributes = { + [SENTRY_OP]: op, + [SENTRY_ORIGIN]: origin, + [MESSAGING_SYSTEM]: 'bullmq', + ...(options?.attributes && toSentryAttributes(options.attributes)), + }; + + // Internal operations such as `moveStalledJobsToWait` run on timers. Without a parent, each tick would start a new root span. + const span = + op === QUEUE_PROCESS + ? startNewTrace(() => startInactiveSpan({ name, attributes })) + : startInactiveSpan({ name, attributes, onlyIfParent: op === QUEUE }); + + if (context?.producerSpanContext) { + const producerSpanCtx = { + traceId: context.producerSpanContext.traceId, + spanId: context.producerSpanContext.spanId, + traceFlags: context.producerSpanContext.sampled ? 1 : 0, + }; + + span.addLink({ + context: producerSpanCtx, + attributes: { + [SENTRY_LINK_TYPE]: 'previous_trace', + }, + }); + } + + return new SentryBullMQSpan(span, getCurrentScope()); + } +} diff --git a/packages/node/src/integrations/tracing/bullmq/types.ts b/packages/node/src/integrations/tracing/bullmq/types.ts new file mode 100644 index 000000000000..81d2e96696ce --- /dev/null +++ b/packages/node/src/integrations/tracing/bullmq/types.ts @@ -0,0 +1,81 @@ +import type { Span } from '@opentelemetry/api'; +import type { Scope } from '@sentry/core'; + +/** + * Vendored from bullmq@5.79.1 — src/interfaces/telemetry.ts + * https://github.com/taskforcesh/bullmq/blob/master/src/interfaces/telemetry.ts + * + * Minimal subset of BullMQ's Telemetry interface so we don't depend on bullmq at runtime. + * + * SPDX-License-Identifier: MIT + * Copyright (c) 2018 BullForce Labs AB and contributors + */ + +export type AttributeValue = string | number | boolean | Array; + +export interface SpanOptions { + kind?: number; + attributes?: Record; + parent?: unknown; +} + +export interface TelemetrySpan { + setAttribute(key: string, value: AttributeValue): void; + setAttributes(attributes: Record): void; + addEvent(name: string, attributes?: Record): void; + recordException(exception: Error | string | { code?: number; message?: string; name?: string }): void; + setSpanOnContext(context: unknown): unknown; + end(): void; +} + +export interface Tracer { + startSpan(name: string, options?: SpanOptions, context?: Context): TelemetrySpan; +} + +export interface ContextManager { + active(): Context; + with unknown>(context: Context, fn: A): ReturnType; + // Added in bullmq@6.3.11 + root?(): Context; + getMetadata(context: Context): string; + fromMetadata(activeContext: Context, metadata: string): Context; +} + +export interface MetricOptions { + description?: string; + unit?: string; +} + +export interface Counter { + add(value: number, attributes?: Record): void; +} + +export interface Histogram { + record(value: number, attributes?: Record): void; +} + +export interface Gauge { + record(value: number, attributes?: Record): void; +} + +export interface Meter { + createCounter(name: string, options?: MetricOptions): Counter; + createHistogram(name: string, options?: MetricOptions): Histogram; + createGauge?(name: string, options?: MetricOptions): Gauge; +} + +export interface Telemetry { + tracer: Tracer; + contextManager: ContextManager; + meter?: Meter; +} + +export interface SentryContext { + span: Span | undefined; + scope: Scope; + producerSpanContext?: { + traceId: string; + spanId: string; + sampled: boolean; + }; +} diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts new file mode 100644 index 000000000000..768c7604956e --- /dev/null +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -0,0 +1,577 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as SentryCore from '@sentry/core'; +import { BullMQTelemetry } from '../../../src/integrations/tracing/bullmq'; + +vi.mock('@sentry/core', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + startInactiveSpan: vi.fn(() => mockOtelSpan()), + getActiveSpan: vi.fn(() => undefined), + getCurrentScope: vi.fn(() => mockScope()), + withActiveSpan: vi.fn((_span, fn) => fn()), + startNewTrace: vi.fn(fn => fn()), + withIsolationScope: vi.fn(fn => fn()), + spanToTraceHeader: vi.fn(() => '00-traceid-spanid-01'), + captureException: vi.fn(), + metrics: { + count: vi.fn(), + distribution: vi.fn(), + gauge: vi.fn(), + }, + }; +}); + +function mockOtelSpan() { + return { + setAttribute: vi.fn(), + setAttributes: vi.fn(), + addEvent: vi.fn(), + addLink: vi.fn(), + recordException: vi.fn(), + end: vi.fn(), + spanContext: () => ({ spanId: 'abc123', traceId: 'def456', traceFlags: 1 }), + }; +} + +function mockScope() { + return { + setTag: vi.fn(), + setContext: vi.fn(), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('BullMQTelemetry', () => { + it('exposes tracer and contextManager without a meter by default', () => { + const telemetry = new BullMQTelemetry(); + + expect(telemetry.tracer).toBeDefined(); + expect(telemetry.contextManager).toBeDefined(); + expect(telemetry.meter).toBeUndefined(); + }); + + it('exposes a meter when enableMetrics is true', () => { + const telemetry = new BullMQTelemetry({ enableMetrics: true }); + + expect(telemetry.meter).toBeDefined(); + }); +}); + +describe('SentryBullMQTracer', () => { + describe('startSpan', () => { + it.each([ + { name: 'add myQueue', expectedOp: 'queue.publish', expectedOrigin: 'auto.queue.bullmq.producer' }, + { name: 'addBulk myQueue', expectedOp: 'queue.publish', expectedOrigin: 'auto.queue.bullmq.producer' }, + { name: 'addFlow myQueue', expectedOp: 'queue.publish', expectedOrigin: 'auto.queue.bullmq.producer' }, + { name: 'addBulkFlows myQueue', expectedOp: 'queue.publish', expectedOrigin: 'auto.queue.bullmq.producer' }, + { name: 'pause myQueue', expectedOp: 'queue', expectedOrigin: 'auto.queue.bullmq' }, + { name: 'close myQueue', expectedOp: 'queue', expectedOrigin: 'auto.queue.bullmq' }, + { name: 'drain myQueue', expectedOp: 'queue', expectedOrigin: 'auto.queue.bullmq' }, + { name: 'getNextJob myQueue', expectedOp: 'queue', expectedOrigin: 'auto.queue.bullmq' }, + ])('maps "$name" to op=$expectedOp and origin=$expectedOrigin', ({ name, expectedOp, expectedOrigin }) => { + const telemetry = new BullMQTelemetry(); + + telemetry.tracer.startSpan(name); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({ + name, + attributes: { + 'sentry.op': expectedOp, + 'sentry.origin': expectedOrigin, + 'messaging.system': 'bullmq', + }, + onlyIfParent: expectedOp === 'queue', + }); + }); + + it('maps "process myQueue" to op=queue.process and origin=auto.queue.bullmq.consumer', () => { + const telemetry = new BullMQTelemetry(); + + telemetry.tracer.startSpan('process myQueue'); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({ + name: 'process myQueue', + attributes: { + 'sentry.op': 'queue.process', + 'sentry.origin': 'auto.queue.bullmq.consumer', + 'messaging.system': 'bullmq', + }, + }); + }); + + it('merges attributes from SpanOptions and replaces mapped ones with messaging attributes', () => { + const telemetry = new BullMQTelemetry(); + + telemetry.tracer.startSpan('add emails', { + attributes: { 'bullmq.queue.name': 'emails', 'bullmq.job.name': 'welcome-email' }, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({ + name: 'add emails', + attributes: { + 'sentry.op': 'queue.publish', + 'sentry.origin': 'auto.queue.bullmq.producer', + 'messaging.system': 'bullmq', + 'messaging.destination.name': 'emails', + 'bullmq.job.name': 'welcome-email', + }, + onlyIfParent: false, + }); + }); + + it('starts each queue.process span in a new trace', () => { + const telemetry = new BullMQTelemetry(); + + telemetry.tracer.startSpan('process notifications'); + + expect(SentryCore.startNewTrace).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('starts queue.publish spans as children of the active span', () => { + const telemetry = new BullMQTelemetry(); + + telemetry.tracer.startSpan('add notifications'); + + expect(SentryCore.startNewTrace).not.toHaveBeenCalled(); + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ onlyIfParent: false })); + }); + + it('only starts spans for internal operations when there is a parent span', () => { + const telemetry = new BullMQTelemetry(); + + telemetry.tracer.startSpan('moveStalledJobsToWait notifications'); + + expect(SentryCore.startNewTrace).not.toHaveBeenCalled(); + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ onlyIfParent: true })); + }); + + it('adds span link to producer when context has producerSpanContext', () => { + const telemetry = new BullMQTelemetry(); + + telemetry.tracer.startSpan('process myQueue', undefined, { + span: undefined, + scope: mockScope() as any, + producerSpanContext: { + traceId: 'aabbccddaabbccddaabbccddaabbccdd', + spanId: '1122334455667788', + sampled: true, + }, + }); + + const span = vi.mocked(SentryCore.startInactiveSpan).mock.results[0]!.value; + expect(span.addLink).toHaveBeenCalledWith({ + context: { + traceId: 'aabbccddaabbccddaabbccddaabbccdd', + spanId: '1122334455667788', + traceFlags: 1, + }, + attributes: { + 'sentry.link.type': 'previous_trace', + }, + }); + expect(span.setAttribute).not.toHaveBeenCalled(); + }); + + it('does not add span link when context has no producerSpanContext', () => { + const telemetry = new BullMQTelemetry(); + + telemetry.tracer.startSpan('process myQueue', undefined, { + span: undefined, + scope: mockScope() as any, + }); + + const span = vi.mocked(SentryCore.startInactiveSpan).mock.results[0]!.value; + expect(span.addLink).not.toHaveBeenCalled(); + }); + }); +}); + +describe('SentryBullMQSpan', () => { + function createSpan() { + const telemetry = new BullMQTelemetry(); + const span = telemetry.tracer.startSpan('Queue.add test-queue'); + const otelSpan = vi.mocked(SentryCore.startInactiveSpan).mock.results[0]!.value; + return { span, otelSpan }; + } + + it('delegates setAttribute to the underlying OTel span', () => { + const { span, otelSpan } = createSpan(); + + span.setAttribute('bullmq.job.name', 'welcome-email'); + + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ 'bullmq.job.name': 'welcome-email' }); + }); + + it('delegates setAttributes to the underlying OTel span', () => { + const { span, otelSpan } = createSpan(); + + span.setAttributes({ 'bullmq.job.name': 'welcome-email', 'bullmq.worker.name': 'mailer' }); + + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ + 'bullmq.job.name': 'welcome-email', + 'bullmq.worker.name': 'mailer', + }); + }); + + it('replaces the BullMQ attributes that have a messaging convention and keeps the others', () => { + const { span, otelSpan } = createSpan(); + + span.setAttributes({ + 'bullmq.queue.name': 'emails', + 'bullmq.queue.operation': 'process', + 'bullmq.job.id': '42', + 'bullmq.job.attempts.made': 3, + 'bullmq.job.bulk.count': 5, + 'bullmq.job.name': 'welcome-email', + }); + + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ + 'bullmq.job.name': 'welcome-email', + 'messaging.destination.name': 'emails', + 'messaging.operation.name': 'process', + 'messaging.message.id': '42', + 'messaging.message.retry.count': 2, + 'messaging.batch.message_count': 5, + }); + }); + + it('replaces the queue name when BullMQ sets it with setAttribute', () => { + const { span, otelSpan } = createSpan(); + + span.setAttribute('bullmq.queue.name', 'emails'); + + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ + 'messaging.destination.name': 'emails', + }); + }); + + it('sets a retry count of 0 for the first attempt', () => { + const { span, otelSpan } = createSpan(); + + span.setAttributes({ 'bullmq.job.attempts.made': 1 }); + + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ + 'messaging.message.retry.count': 0, + }); + }); + + it('delegates addEvent to the underlying OTel span', () => { + const { span, otelSpan } = createSpan(); + + span.addEvent('job.retry', { 'bullmq.job.attempt': 3 }); + + expect(otelSpan.addEvent).toHaveBeenCalledWith('job.retry', { 'bullmq.job.attempt': 3 }); + }); + + it('delegates addEvent without attributes', () => { + const { span, otelSpan } = createSpan(); + + span.addEvent('job.completed'); + + expect(otelSpan.addEvent).toHaveBeenCalledWith('job.completed', undefined); + }); + + describe('recordException', () => { + it('records Error instances on the OTel span without capturing them', () => { + const { span, otelSpan } = createSpan(); + const error = new Error('Redis connection refused'); + + span.recordException(error); + + expect(otelSpan.recordException).toHaveBeenCalledWith(error); + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('wraps non-Error exception objects in an Error', () => { + const { span, otelSpan } = createSpan(); + + span.recordException({ code: 503, message: 'Service unavailable' }); + + expect(otelSpan.recordException).toHaveBeenCalledWith(new Error('Service unavailable')); + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('wraps string exceptions in an Error', () => { + const { span, otelSpan } = createSpan(); + + (span as any).recordException('Connection timed out'); + + expect(otelSpan.recordException).toHaveBeenCalledWith(new Error('Connection timed out')); + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('uses fallback message when non-Error exception has no message', () => { + const { span, otelSpan } = createSpan(); + + span.recordException({ code: 500 }); + + expect(otelSpan.recordException).toHaveBeenCalledWith(new Error('Unknown error')); + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + }); + + describe('addEvent', () => { + it('captures exception when event name is "job failed"', () => { + const { span } = createSpan(); + + span.addEvent('job failed', { 'bullmq.job.failed.reason': 'Connection refused' }); + + expect(SentryCore.captureException).toHaveBeenCalledWith(new Error('Connection refused'), { + mechanism: { handled: false, type: 'auto.queue.bullmq' }, + }); + }); + + it('does not capture exception for other event names', () => { + const { span } = createSpan(); + + span.addEvent('job completed', { 'bullmq.job.result': '{"ok":true}' }); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + }); + + it('returns context with span and scope from setSpanOnContext', () => { + const { span, otelSpan } = createSpan(); + const inputContext = { existingKey: 'value' }; + + const result = span.setSpanOnContext(inputContext) as Record; + + expect(result.existingKey).toBe('value'); + expect(result.span).toBe(otelSpan); + expect(result.scope).toBeDefined(); + }); + + it('delegates end to the underlying OTel span', () => { + const { span, otelSpan } = createSpan(); + + span.end(); + + expect(otelSpan.end).toHaveBeenCalledOnce(); + }); +}); + +describe('SentryBullMQContextManager', () => { + it('returns current active span and scope from active()', () => { + const telemetry = new BullMQTelemetry(); + + const context = telemetry.contextManager.active(); + + expect(SentryCore.getActiveSpan).toHaveBeenCalledOnce(); + expect(SentryCore.getCurrentScope).toHaveBeenCalled(); + expect(context).toHaveProperty('span'); + expect(context).toHaveProperty('scope'); + }); + + describe('with', () => { + it('calls withActiveSpan inside withIsolationScope when context has a span', () => { + const telemetry = new BullMQTelemetry(); + const fakeSpan = mockOtelSpan(); + const context = { span: fakeSpan, scope: mockScope() }; + const fn = vi.fn(() => 'result'); + + const result = telemetry.contextManager.with(context as any, fn); + + expect(result).toBe('result'); + expect(SentryCore.withIsolationScope).toHaveBeenCalledOnce(); + expect(SentryCore.withActiveSpan).toHaveBeenCalledWith(fakeSpan, fn); + }); + + it('clears the active span inside withIsolationScope when context has no span', () => { + const telemetry = new BullMQTelemetry(); + const context = { span: undefined, scope: mockScope() }; + const fn = vi.fn(() => 'result'); + + const result = telemetry.contextManager.with(context as any, fn); + + expect(result).toBe('result'); + expect(SentryCore.withIsolationScope).toHaveBeenCalledOnce(); + expect(SentryCore.withActiveSpan).toHaveBeenCalledWith(null, fn); + }); + }); + + describe('root', () => { + it('returns a context without a span that runs callbacks without an active span', () => { + const telemetry = new BullMQTelemetry(); + const fn = vi.fn(() => 'result'); + + const context = telemetry.contextManager.root!(); + const result = telemetry.contextManager.with(context, fn); + + expect(context.span).toBeUndefined(); + expect(result).toBe('result'); + expect(SentryCore.withActiveSpan).toHaveBeenCalledWith(null, fn); + }); + }); + + describe('getMetadata', () => { + it('returns sentry-trace header when context has a span', () => { + const telemetry = new BullMQTelemetry(); + const fakeSpan = mockOtelSpan(); + const context = { span: fakeSpan, scope: mockScope() }; + + const metadata = telemetry.contextManager.getMetadata(context as any); + + expect(SentryCore.spanToTraceHeader).toHaveBeenCalledWith(fakeSpan); + expect(metadata).toBe('00-traceid-spanid-01'); + }); + + it('returns empty string when context has no span', () => { + const telemetry = new BullMQTelemetry(); + const context = { span: undefined, scope: mockScope() }; + + const metadata = telemetry.contextManager.getMetadata(context as any); + + expect(metadata).toBe(''); + expect(SentryCore.spanToTraceHeader).not.toHaveBeenCalled(); + }); + }); + + describe('fromMetadata', () => { + it('parses sentry-trace header and attaches producerSpanContext', () => { + const telemetry = new BullMQTelemetry(); + const fakeSpan = mockOtelSpan(); + const context = { span: fakeSpan, scope: mockScope() }; + + const result = telemetry.contextManager.fromMetadata( + context as any, + 'aabbccddaabbccddaabbccddaabbccdd-1122334455667788-1', + ); + + expect(result.span).toBe(fakeSpan); + expect(result.scope).toBe(context.scope); + expect(result.producerSpanContext).toEqual({ + traceId: 'aabbccddaabbccddaabbccddaabbccdd', + spanId: '1122334455667788', + sampled: true, + }); + }); + + it('returns the active context unchanged when metadata is empty', () => { + const telemetry = new BullMQTelemetry(); + const context = { span: undefined, scope: mockScope() }; + + const result = telemetry.contextManager.fromMetadata(context as any, ''); + + expect(result).toBe(context); + }); + + it('returns the active context unchanged when metadata is invalid', () => { + const telemetry = new BullMQTelemetry(); + const context = { span: undefined, scope: mockScope() }; + + const result = telemetry.contextManager.fromMetadata(context as any, 'not-a-valid-header'); + + expect(result).toBe(context); + }); + }); +}); + +describe('SentryBullMQMeter', () => { + describe('counter', () => { + it('delegates add to metrics.count with name and unit', () => { + const telemetry = new BullMQTelemetry({ enableMetrics: true }); + const counter = telemetry.meter!.createCounter('bullmq.jobs.completed', { unit: '1' }); + + counter.add(5, { 'queue.name': 'emails' }); + + expect(SentryCore.metrics.count).toHaveBeenCalledWith('bullmq.jobs.completed', 5, { + unit: '1', + attributes: { 'queue.name': 'emails' }, + }); + }); + + it('passes undefined attributes when none provided', () => { + const telemetry = new BullMQTelemetry({ enableMetrics: true }); + const counter = telemetry.meter!.createCounter('bullmq.jobs.failed'); + + counter.add(1); + + expect(SentryCore.metrics.count).toHaveBeenCalledWith('bullmq.jobs.failed', 1, { + unit: undefined, + attributes: undefined, + }); + }); + }); + + describe('histogram', () => { + it('delegates record to metrics.distribution', () => { + const telemetry = new BullMQTelemetry({ enableMetrics: true }); + const histogram = telemetry.meter!.createHistogram('bullmq.job.duration', { unit: 'ms' }); + + histogram.record(142.5, { 'queue.name': 'notifications' }); + + expect(SentryCore.metrics.distribution).toHaveBeenCalledWith('bullmq.job.duration', 142.5, { + unit: 'ms', + attributes: { 'queue.name': 'notifications' }, + }); + }); + }); + + describe('gauge', () => { + it('delegates record to metrics.gauge', () => { + const telemetry = new BullMQTelemetry({ enableMetrics: true }); + const gauge = telemetry.meter!.createGauge!('bullmq.queue.size', { unit: '1' }); + + gauge.record(37, { 'queue.name': 'reports' }); + + expect(SentryCore.metrics.gauge).toHaveBeenCalledWith('bullmq.queue.size', 37, { + unit: '1', + attributes: { 'queue.name': 'reports' }, + }); + }); + }); + + describe('attribute filtering', () => { + it('replaces the BullMQ queue name with the messaging destination', () => { + const telemetry = new BullMQTelemetry({ enableMetrics: true }); + const counter = telemetry.meter!.createCounter('bullmq.jobs.completed'); + + counter.add(1, { 'bullmq.queue.name': 'emails', 'bullmq.job.name': 'welcome-email' }); + + expect(SentryCore.metrics.count).toHaveBeenCalledWith('bullmq.jobs.completed', 1, { + unit: undefined, + attributes: { + 'bullmq.job.name': 'welcome-email', + 'messaging.destination.name': 'emails', + }, + }); + }); + + it('filters out array attribute values', () => { + const telemetry = new BullMQTelemetry({ enableMetrics: true }); + const counter = telemetry.meter!.createCounter('bullmq.jobs.completed'); + + counter.add(1, { + 'queue.name': 'emails', + tags: ['urgent', 'retry'], + priority: 5, + enabled: true, + }); + + expect(SentryCore.metrics.count).toHaveBeenCalledWith('bullmq.jobs.completed', 1, { + unit: undefined, + attributes: { + 'queue.name': 'emails', + priority: 5, + enabled: true, + }, + }); + }); + + it('returns empty object when all attributes are arrays', () => { + const telemetry = new BullMQTelemetry({ enableMetrics: true }); + const counter = telemetry.meter!.createCounter('bullmq.jobs.completed'); + + counter.add(1, { tags: ['a', 'b'] }); + + expect(SentryCore.metrics.count).toHaveBeenCalledWith('bullmq.jobs.completed', 1, { + unit: undefined, + attributes: {}, + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index c07d28325713..ff24467af805 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5643,6 +5643,36 @@ dependencies: sparse-bitfield "^3.0.3" +"@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz#22619f76a6b10ba78c8b74025b0d9754cad69cc7" + integrity sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ== + +"@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz#c2fc0573afe08b0cf213e66eef76842b121d1577" + integrity sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w== + +"@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz#4e3822f5522e18ed92611b894dc5db1bc882f39d" + integrity sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw== + +"@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz#27ec4bc7eb6c311c982a50f1a6e1e1414638a6f8" + integrity sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw== + +"@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz#37317d833c7d01d086f6155fa59c478adb6839f6" + integrity sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ== + +"@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz#a1c79dcc9ae5f8c02aea8c2f144e5af6a822e5e8" + integrity sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ== + "@mswjs/interceptors@^0.41.0": version "0.41.9" resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.41.9.tgz#9d90bbd60d1ddc30dbcbb827a9bb2e470493530d" @@ -12317,6 +12347,18 @@ builtins@^5.0.0: dependencies: semver "^7.0.0" +bullmq@^5.79.1: + version "5.79.1" + resolved "https://registry.yarnpkg.com/bullmq/-/bullmq-5.79.1.tgz#12d33ad99a9addfb4bb96a9ca74363ef5b11b689" + integrity sha512-cteoHRr1FGOTUgzFrnMyBNGtQhNeVR8Ej6nImNSHQDJi4tj6GMD0p9ZG65ZsTnvR9RVf18dhRxWu4kFl634QGA== + dependencies: + cron-parser "4.9.0" + ioredis "5.10.1" + msgpackr "2.0.2" + node-abort-controller "3.1.1" + semver "7.8.1" + tslib "2.8.1" + bun-types@^1.2.9: version "1.2.9" resolved "https://registry.yarnpkg.com/bun-types/-/bun-types-1.2.9.tgz#e0208ba62f534eb64284c1f347f73bde7105c0f0" @@ -13267,7 +13309,7 @@ critters@0.0.16: postcss "^8.3.7" pretty-bytes "^5.3.0" -cron-parser@^4.2.0: +cron-parser@4.9.0, cron-parser@^4.2.0: version "4.9.0" resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.9.0.tgz#0340694af3e46a0894978c6f52a6dbb5c0f11ad5" integrity sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q== @@ -13828,7 +13870,7 @@ detect-indent@^6.0.0: resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd" integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA== -detect-libc@^2.0.0, detect-libc@^2.0.3, detect-libc@^2.0.4, detect-libc@^2.1.2: +detect-libc@^2.0.0, detect-libc@^2.0.1, detect-libc@^2.0.3, detect-libc@^2.0.4, detect-libc@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== @@ -20310,6 +20352,27 @@ ms@2.1.3, ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +msgpackr-extract@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz#d252698947f7a1a62478d22bfb789ba48dd4c1ac" + integrity sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw== + dependencies: + node-gyp-build-optional-packages "5.2.2" + optionalDependencies: + "@msgpackr-extract/msgpackr-extract-darwin-arm64" "3.0.4" + "@msgpackr-extract/msgpackr-extract-darwin-x64" "3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-arm" "3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-arm64" "3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-x64" "3.0.4" + "@msgpackr-extract/msgpackr-extract-win32-x64" "3.0.4" + +msgpackr@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-2.0.2.tgz#2b9838ba796d4c9760878a04d4fa69391178b5ac" + integrity sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ== + optionalDependencies: + msgpackr-extract "^3.0.4" + muggle-string@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/muggle-string/-/muggle-string-0.4.1.tgz#3b366bd43b32f809dc20659534dd30e7c8a0d328" @@ -20674,6 +20737,11 @@ node-abi@^3.73.0, node-abi@^3.89.0: dependencies: semver "^7.3.5" +node-abort-controller@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" + integrity sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ== + node-addon-api@^3.0.0: version "3.2.1" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" @@ -20706,6 +20774,13 @@ node-forge@^1, node-forge@^1.3.1, node-forge@^1.4.0: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2" integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ== +node-gyp-build-optional-packages@5.2.2: + version "5.2.2" + resolved "https://sfw.security.sentry.io/npm/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz#522f50c2d53134d7f3a76cd7255de4ab6c96a3a4" + integrity sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw== + dependencies: + detect-libc "^2.0.1" + node-gyp-build@^4.2.2: version "4.6.0" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.6.0.tgz#0c52e4cbf54bbd28b709820ef7b6a3c2d6209055" @@ -24698,6 +24773,11 @@ semver@7.7.4: resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== +semver@7.8.1: + version "7.8.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.1.tgz#bf4970b5e70fda0686363cc18bfe8805d5ed957e" + integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg== + semver@^5.3.0, semver@^5.6.0: version "5.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"