From ba1114c9b5cb4c06c98bfb4d7403071140c955c3 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 22 Jun 2026 17:12:32 +0200 Subject: [PATCH 01/39] 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 ++++++++++++++++++ pnpm-lock.yaml | 126 +++++ 23 files changed, 1448 insertions(+) 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 a3dfca6c43a2..e2c9df0b1d00 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -65,6 +65,7 @@ "ai": "^4.3.16", "amqplib": "^0.10.9", "body-parser": "^2.3.0", + "bullmq": "^5.79.1", "consola": "^3.2.3", "cors": "^2.8.5", "cron": "^3.1.6", 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 130194f72cf5..03db068d2a6a 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -65,6 +65,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/pnpm-lock.yaml b/pnpm-lock.yaml index 01bc7f56e472..b8cd011a5fe2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -694,6 +694,9 @@ importers: body-parser: specifier: ^2.3.0 version: 2.3.0(supports-color@10.0.0) + bullmq: + specifier: ^5.79.1 + version: 5.81.5(redis@5.12.1(@opentelemetry/api@1.9.0))(supports-color@10.0.0) consola: specifier: ^3.2.3 version: 3.4.2 @@ -6112,6 +6115,36 @@ packages: '@mongodb-js/saslprep@1.4.6': resolution: {integrity: sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@mswjs/interceptors@0.41.9': resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} @@ -10354,6 +10387,15 @@ packages: builtins@5.0.1: resolution: {integrity: sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==} + bullmq@5.81.5: + resolution: {integrity: sha512-pW7pFc/X+9AEIx91TWTdIpZ1icfbJnA0unsyzD7VICjCUw8a3qedSjej5dxxn6LjfM1kr2jvalOCGbCmoA5Kxg==} + engines: {node: '>=12.22.0'} + peerDependencies: + redis: '>=5.0.0' + peerDependenciesMeta: + redis: + optional: true + bun-types@1.2.9: resolution: {integrity: sha512-dk/kOEfQbajENN/D6FyiSgOKEuUi9PWfqKQJEgwKrCMWbjS/S6tEXp178mWvWAcUSYm9ArDlWHZKO3T/4cLXiw==} @@ -13179,6 +13221,10 @@ packages: resolution: {integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==} engines: {node: '>=12.22.0'} + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + ioredis@6.0.0: resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==} engines: {node: '>=20.0.0'} @@ -14719,6 +14765,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} + muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} @@ -14928,6 +14981,9 @@ packages: resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} engines: {node: '>=10'} + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@3.2.1: resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} @@ -14955,6 +15011,10 @@ packages: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp-build@4.6.0: resolution: {integrity: sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==} hasBin: true @@ -24242,6 +24302,24 @@ snapshots: dependencies: sparse-bitfield: 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@mswjs/interceptors@0.41.9': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -29110,6 +29188,19 @@ snapshots: dependencies: semver: 7.8.5 + bullmq@5.81.5(redis@5.12.1(@opentelemetry/api@1.9.0))(supports-color@10.0.0): + dependencies: + cron-parser: 4.9.0 + ioredis: 5.11.1(supports-color@10.0.0) + msgpackr: 2.0.5 + node-abort-controller: 3.1.1 + semver: 7.8.5 + tslib: 2.8.1 + optionalDependencies: + redis: 5.12.1(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + bun-types@1.2.9: dependencies: '@types/node': 20.19.43 @@ -32601,6 +32692,18 @@ snapshots: transitivePeerDependencies: - supports-color + ioredis@5.11.1(supports-color@10.0.0): + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3(supports-color@10.0.0) + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ioredis@6.0.0(supports-color@10.0.0): dependencies: '@ioredis/commands': 2.0.0 @@ -34382,6 +34485,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + 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 + optional: true + + msgpackr@2.0.5: + optionalDependencies: + msgpackr-extract: 3.0.4 + muggle-string@0.4.1: {} multer@2.1.1: @@ -34997,6 +35116,8 @@ snapshots: dependencies: semver: 7.8.5 + node-abort-controller@3.1.1: {} + node-addon-api@3.2.1: optional: true @@ -35014,6 +35135,11 @@ snapshots: node-forge@1.4.0: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp-build@4.6.0: {} node-gyp@9.3.0(supports-color@10.0.0): From 1e5a3bd5432dec109bc53443342b04bd8c849901 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 29 Sep 2026 16:58:17 +0200 Subject: [PATCH 02/39] 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 80158e311e49..4710c4b4b20f 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 fd07e76a3fba..839aee7ed716 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -162,6 +162,7 @@ export { zodErrorsIntegration, profiler, amqplibIntegration, + BullMQTelemetry, anthropicAIIntegration, googleGenAIIntegration, vercelAIIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 4e91068d21f8..b8efd1d74901 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -179,6 +179,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 b90e609bbfc1..99a4451345f4 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -159,6 +159,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 18ba6ae340704aeecb4d07527970306bb8cc8786 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 30 Sep 2026 15:44:16 +0200 Subject: [PATCH 03/39] 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 5586dcbc976fb10fc68701c1eaaa8c59d5f8ff4c Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:10:28 +0200 Subject: [PATCH 04/39] 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 7e70f7293d87cf400288b08e6d4d01f11663b163 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:10:38 +0200 Subject: [PATCH 05/39] 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 b683c82f3bf1d0219ae7428eb7ffab317ca2d68b Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:11:22 +0200 Subject: [PATCH 06/39] 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 17d616260291c836b10eab49c0304eeaad360365 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:11:36 +0200 Subject: [PATCH 07/39] 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 81ad97aa700d551b010505a7a5fc206f48df392f Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:11:48 +0200 Subject: [PATCH 08/39] 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 97c53f68d413de497320d26002ab1cb896a63274 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:12:00 +0200 Subject: [PATCH 09/39] 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 f460f0f90675de80b8452df0afb277e2227b7c8d Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:12:08 +0200 Subject: [PATCH 10/39] 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 74fb35bf6dd23246ef1f20d7924afa69a50db9d6 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:18:16 +0200 Subject: [PATCH 11/39] 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 5bba44dd52927045a1ad475462efc4588f477dcc Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 8 Oct 2026 11:19:09 +0200 Subject: [PATCH 12/39] 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 e75de4019aca2bd111dd64a9890efd5851c5209b Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 9 Oct 2026 07:43:12 +0200 Subject: [PATCH 13/39] 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 2a5a66ccd03d4e34644c361134d94c4171739bc6 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 9 Oct 2026 10:17:29 +0200 Subject: [PATCH 14/39] 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 6047f055ba984801b4b52f1ba15e0d5ca3fe4f8d Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 9 Oct 2026 10:28:52 +0200 Subject: [PATCH 15/39] 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 de81080d586baefaa0b5a320df0cd2b49a42a26e Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 9 Oct 2026 10:52:17 +0200 Subject: [PATCH 16/39] 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', + ]), + ); + }); + }); }); From de217de5b9d43d7d05a32d64c3fefc18deebfed0 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 9 Oct 2026 11:24:57 +0200 Subject: [PATCH 17/39] Convert BullMQ metric units to Sentry units --- .../suites/tracing/bullmq/test.ts | 8 +++---- .../src/integrations/tracing/bullmq/meter.ts | 22 ++++++++++++++++--- .../test/integrations/tracing/bullmq.test.ts | 22 ++++++++++++++----- 3 files changed, 40 insertions(+), 12 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 07d89c7adbe9..d8ea6160ba76 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -131,12 +131,12 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 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.objectContaining({ type: 'counter', unit: 'none', value: expect.any(Number) }), + expect.objectContaining({ type: 'counter', unit: 'none', 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) }), + expect.objectContaining({ type: 'distribution', unit: 'millisecond', value: expect.any(Number) }), + expect.objectContaining({ type: 'distribution', unit: 'millisecond', value: expect.any(Number) }), ]); }, }) diff --git a/packages/node/src/integrations/tracing/bullmq/meter.ts b/packages/node/src/integrations/tracing/bullmq/meter.ts index e8c96bcf750b..dbf4db0293fa 100644 --- a/packages/node/src/integrations/tracing/bullmq/meter.ts +++ b/packages/node/src/integrations/tracing/bullmq/meter.ts @@ -17,6 +17,22 @@ function toMetricAttributes( return result; } +/** + * Converts the OpenTelemetry (UCUM) unit that BullMQ passes to the matching Sentry unit. + */ +function toSentryUnit(unit?: string): string | undefined { + if (unit === 'ms') { + return 'millisecond'; + } + + // In UCUM, `1` and annotations such as `{jobs}` mark a value without a unit. + if (unit === '1' || unit?.startsWith('{')) { + return 'none'; + } + + return unit; +} + class SentryBullMQCounter implements Counter { private _name: string; private _unit?: string; @@ -70,14 +86,14 @@ class SentryBullMQGauge implements Gauge { export class SentryBullMQMeter implements Meter { public createCounter(name: string, options?: MetricOptions): Counter { - return new SentryBullMQCounter(name, options?.unit); + return new SentryBullMQCounter(name, toSentryUnit(options?.unit)); } public createHistogram(name: string, options?: MetricOptions): Histogram { - return new SentryBullMQHistogram(name, options?.unit); + return new SentryBullMQHistogram(name, toSentryUnit(options?.unit)); } public createGauge(name: string, options?: MetricOptions): Gauge { - return new SentryBullMQGauge(name, options?.unit); + return new SentryBullMQGauge(name, toSentryUnit(options?.unit)); } } diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index 768c7604956e..0cb2ec26d900 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -479,7 +479,7 @@ describe('SentryBullMQMeter', () => { counter.add(5, { 'queue.name': 'emails' }); expect(SentryCore.metrics.count).toHaveBeenCalledWith('bullmq.jobs.completed', 5, { - unit: '1', + unit: 'none', attributes: { 'queue.name': 'emails' }, }); }); @@ -505,21 +505,33 @@ describe('SentryBullMQMeter', () => { histogram.record(142.5, { 'queue.name': 'notifications' }); expect(SentryCore.metrics.distribution).toHaveBeenCalledWith('bullmq.job.duration', 142.5, { - unit: 'ms', + unit: 'millisecond', attributes: { 'queue.name': 'notifications' }, }); }); + + it('keeps a unit that is already a Sentry unit', () => { + const telemetry = new BullMQTelemetry({ enableMetrics: true }); + const histogram = telemetry.meter!.createHistogram('bullmq.job.payload', { unit: 'byte' }); + + histogram.record(512); + + expect(SentryCore.metrics.distribution).toHaveBeenCalledWith('bullmq.job.payload', 512, { + unit: 'byte', + attributes: undefined, + }); + }); }); describe('gauge', () => { it('delegates record to metrics.gauge', () => { const telemetry = new BullMQTelemetry({ enableMetrics: true }); - const gauge = telemetry.meter!.createGauge!('bullmq.queue.size', { unit: '1' }); + const gauge = telemetry.meter!.createGauge!('bullmq.queue.jobs', { unit: '{jobs}' }); gauge.record(37, { 'queue.name': 'reports' }); - expect(SentryCore.metrics.gauge).toHaveBeenCalledWith('bullmq.queue.size', 37, { - unit: '1', + expect(SentryCore.metrics.gauge).toHaveBeenCalledWith('bullmq.queue.jobs', 37, { + unit: 'none', attributes: { 'queue.name': 'reports' }, }); }); From e2365226d85be70c9879b50cda5daefa5d8fc6b5 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 16 Oct 2026 09:02:06 +0200 Subject: [PATCH 18/39] Use the message_origin link type for BullMQ consumer spans --- .../test-applications/node-bullmq/tests/bullmq.test.ts | 2 +- .../node-integration-tests/suites/tracing/bullmq/test.ts | 2 +- packages/node/src/integrations/tracing/bullmq/tracer.ts | 2 +- packages/node/test/integrations/tracing/bullmq.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 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 bc76586b8e27..9d74f36007da 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 @@ -100,7 +100,7 @@ test('Links the queue.process segment span to its producer span', async ({ baseU trace_id: producerSpan.trace_id, span_id: producerSpan.span_id, sampled: true, - attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } }, + attributes: { 'sentry.link.type': { type: 'string', value: 'message_origin' } }, }, ]); }); 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 d8ea6160ba76..fca1c28b26cc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -115,7 +115,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { trace_id: producer.trace_id, span_id: producer.span_id, sampled: true, - attributes: { 'sentry.link.type': { type: 'string', value: 'previous_trace' } }, + attributes: { 'sentry.link.type': { type: 'string', value: 'message_origin' } }, }); } diff --git a/packages/node/src/integrations/tracing/bullmq/tracer.ts b/packages/node/src/integrations/tracing/bullmq/tracer.ts index ff9648e390ab..5722b013e451 100644 --- a/packages/node/src/integrations/tracing/bullmq/tracer.ts +++ b/packages/node/src/integrations/tracing/bullmq/tracer.ts @@ -70,7 +70,7 @@ export class SentryBullMQTracer implements Tracer { span.addLink({ context: producerSpanCtx, attributes: { - [SENTRY_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'message_origin', }, }); } diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index 0cb2ec26d900..e4d6159c6e7f 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -170,7 +170,7 @@ describe('SentryBullMQTracer', () => { traceFlags: 1, }, attributes: { - 'sentry.link.type': 'previous_trace', + 'sentry.link.type': 'message_origin', }, }); expect(span.setAttribute).not.toHaveBeenCalled(); From 5983d0c04db50077a91db75775cb3253dd1c9f18 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 20 Oct 2026 13:31:14 +0200 Subject: [PATCH 19/39] Leave out the BullMQ attributes that hold JSON strings --- .../suites/tracing/bullmq/test.ts | 9 +++++---- .../src/integrations/tracing/bullmq/span.ts | 19 +++++++++++++++++-- .../test/integrations/tracing/bullmq.test.ts | 16 ++++++++++++++++ 3 files changed, 38 insertions(+), 6 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 fca1c28b26cc..7a60cce5ab3b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -234,9 +234,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { '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', @@ -245,18 +243,21 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { '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', ]), ); + expect( + [...attributeKeys].filter(key => + ['bullmq.job.options', 'bullmq.job.progress', 'bullmq.queue.options', 'bullmq.worker.options'].includes(key), + ), + ).toEqual([]); }); }); }); diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index b2bc84ae6cca..2be9c4cea1f9 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -10,12 +10,21 @@ import type { Scope } from '@sentry/core'; import { captureException } from '@sentry/core'; import type { AttributeValue, TelemetrySpan } from './types'; +// BullMQ sets these attributes to `JSON.stringify()` output. They are left out until Sentry attributes support objects. +const JSON_STRING_ATTRIBUTES = new Set([ + 'bullmq.job.options', + 'bullmq.job.progress', + 'bullmq.queue.options', + 'bullmq.worker.options', +]); + function toOtelAttributes(attributes: Record): Attributes { return attributes as Attributes; } /** - * Returns the BullMQ attributes, with each attribute that has a `messaging.*` convention replaced by that convention. + * Returns the BullMQ attributes, with each attribute that has a `messaging.*` convention replaced by that convention, + * and without the attributes that hold JSON strings. */ export function toSentryAttributes(attributes: Record): Attributes { const { @@ -26,7 +35,13 @@ export function toSentryAttributes(attributes: Record): 'bullmq.job.bulk.count': bulkCount, ...rest } = attributes; - const result = toOtelAttributes(rest); + const result: Attributes = {}; + + for (const [key, value] of Object.entries(toOtelAttributes(rest))) { + if (!JSON_STRING_ATTRIBUTES.has(key)) { + result[key] = value; + } + } if (typeof queueName === 'string') { result[MESSAGING_DESTINATION_NAME] = queueName; diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index e4d6159c6e7f..df87ae7006f2 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -259,6 +259,22 @@ describe('SentryBullMQSpan', () => { }); }); + it('leaves out the BullMQ attributes that hold JSON strings', () => { + const { span, otelSpan } = createSpan(); + + span.setAttributes({ + 'bullmq.job.id': '42', + 'bullmq.job.options': '{"removeChildren":true}', + 'bullmq.job.progress': '{"percentage":50}', + 'bullmq.queue.options': '{"count":10}', + 'bullmq.worker.options': '{"block":false}', + }); + + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ + 'messaging.message.id': '42', + }); + }); + it('delegates addEvent to the underlying OTel span', () => { const { span, otelSpan } = createSpan(); From c6e1c78e4efce0b720967c653c58b99f2d5e0294 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 20 Oct 2026 13:32:23 +0200 Subject: [PATCH 20/39] Prefix the BullMQ attributes with messaging.bullmq --- .../node-bullmq/tests/bullmq.test.ts | 8 ++- .../suites/tracing/bullmq/test.ts | 69 ++++++++++--------- .../src/integrations/tracing/bullmq/span.ts | 6 +- .../test/integrations/tracing/bullmq.test.ts | 16 ++--- 4 files changed, 54 insertions(+), 45 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 9d74f36007da..57bf0f5f0d7e 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 @@ -55,7 +55,7 @@ test('BullMQ processor breadcrumbs do not leak into subsequent HTTP requests', a return ( span.is_segment && getSpanOp(span) === 'queue.process' && - span.attributes['bullmq.job.name']?.value === 'breadcrumb-job' + span.attributes['messaging.bullmq.job.name']?.value === 'breadcrumb-job' ); }); @@ -79,12 +79,14 @@ test('BullMQ processor breadcrumbs do not leak into subsequent HTTP requests', a 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'; + return getSpanOp(span) === 'queue.publish' && span.attributes['messaging.bullmq.job.name']?.value === 'link-job'; }); const consumerSpanPromise = waitForStreamedSpan('node-bullmq', span => { return ( - span.is_segment && getSpanOp(span) === 'queue.process' && span.attributes['bullmq.job.name']?.value === 'link-job' + span.is_segment && + getSpanOp(span) === 'queue.process' && + span.attributes['messaging.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 7a60cce5ab3b..f5f687e8b188 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -41,7 +41,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { }), ); - producerSpans[publishSpan.attributes['bullmq.job.name']!.value as string] = { + producerSpans[publishSpan.attributes['messaging.bullmq.job.name']!.value as string] = { trace_id: publishSpan.trace_id, span_id: publishSpan.span_id, messageId: publishSpan.attributes['messaging.message.id']!.value, @@ -52,7 +52,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { .expect({ span: container => { const consumerSegment = container.items.find( - item => item.is_segment && item.attributes['bullmq.job.name']?.value === 'test-job-1', + item => item.is_segment && item.attributes['messaging.bullmq.job.name']?.value === 'test-job-1', ); expect(consumerSegment).toEqual( @@ -82,7 +82,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { .expect({ span: container => { const consumerSegment = container.items.find( - item => item.is_segment && item.attributes['bullmq.job.name']?.value === 'test-job-2', + item => item.is_segment && item.attributes['messaging.bullmq.job.name']?.value === 'test-job-2', ); expect(consumerSegment).toEqual( @@ -223,39 +223,46 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { .start() .completed(); - // `bullmq.job.state` and `bullmq.queue.clean.count` are only set by BullMQ 6. + // `messaging.bullmq.job.state` and `messaging.bullmq.queue.clean.count` need 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.processed.timestamp', - '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.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.rate.limit', - 'bullmq.worker.stalled.jobs', + 'messaging.bullmq.flow.name', + 'messaging.bullmq.job.attempt_finished_timestamp', + 'messaging.bullmq.job.bulk.names', + 'messaging.bullmq.job.deduplication.key', + 'messaging.bullmq.job.finished.timestamp', + 'messaging.bullmq.job.ids', + 'messaging.bullmq.job.key', + 'messaging.bullmq.job.name', + 'messaging.bullmq.job.processed.timestamp', + 'messaging.bullmq.job.scheduler.id', + 'messaging.bullmq.job.status', + 'messaging.bullmq.job.type', + 'messaging.bullmq.queue.clean.limit', + 'messaging.bullmq.queue.drain.delay', + 'messaging.bullmq.queue.event.max.length', + 'messaging.bullmq.queue.grace', + 'messaging.bullmq.queue.jobs.state', + 'messaging.bullmq.queue.rate.limit', + 'messaging.bullmq.worker.do.not.wait.active', + 'messaging.bullmq.worker.force.close', + 'messaging.bullmq.worker.id', + 'messaging.bullmq.worker.jobs.to.extend.locks', + 'messaging.bullmq.worker.name', + 'messaging.bullmq.worker.rate.limit', + 'messaging.bullmq.worker.stalled.jobs', ]), ); expect( - [...attributeKeys].filter(key => - ['bullmq.job.options', 'bullmq.job.progress', 'bullmq.queue.options', 'bullmq.worker.options'].includes(key), + [...attributeKeys].filter( + key => + key.startsWith('bullmq.') || + [ + 'messaging.bullmq.job.options', + 'messaging.bullmq.job.progress', + 'messaging.bullmq.queue.options', + 'messaging.bullmq.worker.options', + ].includes(key), ), ).toEqual([]); }); diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index 2be9c4cea1f9..3fdb433ac835 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -23,8 +23,8 @@ function toOtelAttributes(attributes: Record): Attribute } /** - * Returns the BullMQ attributes, with each attribute that has a `messaging.*` convention replaced by that convention, - * and without the attributes that hold JSON strings. + * Returns the BullMQ attributes with Sentry keys: the `messaging.*` convention where one exists, and the + * `messaging.bullmq.*` key for all others. The attributes that hold JSON strings are left out. */ export function toSentryAttributes(attributes: Record): Attributes { const { @@ -39,7 +39,7 @@ export function toSentryAttributes(attributes: Record): for (const [key, value] of Object.entries(toOtelAttributes(rest))) { if (!JSON_STRING_ATTRIBUTES.has(key)) { - result[key] = value; + result[key.startsWith('bullmq.') ? `messaging.${key}` : key] = value; } } diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index df87ae7006f2..73b2b0a31186 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -103,7 +103,7 @@ describe('SentryBullMQTracer', () => { }); }); - it('merges attributes from SpanOptions and replaces mapped ones with messaging attributes', () => { + it('merges attributes from SpanOptions and gives them messaging keys', () => { const telemetry = new BullMQTelemetry(); telemetry.tracer.startSpan('add emails', { @@ -117,7 +117,7 @@ describe('SentryBullMQTracer', () => { 'sentry.origin': 'auto.queue.bullmq.producer', 'messaging.system': 'bullmq', 'messaging.destination.name': 'emails', - 'bullmq.job.name': 'welcome-email', + 'messaging.bullmq.job.name': 'welcome-email', }, onlyIfParent: false, }); @@ -203,7 +203,7 @@ describe('SentryBullMQSpan', () => { span.setAttribute('bullmq.job.name', 'welcome-email'); - expect(otelSpan.setAttributes).toHaveBeenCalledWith({ 'bullmq.job.name': 'welcome-email' }); + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ 'messaging.bullmq.job.name': 'welcome-email' }); }); it('delegates setAttributes to the underlying OTel span', () => { @@ -212,12 +212,12 @@ describe('SentryBullMQSpan', () => { span.setAttributes({ 'bullmq.job.name': 'welcome-email', 'bullmq.worker.name': 'mailer' }); expect(otelSpan.setAttributes).toHaveBeenCalledWith({ - 'bullmq.job.name': 'welcome-email', - 'bullmq.worker.name': 'mailer', + 'messaging.bullmq.job.name': 'welcome-email', + 'messaging.bullmq.worker.name': 'mailer', }); }); - it('replaces the BullMQ attributes that have a messaging convention and keeps the others', () => { + it('replaces the BullMQ attributes that have a messaging convention and prefixes the others', () => { const { span, otelSpan } = createSpan(); span.setAttributes({ @@ -230,7 +230,7 @@ describe('SentryBullMQSpan', () => { }); expect(otelSpan.setAttributes).toHaveBeenCalledWith({ - 'bullmq.job.name': 'welcome-email', + 'messaging.bullmq.job.name': 'welcome-email', 'messaging.destination.name': 'emails', 'messaging.operation.name': 'process', 'messaging.message.id': '42', @@ -563,7 +563,7 @@ describe('SentryBullMQMeter', () => { expect(SentryCore.metrics.count).toHaveBeenCalledWith('bullmq.jobs.completed', 1, { unit: undefined, attributes: { - 'bullmq.job.name': 'welcome-email', + 'messaging.bullmq.job.name': 'welcome-email', 'messaging.destination.name': 'emails', }, }); From 6c9b563216593a6f530898e726191ef277feed94 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 20 Oct 2026 14:00:04 +0200 Subject: [PATCH 21/39] Prefix the BullMQ metric names with messaging.bullmq --- .../node-bullmq/tests/bullmq.test.ts | 12 ++++++------ .../suites/tracing/bullmq/test.ts | 8 ++++---- .../src/integrations/tracing/bullmq/meter.ts | 8 ++++---- .../node/src/integrations/tracing/bullmq/span.ts | 9 ++++++++- .../test/integrations/tracing/bullmq.test.ts | 16 ++++++++-------- 5 files changed, 30 insertions(+), 23 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 57bf0f5f0d7e..e2f3b82dcc6b 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 @@ -107,30 +107,30 @@ test('Links the queue.process segment span to its producer span', async ({ baseU ]); }); -test('Emits bullmq.jobs.completed counter metric on successful job', async ({ baseURL }) => { +test('Emits messaging.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'; + return metric.name === 'messaging.bullmq.jobs.completed' && metric.type === 'counter'; }); await fetch(`${baseURL}/enqueue/success`); const metric = await metricPromise; - expect(metric.name).toBe('bullmq.jobs.completed'); + expect(metric.name).toBe('messaging.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 }) => { +test('Emits messaging.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'; + return metric.name === 'messaging.bullmq.job.duration' && metric.type === 'distribution'; }); await fetch(`${baseURL}/enqueue/success`); const metric = await metricPromise; - expect(metric.name).toBe('bullmq.job.duration'); + expect(metric.name).toBe('messaging.bullmq.job.duration'); expect(metric.type).toBe('distribution'); expect(metric.value).toEqual(expect.any(Number)); }); 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 f5f687e8b188..2742a43a0cf6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -130,11 +130,11 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { const items = metrics.items || []; expect(items).toHaveLength(4); - expect(items.filter(item => item.name === 'bullmq.jobs.completed')).toEqual([ + expect(items.filter(item => item.name === 'messaging.bullmq.jobs.completed')).toEqual([ expect.objectContaining({ type: 'counter', unit: 'none', value: expect.any(Number) }), expect.objectContaining({ type: 'counter', unit: 'none', value: expect.any(Number) }), ]); - expect(items.filter(item => item.name === 'bullmq.job.duration')).toEqual([ + expect(items.filter(item => item.name === 'messaging.bullmq.job.duration')).toEqual([ expect.objectContaining({ type: 'distribution', unit: 'millisecond', value: expect.any(Number) }), expect.objectContaining({ type: 'distribution', unit: 'millisecond', value: expect.any(Number) }), ]); @@ -212,8 +212,8 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { }) .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(); + expect(metrics.items.find(item => item.name === 'messaging.bullmq.jobs.completed')).toBeDefined(); + expect(metrics.items.find(item => item.name === 'messaging.bullmq.queue.jobs')).toBeDefined(); for (const item of metrics.items) { Object.keys(item.attributes || {}).forEach(key => attributeKeys.add(key)); diff --git a/packages/node/src/integrations/tracing/bullmq/meter.ts b/packages/node/src/integrations/tracing/bullmq/meter.ts index dbf4db0293fa..f53914feaef7 100644 --- a/packages/node/src/integrations/tracing/bullmq/meter.ts +++ b/packages/node/src/integrations/tracing/bullmq/meter.ts @@ -1,5 +1,5 @@ import { metrics } from '@sentry/core'; -import { toSentryAttributes } from './span'; +import { toMessagingKey, toSentryAttributes } from './span'; import type { AttributeValue, Counter, Gauge, Histogram, Meter, MetricOptions } from './types'; function toMetricAttributes( @@ -86,14 +86,14 @@ class SentryBullMQGauge implements Gauge { export class SentryBullMQMeter implements Meter { public createCounter(name: string, options?: MetricOptions): Counter { - return new SentryBullMQCounter(name, toSentryUnit(options?.unit)); + return new SentryBullMQCounter(toMessagingKey(name), toSentryUnit(options?.unit)); } public createHistogram(name: string, options?: MetricOptions): Histogram { - return new SentryBullMQHistogram(name, toSentryUnit(options?.unit)); + return new SentryBullMQHistogram(toMessagingKey(name), toSentryUnit(options?.unit)); } public createGauge(name: string, options?: MetricOptions): Gauge { - return new SentryBullMQGauge(name, toSentryUnit(options?.unit)); + return new SentryBullMQGauge(toMessagingKey(name), toSentryUnit(options?.unit)); } } diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index 3fdb433ac835..4d6ee40774bf 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -22,6 +22,13 @@ function toOtelAttributes(attributes: Record): Attribute return attributes as Attributes; } +/** + * Returns `messaging.` for a `bullmq.*` attribute key or metric name, and all other keys unchanged. + */ +export function toMessagingKey(key: string): string { + return key.startsWith('bullmq.') ? `messaging.${key}` : key; +} + /** * Returns the BullMQ attributes with Sentry keys: the `messaging.*` convention where one exists, and the * `messaging.bullmq.*` key for all others. The attributes that hold JSON strings are left out. @@ -39,7 +46,7 @@ export function toSentryAttributes(attributes: Record): for (const [key, value] of Object.entries(toOtelAttributes(rest))) { if (!JSON_STRING_ATTRIBUTES.has(key)) { - result[key.startsWith('bullmq.') ? `messaging.${key}` : key] = value; + result[toMessagingKey(key)] = value; } } diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index 73b2b0a31186..a20c8ea6a5e6 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -494,7 +494,7 @@ describe('SentryBullMQMeter', () => { counter.add(5, { 'queue.name': 'emails' }); - expect(SentryCore.metrics.count).toHaveBeenCalledWith('bullmq.jobs.completed', 5, { + expect(SentryCore.metrics.count).toHaveBeenCalledWith('messaging.bullmq.jobs.completed', 5, { unit: 'none', attributes: { 'queue.name': 'emails' }, }); @@ -506,7 +506,7 @@ describe('SentryBullMQMeter', () => { counter.add(1); - expect(SentryCore.metrics.count).toHaveBeenCalledWith('bullmq.jobs.failed', 1, { + expect(SentryCore.metrics.count).toHaveBeenCalledWith('messaging.bullmq.jobs.failed', 1, { unit: undefined, attributes: undefined, }); @@ -520,7 +520,7 @@ describe('SentryBullMQMeter', () => { histogram.record(142.5, { 'queue.name': 'notifications' }); - expect(SentryCore.metrics.distribution).toHaveBeenCalledWith('bullmq.job.duration', 142.5, { + expect(SentryCore.metrics.distribution).toHaveBeenCalledWith('messaging.bullmq.job.duration', 142.5, { unit: 'millisecond', attributes: { 'queue.name': 'notifications' }, }); @@ -532,7 +532,7 @@ describe('SentryBullMQMeter', () => { histogram.record(512); - expect(SentryCore.metrics.distribution).toHaveBeenCalledWith('bullmq.job.payload', 512, { + expect(SentryCore.metrics.distribution).toHaveBeenCalledWith('messaging.bullmq.job.payload', 512, { unit: 'byte', attributes: undefined, }); @@ -546,7 +546,7 @@ describe('SentryBullMQMeter', () => { gauge.record(37, { 'queue.name': 'reports' }); - expect(SentryCore.metrics.gauge).toHaveBeenCalledWith('bullmq.queue.jobs', 37, { + expect(SentryCore.metrics.gauge).toHaveBeenCalledWith('messaging.bullmq.queue.jobs', 37, { unit: 'none', attributes: { 'queue.name': 'reports' }, }); @@ -560,7 +560,7 @@ describe('SentryBullMQMeter', () => { counter.add(1, { 'bullmq.queue.name': 'emails', 'bullmq.job.name': 'welcome-email' }); - expect(SentryCore.metrics.count).toHaveBeenCalledWith('bullmq.jobs.completed', 1, { + expect(SentryCore.metrics.count).toHaveBeenCalledWith('messaging.bullmq.jobs.completed', 1, { unit: undefined, attributes: { 'messaging.bullmq.job.name': 'welcome-email', @@ -580,7 +580,7 @@ describe('SentryBullMQMeter', () => { enabled: true, }); - expect(SentryCore.metrics.count).toHaveBeenCalledWith('bullmq.jobs.completed', 1, { + expect(SentryCore.metrics.count).toHaveBeenCalledWith('messaging.bullmq.jobs.completed', 1, { unit: undefined, attributes: { 'queue.name': 'emails', @@ -596,7 +596,7 @@ describe('SentryBullMQMeter', () => { counter.add(1, { tags: ['a', 'b'] }); - expect(SentryCore.metrics.count).toHaveBeenCalledWith('bullmq.jobs.completed', 1, { + expect(SentryCore.metrics.count).toHaveBeenCalledWith('messaging.bullmq.jobs.completed', 1, { unit: undefined, attributes: {}, }); From 761114e0f7892ec1dba2ab5d276fb275a3d13695 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 20 Oct 2026 16:01:35 +0200 Subject: [PATCH 22/39] Use the messaging.bullmq metric names in the enableMetrics JSDoc --- packages/node/src/integrations/tracing/bullmq/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node/src/integrations/tracing/bullmq/index.ts b/packages/node/src/integrations/tracing/bullmq/index.ts index ee27f875fe27..1315bc00f1e8 100644 --- a/packages/node/src/integrations/tracing/bullmq/index.ts +++ b/packages/node/src/integrations/tracing/bullmq/index.ts @@ -41,7 +41,7 @@ export class BullMQTelemetry implements Telemetry { public constructor( options: { /** - * Sends BullMQ's job metrics, such as `bullmq.jobs.completed` and `bullmq.job.duration`, to Sentry. Defaults to `false`. + * Sends BullMQ's job metrics, such as `messaging.bullmq.jobs.completed` and `messaging.bullmq.job.duration`, to Sentry. Defaults to `false`. */ enableMetrics?: boolean; } = {}, From 0f5c4ff81adfd78bac48e1abc103528085bc3ca2 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 21 Oct 2026 09:13:10 +0200 Subject: [PATCH 23/39] Map the BullMQ worker ID to messaging.client.id --- .../node-integration-tests/suites/tracing/bullmq/test.ts | 3 ++- packages/node/src/integrations/tracing/bullmq/span.ts | 5 +++++ packages/node/test/integrations/tracing/bullmq.test.ts | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) 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 2742a43a0cf6..5f7a484682c1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -226,6 +226,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { // `messaging.bullmq.job.state` and `messaging.bullmq.queue.clean.count` need BullMQ 6. expect([...attributeKeys]).toEqual( expect.arrayContaining([ + 'messaging.client.id', 'messaging.bullmq.flow.name', 'messaging.bullmq.job.attempt_finished_timestamp', 'messaging.bullmq.job.bulk.names', @@ -246,7 +247,6 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.bullmq.queue.rate.limit', 'messaging.bullmq.worker.do.not.wait.active', 'messaging.bullmq.worker.force.close', - 'messaging.bullmq.worker.id', 'messaging.bullmq.worker.jobs.to.extend.locks', 'messaging.bullmq.worker.name', 'messaging.bullmq.worker.rate.limit', @@ -258,6 +258,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { key => key.startsWith('bullmq.') || [ + 'messaging.bullmq.worker.id', 'messaging.bullmq.job.options', 'messaging.bullmq.job.progress', 'messaging.bullmq.queue.options', diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index 4d6ee40774bf..5dd66d9a27b9 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -40,6 +40,7 @@ export function toSentryAttributes(attributes: Record): 'bullmq.job.id': jobId, 'bullmq.job.attempts.made': attemptsMade, 'bullmq.job.bulk.count': bulkCount, + 'bullmq.worker.id': workerId, ...rest } = attributes; const result: Attributes = {}; @@ -71,6 +72,10 @@ export function toSentryAttributes(attributes: Record): result[MESSAGING_BATCH_MESSAGE_COUNT] = bulkCount; } + if (typeof workerId === 'string') { + result['messaging.client.id'] = workerId; + } + return result; } diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index a20c8ea6a5e6..3f69cc35595b 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -226,6 +226,7 @@ describe('SentryBullMQSpan', () => { 'bullmq.job.id': '42', 'bullmq.job.attempts.made': 3, 'bullmq.job.bulk.count': 5, + 'bullmq.worker.id': '03327d57-05cc-4528-9d24-c59bf6520039', 'bullmq.job.name': 'welcome-email', }); @@ -236,6 +237,7 @@ describe('SentryBullMQSpan', () => { 'messaging.message.id': '42', 'messaging.message.retry.count': 2, 'messaging.batch.message_count': 5, + 'messaging.client.id': '03327d57-05cc-4528-9d24-c59bf6520039', }); }); From bc0e7901bf9d4459ff235343c1cc38fb218e6f14 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 21 Oct 2026 09:13:28 +0200 Subject: [PATCH 24/39] Send the BullMQ job status as messaging.bullmq.job.state --- .../suites/tracing/bullmq/test.ts | 5 +++-- .../src/integrations/tracing/bullmq/span.ts | 8 ++++++++ .../test/integrations/tracing/bullmq.test.ts | 20 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 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 5f7a484682c1..e5d46b5327b7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -223,7 +223,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { .start() .completed(); - // `messaging.bullmq.job.state` and `messaging.bullmq.queue.clean.count` need BullMQ 6. + // `messaging.bullmq.queue.clean.count` needs BullMQ 6. expect([...attributeKeys]).toEqual( expect.arrayContaining([ 'messaging.client.id', @@ -237,7 +237,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.bullmq.job.name', 'messaging.bullmq.job.processed.timestamp', 'messaging.bullmq.job.scheduler.id', - 'messaging.bullmq.job.status', + 'messaging.bullmq.job.state', 'messaging.bullmq.job.type', 'messaging.bullmq.queue.clean.limit', 'messaging.bullmq.queue.drain.delay', @@ -259,6 +259,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { key.startsWith('bullmq.') || [ 'messaging.bullmq.worker.id', + 'messaging.bullmq.job.status', 'messaging.bullmq.job.options', 'messaging.bullmq.job.progress', 'messaging.bullmq.queue.options', diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index 5dd66d9a27b9..29e714c022ff 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -41,6 +41,8 @@ export function toSentryAttributes(attributes: Record): 'bullmq.job.attempts.made': attemptsMade, 'bullmq.job.bulk.count': bulkCount, 'bullmq.worker.id': workerId, + 'bullmq.job.state': jobState, + 'bullmq.job.status': jobStatus, ...rest } = attributes; const result: Attributes = {}; @@ -76,6 +78,12 @@ export function toSentryAttributes(attributes: Record): result['messaging.client.id'] = workerId; } + // BullMQ before 5.81.2 sets `bullmq.job.status` instead of `bullmq.job.state`. + const state = jobState ?? jobStatus; + if (typeof state === 'string') { + result['messaging.bullmq.job.state'] = state; + } + return result; } diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index 3f69cc35595b..650bd07c8635 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -277,6 +277,26 @@ describe('SentryBullMQSpan', () => { }); }); + it('sends bullmq.job.status as messaging.bullmq.job.state', () => { + const { span, otelSpan } = createSpan(); + + span.setAttributes({ 'bullmq.job.status': 'completed' }); + + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ + 'messaging.bullmq.job.state': 'completed', + }); + }); + + it('prefers bullmq.job.state when BullMQ sets it together with bullmq.job.status', () => { + const { span, otelSpan } = createSpan(); + + span.setAttributes({ 'bullmq.job.state': 'failed', 'bullmq.job.status': 'completed' }); + + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ + 'messaging.bullmq.job.state': 'failed', + }); + }); + it('delegates addEvent to the underlying OTel span', () => { const { span, otelSpan } = createSpan(); From 7b4cb473bb719b0b949c4acb48d7426f0687df54 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 21 Oct 2026 09:13:46 +0200 Subject: [PATCH 25/39] Leave out the deprecated bullmq.job.finished.timestamp attribute --- .../suites/tracing/bullmq/test.ts | 2 +- .../node/src/integrations/tracing/bullmq/span.ts | 10 ++++++---- .../node/test/integrations/tracing/bullmq.test.ts | 13 +++++++++++++ 3 files changed, 20 insertions(+), 5 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 e5d46b5327b7..4b0a4d227782 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -231,7 +231,6 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.bullmq.job.attempt_finished_timestamp', 'messaging.bullmq.job.bulk.names', 'messaging.bullmq.job.deduplication.key', - 'messaging.bullmq.job.finished.timestamp', 'messaging.bullmq.job.ids', 'messaging.bullmq.job.key', 'messaging.bullmq.job.name', @@ -260,6 +259,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { [ 'messaging.bullmq.worker.id', 'messaging.bullmq.job.status', + 'messaging.bullmq.job.finished.timestamp', 'messaging.bullmq.job.options', 'messaging.bullmq.job.progress', 'messaging.bullmq.queue.options', diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index 29e714c022ff..40581c9a4503 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -10,12 +10,14 @@ import type { Scope } from '@sentry/core'; import { captureException } from '@sentry/core'; import type { AttributeValue, TelemetrySpan } from './types'; -// BullMQ sets these attributes to `JSON.stringify()` output. They are left out until Sentry attributes support objects. -const JSON_STRING_ATTRIBUTES = new Set([ +const DROPPED_ATTRIBUTES = new Set([ + // BullMQ sets these to `JSON.stringify()` output. They are left out until Sentry attributes support objects. 'bullmq.job.options', 'bullmq.job.progress', 'bullmq.queue.options', 'bullmq.worker.options', + // BullMQ deprecated this for `bullmq.job.attempt_finished_timestamp`, which it sets on the same spans. + 'bullmq.job.finished.timestamp', ]); function toOtelAttributes(attributes: Record): Attributes { @@ -31,7 +33,7 @@ export function toMessagingKey(key: string): string { /** * Returns the BullMQ attributes with Sentry keys: the `messaging.*` convention where one exists, and the - * `messaging.bullmq.*` key for all others. The attributes that hold JSON strings are left out. + * `messaging.bullmq.*` key for all others. The attributes that hold JSON strings and the deprecated ones are left out. */ export function toSentryAttributes(attributes: Record): Attributes { const { @@ -48,7 +50,7 @@ export function toSentryAttributes(attributes: Record): const result: Attributes = {}; for (const [key, value] of Object.entries(toOtelAttributes(rest))) { - if (!JSON_STRING_ATTRIBUTES.has(key)) { + if (!DROPPED_ATTRIBUTES.has(key)) { result[toMessagingKey(key)] = value; } } diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index 650bd07c8635..95338b21ed0d 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -277,6 +277,19 @@ describe('SentryBullMQSpan', () => { }); }); + it('leaves out the deprecated bullmq.job.finished.timestamp', () => { + const { span, otelSpan } = createSpan(); + + span.setAttributes({ + 'bullmq.job.finished.timestamp': 1791184722000, + 'bullmq.job.attempt_finished_timestamp': 1791184722000, + }); + + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ + 'messaging.bullmq.job.attempt_finished_timestamp': 1791184722000, + }); + }); + it('sends bullmq.job.status as messaging.bullmq.job.state', () => { const { span, otelSpan } = createSpan(); From c7d1afa631fd84e91d79581ee7a23adc773a508c Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 21 Oct 2026 09:14:18 +0200 Subject: [PATCH 26/39] Use snake_case for the multi-word BullMQ attribute keys --- .../suites/tracing/bullmq/test.ts | 24 +++++++++++------ .../src/integrations/tracing/bullmq/span.ts | 17 ++++++++++-- .../test/integrations/tracing/bullmq.test.ts | 26 +++++++++++++++++++ 3 files changed, 57 insertions(+), 10 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 4b0a4d227782..b6001a09366e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -234,22 +234,22 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.bullmq.job.ids', 'messaging.bullmq.job.key', 'messaging.bullmq.job.name', - 'messaging.bullmq.job.processed.timestamp', + 'messaging.bullmq.job.processed_timestamp', 'messaging.bullmq.job.scheduler.id', 'messaging.bullmq.job.state', 'messaging.bullmq.job.type', 'messaging.bullmq.queue.clean.limit', 'messaging.bullmq.queue.drain.delay', - 'messaging.bullmq.queue.event.max.length', + 'messaging.bullmq.queue.event.max_length', 'messaging.bullmq.queue.grace', 'messaging.bullmq.queue.jobs.state', - 'messaging.bullmq.queue.rate.limit', - 'messaging.bullmq.worker.do.not.wait.active', - 'messaging.bullmq.worker.force.close', - 'messaging.bullmq.worker.jobs.to.extend.locks', + 'messaging.bullmq.queue.rate_limit', + 'messaging.bullmq.worker.do_not_wait_active', + 'messaging.bullmq.worker.force_close', + 'messaging.bullmq.worker.jobs_to_extend_locks', 'messaging.bullmq.worker.name', - 'messaging.bullmq.worker.rate.limit', - 'messaging.bullmq.worker.stalled.jobs', + 'messaging.bullmq.worker.rate_limit', + 'messaging.bullmq.worker.stalled_jobs', ]), ); expect( @@ -260,6 +260,14 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.bullmq.worker.id', 'messaging.bullmq.job.status', 'messaging.bullmq.job.finished.timestamp', + 'messaging.bullmq.job.processed.timestamp', + 'messaging.bullmq.queue.event.max.length', + 'messaging.bullmq.queue.rate.limit', + 'messaging.bullmq.worker.do.not.wait.active', + 'messaging.bullmq.worker.force.close', + 'messaging.bullmq.worker.jobs.to.extend.locks', + 'messaging.bullmq.worker.rate.limit', + 'messaging.bullmq.worker.stalled.jobs', 'messaging.bullmq.job.options', 'messaging.bullmq.job.progress', 'messaging.bullmq.queue.options', diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index 40581c9a4503..91ee3244b470 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -24,11 +24,24 @@ function toOtelAttributes(attributes: Record): Attribute return attributes as Attributes; } +// BullMQ separates the words of these keys with dots. Sentry keys use dots only between sub-concepts. +const SNAKE_CASE_KEYS = new Map([ + ['bullmq.job.processed.timestamp', 'messaging.bullmq.job.processed_timestamp'], + ['bullmq.queue.event.max.length', 'messaging.bullmq.queue.event.max_length'], + ['bullmq.queue.rate.limit', 'messaging.bullmq.queue.rate_limit'], + ['bullmq.worker.do.not.wait.active', 'messaging.bullmq.worker.do_not_wait_active'], + ['bullmq.worker.force.close', 'messaging.bullmq.worker.force_close'], + ['bullmq.worker.jobs.to.extend.locks', 'messaging.bullmq.worker.jobs_to_extend_locks'], + ['bullmq.worker.rate.limit', 'messaging.bullmq.worker.rate_limit'], + ['bullmq.worker.stalled.jobs', 'messaging.bullmq.worker.stalled_jobs'], +]); + /** - * Returns `messaging.` for a `bullmq.*` attribute key or metric name, and all other keys unchanged. + * Returns the Sentry key for a `bullmq.*` attribute key or metric name: `messaging.`, with snake_case for the + * keys in `SNAKE_CASE_KEYS`. Returns all other keys unchanged. */ export function toMessagingKey(key: string): string { - return key.startsWith('bullmq.') ? `messaging.${key}` : key; + return SNAKE_CASE_KEYS.get(key) ?? (key.startsWith('bullmq.') ? `messaging.${key}` : key); } /** diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index 95338b21ed0d..7448fbf3d5f3 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -277,6 +277,32 @@ describe('SentryBullMQSpan', () => { }); }); + it('uses snake_case for the words of multi-word BullMQ keys', () => { + const { span, otelSpan } = createSpan(); + + span.setAttributes({ + 'bullmq.job.processed.timestamp': 1, + 'bullmq.queue.event.max.length': 2, + 'bullmq.queue.rate.limit': 3, + 'bullmq.worker.do.not.wait.active': 4, + 'bullmq.worker.force.close': 5, + 'bullmq.worker.jobs.to.extend.locks': 6, + 'bullmq.worker.rate.limit': 7, + 'bullmq.worker.stalled.jobs': 8, + }); + + expect(otelSpan.setAttributes).toHaveBeenCalledWith({ + 'messaging.bullmq.job.processed_timestamp': 1, + 'messaging.bullmq.queue.event.max_length': 2, + 'messaging.bullmq.queue.rate_limit': 3, + 'messaging.bullmq.worker.do_not_wait_active': 4, + 'messaging.bullmq.worker.force_close': 5, + 'messaging.bullmq.worker.jobs_to_extend_locks': 6, + 'messaging.bullmq.worker.rate_limit': 7, + 'messaging.bullmq.worker.stalled_jobs': 8, + }); + }); + it('leaves out the deprecated bullmq.job.finished.timestamp', () => { const { span, otelSpan } = createSpan(); From 1d6e6f16f648b88de45e52d88c188ca3e2ee794a Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 21 Oct 2026 09:14:59 +0200 Subject: [PATCH 27/39] Send the BullMQ job duration as messaging.process.duration in seconds --- .../node-bullmq/tests/bullmq.test.ts | 6 ++--- .../suites/tracing/bullmq/test.ts | 15 ++++++++++--- .../src/integrations/tracing/bullmq/index.ts | 2 +- .../src/integrations/tracing/bullmq/meter.ts | 22 +++++++++++++++++++ .../test/integrations/tracing/bullmq.test.ts | 14 +++++++----- 5 files changed, 47 insertions(+), 12 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 e2f3b82dcc6b..2adcaeadc365 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 @@ -121,16 +121,16 @@ test('Emits messaging.bullmq.jobs.completed counter metric on successful job', a expect(metric.value).toEqual(expect.any(Number)); }); -test('Emits messaging.bullmq.job.duration histogram metric on job completion', async ({ baseURL }) => { +test('Emits messaging.process.duration histogram metric on job completion', async ({ baseURL }) => { const metricPromise = waitForMetric('node-bullmq', metric => { - return metric.name === 'messaging.bullmq.job.duration' && metric.type === 'distribution'; + return metric.name === 'messaging.process.duration' && metric.type === 'distribution'; }); await fetch(`${baseURL}/enqueue/success`); const metric = await metricPromise; - expect(metric.name).toBe('messaging.bullmq.job.duration'); + expect(metric.name).toBe('messaging.process.duration'); expect(metric.type).toBe('distribution'); expect(metric.value).toEqual(expect.any(Number)); }); 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 b6001a09366e..f12fd2ee0b43 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -134,9 +134,18 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { expect.objectContaining({ type: 'counter', unit: 'none', value: expect.any(Number) }), expect.objectContaining({ type: 'counter', unit: 'none', value: expect.any(Number) }), ]); - expect(items.filter(item => item.name === 'messaging.bullmq.job.duration')).toEqual([ - expect.objectContaining({ type: 'distribution', unit: 'millisecond', value: expect.any(Number) }), - expect.objectContaining({ type: 'distribution', unit: 'millisecond', value: expect.any(Number) }), + const processDuration = expect.objectContaining({ + type: 'distribution', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining({ + 'messaging.system': { value: 'bullmq', type: 'string' }, + 'messaging.operation.name': { value: 'process', type: 'string' }, + }), + }); + expect(items.filter(item => item.name === 'messaging.process.duration')).toEqual([ + processDuration, + processDuration, ]); }, }) diff --git a/packages/node/src/integrations/tracing/bullmq/index.ts b/packages/node/src/integrations/tracing/bullmq/index.ts index 1315bc00f1e8..984b1af13591 100644 --- a/packages/node/src/integrations/tracing/bullmq/index.ts +++ b/packages/node/src/integrations/tracing/bullmq/index.ts @@ -41,7 +41,7 @@ export class BullMQTelemetry implements Telemetry { public constructor( options: { /** - * Sends BullMQ's job metrics, such as `messaging.bullmq.jobs.completed` and `messaging.bullmq.job.duration`, to Sentry. Defaults to `false`. + * Sends BullMQ's job metrics, such as `messaging.bullmq.jobs.completed` and `messaging.process.duration`, to Sentry. Defaults to `false`. */ enableMetrics?: boolean; } = {}, diff --git a/packages/node/src/integrations/tracing/bullmq/meter.ts b/packages/node/src/integrations/tracing/bullmq/meter.ts index f53914feaef7..bfae7aa342f8 100644 --- a/packages/node/src/integrations/tracing/bullmq/meter.ts +++ b/packages/node/src/integrations/tracing/bullmq/meter.ts @@ -1,3 +1,4 @@ +import { MESSAGING_OPERATION_NAME, MESSAGING_SYSTEM } from '@sentry/conventions/attributes'; import { metrics } from '@sentry/core'; import { toMessagingKey, toSentryAttributes } from './span'; import type { AttributeValue, Counter, Gauge, Histogram, Meter, MetricOptions } from './types'; @@ -84,12 +85,33 @@ class SentryBullMQGauge implements Gauge { } } +/** + * Records BullMQ's `bullmq.job.duration` histogram, which is in milliseconds, as the OTel + * `messaging.process.duration` metric in seconds. + */ +class SentryBullMQProcessDuration implements Histogram { + public record(value: number, attributes?: Record): void { + metrics.distribution('messaging.process.duration', value / 1000, { + unit: 'second', + attributes: { + ...toMetricAttributes(attributes), + [MESSAGING_SYSTEM]: 'bullmq', + [MESSAGING_OPERATION_NAME]: 'process', + }, + }); + } +} + export class SentryBullMQMeter implements Meter { public createCounter(name: string, options?: MetricOptions): Counter { return new SentryBullMQCounter(toMessagingKey(name), toSentryUnit(options?.unit)); } public createHistogram(name: string, options?: MetricOptions): Histogram { + if (name === 'bullmq.job.duration') { + return new SentryBullMQProcessDuration(); + } + return new SentryBullMQHistogram(toMessagingKey(name), toSentryUnit(options?.unit)); } diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index 7448fbf3d5f3..781e5d693041 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -575,15 +575,19 @@ describe('SentryBullMQMeter', () => { }); describe('histogram', () => { - it('delegates record to metrics.distribution', () => { + it('records bullmq.job.duration as messaging.process.duration in seconds', () => { const telemetry = new BullMQTelemetry({ enableMetrics: true }); const histogram = telemetry.meter!.createHistogram('bullmq.job.duration', { unit: 'ms' }); - histogram.record(142.5, { 'queue.name': 'notifications' }); + histogram.record(1500, { 'bullmq.queue.name': 'notifications' }); - expect(SentryCore.metrics.distribution).toHaveBeenCalledWith('messaging.bullmq.job.duration', 142.5, { - unit: 'millisecond', - attributes: { 'queue.name': 'notifications' }, + expect(SentryCore.metrics.distribution).toHaveBeenCalledWith('messaging.process.duration', 1.5, { + unit: 'second', + attributes: { + 'messaging.destination.name': 'notifications', + 'messaging.system': 'bullmq', + 'messaging.operation.name': 'process', + }, }); }); From adfeb98bec3e84a51dc0c34ded5091c7201de898 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Sat, 24 Oct 2026 16:01:10 +0100 Subject: [PATCH 28/39] Set the error status on the process span of a failed BullMQ job --- .../src/integrations/tracing/bullmq/span.ts | 4 +++- .../test/integrations/tracing/bullmq.test.ts | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index 91ee3244b470..f9b2327689fe 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -7,7 +7,7 @@ import { MESSAGING_OPERATION_NAME, } from '@sentry/conventions/attributes'; import type { Scope } from '@sentry/core'; -import { captureException } from '@sentry/core'; +import { captureException, SPAN_STATUS_ERROR } from '@sentry/core'; import type { AttributeValue, TelemetrySpan } from './types'; const DROPPED_ATTRIBUTES = new Set([ @@ -125,6 +125,8 @@ export class SentryBullMQSpan implements TelemetrySpan { // 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') { + this._span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + const reason = attributes?.['bullmq.job.failed.reason']; captureException(new Error(String(reason || 'Unknown error')), { mechanism: { diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index 781e5d693041..605b703706ae 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -29,6 +29,7 @@ function mockOtelSpan() { addEvent: vi.fn(), addLink: vi.fn(), recordException: vi.fn(), + setStatus: vi.fn(), end: vi.fn(), spanContext: () => ({ spanId: 'abc123', traceId: 'def456', traceFlags: 1 }), }; @@ -402,12 +403,24 @@ describe('SentryBullMQSpan', () => { }); }); - it('does not capture exception for other event names', () => { - const { span } = createSpan(); + it('sets the error status on the span when the event name is "job failed"', () => { + const { span, otelSpan } = createSpan(); + + span.addEvent('job failed', { 'bullmq.job.failed.reason': 'Connection refused' }); + + expect(otelSpan.setStatus).toHaveBeenCalledWith({ + code: SentryCore.SPAN_STATUS_ERROR, + message: 'internal_error', + }); + }); + + it('does not capture an exception or set an error status for other event names', () => { + const { span, otelSpan } = createSpan(); span.addEvent('job completed', { 'bullmq.job.result': '{"ok":true}' }); expect(SentryCore.captureException).not.toHaveBeenCalled(); + expect(otelSpan.setStatus).not.toHaveBeenCalled(); }); }); From f455ddf4260da2e4cc286127dc432de8bfb5ea51 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Sat, 24 Oct 2026 16:01:10 +0100 Subject: [PATCH 29/39] Assert the names, status and all attributes of the BullMQ spans and metrics in the integration and e2e tests --- .../test-applications/node-bullmq/src/app.mjs | 19 +- .../node-bullmq/tests/bullmq.test.ts | 143 ++- .../suites/tracing/bullmq/test.ts | 899 +++++++++++++----- 3 files changed, 779 insertions(+), 282 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 af2023ab3af9..4b51a6f775f5 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 @@ -32,23 +32,8 @@ worker.on('error', err => { console.error('Worker error:', err); }); -app.get('/enqueue/success', async (req, res) => { - await testQueue.add('success-job', { data: 'test' }); - res.send('Job enqueued'); -}); - -app.get('/enqueue/fail', async (req, res) => { - await testQueue.add('fail-job', { data: 'test' }); - res.send('Job enqueued'); -}); - -app.get('/enqueue/breadcrumb-test', async (req, res) => { - await testQueue.add('breadcrumb-job', { data: 'test' }); - res.send('Job enqueued'); -}); - -app.get('/enqueue/link-test', async (req, res) => { - await testQueue.add('link-job', { data: 'test' }); +app.get('/enqueue/:jobName', async (req, res) => { + await testQueue.add(req.params.jobName, { data: 'test' }); res.send('Job enqueued'); }); 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 2adcaeadc365..cc5ecd2a50ea 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 @@ -3,51 +3,97 @@ import { getSpanOp, waitForError, waitForMetric, waitForStreamedSpan } from '@se test('Creates a queue.publish span when adding a job', async ({ baseURL }) => { const publishSpanPromise = waitForStreamedSpan('node-bullmq', span => { - return ( - getSpanOp(span) === 'queue.publish' && span.attributes['sentry.segment.name']?.value === 'GET /enqueue/success' - ); + return getSpanOp(span) === 'queue.publish' && span.attributes['messaging.bullmq.job.name']?.value === 'publish-job'; }); - await fetch(`${baseURL}/enqueue/success`); + await fetch(`${baseURL}/enqueue/publish-job`); const publishSpan = await publishSpanPromise; + expect(publishSpan.name).toBe('add test-queue.publish-job'); 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'); + expect(publishSpan.status).toBe('ok'); + expect(publishSpan.attributes['sentry.segment.name']?.value).toBe('GET /enqueue/:jobName'); + expect( + Object.fromEntries( + Object.entries(publishSpan.attributes).filter( + ([key]) => + key.startsWith('messaging.') || key.startsWith('bullmq.') || key === 'sentry.op' || key === 'sentry.origin', + ), + ), + ).toEqual({ + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'test-queue' }, + 'messaging.operation.name': { type: 'string', value: 'add' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: 'publish-job' }, + }); }); 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'; + return ( + span.is_segment && + getSpanOp(span) === 'queue.process' && + span.attributes['messaging.bullmq.job.name']?.value === 'process-job' + ); }); - await fetch(`${baseURL}/enqueue/success`); + await fetch(`${baseURL}/enqueue/process-job`); const processSpan = await processSpanPromise; - expect(processSpan.attributes['sentry.origin']?.value).toBe('auto.queue.bullmq.consumer'); - expect(processSpan.attributes['messaging.system']?.value).toBe('bullmq'); + expect(processSpan.name).toBe('process test-queue'); + expect(processSpan.status).toBe('ok'); + expect( + Object.fromEntries( + Object.entries(processSpan.attributes).filter( + ([key]) => + key.startsWith('messaging.') || key.startsWith('bullmq.') || key === 'sentry.op' || key === 'sentry.origin', + ), + ), + ).toEqual({ + '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.operation.name': { type: 'string', value: 'process' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.message.retry.count': { type: 'integer', value: 0 }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' }, + 'messaging.bullmq.job.processed_timestamp': { type: 'integer', value: expect.any(Number) }, + 'messaging.bullmq.job.attempt_finished_timestamp': { type: 'integer', value: expect.any(Number) }, + }); }); 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'; + }); + const processSpanPromise = waitForStreamedSpan('node-bullmq', span => { return ( - !event.type && - event.exception?.values?.[0]?.value === 'Test error from BullMQ processor' && - event.exception?.values?.[0]?.mechanism?.type === 'auto.queue.bullmq' + span.is_segment && + getSpanOp(span) === 'queue.process' && + span.attributes['messaging.bullmq.job.name']?.value === 'fail-job' ); }); - await fetch(`${baseURL}/enqueue/fail`); + await fetch(`${baseURL}/enqueue/fail-job`); const errorEvent = await errorEventPromise; + const processSpan = await processSpanPromise; expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.queue.bullmq', }); + expect(processSpan.status).toBe('error'); + expect(errorEvent.contexts?.trace?.trace_id).toBe(processSpan.trace_id); + expect(errorEvent.contexts?.trace?.span_id).toBe(processSpan.span_id); }); test('BullMQ processor breadcrumbs do not leak into subsequent HTTP requests', async ({ baseURL }) => { @@ -59,7 +105,7 @@ test('BullMQ processor breadcrumbs do not leak into subsequent HTTP requests', a ); }); - await fetch(`${baseURL}/enqueue/breadcrumb-test`); + await fetch(`${baseURL}/enqueue/breadcrumb-job`); await processSpanPromise; @@ -90,13 +136,14 @@ test('Links the queue.process segment span to its producer span', async ({ baseU ); }); - await fetch(`${baseURL}/enqueue/link-test`); + await fetch(`${baseURL}/enqueue/link-job`); const producerSpan = await producerSpanPromise; const consumerSpan = await consumerSpanPromise; - expect(producerSpan.attributes['sentry.segment.name']?.value).toBe('GET /enqueue/link-test'); - expect(consumerSpan.attributes['sentry.previous_trace']).toBeUndefined(); + expect(producerSpan.name).toBe('add test-queue.link-job'); + expect(consumerSpan.name).toBe('process test-queue'); + expect(consumerSpan.trace_id).not.toBe(producerSpan.trace_id); expect(consumerSpan.links).toEqual([ { trace_id: producerSpan.trace_id, @@ -109,28 +156,68 @@ test('Links the queue.process segment span to its producer span', async ({ baseU test('Emits messaging.bullmq.jobs.completed counter metric on successful job', async ({ baseURL }) => { const metricPromise = waitForMetric('node-bullmq', metric => { - return metric.name === 'messaging.bullmq.jobs.completed' && metric.type === 'counter'; + return ( + metric.name === 'messaging.bullmq.jobs.completed' && + metric.attributes?.['messaging.bullmq.job.name']?.value === 'counter-job' + ); }); - await fetch(`${baseURL}/enqueue/success`); + await fetch(`${baseURL}/enqueue/counter-job`); const metric = await metricPromise; - expect(metric.name).toBe('messaging.bullmq.jobs.completed'); - expect(metric.type).toBe('counter'); - expect(metric.value).toEqual(expect.any(Number)); + expect({ + type: metric.type, + unit: metric.unit, + value: metric.value, + attributes: Object.fromEntries( + Object.entries(metric.attributes ?? {}).filter( + ([key]) => key.startsWith('messaging.') || key.startsWith('bullmq.'), + ), + ), + }).toEqual({ + type: 'counter', + unit: 'none', + value: 1, + attributes: { + 'messaging.destination.name': { type: 'string', value: 'test-queue' }, + 'messaging.bullmq.job.name': { type: 'string', value: 'counter-job' }, + 'messaging.bullmq.job.state': { type: 'string', value: 'completed' }, + }, + }); }); test('Emits messaging.process.duration histogram metric on job completion', async ({ baseURL }) => { const metricPromise = waitForMetric('node-bullmq', metric => { - return metric.name === 'messaging.process.duration' && metric.type === 'distribution'; + return ( + metric.name === 'messaging.process.duration' && + metric.attributes?.['messaging.bullmq.job.name']?.value === 'duration-job' + ); }); - await fetch(`${baseURL}/enqueue/success`); + await fetch(`${baseURL}/enqueue/duration-job`); const metric = await metricPromise; - expect(metric.name).toBe('messaging.process.duration'); - expect(metric.type).toBe('distribution'); - expect(metric.value).toEqual(expect.any(Number)); + expect({ + type: metric.type, + unit: metric.unit, + value: metric.value, + attributes: Object.fromEntries( + Object.entries(metric.attributes ?? {}).filter( + ([key]) => key.startsWith('messaging.') || key.startsWith('bullmq.'), + ), + ), + }).toEqual({ + type: 'distribution', + unit: 'second', + value: expect.any(Number), + attributes: { + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.operation.name': { type: 'string', value: 'process' }, + 'messaging.destination.name': { type: 'string', value: 'test-queue' }, + 'messaging.bullmq.job.name': { type: 'string', value: 'duration-job' }, + 'messaging.bullmq.job.state': { type: 'string', value: 'completed' }, + }, + }); }); 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 f12fd2ee0b43..186390a6ad57 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -1,4 +1,4 @@ -import type { SerializedMetricContainer } from '@sentry/core'; +import type { Event, SerializedMetric } from '@sentry/core'; import { afterAll, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; @@ -9,281 +9,706 @@ 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-jobs'); - const publishSpans = container.items.filter( - item => item.attributes['sentry.origin']?.value === 'auto.queue.bullmq.producer', - ); + const runner = createRunner().ignore('trace_metric'); + const producerSpansPromise = runner.collectStreamedSpansUntilSegment('enqueue test-jobs'); + const consumer1SpansPromise = runner.collectStreamedSpansUntilSegment( + span => span.attributes['messaging.bullmq.job.name']?.value === 'test-job-1', + ); + const consumer2SpansPromise = runner.collectStreamedSpansUntilSegment( + span => span.attributes['messaging.bullmq.job.name']?.value === 'test-job-2', + ); - expect(producerSegment).toBeDefined(); - expect(publishSpans).toHaveLength(2); + await runner.start().completed(); - for (const publishSpan of publishSpans) { - expect(publishSpan).toEqual( - expect.objectContaining({ - parent_span_id: producerSegment!.span_id, - is_segment: false, - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'queue.publish' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'test-queue' }, - 'messaging.message.id': { type: 'string', value: expect.any(String) }, - 'messaging.operation.name': { type: 'string', value: 'add' }, - }), - }), - ); + const producerSpans = await producerSpansPromise; + const consumerSpans = [await consumer1SpansPromise, await consumer2SpansPromise]; + const producerSegment = producerSpans.find(span => span.is_segment)!; + const publishSpans = producerSpans + .filter(span => span.attributes['messaging.system']?.value === 'bullmq') + .sort((a, b) => a.start_timestamp - b.start_timestamp); + const consumerSegments = consumerSpans.map(spans => spans.find(span => span.is_segment)!); - producerSpans[publishSpan.attributes['messaging.bullmq.job.name']!.value as string] = { - trace_id: publishSpan.trace_id, - span_id: publishSpan.span_id, - messageId: publishSpan.attributes['messaging.message.id']!.value, - }; - } + expect( + publishSpans.map(span => ({ + name: span.name, + parent_span_id: span.parent_span_id, + status: span.status, + attributes: Object.fromEntries( + Object.entries(span.attributes).filter( + ([key]) => + key.startsWith('messaging.') || + key.startsWith('bullmq.') || + key === 'sentry.op' || + key === 'sentry.origin', + ), + ), + })), + ).toEqual( + ['test-job-1', 'test-job-2'].map(jobName => ({ + name: `add test-queue.${jobName}`, + parent_span_id: producerSegment.span_id, + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'test-queue' }, + 'messaging.operation.name': { type: 'string', value: 'add' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: jobName }, }, - }) - .expect({ - span: container => { - const consumerSegment = container.items.find( - item => item.is_segment && item.attributes['messaging.bullmq.job.name']?.value === 'test-job-1', - ); - - expect(consumerSegment).toEqual( - expect.objectContaining({ - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'queue.process' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.consumer' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'test-queue' }, - 'messaging.message.id': { type: 'string', value: expect.any(String) }, - 'messaging.message.retry.count': { type: 'integer', value: 0 }, - 'messaging.operation.name': { type: 'string', value: 'process' }, - }), - }), - ); - expect(consumerSegment!.attributes['sentry.previous_trace']).toBeUndefined(); - expect(consumerSegment!.attributes['bullmq.queue.name']).toBeUndefined(); + })), + ); - consumerSpans['test-job-1'] = { - trace_id: consumerSegment!.trace_id, - link: consumerSegment!.links?.[0], - messageId: consumerSegment!.attributes['messaging.message.id']!.value, - }; + expect( + consumerSpans.map(spans => + spans + .filter(span => span.attributes['messaging.system']?.value === 'bullmq') + .sort((a, b) => a.start_timestamp - b.start_timestamp) + .map(span => ({ + name: span.name, + is_segment: span.is_segment, + status: span.status, + attributes: Object.fromEntries( + Object.entries(span.attributes).filter( + ([key]) => + key.startsWith('messaging.') || + key.startsWith('bullmq.') || + key === 'sentry.op' || + key === 'sentry.origin', + ), + ), + })), + ), + ).toEqual( + ['test-job-1', 'test-job-2'].map(jobName => [ + { + name: 'process test-queue', + is_segment: true, + status: 'ok', + attributes: { + '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.operation.name': { type: 'string', value: 'process' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.message.retry.count': { type: 'integer', value: 0 }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: jobName }, + 'messaging.bullmq.job.processed_timestamp': { type: 'integer', value: expect.any(Number) }, + 'messaging.bullmq.job.attempt_finished_timestamp': { type: 'integer', value: expect.any(Number) }, + }, }, - }) - .expect({ - span: container => { - const consumerSegment = container.items.find( - item => item.is_segment && item.attributes['messaging.bullmq.job.name']?.value === 'test-job-2', - ); - - expect(consumerSegment).toEqual( - expect.objectContaining({ - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'queue.process' }, - 'messaging.destination.name': { type: 'string', value: 'test-queue' }, - }), - }), - ); - - consumerSpans['test-job-2'] = { - trace_id: consumerSegment!.trace_id, - link: consumerSegment!.links?.[0], - messageId: consumerSegment!.attributes['messaging.message.id']!.value, - }; + { + name: 'complete test-queue', + is_segment: false, + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'test-queue' }, + 'messaging.operation.name': { type: 'string', value: 'complete' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: jobName }, + }, }, - }) - .start() - .completed(); - - for (const jobName of ['test-job-1', 'test-job-2']) { - const producer = producerSpans[jobName]!; - const consumer = consumerSpans[jobName]!; - - expect(consumer.trace_id).not.toBe(producer.trace_id); - expect(consumer.messageId).toBe(producer.messageId); - expect(consumer.link).toEqual({ - trace_id: producer.trace_id, - span_id: producer.span_id, - sampled: true, - attributes: { 'sentry.link.type': { type: 'string', value: 'message_origin' } }, - }); - } + ]), + ); - expect(consumerSpans['test-job-1']!.trace_id).not.toBe(consumerSpans['test-job-2']!.trace_id); + expect(consumerSegments.map(segment => segment.attributes['messaging.message.id'])).toEqual( + publishSpans.map(span => span.attributes['messaging.message.id']), + ); + expect(consumerSegments.map(segment => segment.links)).toEqual( + publishSpans.map(span => [ + { + trace_id: span.trace_id, + span_id: span.span_id, + sampled: true, + attributes: { 'sentry.link.type': { type: 'string', value: 'message_origin' } }, + }, + ]), + ); + expect(new Set([producerSegment.trace_id, ...consumerSegments.map(segment => segment.trace_id)]).size).toBe(3); }); test('emits completion counter and duration histogram for processed jobs', { timeout: 90_000 }, async () => { + let metrics: SerializedMetric[] = []; + await createRunner() .ignore('span') .expect({ - trace_metric: (metrics: SerializedMetricContainer) => { - const items = metrics.items || []; - - expect(items).toHaveLength(4); - expect(items.filter(item => item.name === 'messaging.bullmq.jobs.completed')).toEqual([ - expect.objectContaining({ type: 'counter', unit: 'none', value: expect.any(Number) }), - expect.objectContaining({ type: 'counter', unit: 'none', value: expect.any(Number) }), - ]); - const processDuration = expect.objectContaining({ - type: 'distribution', - unit: 'second', - value: expect.any(Number), - attributes: expect.objectContaining({ - 'messaging.system': { value: 'bullmq', type: 'string' }, - 'messaging.operation.name': { value: 'process', type: 'string' }, - }), - }); - expect(items.filter(item => item.name === 'messaging.process.duration')).toEqual([ - processDuration, - processDuration, - ]); + trace_metric: container => { + metrics = container.items; }, }) .start() .completed(); + + expect( + metrics.map(metric => ({ + name: metric.name, + type: metric.type, + unit: metric.unit, + value: metric.value, + attributes: Object.fromEntries( + Object.entries(metric.attributes ?? {}).filter( + ([key]) => key.startsWith('messaging.') || key.startsWith('bullmq.'), + ), + ), + })), + ).toEqual( + ['test-job-1', 'test-job-2'].flatMap(jobName => [ + { + name: 'messaging.bullmq.jobs.completed', + type: 'counter', + unit: 'none', + value: 1, + attributes: { + 'messaging.destination.name': { type: 'string', value: 'test-queue' }, + 'messaging.bullmq.job.name': { type: 'string', value: jobName }, + 'messaging.bullmq.job.state': { type: 'string', value: 'completed' }, + }, + }, + { + name: 'messaging.process.duration', + type: 'distribution', + unit: 'second', + value: expect.any(Number), + attributes: { + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.operation.name': { type: 'string', value: 'process' }, + 'messaging.destination.name': { type: 'string', value: 'test-queue' }, + 'messaging.bullmq.job.name': { type: 'string', value: jobName }, + 'messaging.bullmq.job.state': { type: 'string', value: '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; + test( + 'captures an error for a failed job and sets the error status on its process span', + { timeout: 90_000 }, + async () => { + let errorEvent: Event | undefined; + const runner = createRunner().ignore('trace_metric'); + const consumerSpansPromise = runner.collectStreamedSpansUntilSegment('process test-failed-queue'); - 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 }, - }), - ]); + await runner + .expect({ + event: event => { + errorEvent = event; + }, + }) + .start() + .completed(); - 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', - ); + const consumerSpans = await consumerSpansPromise; + const processSpan = consumerSpans.find(span => span.is_segment)!; - expect(consumerSegment).toBeDefined(); - - consumerTraceId = consumerSegment!.trace_id; + expect( + consumerSpans + .filter(span => span.attributes['messaging.system']?.value === 'bullmq') + .sort((a, b) => a.start_timestamp - b.start_timestamp) + .map(span => ({ + name: span.name, + status: span.status, + attributes: Object.fromEntries( + Object.entries(span.attributes).filter( + ([key]) => + key.startsWith('messaging.') || + key.startsWith('bullmq.') || + key === 'sentry.op' || + key === 'sentry.origin', + ), + ), + })), + ).toEqual([ + { + name: 'process test-failed-queue', + status: 'error', + attributes: { + '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-failed-queue' }, + 'messaging.operation.name': { type: 'string', value: 'process' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.message.retry.count': { type: 'integer', value: 0 }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: 'failing-job' }, + 'messaging.bullmq.job.processed_timestamp': { type: 'integer', value: expect.any(Number) }, + 'messaging.bullmq.job.attempt_finished_timestamp': { type: 'integer', value: expect.any(Number) }, + }, }, - }) - .start() - .completed(); - - expect(errorTraceId).toBe(consumerTraceId); - }); + { + name: 'fail test-failed-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'test-failed-queue' }, + 'messaging.operation.name': { type: 'string', value: 'fail' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: 'failing-job' }, + }, + }, + ]); + expect(errorEvent?.exception?.values).toHaveLength(1); + expect(errorEvent?.exception?.values?.[0]?.type).toBe('Error'); + expect(errorEvent?.exception?.values?.[0]?.value).toBe('Job failed on purpose'); + expect(errorEvent?.exception?.values?.[0]?.mechanism).toEqual({ type: 'auto.queue.bullmq', handled: false }); + expect(errorEvent?.contexts?.trace?.trace_id).toBe(processSpan.trace_id); + expect(errorEvent?.contexts?.trace?.span_id).toBe(processSpan.span_id); + }, + ); }); 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(); + test('sends the attributes and metrics of all BullMQ operations', { timeout: 90_000 }, async () => { + let metrics: SerializedMetric[] = []; + const runner = createRunner(); + const opsSpansPromise = runner.collectStreamedSpansUntilSegment('bullmq attributes'); + const processSpansPromise = runner.collectStreamedSpansUntilSegment('process attributes-process-queue'); - await createRunner() - .unordered() + await runner .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)); - } + trace_metric: container => { + metrics = container.items; }, }) - .expect({ - span: container => { - expect( - container.items.find(item => item.is_segment && item.attributes['sentry.op']?.value === 'queue.process'), - ).toBeDefined(); + .start() + .completed(); + + const opsSpans = await opsSpansPromise; + const processSpans = await processSpansPromise; - for (const item of container.items) { - Object.keys(item.attributes).forEach(key => attributeKeys.add(key)); - } + expect( + opsSpans + .filter(span => span.attributes['messaging.system']?.value === 'bullmq') + .sort((a, b) => a.start_timestamp - b.start_timestamp) + .map(span => ({ + name: span.name, + status: span.status, + attributes: Object.fromEntries( + Object.entries(span.attributes).filter( + ([key]) => + key.startsWith('messaging.') || + key.startsWith('bullmq.') || + key === 'sentry.op' || + key === 'sentry.origin', + ), + ), + })), + ).toEqual([ + { + name: 'add attributes-process-queue.process-job', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' }, + 'messaging.operation.name': { type: 'string', value: 'add' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' }, }, - }) - .expect({ - trace_metric: (metrics: SerializedMetricContainer) => { - expect(metrics.items.find(item => item.name === 'messaging.bullmq.jobs.completed')).toBeDefined(); - expect(metrics.items.find(item => item.name === 'messaging.bullmq.queue.jobs')).toBeDefined(); + }, + { + name: 'addBulk attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'addBulk' }, + 'messaging.batch.message_count': { type: 'integer', value: 2 }, + 'messaging.bullmq.job.bulk.names': { type: 'array', value: ['bulk-job-1', 'bulk-job-2'] }, + }, + }, + { + name: 'addFlow attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'addFlow' }, + 'messaging.bullmq.flow.name': { type: 'string', value: 'flow-root' }, + }, + }, + { + name: 'addNode attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'addNode' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: 'flow-root' }, + }, + }, + { + name: 'addNode attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'addNode' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: 'flow-child' }, + }, + }, + { + name: 'add attributes-ops-queue.scheduled-job', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'add' }, + 'messaging.message.id': { + type: 'string', + value: expect.stringMatching(/^repeat:attributes-scheduler:\d+$/), + }, + 'messaging.bullmq.job.scheduler.id': { type: 'string', value: 'attributes-scheduler' }, + }, + }, + { + name: 'add attributes-ops-queue.progress-job', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'add' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: 'progress-job' }, + }, + }, + { + name: 'updateJobProgress attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'updateJobProgress' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + }, + }, + { + name: 'remove attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'remove' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + }, + }, + { + name: 'removeDeduplicationKey attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'removeDeduplicationKey' }, + 'messaging.bullmq.job.deduplication.key': { type: 'string', value: 'attributes-deduplication-id' }, + }, + }, + { + name: 'removeDebounceKey attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'removeDebounceKey' }, + 'messaging.bullmq.job.key': { type: 'string', value: 'attributes-debounce-id' }, + }, + }, + { + name: 'retryJobs attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'retryJobs' }, + }, + }, + { + name: 'promoteJobs attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'promoteJobs' }, + }, + }, + { + name: 'trimEvents attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'trimEvents' }, + 'messaging.bullmq.queue.event.max_length': { type: 'integer', value: 100 }, + }, + }, + { + // `messaging.bullmq.queue.clean.count` replaces `messaging.bullmq.job.ids` in BullMQ 6. + name: 'clean attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'clean' }, + 'messaging.bullmq.job.type': { type: 'string', value: 'completed' }, + 'messaging.bullmq.job.ids': { type: 'array', value: [] }, + 'messaging.bullmq.queue.clean.limit': { type: 'integer', value: 100 }, + 'messaging.bullmq.queue.grace': { type: 'integer', value: 0 }, + }, + }, + { + name: 'getNextJob attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'getNextJob' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, + }, + }, + { + name: 'moveStalledJobsToWait attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'moveStalledJobsToWait' }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, + 'messaging.bullmq.worker.stalled_jobs': { type: 'array', value: [] }, + }, + }, + { + name: 'extendLocks attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'extendLocks' }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, + 'messaging.bullmq.worker.jobs_to_extend_locks': { type: 'array', value: [] }, + }, + }, + { + name: 'rateLimit attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'rateLimit' }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.worker.rate_limit': { type: 'integer', value: 1 }, + }, + }, + { + name: 'pause attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'pause' }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, + 'messaging.bullmq.worker.do_not_wait_active': { type: 'boolean', value: true }, + }, + }, + { + name: 'close attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'close' }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, + 'messaging.bullmq.worker.force_close': { type: 'boolean', value: true }, + }, + }, + { + name: 'rateLimit attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'rateLimit' }, + 'messaging.bullmq.queue.rate_limit': { type: 'integer', value: 1 }, + }, + }, + { + name: 'drain attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'drain' }, + 'messaging.bullmq.queue.drain.delay': { type: 'boolean', value: true }, + }, + }, + ]); - for (const item of metrics.items) { - Object.keys(item.attributes || {}).forEach(key => attributeKeys.add(key)); - } + expect( + processSpans + .filter(span => span.attributes['messaging.system']?.value === 'bullmq') + .sort((a, b) => a.start_timestamp - b.start_timestamp) + .map(span => ({ + name: span.name, + status: span.status, + attributes: Object.fromEntries( + Object.entries(span.attributes).filter( + ([key]) => + key.startsWith('messaging.') || + key.startsWith('bullmq.') || + key === 'sentry.op' || + key === 'sentry.origin', + ), + ), + })), + ).toEqual([ + { + name: 'process attributes-process-queue', + status: 'ok', + attributes: { + '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: 'attributes-process-queue' }, + 'messaging.operation.name': { type: 'string', value: 'process' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.message.retry.count': { type: 'integer', value: 0 }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-worker' }, + 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' }, + 'messaging.bullmq.job.processed_timestamp': { type: 'integer', value: expect.any(Number) }, + 'messaging.bullmq.job.attempt_finished_timestamp': { type: 'integer', value: expect.any(Number) }, }, - }) - .start() - .completed(); + }, + { + name: 'complete attributes-process-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' }, + 'messaging.operation.name': { type: 'string', value: 'complete' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' }, + }, + }, + ]); - // `messaging.bullmq.queue.clean.count` needs BullMQ 6. - expect([...attributeKeys]).toEqual( - expect.arrayContaining([ - 'messaging.client.id', - 'messaging.bullmq.flow.name', - 'messaging.bullmq.job.attempt_finished_timestamp', - 'messaging.bullmq.job.bulk.names', - 'messaging.bullmq.job.deduplication.key', - 'messaging.bullmq.job.ids', - 'messaging.bullmq.job.key', - 'messaging.bullmq.job.name', - 'messaging.bullmq.job.processed_timestamp', - 'messaging.bullmq.job.scheduler.id', - 'messaging.bullmq.job.state', - 'messaging.bullmq.job.type', - 'messaging.bullmq.queue.clean.limit', - 'messaging.bullmq.queue.drain.delay', - 'messaging.bullmq.queue.event.max_length', - 'messaging.bullmq.queue.grace', - 'messaging.bullmq.queue.jobs.state', - 'messaging.bullmq.queue.rate_limit', - 'messaging.bullmq.worker.do_not_wait_active', - 'messaging.bullmq.worker.force_close', - 'messaging.bullmq.worker.jobs_to_extend_locks', - 'messaging.bullmq.worker.name', - 'messaging.bullmq.worker.rate_limit', - 'messaging.bullmq.worker.stalled_jobs', - ]), - ); expect( - [...attributeKeys].filter( - key => - key.startsWith('bullmq.') || - [ - 'messaging.bullmq.worker.id', - 'messaging.bullmq.job.status', - 'messaging.bullmq.job.finished.timestamp', - 'messaging.bullmq.job.processed.timestamp', - 'messaging.bullmq.queue.event.max.length', - 'messaging.bullmq.queue.rate.limit', - 'messaging.bullmq.worker.do.not.wait.active', - 'messaging.bullmq.worker.force.close', - 'messaging.bullmq.worker.jobs.to.extend.locks', - 'messaging.bullmq.worker.rate.limit', - 'messaging.bullmq.worker.stalled.jobs', - 'messaging.bullmq.job.options', - 'messaging.bullmq.job.progress', - 'messaging.bullmq.queue.options', - 'messaging.bullmq.worker.options', - ].includes(key), - ), - ).toEqual([]); + metrics.map(metric => ({ + name: metric.name, + type: metric.type, + unit: metric.unit, + value: metric.value, + attributes: Object.fromEntries( + Object.entries(metric.attributes ?? {}).filter( + ([key]) => key.startsWith('messaging.') || key.startsWith('bullmq.'), + ), + ), + })), + ).toEqual([ + { + name: 'messaging.bullmq.jobs.completed', + type: 'counter', + unit: 'none', + value: 1, + attributes: { + 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' }, + 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' }, + 'messaging.bullmq.job.state': { type: 'string', value: 'completed' }, + }, + }, + { + name: 'messaging.process.duration', + type: 'distribution', + unit: 'second', + value: expect.any(Number), + attributes: { + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.operation.name': { type: 'string', value: 'process' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' }, + 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' }, + 'messaging.bullmq.job.state': { type: 'string', value: 'completed' }, + }, + }, + ...[ + { state: 'waiting', value: 0 }, + { state: 'completed', value: 1 }, + { state: 'paused', value: 0 }, + ].map(({ state, value }) => ({ + name: 'messaging.bullmq.queue.jobs', + type: 'gauge', + unit: 'none', + value, + attributes: { + 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' }, + 'messaging.bullmq.queue.jobs.state': { type: 'string', value: state }, + }, + })), + ]); }); }); }); From 8f1bc319b64b57bc68df9a80fde89c287b18528b Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Sat, 24 Oct 2026 16:01:10 +0100 Subject: [PATCH 30/39] Use exact assertions in the BullMQ unit tests and remove the delegation-only tests --- .../test/integrations/tracing/bullmq.test.ts | 147 +++++++++--------- 1 file changed, 76 insertions(+), 71 deletions(-) diff --git a/packages/node/test/integrations/tracing/bullmq.test.ts b/packages/node/test/integrations/tracing/bullmq.test.ts index 605b703706ae..a4fec420645f 100644 --- a/packages/node/test/integrations/tracing/bullmq.test.ts +++ b/packages/node/test/integrations/tracing/bullmq.test.ts @@ -47,15 +47,13 @@ beforeEach(() => { }); describe('BullMQTelemetry', () => { - it('exposes tracer and contextManager without a meter by default', () => { + it('does not create 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', () => { + it('creates a meter when enableMetrics is true', () => { const telemetry = new BullMQTelemetry({ enableMetrics: true }); expect(telemetry.meter).toBeDefined(); @@ -73,6 +71,7 @@ describe('SentryBullMQTracer', () => { { 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' }, + { name: 'moveStalledJobsToWait myQueue', expectedOp: 'queue', expectedOrigin: 'auto.queue.bullmq' }, ])('maps "$name" to op=$expectedOp and origin=$expectedOrigin', ({ name, expectedOp, expectedOrigin }) => { const telemetry = new BullMQTelemetry(); @@ -87,6 +86,7 @@ describe('SentryBullMQTracer', () => { }, onlyIfParent: expectedOp === 'queue', }); + expect(SentryCore.startNewTrace).not.toHaveBeenCalled(); }); it('maps "process myQueue" to op=queue.process and origin=auto.queue.bullmq.consumer', () => { @@ -129,29 +129,40 @@ describe('SentryBullMQTracer', () => { telemetry.tracer.startSpan('process notifications'); - expect(SentryCore.startNewTrace).toHaveBeenCalledWith(expect.any(Function)); + expect(SentryCore.startNewTrace).toHaveBeenCalledOnce(); }); - it('starts queue.publish spans as children of the active span', () => { - const telemetry = new BullMQTelemetry(); - - telemetry.tracer.startSpan('add notifications'); - - expect(SentryCore.startNewTrace).not.toHaveBeenCalled(); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ onlyIfParent: false })); - }); - - it('only starts spans for internal operations when there is a parent span', () => { + it('adds span link to producer when context has producerSpanContext', () => { const telemetry = new BullMQTelemetry(); + const otelSpan = mockOtelSpan(); + vi.mocked(SentryCore.startInactiveSpan).mockReturnValueOnce(otelSpan as any); - telemetry.tracer.startSpan('moveStalledJobsToWait notifications'); + telemetry.tracer.startSpan('process myQueue', undefined, { + span: undefined, + scope: mockScope() as any, + producerSpanContext: { + traceId: 'aabbccddaabbccddaabbccddaabbccdd', + spanId: '1122334455667788', + sampled: true, + }, + }); - expect(SentryCore.startNewTrace).not.toHaveBeenCalled(); - expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ onlyIfParent: true })); + expect(otelSpan.addLink).toHaveBeenCalledWith({ + context: { + traceId: 'aabbccddaabbccddaabbccddaabbccdd', + spanId: '1122334455667788', + traceFlags: 1, + }, + attributes: { + 'sentry.link.type': 'message_origin', + }, + }); }); - it('adds span link to producer when context has producerSpanContext', () => { + it('adds the span link with traceFlags 0 when the producer span was not sampled', () => { const telemetry = new BullMQTelemetry(); + const otelSpan = mockOtelSpan(); + vi.mocked(SentryCore.startInactiveSpan).mockReturnValueOnce(otelSpan as any); telemetry.tracer.startSpan('process myQueue', undefined, { span: undefined, @@ -159,34 +170,33 @@ describe('SentryBullMQTracer', () => { producerSpanContext: { traceId: 'aabbccddaabbccddaabbccddaabbccdd', spanId: '1122334455667788', - sampled: true, + sampled: false, }, }); - const span = vi.mocked(SentryCore.startInactiveSpan).mock.results[0]!.value; - expect(span.addLink).toHaveBeenCalledWith({ + expect(otelSpan.addLink).toHaveBeenCalledWith({ context: { traceId: 'aabbccddaabbccddaabbccddaabbccdd', spanId: '1122334455667788', - traceFlags: 1, + traceFlags: 0, }, attributes: { 'sentry.link.type': 'message_origin', }, }); - expect(span.setAttribute).not.toHaveBeenCalled(); }); it('does not add span link when context has no producerSpanContext', () => { const telemetry = new BullMQTelemetry(); + const otelSpan = mockOtelSpan(); + vi.mocked(SentryCore.startInactiveSpan).mockReturnValueOnce(otelSpan as any); 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(); + expect(otelSpan.addLink).not.toHaveBeenCalled(); }); }); }); @@ -194,30 +204,14 @@ describe('SentryBullMQTracer', () => { 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 }; + const otelSpan = mockOtelSpan(); + const scope = mockScope(); + vi.mocked(SentryCore.startInactiveSpan).mockReturnValueOnce(otelSpan as any); + vi.mocked(SentryCore.getCurrentScope).mockReturnValueOnce(scope as any); + const span = telemetry.tracer.startSpan('process emails'); + return { span, otelSpan, scope }; } - it('delegates setAttribute to the underlying OTel span', () => { - const { span, otelSpan } = createSpan(); - - span.setAttribute('bullmq.job.name', 'welcome-email'); - - expect(otelSpan.setAttributes).toHaveBeenCalledWith({ 'messaging.bullmq.job.name': 'welcome-email' }); - }); - - it('delegates setAttributes to the underlying OTel span', () => { - const { span, otelSpan } = createSpan(); - - span.setAttributes({ 'bullmq.job.name': 'welcome-email', 'bullmq.worker.name': 'mailer' }); - - expect(otelSpan.setAttributes).toHaveBeenCalledWith({ - 'messaging.bullmq.job.name': 'welcome-email', - 'messaging.bullmq.worker.name': 'mailer', - }); - }); - it('replaces the BullMQ attributes that have a messaging convention and prefixes the others', () => { const { span, otelSpan } = createSpan(); @@ -229,10 +223,12 @@ describe('SentryBullMQSpan', () => { 'bullmq.job.bulk.count': 5, 'bullmq.worker.id': '03327d57-05cc-4528-9d24-c59bf6520039', 'bullmq.job.name': 'welcome-email', + 'bullmq.worker.name': 'mailer', }); expect(otelSpan.setAttributes).toHaveBeenCalledWith({ 'messaging.bullmq.job.name': 'welcome-email', + 'messaging.bullmq.worker.name': 'mailer', 'messaging.destination.name': 'emails', 'messaging.operation.name': 'process', 'messaging.message.id': '42', @@ -337,20 +333,12 @@ describe('SentryBullMQSpan', () => { }); }); - 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', () => { + it('passes events to the OTel span without renaming their attributes', () => { const { span, otelSpan } = createSpan(); - span.addEvent('job.completed'); + span.addEvent('job completed', { 'bullmq.job.result': '{"sent":true}' }); - expect(otelSpan.addEvent).toHaveBeenCalledWith('job.completed', undefined); + expect(otelSpan.addEvent).toHaveBeenCalledWith('job completed', { 'bullmq.job.result': '{"sent":true}' }); }); describe('recordException', () => { @@ -425,14 +413,12 @@ describe('SentryBullMQSpan', () => { }); it('returns context with span and scope from setSpanOnContext', () => { - const { span, otelSpan } = createSpan(); + const { span, otelSpan, scope } = createSpan(); const inputContext = { existingKey: 'value' }; - const result = span.setSpanOnContext(inputContext) as Record; + const result = span.setSpanOnContext(inputContext); - expect(result.existingKey).toBe('value'); - expect(result.span).toBe(otelSpan); - expect(result.scope).toBeDefined(); + expect(result).toEqual({ existingKey: 'value', span: otelSpan, scope }); }); it('delegates end to the underlying OTel span', () => { @@ -447,13 +433,14 @@ describe('SentryBullMQSpan', () => { describe('SentryBullMQContextManager', () => { it('returns current active span and scope from active()', () => { const telemetry = new BullMQTelemetry(); + const activeSpan = mockOtelSpan(); + const scope = mockScope(); + vi.mocked(SentryCore.getActiveSpan).mockReturnValueOnce(activeSpan as any); + vi.mocked(SentryCore.getCurrentScope).mockReturnValueOnce(scope as any); const context = telemetry.contextManager.active(); - expect(SentryCore.getActiveSpan).toHaveBeenCalledOnce(); - expect(SentryCore.getCurrentScope).toHaveBeenCalled(); - expect(context).toHaveProperty('span'); - expect(context).toHaveProperty('scope'); + expect(context).toEqual({ span: activeSpan, scope }); }); describe('with', () => { @@ -531,12 +518,30 @@ describe('SentryBullMQContextManager', () => { 'aabbccddaabbccddaabbccddaabbccdd-1122334455667788-1', ); - expect(result.span).toBe(fakeSpan); - expect(result.scope).toBe(context.scope); + expect(result).toEqual({ + span: fakeSpan, + scope: context.scope, + producerSpanContext: { + traceId: 'aabbccddaabbccddaabbccddaabbccdd', + spanId: '1122334455667788', + sampled: true, + }, + }); + }); + + it('marks the producer span context as not sampled when the sentry-trace header has no sampled flag', () => { + const telemetry = new BullMQTelemetry(); + const context = { span: undefined, scope: mockScope() }; + + const result = telemetry.contextManager.fromMetadata( + context as any, + 'aabbccddaabbccddaabbccddaabbccdd-1122334455667788', + ); + expect(result.producerSpanContext).toEqual({ traceId: 'aabbccddaabbccddaabbccddaabbccdd', spanId: '1122334455667788', - sampled: true, + sampled: false, }); }); From 2c3e04ee72ea32f42b3ea10fc19a5254cea478c4 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Sat, 24 Oct 2026 16:25:02 +0100 Subject: [PATCH 31/39] Skip the build script of msgpackr-extract, which bullmq installs --- pnpm-workspace.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3359607240fb..0ac22b493635 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -108,6 +108,7 @@ allowBuilds: core-js-pure: false esbuild: true mongodb-memory-server-global: true + msgpackr-extract: false nice-napi: true nx: true prisma: true From 56da3999d71fcd1decc287a44654d0757f04880e Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Sat, 24 Oct 2026 16:25:02 +0100 Subject: [PATCH 32/39] Use the BullMQ attribute constants from @sentry/conventions --- .../src/integrations/tracing/bullmq/span.ts | 71 +++++++++++++++---- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index f9b2327689fe..e27b1e79a625 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -1,6 +1,31 @@ import type { Attributes, Span } from '@opentelemetry/api'; import { MESSAGING_BATCH_MESSAGE_COUNT, + MESSAGING_BULLMQ_FLOW_NAME, + MESSAGING_BULLMQ_JOB_ATTEMPT_FINISHED_TIMESTAMP, + MESSAGING_BULLMQ_JOB_BULK_NAMES, + MESSAGING_BULLMQ_JOB_DEDUPLICATION_KEY, + MESSAGING_BULLMQ_JOB_IDS, + MESSAGING_BULLMQ_JOB_KEY, + MESSAGING_BULLMQ_JOB_NAME, + MESSAGING_BULLMQ_JOB_PROCESSED_TIMESTAMP, + MESSAGING_BULLMQ_JOB_SCHEDULER_ID, + MESSAGING_BULLMQ_JOB_STATE, + MESSAGING_BULLMQ_JOB_TYPE, + MESSAGING_BULLMQ_QUEUE_CLEAN_COUNT, + MESSAGING_BULLMQ_QUEUE_CLEAN_LIMIT, + MESSAGING_BULLMQ_QUEUE_DRAIN_DELAY, + MESSAGING_BULLMQ_QUEUE_EVENT_MAX_LENGTH, + MESSAGING_BULLMQ_QUEUE_GRACE, + MESSAGING_BULLMQ_QUEUE_JOBS_STATE, + MESSAGING_BULLMQ_QUEUE_RATE_LIMIT, + MESSAGING_BULLMQ_WORKER_DO_NOT_WAIT_ACTIVE, + MESSAGING_BULLMQ_WORKER_FORCE_CLOSE, + MESSAGING_BULLMQ_WORKER_JOBS_TO_EXTEND_LOCKS, + MESSAGING_BULLMQ_WORKER_NAME, + MESSAGING_BULLMQ_WORKER_RATE_LIMIT, + MESSAGING_BULLMQ_WORKER_STALLED_JOBS, + MESSAGING_CLIENT_ID, MESSAGING_DESTINATION_NAME, MESSAGING_MESSAGE_ID, MESSAGING_MESSAGE_RETRY_COUNT, @@ -24,24 +49,40 @@ function toOtelAttributes(attributes: Record): Attribute return attributes as Attributes; } -// BullMQ separates the words of these keys with dots. Sentry keys use dots only between sub-concepts. -const SNAKE_CASE_KEYS = new Map([ - ['bullmq.job.processed.timestamp', 'messaging.bullmq.job.processed_timestamp'], - ['bullmq.queue.event.max.length', 'messaging.bullmq.queue.event.max_length'], - ['bullmq.queue.rate.limit', 'messaging.bullmq.queue.rate_limit'], - ['bullmq.worker.do.not.wait.active', 'messaging.bullmq.worker.do_not_wait_active'], - ['bullmq.worker.force.close', 'messaging.bullmq.worker.force_close'], - ['bullmq.worker.jobs.to.extend.locks', 'messaging.bullmq.worker.jobs_to_extend_locks'], - ['bullmq.worker.rate.limit', 'messaging.bullmq.worker.rate_limit'], - ['bullmq.worker.stalled.jobs', 'messaging.bullmq.worker.stalled_jobs'], +// The BullMQ attribute keys and their `messaging.bullmq.*` keys in the Sentry conventions. Where BullMQ separates +// the words of a key with dots, the Sentry key uses snake_case. +const MESSAGING_BULLMQ_KEYS = new Map([ + ['bullmq.flow.name', MESSAGING_BULLMQ_FLOW_NAME], + ['bullmq.job.attempt_finished_timestamp', MESSAGING_BULLMQ_JOB_ATTEMPT_FINISHED_TIMESTAMP], + ['bullmq.job.bulk.names', MESSAGING_BULLMQ_JOB_BULK_NAMES], + ['bullmq.job.deduplication.key', MESSAGING_BULLMQ_JOB_DEDUPLICATION_KEY], + ['bullmq.job.ids', MESSAGING_BULLMQ_JOB_IDS], + ['bullmq.job.key', MESSAGING_BULLMQ_JOB_KEY], + ['bullmq.job.name', MESSAGING_BULLMQ_JOB_NAME], + ['bullmq.job.processed.timestamp', MESSAGING_BULLMQ_JOB_PROCESSED_TIMESTAMP], + ['bullmq.job.scheduler.id', MESSAGING_BULLMQ_JOB_SCHEDULER_ID], + ['bullmq.job.type', MESSAGING_BULLMQ_JOB_TYPE], + ['bullmq.queue.clean.count', MESSAGING_BULLMQ_QUEUE_CLEAN_COUNT], + ['bullmq.queue.clean.limit', MESSAGING_BULLMQ_QUEUE_CLEAN_LIMIT], + ['bullmq.queue.drain.delay', MESSAGING_BULLMQ_QUEUE_DRAIN_DELAY], + ['bullmq.queue.event.max.length', MESSAGING_BULLMQ_QUEUE_EVENT_MAX_LENGTH], + ['bullmq.queue.grace', MESSAGING_BULLMQ_QUEUE_GRACE], + ['bullmq.queue.jobs.state', MESSAGING_BULLMQ_QUEUE_JOBS_STATE], + ['bullmq.queue.rate.limit', MESSAGING_BULLMQ_QUEUE_RATE_LIMIT], + ['bullmq.worker.do.not.wait.active', MESSAGING_BULLMQ_WORKER_DO_NOT_WAIT_ACTIVE], + ['bullmq.worker.force.close', MESSAGING_BULLMQ_WORKER_FORCE_CLOSE], + ['bullmq.worker.jobs.to.extend.locks', MESSAGING_BULLMQ_WORKER_JOBS_TO_EXTEND_LOCKS], + ['bullmq.worker.name', MESSAGING_BULLMQ_WORKER_NAME], + ['bullmq.worker.rate.limit', MESSAGING_BULLMQ_WORKER_RATE_LIMIT], + ['bullmq.worker.stalled.jobs', MESSAGING_BULLMQ_WORKER_STALLED_JOBS], ]); /** - * Returns the Sentry key for a `bullmq.*` attribute key or metric name: `messaging.`, with snake_case for the - * keys in `SNAKE_CASE_KEYS`. Returns all other keys unchanged. + * Returns the Sentry key for a `bullmq.*` attribute key or metric name: the key from `MESSAGING_BULLMQ_KEYS`, or + * `messaging.` for the keys that are not in it, such as the metric names. Returns all other keys unchanged. */ export function toMessagingKey(key: string): string { - return SNAKE_CASE_KEYS.get(key) ?? (key.startsWith('bullmq.') ? `messaging.${key}` : key); + return MESSAGING_BULLMQ_KEYS.get(key) ?? (key.startsWith('bullmq.') ? `messaging.${key}` : key); } /** @@ -90,13 +131,13 @@ export function toSentryAttributes(attributes: Record): } if (typeof workerId === 'string') { - result['messaging.client.id'] = workerId; + result[MESSAGING_CLIENT_ID] = workerId; } // BullMQ before 5.81.2 sets `bullmq.job.status` instead of `bullmq.job.state`. const state = jobState ?? jobStatus; if (typeof state === 'string') { - result['messaging.bullmq.job.state'] = state; + result[MESSAGING_BULLMQ_JOB_STATE] = state; } return result; From 5230581d12d2f5aefd7c705f5a6cdec71d325aa9 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Sat, 24 Oct 2026 16:25:02 +0100 Subject: [PATCH 33/39] Remove the unnecessary type assertion for the BullMQ attributes --- packages/node/src/integrations/tracing/bullmq/span.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index e27b1e79a625..a284d37b87a9 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -45,10 +45,6 @@ const DROPPED_ATTRIBUTES = new Set([ 'bullmq.job.finished.timestamp', ]); -function toOtelAttributes(attributes: Record): Attributes { - return attributes as Attributes; -} - // The BullMQ attribute keys and their `messaging.bullmq.*` keys in the Sentry conventions. Where BullMQ separates // the words of a key with dots, the Sentry key uses snake_case. const MESSAGING_BULLMQ_KEYS = new Map([ @@ -103,7 +99,7 @@ export function toSentryAttributes(attributes: Record): } = attributes; const result: Attributes = {}; - for (const [key, value] of Object.entries(toOtelAttributes(rest))) { + for (const [key, value] of Object.entries(rest)) { if (!DROPPED_ATTRIBUTES.has(key)) { result[toMessagingKey(key)] = value; } @@ -161,7 +157,7 @@ export class SentryBullMQSpan implements TelemetrySpan { } public addEvent(name: string, attributes?: Record): void { - this._span.addEvent(name, attributes ? toOtelAttributes(attributes) : undefined); + this._span.addEvent(name, attributes); // 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 From 9fdf51fc8d9c77e8ad3daa6b58733bfbfe90c0c7 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Sat, 24 Oct 2026 16:25:02 +0100 Subject: [PATCH 34/39] Find the segments by name in the BullMQ integration tests --- .../node-integration-tests/suites/tracing/bullmq/test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 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 186390a6ad57..5f63dcac4d81 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -22,11 +22,13 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { const producerSpans = await producerSpansPromise; const consumerSpans = [await consumer1SpansPromise, await consumer2SpansPromise]; - const producerSegment = producerSpans.find(span => span.is_segment)!; + const producerSegment = producerSpans.find(span => span.is_segment && span.name === 'enqueue test-jobs')!; const publishSpans = producerSpans .filter(span => span.attributes['messaging.system']?.value === 'bullmq') .sort((a, b) => a.start_timestamp - b.start_timestamp); - const consumerSegments = consumerSpans.map(spans => spans.find(span => span.is_segment)!); + const consumerSegments = consumerSpans.map(spans => + spans.find(span => span.is_segment && span.name === 'process test-queue')!, + ); expect( publishSpans.map(span => ({ @@ -208,7 +210,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { .completed(); const consumerSpans = await consumerSpansPromise; - const processSpan = consumerSpans.find(span => span.is_segment)!; + const processSpan = consumerSpans.find(span => span.is_segment && span.name === 'process test-failed-queue')!; expect( consumerSpans From bfc947f86b299dc165eb9cc91e82a470ae9587f5 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Sun, 25 Oct 2026 11:50:29 +0100 Subject: [PATCH 35/39] Allow the build script of msgpackr-extract, which bullmq installs --- pnpm-workspace.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0ac22b493635..4855dbe48d3b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -108,7 +108,7 @@ allowBuilds: core-js-pure: false esbuild: true mongodb-memory-server-global: true - msgpackr-extract: false + msgpackr-extract: true nice-napi: true nx: true prisma: true From f86b98d4704fc06447a7b989df0322230fa7ed3d Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Sat, 24 Oct 2026 17:06:09 +0100 Subject: [PATCH 36/39] Use the pnpm 12 setup of the other e2e apps in node-bullmq --- .../e2e-tests/test-applications/node-bullmq/package.json | 4 ++-- .../test-applications/node-bullmq/pnpm-workspace.yaml | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-bullmq/pnpm-workspace.yaml diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/package.json b/dev-packages/e2e-tests/test-applications/node-bullmq/package.json index cbd47311eec6..b86c4b846925 100644 --- a/dev-packages/e2e-tests/test-applications/node-bullmq/package.json +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/package.json @@ -16,8 +16,8 @@ "express": "^4.21.0" }, "devDependencies": { - "@playwright/test": "~1.56.0", - "@sentry-internal/test-utils": "link:../../../test-utils" + "@playwright/test": "1.64.1", + "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/node-bullmq/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/node-bullmq/pnpm-workspace.yaml new file mode 100644 index 000000000000..0f973fea7a88 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + msgpackr-extract: true +minimumReleaseAge: 0 From 5d96d431907179831cf9e5d38cd54382dcd41509 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Sat, 24 Oct 2026 17:06:09 +0100 Subject: [PATCH 37/39] Compare all span and metric attributes in the BullMQ integration and e2e tests --- .../node-bullmq/src/instrument.mjs | 5 + .../node-bullmq/tests/bullmq.test.ts | 60 +- .../suites/tracing/bullmq/instrument.mjs | 3 + .../suites/tracing/bullmq/test.ts | 726 ++++++++++-------- 4 files changed, 436 insertions(+), 358 deletions(-) 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 index fcf7d3c23b04..5644b907f3f1 100644 --- a/dev-packages/e2e-tests/test-applications/node-bullmq/src/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/node-bullmq/src/instrument.mjs @@ -4,4 +4,9 @@ Sentry.init({ dsn: process.env.E2E_TEST_DSN, tunnel: 'http://localhost:3031/', tracesSampleRate: 1.0, + // A fixed release, so that a release from the CI environment does not change the span attributes. + release: '1.0', + // The Context integration adds machine-specific attributes to segment spans, and adds the `os.*` ones only after an + // async lookup. Without it, the tests can compare all span attributes. + integrations: integrations => integrations.filter(integration => integration.name !== 'Context'), }); 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 cc5ecd2a50ea..2508bdb5b2b9 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 @@ -13,15 +13,7 @@ test('Creates a queue.publish span when adding a job', async ({ baseURL }) => { expect(publishSpan.name).toBe('add test-queue.publish-job'); expect(publishSpan.is_segment).toBe(false); expect(publishSpan.status).toBe('ok'); - expect(publishSpan.attributes['sentry.segment.name']?.value).toBe('GET /enqueue/:jobName'); - expect( - Object.fromEntries( - Object.entries(publishSpan.attributes).filter( - ([key]) => - key.startsWith('messaging.') || key.startsWith('bullmq.') || key === 'sentry.op' || key === 'sentry.origin', - ), - ), - ).toEqual({ + expect(publishSpan.attributes).toEqual({ 'sentry.op': { type: 'string', value: 'queue.publish' }, 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, 'messaging.system': { type: 'string', value: 'bullmq' }, @@ -29,6 +21,14 @@ test('Creates a queue.publish span when adding a job', async ({ baseURL }) => { 'messaging.operation.name': { type: 'string', value: 'add' }, 'messaging.message.id': { type: 'string', value: expect.any(String) }, 'messaging.bullmq.job.name': { type: 'string', value: 'publish-job' }, + 'sentry.environment': { type: 'string', value: 'production' }, + 'sentry.is_localhost': { type: 'boolean', value: true }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.node' }, + 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, + 'sentry.segment.id': { type: 'string', value: expect.stringMatching(/^[\da-f]{16}$/) }, + 'sentry.segment.name': { type: 'string', value: 'GET /enqueue/:jobName' }, + 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, }); }); @@ -47,14 +47,7 @@ test('Creates a segment span for queue.process when processing a job', async ({ expect(processSpan.name).toBe('process test-queue'); expect(processSpan.status).toBe('ok'); - expect( - Object.fromEntries( - Object.entries(processSpan.attributes).filter( - ([key]) => - key.startsWith('messaging.') || key.startsWith('bullmq.') || key === 'sentry.op' || key === 'sentry.origin', - ), - ), - ).toEqual({ + expect(processSpan.attributes).toEqual({ 'sentry.op': { type: 'string', value: 'queue.process' }, 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.consumer' }, 'messaging.system': { type: 'string', value: 'bullmq' }, @@ -66,6 +59,17 @@ test('Creates a segment span for queue.process when processing a job', async ({ 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' }, 'messaging.bullmq.job.processed_timestamp': { type: 'integer', value: expect.any(Number) }, 'messaging.bullmq.job.attempt_finished_timestamp': { type: 'integer', value: expect.any(Number) }, + 'sentry.environment': { type: 'string', value: 'production' }, + 'sentry.is_localhost': { type: 'boolean', value: false }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sample_rate': { type: 'integer', value: 1 }, + 'sentry.sdk.integrations': { type: 'array', value: expect.any(Array) }, + 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.node' }, + 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, + 'sentry.segment.id': { type: 'string', value: processSpan.span_id }, + 'sentry.segment.name': { type: 'string', value: 'process test-queue' }, + 'sentry.segment.name.source': { type: 'string', value: 'custom' }, + 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, }); }); @@ -170,11 +174,7 @@ test('Emits messaging.bullmq.jobs.completed counter metric on successful job', a type: metric.type, unit: metric.unit, value: metric.value, - attributes: Object.fromEntries( - Object.entries(metric.attributes ?? {}).filter( - ([key]) => key.startsWith('messaging.') || key.startsWith('bullmq.'), - ), - ), + attributes: metric.attributes, }).toEqual({ type: 'counter', unit: 'none', @@ -183,6 +183,11 @@ test('Emits messaging.bullmq.jobs.completed counter metric on successful job', a 'messaging.destination.name': { type: 'string', value: 'test-queue' }, 'messaging.bullmq.job.name': { type: 'string', value: 'counter-job' }, 'messaging.bullmq.job.state': { type: 'string', value: 'completed' }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.node' }, + 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, + 'sentry.timestamp.sequence': { type: 'integer', value: expect.any(Number) }, + 'server.address': { type: 'string', value: expect.any(String) }, }, }); }); @@ -203,11 +208,7 @@ test('Emits messaging.process.duration histogram metric on job completion', asyn type: metric.type, unit: metric.unit, value: metric.value, - attributes: Object.fromEntries( - Object.entries(metric.attributes ?? {}).filter( - ([key]) => key.startsWith('messaging.') || key.startsWith('bullmq.'), - ), - ), + attributes: metric.attributes, }).toEqual({ type: 'distribution', unit: 'second', @@ -218,6 +219,11 @@ test('Emits messaging.process.duration histogram metric on job completion', asyn 'messaging.destination.name': { type: 'string', value: 'test-queue' }, 'messaging.bullmq.job.name': { type: 'string', value: 'duration-job' }, 'messaging.bullmq.job.state': { type: 'string', value: 'completed' }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: 'sentry.javascript.node' }, + 'sentry.sdk.version': { type: 'string', value: expect.any(String) }, + 'sentry.timestamp.sequence': { type: 'integer', value: expect.any(Number) }, + 'server.address': { type: 'string', value: expect.any(String) }, }, }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/bullmq/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/bullmq/instrument.mjs index 46a27dd03b74..039b3bdb5104 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/instrument.mjs @@ -6,4 +6,7 @@ Sentry.init({ release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, + // The Context integration adds machine-specific attributes to segment spans, and adds the `os.*` ones only after an + // async lookup. Without it, the tests can compare all span attributes. + integrations: integrations => integrations.filter(integration => integration.name !== 'Context'), }); 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 5f63dcac4d81..e04c2757d31f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -1,5 +1,7 @@ import type { Event, SerializedMetric } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; import { afterAll, expect } from 'vitest'; +import { EXPECTED_SDK_NAME } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { @@ -35,15 +37,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { name: span.name, parent_span_id: span.parent_span_id, status: span.status, - attributes: Object.fromEntries( - Object.entries(span.attributes).filter( - ([key]) => - key.startsWith('messaging.') || - key.startsWith('bullmq.') || - key === 'sentry.op' || - key === 'sentry.origin', - ), - ), + attributes: span.attributes, })), ).toEqual( ['test-job-1', 'test-job-2'].map(jobName => ({ @@ -58,6 +52,14 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.operation.name': { type: 'string', value: 'add' }, 'messaging.message.id': { type: 'string', value: expect.any(String) }, 'messaging.bullmq.job.name': { type: 'string', value: jobName }, + 'sentry.environment': { type: 'string', value: 'production' }, + 'sentry.is_localhost': { type: 'boolean', value: false }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: EXPECTED_SDK_NAME }, + 'sentry.sdk.version': { type: 'string', value: SDK_VERSION }, + 'sentry.segment.id': { type: 'string', value: producerSegment.span_id }, + 'sentry.segment.name': { type: 'string', value: 'enqueue test-jobs' }, + 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, }, })), ); @@ -71,19 +73,11 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { name: span.name, is_segment: span.is_segment, status: span.status, - attributes: Object.fromEntries( - Object.entries(span.attributes).filter( - ([key]) => - key.startsWith('messaging.') || - key.startsWith('bullmq.') || - key === 'sentry.op' || - key === 'sentry.origin', - ), - ), + attributes: span.attributes, })), ), ).toEqual( - ['test-job-1', 'test-job-2'].map(jobName => [ + ['test-job-1', 'test-job-2'].map((jobName, index) => [ { name: 'process test-queue', is_segment: true, @@ -100,6 +94,17 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.bullmq.job.name': { type: 'string', value: jobName }, 'messaging.bullmq.job.processed_timestamp': { type: 'integer', value: expect.any(Number) }, 'messaging.bullmq.job.attempt_finished_timestamp': { type: 'integer', value: expect.any(Number) }, + 'sentry.environment': { type: 'string', value: 'production' }, + 'sentry.is_localhost': { type: 'boolean', value: false }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: EXPECTED_SDK_NAME }, + 'sentry.sdk.version': { type: 'string', value: SDK_VERSION }, + 'sentry.segment.id': { type: 'string', value: consumerSegments[index]!.span_id }, + 'sentry.segment.name': { type: 'string', value: 'process test-queue' }, + 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, + 'sentry.sample_rate': { type: 'integer', value: 1 }, + 'sentry.sdk.integrations': { type: 'array', value: expect.any(Array) }, + 'sentry.segment.name.source': { type: 'string', value: 'custom' }, }, }, { @@ -114,6 +119,14 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.operation.name': { type: 'string', value: 'complete' }, 'messaging.message.id': { type: 'string', value: expect.any(String) }, 'messaging.bullmq.job.name': { type: 'string', value: jobName }, + 'sentry.environment': { type: 'string', value: 'production' }, + 'sentry.is_localhost': { type: 'boolean', value: false }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: EXPECTED_SDK_NAME }, + 'sentry.sdk.version': { type: 'string', value: SDK_VERSION }, + 'sentry.segment.id': { type: 'string', value: consumerSegments[index]!.span_id }, + 'sentry.segment.name': { type: 'string', value: 'process test-queue' }, + 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, }, }, ]), @@ -154,11 +167,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { type: metric.type, unit: metric.unit, value: metric.value, - attributes: Object.fromEntries( - Object.entries(metric.attributes ?? {}).filter( - ([key]) => key.startsWith('messaging.') || key.startsWith('bullmq.'), - ), - ), + attributes: metric.attributes, })), ).toEqual( ['test-job-1', 'test-job-2'].flatMap(jobName => [ @@ -171,6 +180,11 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.destination.name': { type: 'string', value: 'test-queue' }, 'messaging.bullmq.job.name': { type: 'string', value: jobName }, 'messaging.bullmq.job.state': { type: 'string', value: 'completed' }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: EXPECTED_SDK_NAME }, + 'sentry.sdk.version': { type: 'string', value: SDK_VERSION }, + 'sentry.timestamp.sequence': { type: 'integer', value: expect.any(Number) }, + 'server.address': { type: 'string', value: expect.any(String) }, }, }, { @@ -184,6 +198,11 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.destination.name': { type: 'string', value: 'test-queue' }, 'messaging.bullmq.job.name': { type: 'string', value: jobName }, 'messaging.bullmq.job.state': { type: 'string', value: 'completed' }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: EXPECTED_SDK_NAME }, + 'sentry.sdk.version': { type: 'string', value: SDK_VERSION }, + 'sentry.timestamp.sequence': { type: 'integer', value: expect.any(Number) }, + 'server.address': { type: 'string', value: expect.any(String) }, }, }, ]), @@ -219,15 +238,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { .map(span => ({ name: span.name, status: span.status, - attributes: Object.fromEntries( - Object.entries(span.attributes).filter( - ([key]) => - key.startsWith('messaging.') || - key.startsWith('bullmq.') || - key === 'sentry.op' || - key === 'sentry.origin', - ), - ), + attributes: span.attributes, })), ).toEqual([ { @@ -245,6 +256,18 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.bullmq.job.name': { type: 'string', value: 'failing-job' }, 'messaging.bullmq.job.processed_timestamp': { type: 'integer', value: expect.any(Number) }, 'messaging.bullmq.job.attempt_finished_timestamp': { type: 'integer', value: expect.any(Number) }, + 'sentry.environment': { type: 'string', value: 'production' }, + 'sentry.is_localhost': { type: 'boolean', value: false }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: EXPECTED_SDK_NAME }, + 'sentry.sdk.version': { type: 'string', value: SDK_VERSION }, + 'sentry.segment.id': { type: 'string', value: processSpan.span_id }, + 'sentry.segment.name': { type: 'string', value: 'process test-failed-queue' }, + 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, + 'sentry.sample_rate': { type: 'integer', value: 1 }, + 'sentry.sdk.integrations': { type: 'array', value: expect.any(Array) }, + 'sentry.segment.name.source': { type: 'string', value: 'custom' }, + 'sentry.status.message': { type: 'string', value: 'internal_error' }, }, }, { @@ -258,6 +281,14 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.operation.name': { type: 'string', value: 'fail' }, 'messaging.message.id': { type: 'string', value: expect.any(String) }, 'messaging.bullmq.job.name': { type: 'string', value: 'failing-job' }, + 'sentry.environment': { type: 'string', value: 'production' }, + 'sentry.is_localhost': { type: 'boolean', value: false }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: EXPECTED_SDK_NAME }, + 'sentry.sdk.version': { type: 'string', value: SDK_VERSION }, + 'sentry.segment.id': { type: 'string', value: processSpan.span_id }, + 'sentry.segment.name': { type: 'string', value: 'process test-failed-queue' }, + 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, }, }, ]); @@ -289,6 +320,10 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { const opsSpans = await opsSpansPromise; const processSpans = await processSpansPromise; + const opsSegment = opsSpans.find(span => span.is_segment && span.name === 'bullmq attributes')!; + const processSegment = processSpans.find( + span => span.is_segment && span.name === 'process attributes-process-queue', + )!; expect( opsSpans @@ -297,316 +332,323 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { .map(span => ({ name: span.name, status: span.status, - attributes: Object.fromEntries( - Object.entries(span.attributes).filter( - ([key]) => - key.startsWith('messaging.') || - key.startsWith('bullmq.') || - key === 'sentry.op' || - key === 'sentry.origin', - ), - ), + attributes: span.attributes, })), - ).toEqual([ - { - name: 'add attributes-process-queue.process-job', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue.publish' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' }, - 'messaging.operation.name': { type: 'string', value: 'add' }, - 'messaging.message.id': { type: 'string', value: expect.any(String) }, - 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' }, + ).toEqual( + [ + { + name: 'add attributes-process-queue.process-job', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' }, + 'messaging.operation.name': { type: 'string', value: 'add' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' }, + }, }, - }, - { - name: 'addBulk attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue.publish' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'addBulk' }, - 'messaging.batch.message_count': { type: 'integer', value: 2 }, - 'messaging.bullmq.job.bulk.names': { type: 'array', value: ['bulk-job-1', 'bulk-job-2'] }, + { + name: 'addBulk attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'addBulk' }, + 'messaging.batch.message_count': { type: 'integer', value: 2 }, + 'messaging.bullmq.job.bulk.names': { type: 'array', value: ['bulk-job-1', 'bulk-job-2'] }, + }, }, - }, - { - name: 'addFlow attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue.publish' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'addFlow' }, - 'messaging.bullmq.flow.name': { type: 'string', value: 'flow-root' }, + { + name: 'addFlow attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'addFlow' }, + 'messaging.bullmq.flow.name': { type: 'string', value: 'flow-root' }, + }, }, - }, - { - name: 'addNode attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'addNode' }, - 'messaging.message.id': { type: 'string', value: expect.any(String) }, - 'messaging.bullmq.job.name': { type: 'string', value: 'flow-root' }, + { + name: 'addNode attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'addNode' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: 'flow-root' }, + }, }, - }, - { - name: 'addNode attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'addNode' }, - 'messaging.message.id': { type: 'string', value: expect.any(String) }, - 'messaging.bullmq.job.name': { type: 'string', value: 'flow-child' }, + { + name: 'addNode attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'addNode' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: 'flow-child' }, + }, }, - }, - { - name: 'add attributes-ops-queue.scheduled-job', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue.publish' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'add' }, - 'messaging.message.id': { - type: 'string', - value: expect.stringMatching(/^repeat:attributes-scheduler:\d+$/), + { + name: 'add attributes-ops-queue.scheduled-job', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'add' }, + 'messaging.message.id': { + type: 'string', + value: expect.stringMatching(/^repeat:attributes-scheduler:\d+$/), + }, + 'messaging.bullmq.job.scheduler.id': { type: 'string', value: 'attributes-scheduler' }, }, - 'messaging.bullmq.job.scheduler.id': { type: 'string', value: 'attributes-scheduler' }, }, - }, - { - name: 'add attributes-ops-queue.progress-job', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue.publish' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'add' }, - 'messaging.message.id': { type: 'string', value: expect.any(String) }, - 'messaging.bullmq.job.name': { type: 'string', value: 'progress-job' }, + { + name: 'add attributes-ops-queue.progress-job', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq.producer' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'add' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.job.name': { type: 'string', value: 'progress-job' }, + }, }, - }, - { - name: 'updateJobProgress attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'updateJobProgress' }, - 'messaging.message.id': { type: 'string', value: expect.any(String) }, + { + name: 'updateJobProgress attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'updateJobProgress' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + }, }, - }, - { - name: 'remove attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'remove' }, - 'messaging.message.id': { type: 'string', value: expect.any(String) }, + { + name: 'remove attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'remove' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + }, }, - }, - { - name: 'removeDeduplicationKey attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'removeDeduplicationKey' }, - 'messaging.bullmq.job.deduplication.key': { type: 'string', value: 'attributes-deduplication-id' }, + { + name: 'removeDeduplicationKey attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'removeDeduplicationKey' }, + 'messaging.bullmq.job.deduplication.key': { type: 'string', value: 'attributes-deduplication-id' }, + }, }, - }, - { - name: 'removeDebounceKey attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'removeDebounceKey' }, - 'messaging.bullmq.job.key': { type: 'string', value: 'attributes-debounce-id' }, + { + name: 'removeDebounceKey attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'removeDebounceKey' }, + 'messaging.bullmq.job.key': { type: 'string', value: 'attributes-debounce-id' }, + }, }, - }, - { - name: 'retryJobs attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'retryJobs' }, + { + name: 'retryJobs attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'retryJobs' }, + }, }, - }, - { - name: 'promoteJobs attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'promoteJobs' }, + { + name: 'promoteJobs attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'promoteJobs' }, + }, }, - }, - { - name: 'trimEvents attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'trimEvents' }, - 'messaging.bullmq.queue.event.max_length': { type: 'integer', value: 100 }, + { + name: 'trimEvents attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'trimEvents' }, + 'messaging.bullmq.queue.event.max_length': { type: 'integer', value: 100 }, + }, }, - }, - { - // `messaging.bullmq.queue.clean.count` replaces `messaging.bullmq.job.ids` in BullMQ 6. - name: 'clean attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'clean' }, - 'messaging.bullmq.job.type': { type: 'string', value: 'completed' }, - 'messaging.bullmq.job.ids': { type: 'array', value: [] }, - 'messaging.bullmq.queue.clean.limit': { type: 'integer', value: 100 }, - 'messaging.bullmq.queue.grace': { type: 'integer', value: 0 }, + { + // `messaging.bullmq.queue.clean.count` replaces `messaging.bullmq.job.ids` in BullMQ 6. + name: 'clean attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'clean' }, + 'messaging.bullmq.job.type': { type: 'string', value: 'completed' }, + 'messaging.bullmq.job.ids': { type: 'array', value: [] }, + 'messaging.bullmq.queue.clean.limit': { type: 'integer', value: 100 }, + 'messaging.bullmq.queue.grace': { type: 'integer', value: 0 }, + }, }, - }, - { - name: 'getNextJob attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'getNextJob' }, - 'messaging.message.id': { type: 'string', value: expect.any(String) }, - 'messaging.client.id': { type: 'string', value: expect.any(String) }, - 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, + { + name: 'getNextJob attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'getNextJob' }, + 'messaging.message.id': { type: 'string', value: expect.any(String) }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, + }, }, - }, - { - name: 'moveStalledJobsToWait attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'moveStalledJobsToWait' }, - 'messaging.client.id': { type: 'string', value: expect.any(String) }, - 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, - 'messaging.bullmq.worker.stalled_jobs': { type: 'array', value: [] }, + { + name: 'moveStalledJobsToWait attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'moveStalledJobsToWait' }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, + 'messaging.bullmq.worker.stalled_jobs': { type: 'array', value: [] }, + }, }, - }, - { - name: 'extendLocks attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'extendLocks' }, - 'messaging.client.id': { type: 'string', value: expect.any(String) }, - 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, - 'messaging.bullmq.worker.jobs_to_extend_locks': { type: 'array', value: [] }, + { + name: 'extendLocks attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'extendLocks' }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, + 'messaging.bullmq.worker.jobs_to_extend_locks': { type: 'array', value: [] }, + }, }, - }, - { - name: 'rateLimit attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'rateLimit' }, - 'messaging.client.id': { type: 'string', value: expect.any(String) }, - 'messaging.bullmq.worker.rate_limit': { type: 'integer', value: 1 }, + { + name: 'rateLimit attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'rateLimit' }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.worker.rate_limit': { type: 'integer', value: 1 }, + }, }, - }, - { - name: 'pause attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'pause' }, - 'messaging.client.id': { type: 'string', value: expect.any(String) }, - 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, - 'messaging.bullmq.worker.do_not_wait_active': { type: 'boolean', value: true }, + { + name: 'pause attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'pause' }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, + 'messaging.bullmq.worker.do_not_wait_active': { type: 'boolean', value: true }, + }, }, - }, - { - name: 'close attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'close' }, - 'messaging.client.id': { type: 'string', value: expect.any(String) }, - 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, - 'messaging.bullmq.worker.force_close': { type: 'boolean', value: true }, + { + name: 'close attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'close' }, + 'messaging.client.id': { type: 'string', value: expect.any(String) }, + 'messaging.bullmq.worker.name': { type: 'string', value: 'attributes-ops-worker' }, + 'messaging.bullmq.worker.force_close': { type: 'boolean', value: true }, + }, }, - }, - { - name: 'rateLimit attributes-ops-queue', - status: 'ok', - attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'rateLimit' }, - 'messaging.bullmq.queue.rate_limit': { type: 'integer', value: 1 }, + { + name: 'rateLimit attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'rateLimit' }, + 'messaging.bullmq.queue.rate_limit': { type: 'integer', value: 1 }, + }, }, - }, - { - name: 'drain attributes-ops-queue', - status: 'ok', + { + name: 'drain attributes-ops-queue', + status: 'ok', + attributes: { + 'sentry.op': { type: 'string', value: 'queue' }, + 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, + 'messaging.system': { type: 'string', value: 'bullmq' }, + 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, + 'messaging.operation.name': { type: 'string', value: 'drain' }, + 'messaging.bullmq.queue.drain.delay': { type: 'boolean', value: true }, + }, + }, + ].map(span => ({ + ...span, attributes: { - 'sentry.op': { type: 'string', value: 'queue' }, - 'sentry.origin': { type: 'string', value: 'auto.queue.bullmq' }, - 'messaging.system': { type: 'string', value: 'bullmq' }, - 'messaging.destination.name': { type: 'string', value: 'attributes-ops-queue' }, - 'messaging.operation.name': { type: 'string', value: 'drain' }, - 'messaging.bullmq.queue.drain.delay': { type: 'boolean', value: true }, + ...span.attributes, + 'sentry.environment': { type: 'string', value: 'production' }, + 'sentry.is_localhost': { type: 'boolean', value: false }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: EXPECTED_SDK_NAME }, + 'sentry.sdk.version': { type: 'string', value: SDK_VERSION }, + 'sentry.segment.id': { type: 'string', value: opsSegment.span_id }, + 'sentry.segment.name': { type: 'string', value: 'bullmq attributes' }, + 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, }, - }, - ]); + })), + ); expect( processSpans @@ -615,15 +657,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { .map(span => ({ name: span.name, status: span.status, - attributes: Object.fromEntries( - Object.entries(span.attributes).filter( - ([key]) => - key.startsWith('messaging.') || - key.startsWith('bullmq.') || - key === 'sentry.op' || - key === 'sentry.origin', - ), - ), + attributes: span.attributes, })), ).toEqual([ { @@ -642,6 +676,17 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' }, 'messaging.bullmq.job.processed_timestamp': { type: 'integer', value: expect.any(Number) }, 'messaging.bullmq.job.attempt_finished_timestamp': { type: 'integer', value: expect.any(Number) }, + 'sentry.environment': { type: 'string', value: 'production' }, + 'sentry.is_localhost': { type: 'boolean', value: false }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: EXPECTED_SDK_NAME }, + 'sentry.sdk.version': { type: 'string', value: SDK_VERSION }, + 'sentry.segment.id': { type: 'string', value: processSegment.span_id }, + 'sentry.segment.name': { type: 'string', value: 'process attributes-process-queue' }, + 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, + 'sentry.sample_rate': { type: 'integer', value: 1 }, + 'sentry.sdk.integrations': { type: 'array', value: expect.any(Array) }, + 'sentry.segment.name.source': { type: 'string', value: 'custom' }, }, }, { @@ -655,6 +700,14 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.operation.name': { type: 'string', value: 'complete' }, 'messaging.message.id': { type: 'string', value: expect.any(String) }, 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' }, + 'sentry.environment': { type: 'string', value: 'production' }, + 'sentry.is_localhost': { type: 'boolean', value: false }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: EXPECTED_SDK_NAME }, + 'sentry.sdk.version': { type: 'string', value: SDK_VERSION }, + 'sentry.segment.id': { type: 'string', value: processSegment.span_id }, + 'sentry.segment.name': { type: 'string', value: 'process attributes-process-queue' }, + 'sentry.trace_lifecycle': { type: 'string', value: 'stream' }, }, }, ]); @@ -665,11 +718,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { type: metric.type, unit: metric.unit, value: metric.value, - attributes: Object.fromEntries( - Object.entries(metric.attributes ?? {}).filter( - ([key]) => key.startsWith('messaging.') || key.startsWith('bullmq.'), - ), - ), + attributes: metric.attributes, })), ).toEqual([ { @@ -681,6 +730,11 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' }, 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' }, 'messaging.bullmq.job.state': { type: 'string', value: 'completed' }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: EXPECTED_SDK_NAME }, + 'sentry.sdk.version': { type: 'string', value: SDK_VERSION }, + 'sentry.timestamp.sequence': { type: 'integer', value: expect.any(Number) }, + 'server.address': { type: 'string', value: expect.any(String) }, }, }, { @@ -694,6 +748,11 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' }, 'messaging.bullmq.job.name': { type: 'string', value: 'process-job' }, 'messaging.bullmq.job.state': { type: 'string', value: 'completed' }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: EXPECTED_SDK_NAME }, + 'sentry.sdk.version': { type: 'string', value: SDK_VERSION }, + 'sentry.timestamp.sequence': { type: 'integer', value: expect.any(Number) }, + 'server.address': { type: 'string', value: expect.any(String) }, }, }, ...[ @@ -708,6 +767,11 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { attributes: { 'messaging.destination.name': { type: 'string', value: 'attributes-process-queue' }, 'messaging.bullmq.queue.jobs.state': { type: 'string', value: state }, + 'sentry.release': { type: 'string', value: '1.0' }, + 'sentry.sdk.name': { type: 'string', value: EXPECTED_SDK_NAME }, + 'sentry.sdk.version': { type: 'string', value: SDK_VERSION }, + 'sentry.timestamp.sequence': { type: 'integer', value: expect.any(Number) }, + 'server.address': { type: 'string', value: expect.any(String) }, }, })), ]); From 13b1e10d46be5c1260988686108e6a9224044c1b Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Sun, 25 Oct 2026 12:28:11 +0100 Subject: [PATCH 38/39] Send failed BullMQ jobs without the synthetic stack trace --- .../node-integration-tests/suites/tracing/bullmq/test.ts | 1 + packages/node/src/integrations/tracing/bullmq/span.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) 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 e04c2757d31f..49871dcc9786 100644 --- a/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/bullmq/test.ts @@ -296,6 +296,7 @@ describeWithDockerCompose('bullmq', { workingDirectory: [__dirname] }, () => { expect(errorEvent?.exception?.values?.[0]?.type).toBe('Error'); expect(errorEvent?.exception?.values?.[0]?.value).toBe('Job failed on purpose'); expect(errorEvent?.exception?.values?.[0]?.mechanism).toEqual({ type: 'auto.queue.bullmq', handled: false }); + expect(errorEvent?.exception?.values?.[0]?.stacktrace).toBeUndefined(); expect(errorEvent?.contexts?.trace?.trace_id).toBe(processSpan.trace_id); expect(errorEvent?.contexts?.trace?.span_id).toBe(processSpan.span_id); }, diff --git a/packages/node/src/integrations/tracing/bullmq/span.ts b/packages/node/src/integrations/tracing/bullmq/span.ts index a284d37b87a9..10ec13bee4c2 100644 --- a/packages/node/src/integrations/tracing/bullmq/span.ts +++ b/packages/node/src/integrations/tracing/bullmq/span.ts @@ -165,7 +165,11 @@ export class SentryBullMQSpan implements TelemetrySpan { this._span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); const reason = attributes?.['bullmq.job.failed.reason']; - captureException(new Error(String(reason || 'Unknown error')), { + const error = new Error(String(reason || 'Unknown error')); + // BullMQ passes only the error message (https://github.com/taskforcesh/bullmq/issues/4902), so this stack has only + // BullMQ and SDK frames, the same for every job. Without it, Sentry groups the errors by their message. + error.stack = undefined; + captureException(error, { mechanism: { handled: false, type: 'auto.queue.bullmq', From 894bd93ecf56a95863877f0d91815c035829238b Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Sun, 25 Oct 2026 12:38:32 +0100 Subject: [PATCH 39/39] chore: Dedupe lockfile --- pnpm-lock.yaml | 104 +++++++++++++++++++++---------------------------- 1 file changed, 44 insertions(+), 60 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8cd011a5fe2..675cd618f3dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -741,7 +741,7 @@ importers: version: 5.10.1(supports-color@10.0.0) ioredis-5: specifier: npm:ioredis@^5.11.0 - version: ioredis@5.11.0(supports-color@10.0.0) + version: ioredis@5.11.1(supports-color@10.0.0) ioredis-6: specifier: npm:ioredis@^6.0.0 version: ioredis@6.0.0(supports-color@10.0.0) @@ -1721,13 +1721,13 @@ importers: version: 2.0.1-rc.22(crossws@0.4.10(srvx@0.11.22)) nitro: specifier: ^3.0.260415-beta - version: 3.0.260415-beta(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(lru-cache@11.4.0)(miniflare@4.20260426.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(mysql2@3.23.3(@types/node@20.19.43))(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) + version: 3.0.260415-beta(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(lru-cache@11.4.0)(miniflare@4.20260426.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(mysql2@3.23.3(@types/node@20.19.43))(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) srvx: specifier: ^0.11.22 version: 0.11.22 unstorage: specifier: ^2.0.0-alpha.7 - version: 2.0.0-alpha.7(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0))(lru-cache@11.4.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(ofetch@1.5.1) + version: 2.0.0-alpha.7(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.1(supports-color@10.0.0))(lru-cache@11.4.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(ofetch@1.5.1) packages/node: dependencies: @@ -1817,7 +1817,7 @@ importers: version: 4.20260519.1 '@nuxt/nitro-server': specifier: ^3.21.11 - version: 3.21.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(encoding@0.1.13)(esbuild@0.28.1)(ioredis@5.11.0(supports-color@10.0.0))(magicast@0.5.4)(mysql2@3.23.3(@types/node@20.19.43))(nuxt@3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c))(oxc-parser@0.152.0)(rolldown@1.2.11)(rollup@4.62.3)(srvx@0.11.22)(supports-color@10.0.0)(typescript@7.0.2)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) + version: 3.21.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(encoding@0.1.13)(esbuild@0.28.1)(ioredis@5.11.1(supports-color@10.0.0))(magicast@0.5.4)(mysql2@3.23.3(@types/node@20.19.43))(nuxt@3.21.11(955174819940f662d7743f134f0d89c6))(oxc-parser@0.152.0)(rolldown@1.2.11)(rollup@4.62.3)(srvx@0.11.22)(supports-color@10.0.0)(typescript@7.0.2)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) '@nuxt/schema': specifier: ^3.21.11 version: 3.21.11 @@ -1832,7 +1832,7 @@ importers: version: 1.15.11 nitro: specifier: ^3.0.260311-beta - version: 3.0.260311-beta(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(lru-cache@11.4.0)(miniflare@4.20260426.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(mysql2@3.23.3(@types/node@20.19.43))(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) + version: 3.0.260311-beta(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(lru-cache@11.4.0)(miniflare@4.20260426.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(mysql2@3.23.3(@types/node@20.19.43))(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) nitropack: specifier: ^2.13.4 version: 2.13.4(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(encoding@0.1.13)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(srvx@0.11.22)(supports-color@10.0.0)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) @@ -1841,10 +1841,10 @@ importers: version: 3.25.1 nuxt: specifier: 3.21.11 - version: 3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c) + version: 3.21.11(955174819940f662d7743f134f0d89c6) unstorage: specifier: ^1.17.5 - version: 1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0)) + version: 1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.1(supports-color@10.0.0)) vite: specifier: ^8.3.1 version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) @@ -2309,7 +2309,7 @@ importers: version: 1.0.0(solid-js@1.9.11) '@solidjs/start': specifier: ^1.3.2 - version: 1.3.2(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(supports-color@10.0.0)(vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0))(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) + version: 1.3.2(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(supports-color@10.0.0)(vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0))(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) '@solidjs/testing-library': specifier: 0.8.5 version: 0.8.5(@solidjs/router@1.0.0(solid-js@1.9.11))(solid-js@1.9.11) @@ -2330,7 +2330,7 @@ importers: version: 1.9.11 vinxi: specifier: ^0.5.11 - version: 0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0) + version: 0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0) vite: specifier: ^8.3.1 version: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) @@ -13217,10 +13217,6 @@ packages: resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} - ioredis@5.11.0: - resolution: {integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==} - engines: {node: '>=12.22.0'} - ioredis@5.11.1: resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} engines: {node: '>=12.22.0'} @@ -24564,7 +24560,7 @@ snapshots: pkg-types: 2.3.1 semver: 7.8.5 - '@nuxt/devtools@3.4.1(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@nuxt/schema@3.21.11)(confbox@0.2.4)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.143.0)(rolldown@1.2.11)(supports-color@10.0.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.11)(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)))(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.44(typescript@7.0.2))': + '@nuxt/devtools@3.4.1(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@nuxt/schema@3.21.11)(confbox@0.2.4)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.143.0)(rolldown@1.2.11)(supports-color@10.0.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.11)(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)))(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.44(typescript@7.0.2))': dependencies: '@nuxt/devtools-kit': 3.4.1(@nuxt/schema@3.21.11)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.143.0)(rolldown@1.2.11)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.11)(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)))(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) '@nuxt/devtools-wizard': 3.4.1 @@ -24594,7 +24590,7 @@ snapshots: sirv: 3.0.2 structured-clone-es: 2.0.1 tinyglobby: 0.2.17 - unstorage: 1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0)) + unstorage: 1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.1(supports-color@10.0.0)) vite: 8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) vite-plugin-inspect: 11.4.1(@nuxt/kit@4.6.0(@nuxt/schema@3.21.11)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.143.0)(rolldown@1.2.11)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.11)(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))))(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) vite-plugin-vue-tracer: 1.5.0(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.44(typescript@7.0.2)) @@ -24760,7 +24756,7 @@ snapshots: - magic-string - unplugin - '@nuxt/nitro-server@3.21.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(encoding@0.1.13)(esbuild@0.28.1)(ioredis@5.11.0(supports-color@10.0.0))(magicast@0.5.4)(mysql2@3.23.3(@types/node@20.19.43))(nuxt@3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c))(oxc-parser@0.143.0)(rolldown@1.2.11)(rollup@4.62.3)(srvx@0.11.22)(supports-color@10.0.0)(typescript@7.0.2)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))': + '@nuxt/nitro-server@3.21.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(encoding@0.1.13)(esbuild@0.28.1)(ioredis@5.11.1(supports-color@10.0.0))(magicast@0.5.4)(mysql2@3.23.3(@types/node@20.19.43))(nuxt@3.21.11(955174819940f662d7743f134f0d89c6))(oxc-parser@0.143.0)(rolldown@1.2.11)(rollup@4.62.3)(srvx@0.11.22)(supports-color@10.0.0)(typescript@7.0.2)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 3.21.11(magicast@0.5.4) @@ -24778,7 +24774,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.4(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(encoding@0.1.13)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.143.0)(rolldown@1.2.11)(srvx@0.11.22)(supports-color@10.0.0)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) - nuxt: 3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c) + nuxt: 3.21.11(955174819940f662d7743f134f0d89c6) ohash: 2.0.11 pathe: 2.0.3 pkg-types: 2.3.1 @@ -24786,7 +24782,7 @@ snapshots: std-env: 4.3.0 ufo: 1.6.4 unctx: 2.5.0 - unstorage: 1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0)) + unstorage: 1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.1(supports-color@10.0.0)) vue: 3.5.44(typescript@7.0.2) vue-bundle-renderer: 2.3.2 vue-devtools-stub: 0.1.0 @@ -24834,7 +24830,7 @@ snapshots: - webpack - xml2js - '@nuxt/nitro-server@3.21.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(encoding@0.1.13)(esbuild@0.28.1)(ioredis@5.11.0(supports-color@10.0.0))(magicast@0.5.4)(mysql2@3.23.3(@types/node@20.19.43))(nuxt@3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c))(oxc-parser@0.152.0)(rolldown@1.2.11)(rollup@4.62.3)(srvx@0.11.22)(supports-color@10.0.0)(typescript@7.0.2)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))': + '@nuxt/nitro-server@3.21.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(encoding@0.1.13)(esbuild@0.28.1)(ioredis@5.11.1(supports-color@10.0.0))(magicast@0.5.4)(mysql2@3.23.3(@types/node@20.19.43))(nuxt@3.21.11(955174819940f662d7743f134f0d89c6))(oxc-parser@0.152.0)(rolldown@1.2.11)(rollup@4.62.3)(srvx@0.11.22)(supports-color@10.0.0)(typescript@7.0.2)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 3.21.11(magicast@0.5.4) @@ -24852,7 +24848,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.4(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(encoding@0.1.13)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(srvx@0.11.22)(supports-color@10.0.0)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) - nuxt: 3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c) + nuxt: 3.21.11(955174819940f662d7743f134f0d89c6) ohash: 2.0.11 pathe: 2.0.3 pkg-types: 2.3.1 @@ -24860,7 +24856,7 @@ snapshots: std-env: 4.3.0 ufo: 1.6.4 unctx: 2.5.0 - unstorage: 1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0)) + unstorage: 1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.1(supports-color@10.0.0)) vue: 3.5.44(typescript@7.0.2) vue-bundle-renderer: 2.3.2 vue-devtools-stub: 0.1.0 @@ -24929,7 +24925,7 @@ snapshots: rc9: 3.1.0 std-env: 4.3.0 - '@nuxt/vite-builder@3.21.11(@types/node@20.19.43)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(magicast@0.5.4)(meow@13.2.0)(nuxt@3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c))(optionator@0.9.4)(oxlint@1.89.0(oxlint-tsgolint@7.0.2004))(rolldown@1.2.11)(rollup-plugin-visualizer@6.0.11(rolldown@1.2.11)(rollup@4.62.3))(rollup@4.62.3)(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(typescript@7.0.2)(vue@3.5.44(typescript@7.0.2))(yaml@2.9.0)': + '@nuxt/vite-builder@3.21.11(@types/node@20.19.43)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(magicast@0.5.4)(meow@13.2.0)(nuxt@3.21.11(955174819940f662d7743f134f0d89c6))(optionator@0.9.4)(oxlint@1.89.0(oxlint-tsgolint@7.0.2004))(rolldown@1.2.11)(rollup-plugin-visualizer@6.0.11(rolldown@1.2.11)(rollup@4.62.3))(rollup@4.62.3)(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(typescript@7.0.2)(vue@3.5.44(typescript@7.0.2))(yaml@2.9.0)': dependencies: '@nuxt/kit': 3.21.11(magicast@0.5.4) '@rollup/plugin-replace': 6.0.3(rollup@4.62.3) @@ -24949,7 +24945,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c) + nuxt: 3.21.11(955174819940f662d7743f134f0d89c6) nypm: 0.6.9 ohash: 2.0.11 pathe: 2.0.3 @@ -26737,11 +26733,11 @@ snapshots: dependencies: solid-js: 1.9.11 - '@solidjs/start@1.3.2(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(supports-color@10.0.0)(vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0))(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))': + '@solidjs/start@1.3.2(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(supports-color@10.0.0)(vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0))(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))': dependencies: '@tanstack/server-functions-plugin': 1.121.21(supports-color@10.0.0)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0)) - '@vinxi/server-components': 0.5.1(vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0)) + '@vinxi/server-components': 0.5.1(vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0)) cookie-es: 2.0.1 defu: 6.1.7 error-stack-parser: 2.1.4 @@ -26753,7 +26749,7 @@ snapshots: source-map-js: 1.2.2 terracotta: 1.0.5(solid-js@1.9.11) tinyglobby: 0.2.17 - vinxi: 0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0) + vinxi: 0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0) vite-plugin-solid: 2.11.14(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(supports-color@10.0.0)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)) transitivePeerDependencies: - '@testing-library/jest-dom' @@ -27694,7 +27690,7 @@ snapshots: untun: 0.1.3 uqr: 0.1.3 - '@vinxi/plugin-directives@0.5.1(vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0))': + '@vinxi/plugin-directives@0.5.1(vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0))': dependencies: '@babel/parser': 7.29.9 acorn: 8.18.0 @@ -27705,18 +27701,18 @@ snapshots: magicast: 0.2.11 recast: 0.23.11 tslib: 2.8.1 - vinxi: 0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0) + vinxi: 0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0) - '@vinxi/server-components@0.5.1(vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0))': + '@vinxi/server-components@0.5.1(vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0))': dependencies: - '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0)) + '@vinxi/plugin-directives': 0.5.1(vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0)) acorn: 8.18.0 acorn-loose: 8.4.0 acorn-typescript: 1.4.13(acorn@8.18.0) astring: 1.9.0 magicast: 0.2.11 recast: 0.23.11 - vinxi: 0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0) + vinxi: 0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0) '@vitejs/plugin-vue-jsx@5.1.6(supports-color@10.0.0)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.44(typescript@7.0.2))': dependencies: @@ -32680,18 +32676,6 @@ snapshots: transitivePeerDependencies: - supports-color - ioredis@5.11.0(supports-color@10.0.0): - dependencies: - '@ioredis/commands': 1.10.0 - cluster-key-slot: 1.1.1 - debug: 4.4.3(supports-color@10.0.0) - denque: 2.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - ioredis@5.11.1(supports-color@10.0.0): dependencies: '@ioredis/commands': 1.10.0 @@ -34664,7 +34648,7 @@ snapshots: node-gyp-build: 4.6.0 optional: true - nitro@3.0.260311-beta(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(lru-cache@11.4.0)(miniflare@4.20260426.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(mysql2@3.23.3(@types/node@20.19.43))(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)): + nitro@3.0.260311-beta(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(lru-cache@11.4.0)(miniflare@4.20260426.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(mysql2@3.23.3(@types/node@20.19.43))(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)): dependencies: consola: 3.4.2 crossws: 0.4.10(srvx@0.11.22) @@ -34679,7 +34663,7 @@ snapshots: rolldown: 1.2.11 srvx: 0.11.22 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0))(lru-cache@11.4.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.1(supports-color@10.0.0))(lru-cache@11.4.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.4.2 giget: 3.3.1 @@ -34717,7 +34701,7 @@ snapshots: - sqlite3 - uploadthing - nitro@3.0.260415-beta(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(lru-cache@11.4.0)(miniflare@4.20260426.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(mysql2@3.23.3(@types/node@20.19.43))(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)): + nitro@3.0.260415-beta(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(lru-cache@11.4.0)(miniflare@4.20260426.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(mysql2@3.23.3(@types/node@20.19.43))(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0)): dependencies: consola: 3.4.2 crossws: 0.4.10(srvx@0.11.22) @@ -34732,7 +34716,7 @@ snapshots: rolldown: 1.2.11 srvx: 0.11.22 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0))(lru-cache@11.4.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.1(supports-color@10.0.0))(lru-cache@11.4.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.4.2 giget: 3.3.1 @@ -35305,16 +35289,16 @@ snapshots: nuxi@3.25.1: {} - nuxt@3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c): + nuxt@3.21.11(955174819940f662d7743f134f0d89c6): dependencies: '@dxup/nuxt': 0.5.6(@nuxt/schema@3.21.11)(confbox@0.2.4)(dotenv@17.4.2)(esbuild@0.28.1)(giget@3.3.1)(jiti@2.7.0)(mlly@1.8.2)(oxc-parser@0.143.0)(rolldown@1.2.11)(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) '@nuxt/cli': 3.37.0(@nuxt/schema@3.21.11)(@parcel/watcher@2.5.6)(cac@6.7.14)(commander@14.0.3)(magicast@0.5.4)(supports-color@10.0.0) - '@nuxt/devtools': 3.4.1(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@nuxt/schema@3.21.11)(confbox@0.2.4)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.143.0)(rolldown@1.2.11)(supports-color@10.0.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.11)(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)))(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.44(typescript@7.0.2)) + '@nuxt/devtools': 3.4.1(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@nuxt/schema@3.21.11)(confbox@0.2.4)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(dotenv@17.4.2)(giget@3.3.1)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(magic-string@0.30.21)(mlly@1.8.2)(oxc-parser@0.143.0)(rolldown@1.2.11)(supports-color@10.0.0)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.11)(rollup@4.62.3)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)))(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.44(typescript@7.0.2)) '@nuxt/kit': 3.21.11(magicast@0.5.4) - '@nuxt/nitro-server': 3.21.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(encoding@0.1.13)(esbuild@0.28.1)(ioredis@5.11.0(supports-color@10.0.0))(magicast@0.5.4)(mysql2@3.23.3(@types/node@20.19.43))(nuxt@3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c))(oxc-parser@0.143.0)(rolldown@1.2.11)(rollup@4.62.3)(srvx@0.11.22)(supports-color@10.0.0)(typescript@7.0.2)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) + '@nuxt/nitro-server': 3.21.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(encoding@0.1.13)(esbuild@0.28.1)(ioredis@5.11.1(supports-color@10.0.0))(magicast@0.5.4)(mysql2@3.23.3(@types/node@20.19.43))(nuxt@3.21.11(955174819940f662d7743f134f0d89c6))(oxc-parser@0.143.0)(rolldown@1.2.11)(rollup@4.62.3)(srvx@0.11.22)(supports-color@10.0.0)(typescript@7.0.2)(vite@8.3.1(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0))(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3)) '@nuxt/schema': 3.21.11 '@nuxt/telemetry': 2.8.0(@nuxt/kit@3.21.11(magicast@0.5.4)) - '@nuxt/vite-builder': 3.21.11(@types/node@20.19.43)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(magicast@0.5.4)(meow@13.2.0)(nuxt@3.21.11(0dc0d2df1a20b551c0e4dd687b74c07c))(optionator@0.9.4)(oxlint@1.89.0(oxlint-tsgolint@7.0.2004))(rolldown@1.2.11)(rollup-plugin-visualizer@6.0.11(rolldown@1.2.11)(rollup@4.62.3))(rollup@4.62.3)(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(typescript@7.0.2)(vue@3.5.44(typescript@7.0.2))(yaml@2.9.0) + '@nuxt/vite-builder': 3.21.11(@types/node@20.19.43)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(magicast@0.5.4)(meow@13.2.0)(nuxt@3.21.11(955174819940f662d7743f134f0d89c6))(optionator@0.9.4)(oxlint@1.89.0(oxlint-tsgolint@7.0.2004))(rolldown@1.2.11)(rollup-plugin-visualizer@6.0.11(rolldown@1.2.11)(rollup@4.62.3))(rollup@4.62.3)(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(typescript@7.0.2)(vue@3.5.44(typescript@7.0.2))(yaml@2.9.0) '@unhead/vue': 2.1.17(vue@3.5.44(typescript@7.0.2)) '@vue/shared': 3.5.44 c12: 3.3.4(magicast@0.5.4) @@ -39644,7 +39628,7 @@ snapshots: db0: 0.3.4(mysql2@3.23.3(@types/node@20.19.43)) ioredis: 5.10.1(supports-color@10.0.0) - unstorage@1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0)): + unstorage@1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.1(supports-color@10.0.0)): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -39657,7 +39641,7 @@ snapshots: optionalDependencies: '@azure/storage-blob': 12.31.0(supports-color@10.0.0) db0: 0.3.4(mysql2@3.23.3(@types/node@20.19.43)) - ioredis: 5.11.0(supports-color@10.0.0) + ioredis: 5.11.1(supports-color@10.0.0) unstorage@2.0.0-alpha.7(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.10.1(supports-color@10.0.0))(lru-cache@11.4.0)(mongodb@3.7.4)(ofetch@1.5.1): optionalDependencies: @@ -39669,22 +39653,22 @@ snapshots: mongodb: 3.7.4 ofetch: 1.5.1 - unstorage@2.0.0-alpha.7(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0))(lru-cache@11.4.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(ofetch@1.5.1): + unstorage@2.0.0-alpha.7(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.1(supports-color@10.0.0))(lru-cache@11.4.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(ofetch@1.5.1): optionalDependencies: '@azure/storage-blob': 12.31.0(supports-color@10.0.0) chokidar: 5.0.0 db0: 0.3.4(mysql2@3.23.3(@types/node@20.19.43)) - ioredis: 5.11.0(supports-color@10.0.0) + ioredis: 5.11.1(supports-color@10.0.0) lru-cache: 11.4.0 mongodb: 7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7) ofetch: 1.5.1 - unstorage@2.0.0-alpha.7(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0))(lru-cache@11.4.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.7(@azure/storage-blob@12.31.0(supports-color@10.0.0))(chokidar@5.0.0)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.1(supports-color@10.0.0))(lru-cache@11.4.0)(mongodb@7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7))(ofetch@2.0.0-alpha.3): optionalDependencies: '@azure/storage-blob': 12.31.0(supports-color@10.0.0) chokidar: 5.0.0 db0: 0.3.4(mysql2@3.23.3(@types/node@20.19.43)) - ioredis: 5.11.0(supports-color@10.0.0) + ioredis: 5.11.1(supports-color@10.0.0) lru-cache: 11.4.0 mongodb: 7.1.0(@aws-sdk/credential-providers@3.983.0)(socks@2.8.7) ofetch: 2.0.0-alpha.3 @@ -39805,7 +39789,7 @@ snapshots: '@types/unist': 3.0.0 vfile-message: 4.0.3 - vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.0(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0): + vinxi@0.5.11(@azure/storage-blob@12.31.0(supports-color@10.0.0))(@parcel/watcher@2.5.6)(@types/node@20.19.43)(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(debug@4.4.3(supports-color@10.0.0))(encoding@0.1.13)(ioredis@5.11.1(supports-color@10.0.0))(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(mysql2@3.23.3(@types/node@20.19.43))(oxc-parser@0.152.0)(rolldown@1.2.11)(sass@1.72.0)(srvx@0.11.22)(stylus@0.59.0(supports-color@10.0.0))(supports-color@10.0.0)(terser@5.43.1)(tsx@4.23.0)(webpack@5.107.2(clean-css@5.2.2)(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@6.1.0)(lightningcss@1.33.0)(postcss@8.5.31)(uglify-js@3.13.3))(yaml@2.9.0): dependencies: '@babel/core': 7.29.7(supports-color@10.0.0) '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.0.0)) @@ -39838,7 +39822,7 @@ snapshots: ufo: 1.6.4 unctx: 2.5.0 unenv: 1.10.0 - unstorage: 1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.0(supports-color@10.0.0)) + unstorage: 1.17.5(@azure/storage-blob@12.31.0(supports-color@10.0.0))(db0@0.3.4(mysql2@3.23.3(@types/node@20.19.43)))(ioredis@5.11.1(supports-color@10.0.0)) vite: 6.4.3(@types/node@20.19.43)(jiti@2.7.0)(less@4.2.0(supports-color@10.0.0))(lightningcss@1.33.0)(sass@1.72.0)(stylus@0.59.0(supports-color@10.0.0))(terser@5.43.1)(tsx@4.23.0)(yaml@2.9.0) zod: 4.6.5 transitivePeerDependencies: