From 9aaad765a7b281d7b6ae67daef7053255f61cbdd Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 22 Jun 2026 17:12:32 +0200 Subject: [PATCH 01/16] feat(node): Add BullMQ integration --- .../node-bullmq/docker-compose.yml | 7 + .../node-bullmq/global-setup.mjs | 13 + .../node-bullmq/global-teardown.mjs | 13 + .../node-bullmq/package.json | 25 + .../node-bullmq/playwright.config.mjs | 11 + .../test-applications/node-bullmq/src/app.mjs | 58 +++ .../node-bullmq/src/instrument.mjs | 7 + .../node-bullmq/start-event-proxy.mjs | 6 + .../node-bullmq/tests/bullmq.test.ts | 124 +++++ .../node-integration-tests/package.json | 1 + .../suites/tracing/bullmq/docker-compose.yml | 13 + .../suites/tracing/bullmq/instrument.mjs | 9 + .../suites/tracing/bullmq/scenario.mjs | 32 ++ .../suites/tracing/bullmq/test.ts | 89 ++++ packages/node/src/index.ts | 1 + .../tracing/bullmq/contextManager.ts | 51 ++ .../src/integrations/tracing/bullmq/index.ts | 44 ++ .../src/integrations/tracing/bullmq/meter.ts | 82 +++ .../src/integrations/tracing/bullmq/span.ts | 72 +++ .../src/integrations/tracing/bullmq/tracer.ts | 101 ++++ .../src/integrations/tracing/bullmq/types.ts | 79 +++ .../test/integrations/tracing/bullmq.test.ts | 484 ++++++++++++++++++ yarn.lock | 84 ++- 23 files changed, 1404 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-bullmq/docker-compose.yml create mode 100644 dev-packages/e2e-tests/test-applications/node-bullmq/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-bullmq/global-teardown.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-bullmq/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-bullmq/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-bullmq/src/app.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-bullmq/src/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-bullmq/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-bullmq/tests/bullmq.test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/bullmq/docker-compose.yml create mode 100644 dev-packages/node-integration-tests/suites/tracing/bullmq/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts create mode 100644 packages/node/src/integrations/tracing/bullmq/contextManager.ts create mode 100644 packages/node/src/integrations/tracing/bullmq/index.ts create mode 100644 packages/node/src/integrations/tracing/bullmq/meter.ts create mode 100644 packages/node/src/integrations/tracing/bullmq/span.ts create mode 100644 packages/node/src/integrations/tracing/bullmq/tracer.ts create mode 100644 packages/node/src/integrations/tracing/bullmq/types.ts create mode 100644 packages/node/test/integrations/tracing/bullmq.test.ts 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..8366c8363e46 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/src/app.mjs @@ -0,0 +1,58 @@ +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(); + +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('/check-isolation', async (req, res) => { + 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..a98025d6b9e7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/tests/bullmq.test.ts @@ -0,0 +1,124 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForMetric, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Creates a queue.submit span when adding a job', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('node-bullmq', transactionEvent => { + return transactionEvent.transaction === 'GET /enqueue/success'; + }); + + await fetch(`${baseURL}/enqueue/success`); + + const transaction = await transactionPromise; + + const submitSpan = transaction.spans?.find(span => span.op === 'queue.submit'); + expect(submitSpan).toBeDefined(); + expect(submitSpan!.origin).toBe('auto.queue.bullmq.producer'); + expect(submitSpan!.data?.['messaging.system']).toBe('bullmq'); +}); + +test('Creates a transaction for queue.task when processing a job', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('node-bullmq', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'queue.task'; + }); + + await fetch(`${baseURL}/enqueue/success`); + + const transaction = await transactionPromise; + + expect(transaction.contexts?.trace?.op).toBe('queue.task'); + expect(transaction.contexts?.trace?.origin).toBe('auto.queue.bullmq.consumer'); + expect(transaction.contexts?.trace?.data?.['messaging.system']).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 processTransactionPromise = waitForTransaction('node-bullmq', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'queue.task'; + }); + + await fetch(`${baseURL}/enqueue/breadcrumb-test`); + + await processTransactionPromise; + + const transactionPromise = waitForTransaction('node-bullmq', transactionEvent => { + return transactionEvent.transaction === 'GET /check-isolation'; + }); + + await fetch(`${baseURL}/check-isolation`); + + const transaction = await transactionPromise; + + const leakedBreadcrumb = (transaction.breadcrumbs || []).find( + (b: { message?: string }) => b.message === 'breadcrumb-from-bullmq-processor', + ); + expect(leakedBreadcrumb).toBeUndefined(); +}); + +test('Links consumer transaction to producer span via sentry.previous_trace', async ({ baseURL }) => { + const httpTransactionPromise = waitForTransaction('node-bullmq', transactionEvent => { + return transactionEvent.transaction === 'GET /enqueue/success'; + }); + + const consumerTransactionPromise = waitForTransaction('node-bullmq', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'queue.task'; + }); + + await fetch(`${baseURL}/enqueue/success`); + + const httpTransaction = await httpTransactionPromise; + const consumerTransaction = await consumerTransactionPromise; + + const producerSpan = httpTransaction.spans?.find(span => span.op === 'queue.submit'); + expect(producerSpan).toBeDefined(); + + const previousTrace = consumerTransaction.contexts?.trace?.data?.['sentry.previous_trace']; + expect(previousTrace).toBeDefined(); + expect(previousTrace).toContain(httpTransaction.contexts?.trace?.trace_id); +}); + +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..58a1e6576949 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/docker-compose.yml @@ -0,0 +1,13 @@ +services: + db: + image: redis:latest + restart: always + container_name: integration-tests-bullmq-redis + ports: + - '6380: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.mjs b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs new file mode 100644 index 000000000000..497a0b86cd45 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/node'; +import { Queue, Worker } from 'bullmq'; + +const telemetry = new Sentry.BullMQTelemetry(); +const connection = { host: '127.0.0.1', port: 6380 }; + +async function run() { + const queue = new Queue('test-queue', { connection, telemetry }); + + const worker = new Worker( + 'test-queue', + async () => { + // job processed + }, + { connection, telemetry }, + ); + + const jobProcessed = new Promise(resolve => { + worker.on('completed', () => resolve()); + }); + + await Sentry.startSpan({ name: 'enqueue test-job' }, async () => { + await queue.add('test-job', { data: 'test-data' }); + }); + + await jobProcessed; + 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..99eac2474aa3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -0,0 +1,89 @@ +import type { SerializedMetricContainer, TransactionEvent } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('bullmq', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('traces producer and consumer operations with queue attributes', { timeout: 90_000 }, async () => { + const receivedTransactions: TransactionEvent[] = []; + + await createRunner() + .withDockerCompose({ workingDirectory: [__dirname] }) + .ignore('trace_metric') + .expectN(3, { + transaction: (transaction: TransactionEvent) => { + receivedTransactions.push(transaction); + }, + }) + .expect({ + transaction: (transaction: TransactionEvent) => { + receivedTransactions.push(transaction); + + const producerTransaction = receivedTransactions.find(t => t.transaction === 'enqueue test-job'); + const consumerTransaction = receivedTransactions.find( + t => t.contexts?.trace?.data?.['sentry.origin'] === 'auto.queue.bullmq.consumer', + ); + + expect(producerTransaction).toBeDefined(); + const producerSpan = producerTransaction!.spans?.find(s => s.origin === 'auto.queue.bullmq.producer'); + expect(producerSpan).toBeDefined(); + expect(producerSpan!.op).toBe('queue.submit'); + expect(producerSpan!.status).toBe('ok'); + expect(producerSpan!.data?.['messaging.system']).toBe('bullmq'); + + expect(consumerTransaction).toBeDefined(); + expect(consumerTransaction!.contexts?.trace).toEqual( + expect.objectContaining({ + op: 'queue.task', + status: 'ok', + data: expect.objectContaining({ + 'messaging.system': 'bullmq', + 'sentry.op': 'queue.task', + 'sentry.origin': 'auto.queue.bullmq.consumer', + 'sentry.previous_trace': expect.stringContaining( + producerTransaction!.contexts!.trace!.trace_id as string, + ), + }), + }), + ); + }, + }) + .start() + .completed(); + }); + + test('emits completion counter and duration histogram for processed jobs', { timeout: 90_000 }, async () => { + await createRunner() + .withDockerCompose({ workingDirectory: [__dirname] }) + .ignore('transaction') + .expect({ + trace_metric: (metrics: SerializedMetricContainer) => { + const items = metrics.items || []; + + expect(items).toHaveLength(2); + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'bullmq.jobs.completed', + type: 'counter', + value: expect.any(Number), + }), + expect.objectContaining({ + name: 'bullmq.job.duration', + type: 'distribution', + unit: 'ms', + value: expect.any(Number), + }), + ]), + ); + }, + }) + .start() + .completed(); + }); + }); +}); 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..de3effa120dd --- /dev/null +++ b/packages/node/src/integrations/tracing/bullmq/contextManager.ts @@ -0,0 +1,51 @@ +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(fn) as ReturnType; + } + + 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..bb30396e5cdb --- /dev/null +++ b/packages/node/src/integrations/tracing/bullmq/index.ts @@ -0,0 +1,44 @@ +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, + * }); + * ``` + * + * @see https://docs.bullmq.io/guide/telemetry + */ +export class BullMQTelemetry implements Telemetry { + public tracer: Tracer; + public contextManager: ContextManager; + public meter: Meter; + + public constructor() { + this.tracer = new SentryBullMQTracer(); + this.contextManager = new SentryBullMQContextManager(); + 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..12418310cb82 --- /dev/null +++ b/packages/node/src/integrations/tracing/bullmq/meter.ts @@ -0,0 +1,82 @@ +import { metrics } from '@sentry/core'; +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(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..d41b7dee7cb3 --- /dev/null +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -0,0 +1,72 @@ +import type { Attributes, AttributeValue as OtelAttributeValue, Span } from '@opentelemetry/api'; +import type { Scope } from '@sentry/core'; +import { captureException } from '@sentry/core'; +import type { AttributeValue, TelemetrySpan } from './types'; + +function toOtelAttributeValue(value: AttributeValue): OtelAttributeValue { + return value as OtelAttributeValue; +} + +function toOtelAttributes(attributes: Record): Attributes { + return attributes as Attributes; +} + +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._span.setAttribute(key, toOtelAttributeValue(value)); + } + + public setAttributes(attributes: Record): void { + this._span.setAttributes(toOtelAttributes(attributes)); + } + + public addEvent(name: string, attributes?: Record): void { + this._span.addEvent(name, attributes ? toOtelAttributes(attributes) : undefined); + + 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', + }, + }); + } + } + + 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); + + captureException(error, { + mechanism: { + handled: false, + type: 'auto.queue.bullmq', + }, + }); + } + + 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..6e8d67b19946 --- /dev/null +++ b/packages/node/src/integrations/tracing/bullmq/tracer.ts @@ -0,0 +1,101 @@ +import type { SpanAttributes } from '@sentry/core'; +import { + getCurrentScope, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, + startInactiveSpan, +} from '@sentry/core'; +import { SentryBullMQSpan } from './span'; +import type { AttributeValue, SpanOptions, TelemetrySpan, Tracer, SentryContext } from './types'; + +const MESSAGING_SYSTEM = 'bullmq'; + +// 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.task'; + } + + if (PRODUCER_OPERATIONS.has(operation)) { + return 'queue.submit'; + } + + 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'; +} + +function toSentryAttributes(attributes: Record): SpanAttributes { + return attributes as SpanAttributes; +} + +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 = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + 'messaging.system': MESSAGING_SYSTEM, + }; + + if (options?.attributes) { + Object.assign(attributes, toSentryAttributes(options.attributes)); + } + + const span = startInactiveSpan({ + name, + attributes, + forceTransaction: op === 'queue.task', + }); + + if (context?.producerSpanContext) { + const producerSpanCtx = { + traceId: context.producerSpanContext.traceId, + spanId: context.producerSpanContext.spanId, + traceFlags: context.producerSpanContext.sampled ? 1 : 0, + }; + + span.addLink({ + context: producerSpanCtx, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }); + + // TODO(v11): Remove this once EAP can store span links. We currently only set this attribute so that we + // can obtain the previous trace information from the EAP store. Long-term, EAP will handle + // span links and then we should remove this again. + span.setAttribute( + 'sentry.previous_trace', + `${producerSpanCtx.traceId}-${producerSpanCtx.spanId}-${producerSpanCtx.traceFlags}`, + ); + } + + 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..6fb0d04a8cd0 --- /dev/null +++ b/packages/node/src/integrations/tracing/bullmq/types.ts @@ -0,0 +1,79 @@ +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; + 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..2d7e5f94e362 --- /dev/null +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -0,0 +1,484 @@ +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()), + 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.restoreAllMocks(); +}); + +describe('BullMQTelemetry', () => { + it('exposes tracer, contextManager, and meter', () => { + const telemetry = new BullMQTelemetry(); + + expect(telemetry.tracer).toBeDefined(); + expect(telemetry.contextManager).toBeDefined(); + expect(telemetry.meter).toBeDefined(); + }); +}); + +describe('SentryBullMQTracer', () => { + describe('startSpan', () => { + it.each([ + { name: 'add myQueue', expectedOp: 'queue.submit', expectedOrigin: 'auto.queue.bullmq.producer' }, + { name: 'addBulk myQueue', expectedOp: 'queue.submit', expectedOrigin: 'auto.queue.bullmq.producer' }, + { name: 'addFlow myQueue', expectedOp: 'queue.submit', expectedOrigin: 'auto.queue.bullmq.producer' }, + { name: 'addBulkFlows myQueue', expectedOp: 'queue.submit', expectedOrigin: 'auto.queue.bullmq.producer' }, + { name: 'process myQueue', expectedOp: 'queue.task', expectedOrigin: 'auto.queue.bullmq.consumer' }, + { 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: { + [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_OP]: expectedOp, + [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: expectedOrigin, + 'messaging.system': 'bullmq', + }, + forceTransaction: expectedOp === 'queue.task', + }); + }); + + it('merges user-provided attributes from SpanOptions', () => { + const telemetry = new BullMQTelemetry(); + + telemetry.tracer.startSpan('add emails', { + attributes: { 'messaging.destination.name': 'emails' }, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'messaging.destination.name': 'emails', + }), + }), + ); + }); + + it('forces transaction for queue.task spans', () => { + const telemetry = new BullMQTelemetry(); + + telemetry.tracer.startSpan('process notifications'); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ forceTransaction: true })); + }); + + it('does not force transaction for queue.submit spans', () => { + const telemetry = new BullMQTelemetry(); + + telemetry.tracer.startSpan('add notifications'); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ forceTransaction: false })); + }); + + 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).toHaveBeenCalledWith( + 'sentry.previous_trace', + 'aabbccddaabbccddaabbccddaabbccdd-1122334455667788-1', + ); + }); + + 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('messaging.destination.name', 'emails'); + + expect(otelSpan.setAttribute).toHaveBeenCalledWith('messaging.destination.name', 'emails'); + }); + + it('delegates setAttributes to the underlying OTel span', () => { + const { span, otelSpan } = createSpan(); + + span.setAttributes({ 'messaging.destination.name': 'emails', 'messaging.message.id': '42' }); + + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ + 'messaging.destination.name': 'emails', + 'messaging.message.id': '42', + }); + }); + + 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 and captures via Sentry', () => { + const { span, otelSpan } = createSpan(); + const error = new Error('Redis connection refused'); + + span.recordException(error); + + expect(otelSpan.recordException).toHaveBeenCalledWith(error); + expect(SentryCore.captureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.queue.bullmq' }, + }); + }); + + it('wraps non-Error exception objects in an Error and captures via Sentry', () => { + const { span, otelSpan } = createSpan(); + + span.recordException({ code: 503, message: 'Service unavailable' }); + + const wrappedError = new Error('Service unavailable'); + expect(otelSpan.recordException).toHaveBeenCalledWith(wrappedError); + expect(SentryCore.captureException).toHaveBeenCalledWith(wrappedError, { + mechanism: { handled: false, type: 'auto.queue.bullmq' }, + }); + }); + + it('wraps string exceptions in an Error and captures via Sentry', () => { + const { span, otelSpan } = createSpan(); + + (span as any).recordException('Connection timed out'); + + const wrappedError = new Error('Connection timed out'); + expect(otelSpan.recordException).toHaveBeenCalledWith(wrappedError); + expect(SentryCore.captureException).toHaveBeenCalledWith(wrappedError, { + mechanism: { handled: false, type: 'auto.queue.bullmq' }, + }); + }); + + it('uses fallback message when non-Error exception has no message', () => { + const { span, otelSpan } = createSpan(); + + span.recordException({ code: 500 }); + + const wrappedError = new Error('Unknown error'); + expect(otelSpan.recordException).toHaveBeenCalledWith(wrappedError); + expect(SentryCore.captureException).toHaveBeenCalledWith(wrappedError, { + mechanism: { handled: false, type: 'auto.queue.bullmq' }, + }); + }); + }); + + 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('calls only withIsolationScope when context has no span', () => { + const telemetry = new BullMQTelemetry(); + const context = { span: undefined, scope: mockScope() }; + const fn = vi.fn(() => 'result'); + + telemetry.contextManager.with(context as any, fn); + + expect(SentryCore.withIsolationScope).toHaveBeenCalledOnce(); + expect(SentryCore.withActiveSpan).not.toHaveBeenCalled(); + }); + }); + + 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(); + 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(); + 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(); + 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(); + 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('filters out array attribute values', () => { + const telemetry = new BullMQTelemetry(); + 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(); + 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" From 9dc34311ad98df6e25fba10a99a283a86afc54ec Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 29 Sep 2026 16:58:17 +0200 Subject: [PATCH 02/16] fixup! feat(node): Add BullMQ integration --- .../test-applications/node-bullmq/src/app.mjs | 6 ++ .../node-bullmq/tests/bullmq.test.ts | 84 +++++++++++-------- .../suites/tracing/bullmq/docker-compose.yml | 3 +- .../suites/tracing/bullmq/scenario.mjs | 2 +- .../suites/tracing/bullmq/test.ts | 74 ++++++++-------- packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + .../src/integrations/tracing/bullmq/tracer.ts | 10 +-- .../test/integrations/tracing/bullmq.test.ts | 11 ++- 11 files changed, 109 insertions(+), 85 deletions(-) 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 index 8366c8363e46..6f853266ad35 100644 --- a/dev-packages/e2e-tests/test-applications/node-bullmq/src/app.mjs +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/src/app.mjs @@ -47,7 +47,13 @@ app.get('/enqueue/breadcrumb-test', async (req, res) => { 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'); }); 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 index a98025d6b9e7..c0c9381babf7 100644 --- 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 @@ -1,33 +1,33 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForMetric, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForMetric, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Creates a queue.submit span when adding a job', async ({ baseURL }) => { - const transactionPromise = waitForTransaction('node-bullmq', transactionEvent => { - return transactionEvent.transaction === 'GET /enqueue/success'; + const submitSpanPromise = waitForStreamedSpan('node-bullmq', span => { + return ( + getSpanOp(span) === 'queue.submit' && span.attributes['sentry.segment.name']?.value === 'GET /enqueue/success' + ); }); await fetch(`${baseURL}/enqueue/success`); - const transaction = await transactionPromise; + const submitSpan = await submitSpanPromise; - const submitSpan = transaction.spans?.find(span => span.op === 'queue.submit'); - expect(submitSpan).toBeDefined(); - expect(submitSpan!.origin).toBe('auto.queue.bullmq.producer'); - expect(submitSpan!.data?.['messaging.system']).toBe('bullmq'); + expect(submitSpan.is_segment).toBe(false); + expect(submitSpan.attributes['sentry.origin']?.value).toBe('auto.queue.bullmq.producer'); + expect(submitSpan.attributes['messaging.system']?.value).toBe('bullmq'); }); -test('Creates a transaction for queue.task when processing a job', async ({ baseURL }) => { - const transactionPromise = waitForTransaction('node-bullmq', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'queue.task'; +test('Creates a segment span for queue.task when processing a job', async ({ baseURL }) => { + const taskSpanPromise = waitForStreamedSpan('node-bullmq', span => { + return span.is_segment && getSpanOp(span) === 'queue.task'; }); await fetch(`${baseURL}/enqueue/success`); - const transaction = await transactionPromise; + const taskSpan = await taskSpanPromise; - expect(transaction.contexts?.trace?.op).toBe('queue.task'); - expect(transaction.contexts?.trace?.origin).toBe('auto.queue.bullmq.consumer'); - expect(transaction.contexts?.trace?.data?.['messaging.system']).toBe('bullmq'); + expect(taskSpan.attributes['sentry.origin']?.value).toBe('auto.queue.bullmq.consumer'); + expect(taskSpan.attributes['messaging.system']?.value).toBe('bullmq'); }); test('Sends exception to Sentry on error in job processor', async ({ baseURL }) => { @@ -51,48 +51,60 @@ test('Sends exception to Sentry on error in job processor', async ({ baseURL }) }); test('BullMQ processor breadcrumbs do not leak into subsequent HTTP requests', async ({ baseURL }) => { - const processTransactionPromise = waitForTransaction('node-bullmq', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'queue.task'; + const taskSpanPromise = waitForStreamedSpan('node-bullmq', span => { + return ( + span.is_segment && + getSpanOp(span) === 'queue.task' && + span.attributes['bullmq.job.name']?.value === 'breadcrumb-job' + ); }); await fetch(`${baseURL}/enqueue/breadcrumb-test`); - await processTransactionPromise; + await taskSpanPromise; - const transactionPromise = waitForTransaction('node-bullmq', transactionEvent => { - return transactionEvent.transaction === 'GET /check-isolation'; + const errorEventPromise = waitForError('node-bullmq', event => { + return event.exception?.values?.[0]?.value === 'Isolation check'; }); await fetch(`${baseURL}/check-isolation`); - const transaction = await transactionPromise; + const errorEvent = await errorEventPromise; - const leakedBreadcrumb = (transaction.breadcrumbs || []).find( + const leakedBreadcrumb = (errorEvent.breadcrumbs || []).find( (b: { message?: string }) => b.message === 'breadcrumb-from-bullmq-processor', ); expect(leakedBreadcrumb).toBeUndefined(); }); -test('Links consumer transaction to producer span via sentry.previous_trace', async ({ baseURL }) => { - const httpTransactionPromise = waitForTransaction('node-bullmq', transactionEvent => { - return transactionEvent.transaction === 'GET /enqueue/success'; +test('Links the queue.task segment span to its producer span via sentry.previous_trace', async ({ baseURL }) => { + const producerSpanPromise = waitForStreamedSpan('node-bullmq', span => { + return getSpanOp(span) === 'queue.submit' && span.attributes['bullmq.job.name']?.value === 'link-job'; }); - const consumerTransactionPromise = waitForTransaction('node-bullmq', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'queue.task'; + const consumerSpanPromise = waitForStreamedSpan('node-bullmq', span => { + return ( + span.is_segment && getSpanOp(span) === 'queue.task' && span.attributes['bullmq.job.name']?.value === 'link-job' + ); }); - await fetch(`${baseURL}/enqueue/success`); + await fetch(`${baseURL}/enqueue/link-test`); - const httpTransaction = await httpTransactionPromise; - const consumerTransaction = await consumerTransactionPromise; + const producerSpan = await producerSpanPromise; + const consumerSpan = await consumerSpanPromise; - const producerSpan = httpTransaction.spans?.find(span => span.op === 'queue.submit'); - expect(producerSpan).toBeDefined(); - - const previousTrace = consumerTransaction.contexts?.trace?.data?.['sentry.previous_trace']; - expect(previousTrace).toBeDefined(); - expect(previousTrace).toContain(httpTransaction.contexts?.trace?.trace_id); + expect(producerSpan.attributes['sentry.segment.name']?.value).toBe('GET /enqueue/link-test'); + expect(consumerSpan.attributes['sentry.previous_trace']?.value).toBe( + `${producerSpan.trace_id}-${producerSpan.span_id}-1`, + ); + 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 }) => { 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 index 58a1e6576949..deac6f3496a4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/docker-compose.yml +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/docker-compose.yml @@ -2,9 +2,8 @@ services: db: image: redis:latest restart: always - container_name: integration-tests-bullmq-redis ports: - - '6380:6379' + - '6384:6379' healthcheck: test: ['CMD-SHELL', 'redis-cli ping | grep -q PONG'] interval: 2s diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs index 497a0b86cd45..8552c28acf88 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/node'; import { Queue, Worker } from 'bullmq'; const telemetry = new Sentry.BullMQTelemetry(); -const connection = { host: '127.0.0.1', port: 6380 }; +const connection = { host: '127.0.0.1', port: 6384 }; async function run() { const queue = new Queue('test-queue', { connection, telemetry }); diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts index 99eac2474aa3..67f1159fa87f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -1,53 +1,58 @@ -import type { SerializedMetricContainer, TransactionEvent } from '@sentry/core'; -import { afterAll, describe, expect } from 'vitest'; -import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +import type { SerializedMetricContainer } from '@sentry/core'; +import { afterAll, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; -describe('bullmq', () => { +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 receivedTransactions: TransactionEvent[] = []; - await createRunner() - .withDockerCompose({ workingDirectory: [__dirname] }) .ignore('trace_metric') - .expectN(3, { - transaction: (transaction: TransactionEvent) => { - receivedTransactions.push(transaction); - }, - }) .expect({ - transaction: (transaction: TransactionEvent) => { - receivedTransactions.push(transaction); + span: container => { + const producerSegment = container.items.find(item => item.is_segment && item.name === 'enqueue test-job'); + const producerSpan = container.items.find( + item => item.attributes['sentry.origin']?.value === 'auto.queue.bullmq.producer', + ); + const consumerSpan = container.items.find( + item => item.attributes['sentry.origin']?.value === 'auto.queue.bullmq.consumer', + ); - const producerTransaction = receivedTransactions.find(t => t.transaction === 'enqueue test-job'); - const consumerTransaction = receivedTransactions.find( - t => t.contexts?.trace?.data?.['sentry.origin'] === 'auto.queue.bullmq.consumer', + expect(producerSegment).toBeDefined(); + expect(producerSpan).toEqual( + expect.objectContaining({ + parent_span_id: producerSegment!.span_id, + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'queue.submit' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + }), + }), ); - expect(producerTransaction).toBeDefined(); - const producerSpan = producerTransaction!.spans?.find(s => s.origin === 'auto.queue.bullmq.producer'); - expect(producerSpan).toBeDefined(); - expect(producerSpan!.op).toBe('queue.submit'); - expect(producerSpan!.status).toBe('ok'); - expect(producerSpan!.data?.['messaging.system']).toBe('bullmq'); + const { trace_id: producerTraceId, span_id: producerSpanId } = producerSpan!; - expect(consumerTransaction).toBeDefined(); - expect(consumerTransaction!.contexts?.trace).toEqual( + expect(consumerSpan).toEqual( expect.objectContaining({ - op: 'queue.task', + is_segment: true, status: 'ok', - data: expect.objectContaining({ - 'messaging.system': 'bullmq', - 'sentry.op': 'queue.task', - 'sentry.origin': 'auto.queue.bullmq.consumer', - 'sentry.previous_trace': expect.stringContaining( - producerTransaction!.contexts!.trace!.trace_id as string, - ), + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'queue.task' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'sentry.previous_trace': { type: 'string', value: `${producerTraceId}-${producerSpanId}-1` }, }), + links: [ + { + trace_id: producerTraceId, + span_id: producerSpanId, + sampled: true, + attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } }, + }, + ], }), ); }, @@ -58,8 +63,7 @@ describe('bullmq', () => { test('emits completion counter and duration histogram for processed jobs', { timeout: 90_000 }, async () => { await createRunner() - .withDockerCompose({ workingDirectory: [__dirname] }) - .ignore('transaction') + .ignore('span') .expect({ trace_metric: (metrics: SerializedMetricContainer) => { const items = metrics.items || []; 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/integrations/tracing/bullmq/tracer.ts b/packages/node/src/integrations/tracing/bullmq/tracer.ts index 6e8d67b19946..f3f589846a48 100644 --- a/packages/node/src/integrations/tracing/bullmq/tracer.ts +++ b/packages/node/src/integrations/tracing/bullmq/tracer.ts @@ -5,6 +5,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, startInactiveSpan, + withActiveSpan, } from '@sentry/core'; import { SentryBullMQSpan } from './span'; import type { AttributeValue, SpanOptions, TelemetrySpan, Tracer, SentryContext } from './types'; @@ -67,11 +68,10 @@ export class SentryBullMQTracer implements Tracer { Object.assign(attributes, toSentryAttributes(options.attributes)); } - const span = startInactiveSpan({ - name, - attributes, - forceTransaction: op === 'queue.task', - }); + const span = + op === 'queue.task' + ? withActiveSpan(null, () => startInactiveSpan({ name, attributes })) + : startInactiveSpan({ name, attributes }); if (context?.producerSpanContext) { const producerSpanCtx = { diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index 2d7e5f94e362..61159608de81 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -41,7 +41,7 @@ function mockScope() { } beforeEach(() => { - vi.restoreAllMocks(); + vi.clearAllMocks(); }); describe('BullMQTelemetry', () => { @@ -78,7 +78,6 @@ describe('SentryBullMQTracer', () => { [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: expectedOrigin, 'messaging.system': 'bullmq', }, - forceTransaction: expectedOp === 'queue.task', }); }); @@ -98,20 +97,20 @@ describe('SentryBullMQTracer', () => { ); }); - it('forces transaction for queue.task spans', () => { + it('starts queue.task spans as root spans', () => { const telemetry = new BullMQTelemetry(); telemetry.tracer.startSpan('process notifications'); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ forceTransaction: true })); + expect(SentryCore.withActiveSpan).toHaveBeenCalledWith(null, expect.any(Function)); }); - it('does not force transaction for queue.submit spans', () => { + it('starts queue.submit spans as children of the active span', () => { const telemetry = new BullMQTelemetry(); telemetry.tracer.startSpan('add notifications'); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ forceTransaction: false })); + expect(SentryCore.withActiveSpan).not.toHaveBeenCalled(); }); it('adds span link to producer when context has producerSpanContext', () => { From b41f38a659749875ab8dd7465f34380e3d278921 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 30 Sep 2026 15:44:16 +0200 Subject: [PATCH 03/16] fixup! feat(node): Add BullMQ integration --- .../src/integrations/tracing/bullmq/span.ts | 11 +++---- .../test/integrations/tracing/bullmq.test.ts | 31 ++++++------------- 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index d41b7dee7cb3..3ce0bab1aa0e 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -42,6 +42,10 @@ export class SentryBullMQSpan implements TelemetrySpan { } } + /** + * 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 @@ -49,13 +53,6 @@ export class SentryBullMQSpan implements TelemetrySpan { : new Error(typeof exception === 'string' ? exception : exception.message || 'Unknown error'); this._span.recordException(error); - - captureException(error, { - mechanism: { - handled: false, - type: 'auto.queue.bullmq', - }, - }); } public setSpanOnContext(context: unknown): unknown { diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index 61159608de81..e0097e8406c0 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -201,40 +201,32 @@ describe('SentryBullMQSpan', () => { }); describe('recordException', () => { - it('records Error instances on the OTel span and captures via Sentry', () => { + 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).toHaveBeenCalledWith(error, { - mechanism: { handled: false, type: 'auto.queue.bullmq' }, - }); + expect(SentryCore.captureException).not.toHaveBeenCalled(); }); - it('wraps non-Error exception objects in an Error and captures via Sentry', () => { + it('wraps non-Error exception objects in an Error', () => { const { span, otelSpan } = createSpan(); span.recordException({ code: 503, message: 'Service unavailable' }); - const wrappedError = new Error('Service unavailable'); - expect(otelSpan.recordException).toHaveBeenCalledWith(wrappedError); - expect(SentryCore.captureException).toHaveBeenCalledWith(wrappedError, { - mechanism: { handled: false, type: 'auto.queue.bullmq' }, - }); + expect(otelSpan.recordException).toHaveBeenCalledWith(new Error('Service unavailable')); + expect(SentryCore.captureException).not.toHaveBeenCalled(); }); - it('wraps string exceptions in an Error and captures via Sentry', () => { + it('wraps string exceptions in an Error', () => { const { span, otelSpan } = createSpan(); (span as any).recordException('Connection timed out'); - const wrappedError = new Error('Connection timed out'); - expect(otelSpan.recordException).toHaveBeenCalledWith(wrappedError); - expect(SentryCore.captureException).toHaveBeenCalledWith(wrappedError, { - mechanism: { handled: false, type: 'auto.queue.bullmq' }, - }); + 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', () => { @@ -242,11 +234,8 @@ describe('SentryBullMQSpan', () => { span.recordException({ code: 500 }); - const wrappedError = new Error('Unknown error'); - expect(otelSpan.recordException).toHaveBeenCalledWith(wrappedError); - expect(SentryCore.captureException).toHaveBeenCalledWith(wrappedError, { - mechanism: { handled: false, type: 'auto.queue.bullmq' }, - }); + expect(otelSpan.recordException).toHaveBeenCalledWith(new Error('Unknown error')); + expect(SentryCore.captureException).not.toHaveBeenCalled(); }); }); From 3ada89aee40a2c9587930c6a84e2643bda0319a7 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:10:28 +0200 Subject: [PATCH 04/16] Use queue.publish and queue.process ops for BullMQ spans --- .../node-bullmq/tests/bullmq.test.ts | 38 +++++++++---------- .../suites/tracing/bullmq/test.ts | 4 +- .../src/integrations/tracing/bullmq/tracer.ts | 9 +++-- .../test/integrations/tracing/bullmq.test.ts | 14 +++---- 4 files changed, 33 insertions(+), 32 deletions(-) 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 index c0c9381babf7..ecfd012cee55 100644 --- 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 @@ -1,33 +1,33 @@ import { expect, test } from '@playwright/test'; import { getSpanOp, waitForError, waitForMetric, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('Creates a queue.submit span when adding a job', async ({ baseURL }) => { - const submitSpanPromise = waitForStreamedSpan('node-bullmq', span => { +test('Creates a queue.publish span when adding a job', async ({ baseURL }) => { + const publishSpanPromise = waitForStreamedSpan('node-bullmq', span => { return ( - getSpanOp(span) === 'queue.submit' && span.attributes['sentry.segment.name']?.value === 'GET /enqueue/success' + getSpanOp(span) === 'queue.publish' && span.attributes['sentry.segment.name']?.value === 'GET /enqueue/success' ); }); await fetch(`${baseURL}/enqueue/success`); - const submitSpan = await submitSpanPromise; + const publishSpan = await publishSpanPromise; - expect(submitSpan.is_segment).toBe(false); - expect(submitSpan.attributes['sentry.origin']?.value).toBe('auto.queue.bullmq.producer'); - expect(submitSpan.attributes['messaging.system']?.value).toBe('bullmq'); + 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.task when processing a job', async ({ baseURL }) => { - const taskSpanPromise = waitForStreamedSpan('node-bullmq', span => { - return span.is_segment && getSpanOp(span) === 'queue.task'; +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 taskSpan = await taskSpanPromise; + const processSpan = await processSpanPromise; - expect(taskSpan.attributes['sentry.origin']?.value).toBe('auto.queue.bullmq.consumer'); - expect(taskSpan.attributes['messaging.system']?.value).toBe('bullmq'); + 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 }) => { @@ -51,17 +51,17 @@ test('Sends exception to Sentry on error in job processor', async ({ baseURL }) }); test('BullMQ processor breadcrumbs do not leak into subsequent HTTP requests', async ({ baseURL }) => { - const taskSpanPromise = waitForStreamedSpan('node-bullmq', span => { + const processSpanPromise = waitForStreamedSpan('node-bullmq', span => { return ( span.is_segment && - getSpanOp(span) === 'queue.task' && + getSpanOp(span) === 'queue.process' && span.attributes['bullmq.job.name']?.value === 'breadcrumb-job' ); }); await fetch(`${baseURL}/enqueue/breadcrumb-test`); - await taskSpanPromise; + await processSpanPromise; const errorEventPromise = waitForError('node-bullmq', event => { return event.exception?.values?.[0]?.value === 'Isolation check'; @@ -77,14 +77,14 @@ test('BullMQ processor breadcrumbs do not leak into subsequent HTTP requests', a expect(leakedBreadcrumb).toBeUndefined(); }); -test('Links the queue.task segment span to its producer span via sentry.previous_trace', async ({ baseURL }) => { +test('Links the queue.process segment span to its producer span via sentry.previous_trace', async ({ baseURL }) => { const producerSpanPromise = waitForStreamedSpan('node-bullmq', span => { - return getSpanOp(span) === 'queue.submit' && span.attributes['bullmq.job.name']?.value === 'link-job'; + 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.task' && span.attributes['bullmq.job.name']?.value === 'link-job' + span.is_segment && getSpanOp(span) === 'queue.process' && span.attributes['bullmq.job.name']?.value === 'link-job' ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts index 67f1159fa87f..199b759735a6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -28,7 +28,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { is_segment: false, status: 'ok', attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'queue.submit' }, + 'sentry.op': { type: 'string', value: 'queue.publish' }, 'messaging.system': { type: 'string', value: 'bullmq' }, }), }), @@ -41,7 +41,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { is_segment: true, status: 'ok', attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'queue.task' }, + 'sentry.op': { type: 'string', value: 'queue.process' }, 'messaging.system': { type: 'string', value: 'bullmq' }, 'sentry.previous_trace': { type: 'string', value: `${producerTraceId}-${producerSpanId}-1` }, }), diff --git a/packages/node/src/integrations/tracing/bullmq/tracer.ts b/packages/node/src/integrations/tracing/bullmq/tracer.ts index f3f589846a48..57d94bc084a1 100644 --- a/packages/node/src/integrations/tracing/bullmq/tracer.ts +++ b/packages/node/src/integrations/tracing/bullmq/tracer.ts @@ -1,3 +1,4 @@ +import { QUEUE, QUEUE_PROCESS, QUEUE_PUBLISH } from '@sentry/conventions/op'; import type { SpanAttributes } from '@sentry/core'; import { getCurrentScope, @@ -25,14 +26,14 @@ function getOpFromSpanName(name: string): string { const operation = getOperation(name); if (CONSUMER_OPERATIONS.has(operation)) { - return 'queue.task'; + return QUEUE_PROCESS; } if (PRODUCER_OPERATIONS.has(operation)) { - return 'queue.submit'; + return QUEUE_PUBLISH; } - return 'queue'; + return QUEUE; } function getOriginFromSpanName(name: string): string { @@ -69,7 +70,7 @@ export class SentryBullMQTracer implements Tracer { } const span = - op === 'queue.task' + op === QUEUE_PROCESS ? withActiveSpan(null, () => startInactiveSpan({ name, attributes })) : startInactiveSpan({ name, attributes }); diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index e0097e8406c0..cd3498df3048 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -57,11 +57,11 @@ describe('BullMQTelemetry', () => { describe('SentryBullMQTracer', () => { describe('startSpan', () => { it.each([ - { name: 'add myQueue', expectedOp: 'queue.submit', expectedOrigin: 'auto.queue.bullmq.producer' }, - { name: 'addBulk myQueue', expectedOp: 'queue.submit', expectedOrigin: 'auto.queue.bullmq.producer' }, - { name: 'addFlow myQueue', expectedOp: 'queue.submit', expectedOrigin: 'auto.queue.bullmq.producer' }, - { name: 'addBulkFlows myQueue', expectedOp: 'queue.submit', expectedOrigin: 'auto.queue.bullmq.producer' }, - { name: 'process myQueue', expectedOp: 'queue.task', expectedOrigin: 'auto.queue.bullmq.consumer' }, + { 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: 'process myQueue', expectedOp: 'queue.process', expectedOrigin: 'auto.queue.bullmq.consumer' }, { 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' }, @@ -97,7 +97,7 @@ describe('SentryBullMQTracer', () => { ); }); - it('starts queue.task spans as root spans', () => { + it('starts queue.process spans as root spans', () => { const telemetry = new BullMQTelemetry(); telemetry.tracer.startSpan('process notifications'); @@ -105,7 +105,7 @@ describe('SentryBullMQTracer', () => { expect(SentryCore.withActiveSpan).toHaveBeenCalledWith(null, expect.any(Function)); }); - it('starts queue.submit spans as children of the active span', () => { + it('starts queue.publish spans as children of the active span', () => { const telemetry = new BullMQTelemetry(); telemetry.tracer.startSpan('add notifications'); From eca158e4a6cf89a6f45dae70cc24193457b860b2 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:10:38 +0200 Subject: [PATCH 05/16] Remove the sentry.previous_trace attribute from BullMQ spans --- .../test-applications/node-bullmq/tests/bullmq.test.ts | 6 ++---- .../node-integration-tests/suites/tracing/bullmq/test.ts | 2 +- packages/node/src/integrations/tracing/bullmq/tracer.ts | 8 -------- packages/node/test/integrations/tracing/bullmq.test.ts | 5 +---- 4 files changed, 4 insertions(+), 17 deletions(-) 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 index ecfd012cee55..bc76586b8e27 100644 --- 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 @@ -77,7 +77,7 @@ test('BullMQ processor breadcrumbs do not leak into subsequent HTTP requests', a expect(leakedBreadcrumb).toBeUndefined(); }); -test('Links the queue.process segment span to its producer span via sentry.previous_trace', async ({ baseURL }) => { +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'; }); @@ -94,9 +94,7 @@ test('Links the queue.process segment span to its producer span via sentry.previ const consumerSpan = await consumerSpanPromise; expect(producerSpan.attributes['sentry.segment.name']?.value).toBe('GET /enqueue/link-test'); - expect(consumerSpan.attributes['sentry.previous_trace']?.value).toBe( - `${producerSpan.trace_id}-${producerSpan.span_id}-1`, - ); + expect(consumerSpan.attributes['sentry.previous_trace']).toBeUndefined(); expect(consumerSpan.links).toEqual([ { trace_id: producerSpan.trace_id, diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts index 199b759735a6..47d2b8dcda59 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -43,7 +43,6 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'queue.process' }, 'messaging.system': { type: 'string', value: 'bullmq' }, - 'sentry.previous_trace': { type: 'string', value: `${producerTraceId}-${producerSpanId}-1` }, }), links: [ { @@ -55,6 +54,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { ], }), ); + expect(consumerSpan!.attributes['sentry.previous_trace']).toBeUndefined(); }, }) .start() diff --git a/packages/node/src/integrations/tracing/bullmq/tracer.ts b/packages/node/src/integrations/tracing/bullmq/tracer.ts index 57d94bc084a1..31edc5a70474 100644 --- a/packages/node/src/integrations/tracing/bullmq/tracer.ts +++ b/packages/node/src/integrations/tracing/bullmq/tracer.ts @@ -87,14 +87,6 @@ export class SentryBullMQTracer implements Tracer { [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', }, }); - - // TODO(v11): Remove this once EAP can store span links. We currently only set this attribute so that we - // can obtain the previous trace information from the EAP store. Long-term, EAP will handle - // span links and then we should remove this again. - span.setAttribute( - 'sentry.previous_trace', - `${producerSpanCtx.traceId}-${producerSpanCtx.spanId}-${producerSpanCtx.traceFlags}`, - ); } return new SentryBullMQSpan(span, getCurrentScope()); diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index cd3498df3048..68717d2c67a3 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -137,10 +137,7 @@ describe('SentryBullMQTracer', () => { 'sentry.link.type': 'previous_trace', }, }); - expect(span.setAttribute).toHaveBeenCalledWith( - 'sentry.previous_trace', - 'aabbccddaabbccddaabbccddaabbccdd-1122334455667788-1', - ); + expect(span.setAttribute).not.toHaveBeenCalled(); }); it('does not add span link when context has no producerSpanContext', () => { From b2ca07affbe346f25d10c349858ed49411526da5 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:11:22 +0200 Subject: [PATCH 06/16] Start a new trace for each BullMQ job --- .../suites/tracing/bullmq/scenario.mjs | 17 ++- .../suites/tracing/bullmq/test.ts | 117 ++++++++++++------ .../src/integrations/tracing/bullmq/tracer.ts | 4 +- .../test/integrations/tracing/bullmq.test.ts | 7 +- 4 files changed, 97 insertions(+), 48 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs index 8552c28acf88..46e8e8411d11 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs @@ -15,15 +15,22 @@ async function run() { { connection, telemetry }, ); - const jobProcessed = new Promise(resolve => { - worker.on('completed', () => resolve()); + const jobsProcessed = new Promise(resolve => { + let completed = 0; + worker.on('completed', () => { + completed++; + if (completed === 2) { + resolve(); + } + }); }); - await Sentry.startSpan({ name: 'enqueue test-job' }, async () => { - await queue.add('test-job', { data: 'test-data' }); + 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 jobProcessed; + await jobsProcessed; await worker.close(); await queue.close(); await Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts index 47d2b8dcda59..c3a0770b91ee 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -9,56 +9,104 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 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-job'); - const producerSpan = container.items.find( + 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', ); - const consumerSpan = container.items.find( - item => item.attributes['sentry.origin']?.value === 'auto.queue.bullmq.consumer', - ); expect(producerSegment).toBeDefined(); - expect(producerSpan).toEqual( + 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' }, + }), + }), + ); + + producerSpans[publishSpan.attributes['bullmq.job.name']!.value as string] = { + trace_id: publishSpan.trace_id, + span_id: publishSpan.span_id, + }; + } + }, + }) + .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({ - parent_span_id: producerSegment!.span_id, - is_segment: false, status: 'ok', attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.op': { type: 'string', value: 'queue.process' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.consumer' }, 'messaging.system': { type: 'string', value: 'bullmq' }, }), }), ); + expect(consumerSegment!.attributes['sentry.previous_trace']).toBeUndefined(); - const { trace_id: producerTraceId, span_id: producerSpanId } = producerSpan!; + consumerSpans['test-job-1'] = { + trace_id: consumerSegment!.trace_id, + link: consumerSegment!.links?.[0], + }; + }, + }) + .expect({ + span: container => { + const consumerSegment = container.items.find( + item => item.is_segment && item.attributes['bullmq.job.name']?.value === 'test-job-2', + ); - expect(consumerSpan).toEqual( + expect(consumerSegment).toEqual( expect.objectContaining({ - is_segment: true, status: 'ok', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'queue.process' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, }), - links: [ - { - trace_id: producerTraceId, - span_id: producerSpanId, - sampled: true, - attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } }, - }, - ], }), ); - expect(consumerSpan!.attributes['sentry.previous_trace']).toBeUndefined(); + + consumerSpans['test-job-2'] = { + trace_id: consumerSegment!.trace_id, + link: consumerSegment!.links?.[0], + }; }, }) .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.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 () => { @@ -68,22 +116,15 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { trace_metric: (metrics: SerializedMetricContainer) => { const items = metrics.items || []; - expect(items).toHaveLength(2); - expect(items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: 'bullmq.jobs.completed', - type: 'counter', - value: expect.any(Number), - }), - expect.objectContaining({ - name: 'bullmq.job.duration', - type: 'distribution', - unit: 'ms', - value: expect.any(Number), - }), - ]), - ); + 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() diff --git a/packages/node/src/integrations/tracing/bullmq/tracer.ts b/packages/node/src/integrations/tracing/bullmq/tracer.ts index 31edc5a70474..f4158ed255bc 100644 --- a/packages/node/src/integrations/tracing/bullmq/tracer.ts +++ b/packages/node/src/integrations/tracing/bullmq/tracer.ts @@ -6,7 +6,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, startInactiveSpan, - withActiveSpan, + startNewTrace, } from '@sentry/core'; import { SentryBullMQSpan } from './span'; import type { AttributeValue, SpanOptions, TelemetrySpan, Tracer, SentryContext } from './types'; @@ -71,7 +71,7 @@ export class SentryBullMQTracer implements Tracer { const span = op === QUEUE_PROCESS - ? withActiveSpan(null, () => startInactiveSpan({ name, attributes })) + ? startNewTrace(() => startInactiveSpan({ name, attributes })) : startInactiveSpan({ name, attributes }); if (context?.producerSpanContext) { diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index 68717d2c67a3..a8ded9bd9e6f 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -10,6 +10,7 @@ vi.mock('@sentry/core', async importOriginal => { 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(), @@ -97,12 +98,12 @@ describe('SentryBullMQTracer', () => { ); }); - it('starts queue.process spans as root spans', () => { + it('starts each queue.process span in a new trace', () => { const telemetry = new BullMQTelemetry(); telemetry.tracer.startSpan('process notifications'); - expect(SentryCore.withActiveSpan).toHaveBeenCalledWith(null, expect.any(Function)); + expect(SentryCore.startNewTrace).toHaveBeenCalledWith(expect.any(Function)); }); it('starts queue.publish spans as children of the active span', () => { @@ -110,7 +111,7 @@ describe('SentryBullMQTracer', () => { telemetry.tracer.startSpan('add notifications'); - expect(SentryCore.withActiveSpan).not.toHaveBeenCalled(); + expect(SentryCore.startNewTrace).not.toHaveBeenCalled(); }); it('adds span link to producer when context has producerSpanContext', () => { From 6237a95bd1c964e6af283a04129468efcca1cc07 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:11:36 +0200 Subject: [PATCH 07/16] Only record BullMQ internal operation spans that have a parent span --- .../src/integrations/tracing/bullmq/tracer.ts | 3 ++- .../test/integrations/tracing/bullmq.test.ts | 27 ++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/node/src/integrations/tracing/bullmq/tracer.ts b/packages/node/src/integrations/tracing/bullmq/tracer.ts index f4158ed255bc..6121cb8b6605 100644 --- a/packages/node/src/integrations/tracing/bullmq/tracer.ts +++ b/packages/node/src/integrations/tracing/bullmq/tracer.ts @@ -69,10 +69,11 @@ export class SentryBullMQTracer implements Tracer { Object.assign(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 }); + : startInactiveSpan({ name, attributes, onlyIfParent: op === QUEUE }); if (context?.producerSpanContext) { const producerSpanCtx = { diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index a8ded9bd9e6f..b2dfbb97c4ae 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -62,7 +62,6 @@ describe('SentryBullMQTracer', () => { { 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: 'process myQueue', expectedOp: 'queue.process', expectedOrigin: 'auto.queue.bullmq.consumer' }, { 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' }, @@ -79,6 +78,22 @@ describe('SentryBullMQTracer', () => { [SentryCore.SEMANTIC_ATTRIBUTE_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: { + [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', + [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.queue.bullmq.consumer', + 'messaging.system': 'bullmq', + }, }); }); @@ -112,6 +127,16 @@ describe('SentryBullMQTracer', () => { 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', () => { From dfe62aef32296db8bef79f2476ddfe673d6aa341 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:11:48 +0200 Subject: [PATCH 08/16] Add ContextManager.root to the BullMQ telemetry --- .../tracing/bullmq/contextManager.ts | 13 +++++++++++- .../src/integrations/tracing/bullmq/types.ts | 2 ++ .../test/integrations/tracing/bullmq.test.ts | 21 ++++++++++++++++--- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/node/src/integrations/tracing/bullmq/contextManager.ts b/packages/node/src/integrations/tracing/bullmq/contextManager.ts index de3effa120dd..ad32e5689cdb 100644 --- a/packages/node/src/integrations/tracing/bullmq/contextManager.ts +++ b/packages/node/src/integrations/tracing/bullmq/contextManager.ts @@ -23,7 +23,18 @@ export class SentryBullMQContextManager implements ContextManager return withActiveSpan(context.span as Span, fn) as ReturnType; }); } - return withIsolationScope(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 { diff --git a/packages/node/src/integrations/tracing/bullmq/types.ts b/packages/node/src/integrations/tracing/bullmq/types.ts index 6fb0d04a8cd0..81d2e96696ce 100644 --- a/packages/node/src/integrations/tracing/bullmq/types.ts +++ b/packages/node/src/integrations/tracing/bullmq/types.ts @@ -35,6 +35,8 @@ export interface Tracer { 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; } diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index b2dfbb97c4ae..456fab611c25 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -328,15 +328,30 @@ describe('SentryBullMQContextManager', () => { expect(SentryCore.withActiveSpan).toHaveBeenCalledWith(fakeSpan, fn); }); - it('calls only withIsolationScope when context has no span', () => { + 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'); - telemetry.contextManager.with(context as any, fn); + const result = telemetry.contextManager.with(context as any, fn); + expect(result).toBe('result'); expect(SentryCore.withIsolationScope).toHaveBeenCalledOnce(); - expect(SentryCore.withActiveSpan).not.toHaveBeenCalled(); + 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); }); }); From c06934a7217a6f3540bf2d554c0b1f9e017f14bd Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:12:00 +0200 Subject: [PATCH 09/16] Add messaging attributes to BullMQ spans --- .../suites/tracing/bullmq/test.ts | 14 +++++- .../src/integrations/tracing/bullmq/span.ts | 40 +++++++++++++--- .../src/integrations/tracing/bullmq/tracer.ts | 18 ++----- .../test/integrations/tracing/bullmq.test.ts | 48 +++++++++++++++++-- 4 files changed, 93 insertions(+), 27 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts index c3a0770b91ee..83f6c382abbd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -9,8 +9,8 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 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 = {}; + const producerSpans: Record = {}; + const consumerSpans: Record = {}; await createRunner() .ignore('trace_metric') @@ -34,6 +34,8 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 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) }, }), }), ); @@ -41,6 +43,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 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, }; } }, @@ -58,6 +61,9 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { '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 }, }), }), ); @@ -66,6 +72,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { consumerSpans['test-job-1'] = { trace_id: consumerSegment!.trace_id, link: consumerSegment!.links?.[0], + messageId: consumerSegment!.attributes['messaging.message.id']!.value, }; }, }) @@ -80,6 +87,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { status: 'ok', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'queue.process' }, + 'messaging.destination.name': { type: 'string', value: 'test-queue' }, }), }), ); @@ -87,6 +95,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { consumerSpans['test-job-2'] = { trace_id: consumerSegment!.trace_id, link: consumerSegment!.links?.[0], + messageId: consumerSegment!.attributes['messaging.message.id']!.value, }; }, }) @@ -98,6 +107,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 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, diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index 3ce0bab1aa0e..47bf638e619d 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -1,16 +1,42 @@ -import type { Attributes, AttributeValue as OtelAttributeValue, Span } from '@opentelemetry/api'; +import type { Attributes, Span } from '@opentelemetry/api'; +import { + MESSAGING_DESTINATION_NAME, + MESSAGING_MESSAGE_ID, + MESSAGING_MESSAGE_RETRY_COUNT, +} from '@sentry/conventions/attributes'; import type { Scope } from '@sentry/core'; import { captureException } from '@sentry/core'; import type { AttributeValue, TelemetrySpan } from './types'; -function toOtelAttributeValue(value: AttributeValue): OtelAttributeValue { - return value as OtelAttributeValue; -} - function toOtelAttributes(attributes: Record): Attributes { return attributes as Attributes; } +/** + * Returns the BullMQ attributes plus the `messaging.*` attributes that Sentry's Queues module reads. + */ +export function toSentryAttributes(attributes: Record): Attributes { + const result = { ...toOtelAttributes(attributes) }; + const queueName = attributes['bullmq.queue.name']; + const jobId = attributes['bullmq.job.id']; + const attemptsMade = attributes['bullmq.job.attempts.made']; + + if (typeof queueName === 'string') { + result[MESSAGING_DESTINATION_NAME] = queueName; + } + + 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); + } + + return result; +} + export class SentryBullMQSpan implements TelemetrySpan { private _span: Span; private _scope: Scope; @@ -21,11 +47,11 @@ export class SentryBullMQSpan implements TelemetrySpan { } public setAttribute(key: string, value: AttributeValue): void { - this._span.setAttribute(key, toOtelAttributeValue(value)); + this.setAttributes({ [key]: value }); } public setAttributes(attributes: Record): void { - this._span.setAttributes(toOtelAttributes(attributes)); + this._span.setAttributes(toSentryAttributes(attributes)); } public addEvent(name: string, attributes?: Record): void { diff --git a/packages/node/src/integrations/tracing/bullmq/tracer.ts b/packages/node/src/integrations/tracing/bullmq/tracer.ts index 6121cb8b6605..ee1adc60d95b 100644 --- a/packages/node/src/integrations/tracing/bullmq/tracer.ts +++ b/packages/node/src/integrations/tracing/bullmq/tracer.ts @@ -1,3 +1,4 @@ +import { MESSAGING_SYSTEM } from '@sentry/conventions/attributes'; import { QUEUE, QUEUE_PROCESS, QUEUE_PUBLISH } from '@sentry/conventions/op'; import type { SpanAttributes } from '@sentry/core'; import { @@ -8,10 +9,8 @@ import { startInactiveSpan, startNewTrace, } from '@sentry/core'; -import { SentryBullMQSpan } from './span'; -import type { AttributeValue, SpanOptions, TelemetrySpan, Tracer, SentryContext } from './types'; - -const MESSAGING_SYSTEM = 'bullmq'; +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" @@ -50,10 +49,6 @@ function getOriginFromSpanName(name: string): string { return 'auto.queue.bullmq'; } -function toSentryAttributes(attributes: Record): SpanAttributes { - return attributes as SpanAttributes; -} - export class SentryBullMQTracer implements Tracer { public startSpan(name: string, options?: SpanOptions, context?: SentryContext): TelemetrySpan { const op = getOpFromSpanName(name); @@ -62,13 +57,10 @@ export class SentryBullMQTracer implements Tracer { const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, - 'messaging.system': MESSAGING_SYSTEM, + [MESSAGING_SYSTEM]: 'bullmq', + ...(options?.attributes && toSentryAttributes(options.attributes)), }; - if (options?.attributes) { - Object.assign(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 diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index 456fab611c25..f0e9a683579b 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -97,16 +97,17 @@ describe('SentryBullMQTracer', () => { }); }); - it('merges user-provided attributes from SpanOptions', () => { + it('merges attributes from SpanOptions and adds the matching messaging attributes', () => { const telemetry = new BullMQTelemetry(); telemetry.tracer.startSpan('add emails', { - attributes: { 'messaging.destination.name': 'emails' }, + attributes: { 'bullmq.queue.name': 'emails' }, }); expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.objectContaining({ + 'bullmq.queue.name': 'emails', 'messaging.destination.name': 'emails', }), }), @@ -191,19 +192,56 @@ describe('SentryBullMQSpan', () => { it('delegates setAttribute to the underlying OTel span', () => { const { span, otelSpan } = createSpan(); - span.setAttribute('messaging.destination.name', 'emails'); + span.setAttribute('bullmq.job.name', 'welcome-email'); - expect(otelSpan.setAttribute).toHaveBeenCalledWith('messaging.destination.name', 'emails'); + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ 'bullmq.job.name': 'welcome-email' }); }); it('delegates setAttributes to the underlying OTel span', () => { const { span, otelSpan } = createSpan(); - span.setAttributes({ 'messaging.destination.name': 'emails', 'messaging.message.id': '42' }); + 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('adds messaging attributes for the BullMQ queue name, job id and attempts', () => { + const { span, otelSpan } = createSpan(); + + span.setAttributes({ 'bullmq.queue.name': 'emails', 'bullmq.job.id': '42', 'bullmq.job.attempts.made': 3 }); + + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ + 'bullmq.queue.name': 'emails', + 'bullmq.job.id': '42', + 'bullmq.job.attempts.made': 3, 'messaging.destination.name': 'emails', 'messaging.message.id': '42', + 'messaging.message.retry.count': 2, + }); + }); + + it('adds the messaging destination when BullMQ sets the queue name with setAttribute', () => { + const { span, otelSpan } = createSpan(); + + span.setAttribute('bullmq.queue.name', 'emails'); + + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ + 'bullmq.queue.name': 'emails', + '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({ + 'bullmq.job.attempts.made': 1, + 'messaging.message.retry.count': 0, }); }); From cc4316f3e87a4d033f1d0d5422f517dc5ceaa94f Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:12:08 +0200 Subject: [PATCH 10/16] Add an integration test for failed BullMQ jobs --- .../tracing/bullmq/scenario-failed-job.mjs | 30 ++++++++++++++ .../suites/tracing/bullmq/test.ts | 39 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/bullmq/scenario-failed-job.mjs 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/test.ts b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts index 83f6c382abbd..257936201cdd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -141,4 +141,43 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { .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); + }); + }); }); From 24797c0e1891afea72442e0e24edde7d046af6bf Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:18:16 +0200 Subject: [PATCH 11/16] Use SENTRY_LINK_TYPE from @sentry/conventions for the BullMQ span link --- packages/node/src/integrations/tracing/bullmq/tracer.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/node/src/integrations/tracing/bullmq/tracer.ts b/packages/node/src/integrations/tracing/bullmq/tracer.ts index ee1adc60d95b..ac678cd5d50a 100644 --- a/packages/node/src/integrations/tracing/bullmq/tracer.ts +++ b/packages/node/src/integrations/tracing/bullmq/tracer.ts @@ -1,11 +1,10 @@ -import { MESSAGING_SYSTEM } from '@sentry/conventions/attributes'; +import { MESSAGING_SYSTEM, SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { QUEUE, QUEUE_PROCESS, QUEUE_PUBLISH } from '@sentry/conventions/op'; import type { SpanAttributes } from '@sentry/core'; import { getCurrentScope, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, startInactiveSpan, startNewTrace, } from '@sentry/core'; @@ -77,7 +76,7 @@ export class SentryBullMQTracer implements Tracer { span.addLink({ context: producerSpanCtx, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }); } From 16536cc0d8b7049173e10756f753427aa5c46a6c Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:19:09 +0200 Subject: [PATCH 12/16] Use SENTRY_OP and SENTRY_ORIGIN from @sentry/conventions in the BullMQ tracer --- .../node/src/integrations/tracing/bullmq/tracer.ts | 14 ++++---------- .../node/test/integrations/tracing/bullmq.test.ts | 8 ++++---- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/node/src/integrations/tracing/bullmq/tracer.ts b/packages/node/src/integrations/tracing/bullmq/tracer.ts index ac678cd5d50a..ff9648e390ab 100644 --- a/packages/node/src/integrations/tracing/bullmq/tracer.ts +++ b/packages/node/src/integrations/tracing/bullmq/tracer.ts @@ -1,13 +1,7 @@ -import { MESSAGING_SYSTEM, SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; +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, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startInactiveSpan, - startNewTrace, -} from '@sentry/core'; +import { getCurrentScope, startInactiveSpan, startNewTrace } from '@sentry/core'; import { SentryBullMQSpan, toSentryAttributes } from './span'; import type { SpanOptions, TelemetrySpan, Tracer, SentryContext } from './types'; @@ -54,8 +48,8 @@ export class SentryBullMQTracer implements Tracer { const origin = getOriginFromSpanName(name); const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [SENTRY_OP]: op, + [SENTRY_ORIGIN]: origin, [MESSAGING_SYSTEM]: 'bullmq', ...(options?.attributes && toSentryAttributes(options.attributes)), }; diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index f0e9a683579b..b1c22bec8d0e 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -74,8 +74,8 @@ describe('SentryBullMQTracer', () => { expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({ name, attributes: { - [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_OP]: expectedOp, - [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: expectedOrigin, + 'sentry.op': expectedOp, + 'sentry.origin': expectedOrigin, 'messaging.system': 'bullmq', }, onlyIfParent: expectedOp === 'queue', @@ -90,8 +90,8 @@ describe('SentryBullMQTracer', () => { expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({ name: 'process myQueue', attributes: { - [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process', - [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.queue.bullmq.consumer', + 'sentry.op': 'queue.process', + 'sentry.origin': 'auto.queue.bullmq.consumer', 'messaging.system': 'bullmq', }, }); From b8e696a807fe426fcd622c447a5e7f1d83550a2f Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 9 Oct 2026 07:43:12 +0200 Subject: [PATCH 13/16] Link the BullMQ source of the job failed event --- packages/node/src/integrations/tracing/bullmq/span.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index 47bf638e619d..894bd58680f4 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -57,6 +57,8 @@ export class SentryBullMQSpan implements TelemetrySpan { 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')), { From a8e36d318d09843212f8705dde9e25a3a916e91e Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 9 Oct 2026 10:17:29 +0200 Subject: [PATCH 14/16] Make BullMQ metrics opt-in with enableMetrics --- .../test-applications/node-bullmq/src/app.mjs | 2 +- .../suites/tracing/bullmq/scenario.mjs | 2 +- .../src/integrations/tracing/bullmq/index.ts | 18 ++++++++++++++--- .../test/integrations/tracing/bullmq.test.ts | 20 ++++++++++++------- 4 files changed, 30 insertions(+), 12 deletions(-) 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 index 6f853266ad35..af2023ab3af9 100644 --- a/dev-packages/e2e-tests/test-applications/node-bullmq/src/app.mjs +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/src/app.mjs @@ -8,7 +8,7 @@ const app = express(); const port = 3030; const connection = { host: '127.0.0.1', port: 6379 }; -const telemetry = new Sentry.BullMQTelemetry(); +const telemetry = new Sentry.BullMQTelemetry({ enableMetrics: true }); const testQueue = new Queue('test-queue', { connection, telemetry }); diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs index 46e8e8411d11..ffdcead133e6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/scenario.mjs @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/node'; import { Queue, Worker } from 'bullmq'; -const telemetry = new Sentry.BullMQTelemetry(); +const telemetry = new Sentry.BullMQTelemetry({ enableMetrics: true }); const connection = { host: '127.0.0.1', port: 6384 }; async function run() { diff --git a/packages/node/src/integrations/tracing/bullmq/index.ts b/packages/node/src/integrations/tracing/bullmq/index.ts index bb30396e5cdb..ee27f875fe27 100644 --- a/packages/node/src/integrations/tracing/bullmq/index.ts +++ b/packages/node/src/integrations/tracing/bullmq/index.ts @@ -29,16 +29,28 @@ import type { ContextManager, Meter, Telemetry, Tracer, SentryContext } from './ * }); * ``` * + * 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 meter?: Meter; - public constructor() { + 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(); - this.meter = new SentryBullMQMeter(); + + if (options.enableMetrics) { + this.meter = new SentryBullMQMeter(); + } } } diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index b1c22bec8d0e..4da3d832202c 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -46,11 +46,17 @@ beforeEach(() => { }); describe('BullMQTelemetry', () => { - it('exposes tracer, contextManager, and meter', () => { + 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(); }); }); @@ -459,7 +465,7 @@ describe('SentryBullMQContextManager', () => { describe('SentryBullMQMeter', () => { describe('counter', () => { it('delegates add to metrics.count with name and unit', () => { - const telemetry = new BullMQTelemetry(); + const telemetry = new BullMQTelemetry({ enableMetrics: true }); const counter = telemetry.meter!.createCounter('bullmq.jobs.completed', { unit: '1' }); counter.add(5, { 'queue.name': 'emails' }); @@ -471,7 +477,7 @@ describe('SentryBullMQMeter', () => { }); it('passes undefined attributes when none provided', () => { - const telemetry = new BullMQTelemetry(); + const telemetry = new BullMQTelemetry({ enableMetrics: true }); const counter = telemetry.meter!.createCounter('bullmq.jobs.failed'); counter.add(1); @@ -485,7 +491,7 @@ describe('SentryBullMQMeter', () => { describe('histogram', () => { it('delegates record to metrics.distribution', () => { - const telemetry = new BullMQTelemetry(); + const telemetry = new BullMQTelemetry({ enableMetrics: true }); const histogram = telemetry.meter!.createHistogram('bullmq.job.duration', { unit: 'ms' }); histogram.record(142.5, { 'queue.name': 'notifications' }); @@ -499,7 +505,7 @@ describe('SentryBullMQMeter', () => { describe('gauge', () => { it('delegates record to metrics.gauge', () => { - const telemetry = new BullMQTelemetry(); + const telemetry = new BullMQTelemetry({ enableMetrics: true }); const gauge = telemetry.meter!.createGauge!('bullmq.queue.size', { unit: '1' }); gauge.record(37, { 'queue.name': 'reports' }); @@ -513,7 +519,7 @@ describe('SentryBullMQMeter', () => { describe('attribute filtering', () => { it('filters out array attribute values', () => { - const telemetry = new BullMQTelemetry(); + const telemetry = new BullMQTelemetry({ enableMetrics: true }); const counter = telemetry.meter!.createCounter('bullmq.jobs.completed'); counter.add(1, { @@ -534,7 +540,7 @@ describe('SentryBullMQMeter', () => { }); it('returns empty object when all attributes are arrays', () => { - const telemetry = new BullMQTelemetry(); + const telemetry = new BullMQTelemetry({ enableMetrics: true }); const counter = telemetry.meter!.createCounter('bullmq.jobs.completed'); counter.add(1, { tags: ['a', 'b'] }); From 5b25ab7e9c208c07751ad32acaafe3d24abc2afe Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 9 Oct 2026 10:28:52 +0200 Subject: [PATCH 15/16] Replace BullMQ attributes that have a messaging convention --- .../suites/tracing/bullmq/test.ts | 3 + .../src/integrations/tracing/bullmq/meter.ts | 3 +- .../src/integrations/tracing/bullmq/span.ts | 25 ++++++-- .../test/integrations/tracing/bullmq.test.ts | 57 +++++++++++++------ 4 files changed, 65 insertions(+), 23 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts index 257936201cdd..df4d5dc96915 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -36,6 +36,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { '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' }, }), }), ); @@ -64,10 +65,12 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { '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, diff --git a/packages/node/src/integrations/tracing/bullmq/meter.ts b/packages/node/src/integrations/tracing/bullmq/meter.ts index 12418310cb82..e8c96bcf750b 100644 --- a/packages/node/src/integrations/tracing/bullmq/meter.ts +++ b/packages/node/src/integrations/tracing/bullmq/meter.ts @@ -1,4 +1,5 @@ import { metrics } from '@sentry/core'; +import { toSentryAttributes } from './span'; import type { AttributeValue, Counter, Gauge, Histogram, Meter, MetricOptions } from './types'; function toMetricAttributes( @@ -8,7 +9,7 @@ function toMetricAttributes( return undefined; } const result: Record = {}; - for (const [key, value] of Object.entries(attributes)) { + for (const [key, value] of Object.entries(toSentryAttributes(attributes))) { if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { result[key] = value; } diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index 894bd58680f4..b2bc84ae6cca 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -1,8 +1,10 @@ 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'; @@ -13,18 +15,27 @@ function toOtelAttributes(attributes: Record): Attribute } /** - * Returns the BullMQ attributes plus the `messaging.*` attributes that Sentry's Queues module reads. + * Returns the BullMQ attributes, with each attribute that has a `messaging.*` convention replaced by that convention. */ export function toSentryAttributes(attributes: Record): Attributes { - const result = { ...toOtelAttributes(attributes) }; - const queueName = attributes['bullmq.queue.name']; - const jobId = attributes['bullmq.job.id']; - const attemptsMade = attributes['bullmq.job.attempts.made']; + 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); } @@ -34,6 +45,10 @@ export function toSentryAttributes(attributes: Record): result[MESSAGING_MESSAGE_RETRY_COUNT] = Math.max(attemptsMade - 1, 0); } + if (typeof bulkCount === 'number') { + result[MESSAGING_BATCH_MESSAGE_COUNT] = bulkCount; + } + return result; } diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index 4da3d832202c..768c7604956e 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -103,21 +103,24 @@ describe('SentryBullMQTracer', () => { }); }); - it('merges attributes from SpanOptions and adds the matching messaging attributes', () => { + 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' }, + attributes: { 'bullmq.queue.name': 'emails', 'bullmq.job.name': 'welcome-email' }, }); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: expect.objectContaining({ - 'bullmq.queue.name': 'emails', - 'messaging.destination.name': 'emails', - }), - }), - ); + 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', () => { @@ -214,28 +217,34 @@ describe('SentryBullMQSpan', () => { }); }); - it('adds messaging attributes for the BullMQ queue name, job id and attempts', () => { + 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.job.id': '42', 'bullmq.job.attempts.made': 3 }); - - expect(otelSpan.setAttributes).toHaveBeenCalledWith({ + 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('adds the messaging destination when BullMQ sets the queue name with setAttribute', () => { + 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({ - 'bullmq.queue.name': 'emails', 'messaging.destination.name': 'emails', }); }); @@ -246,7 +255,6 @@ describe('SentryBullMQSpan', () => { span.setAttributes({ 'bullmq.job.attempts.made': 1 }); expect(otelSpan.setAttributes).toHaveBeenCalledWith({ - 'bullmq.job.attempts.made': 1, 'messaging.message.retry.count': 0, }); }); @@ -518,6 +526,21 @@ describe('SentryBullMQMeter', () => { }); 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'); From 4e5113e25dc9f741a5bf2a4d8546c9b93f06d900 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 9 Oct 2026 10:52:17 +0200 Subject: [PATCH 16/16] Test that the registered BullMQ attributes are sent --- .../tracing/bullmq/scenario-attributes.mjs | 68 +++++++++++++++++ .../suites/tracing/bullmq/test.ts | 76 +++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/bullmq/scenario-attributes.mjs 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/test.ts b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts index df4d5dc96915..07d89c7adbe9 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -183,4 +183,80 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 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', + ]), + ); + }); + }); });