From 3aa2c9dc1ea2400d5ef43c730f6f35d91c78d782 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 13 Mar 2026 13:46:13 +0100 Subject: [PATCH 1/4] draft queue instrumentation --- .../nextjs-16/tests/vercel-queue.test.ts | 8 + .../nextjs/src/server/handleOnSpanStart.ts | 7 + packages/nextjs/src/server/index.ts | 2 + .../src/server/vercelQueuesMonitoring.ts | 127 ++++++++++++ .../server/vercelQueuesMonitoring.test.ts | 187 ++++++++++++++++++ 5 files changed, 331 insertions(+) create mode 100644 packages/nextjs/src/server/vercelQueuesMonitoring.ts create mode 100644 packages/nextjs/test/server/vercelQueuesMonitoring.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts index 787c06a79e57..eb4635bc2f5a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts @@ -40,4 +40,12 @@ test('Should create transactions for queue producer and consumer', async ({ requ expect(consumerTransaction).toBeDefined(); expect(consumerTransaction.contexts?.trace?.op).toBe('http.server'); expect(consumerTransaction.contexts?.trace?.status).toBe('ok'); + + // 5. Verify the consumer span has messaging.* attributes from queue instrumentation. + const consumerSpanData = consumerTransaction.contexts?.trace?.data; + expect(consumerSpanData?.['messaging.system']).toBe('vercel.queue'); + expect(consumerSpanData?.['messaging.operation.name']).toBe('process'); + expect(consumerSpanData?.['messaging.destination.name']).toBe('orders'); + expect(consumerSpanData?.['messaging.message.id']).toBeTruthy(); + expect(consumerSpanData?.['messaging.consumer.group.name']).toBeTruthy(); }); diff --git a/packages/nextjs/src/server/handleOnSpanStart.ts b/packages/nextjs/src/server/handleOnSpanStart.ts index c8e2215b2aaf..21af973f2b2f 100644 --- a/packages/nextjs/src/server/handleOnSpanStart.ts +++ b/packages/nextjs/src/server/handleOnSpanStart.ts @@ -16,6 +16,7 @@ import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; import { maybeEnhanceServerComponentSpanName } from '../common/utils/tracingUtils'; import { maybeStartCronCheckIn } from './vercelCronsMonitoring'; +import { maybeEnrichQueueConsumerSpan, maybeEnrichQueueProducerSpan } from './vercelQueuesMonitoring'; /** * Handles the on span start event for Next.js spans. @@ -56,6 +57,9 @@ export function handleOnSpanStart(span: Span): void { // Check if this is a Vercel cron request and start a check-in maybeStartCronCheckIn(rootSpan, route); + + // Enrich queue consumer spans (Vercel Queue push delivery via CloudEvent) + maybeEnrichQueueConsumerSpan(rootSpan); } } @@ -96,4 +100,7 @@ export function handleOnSpanStart(span: Span): void { } maybeEnhanceServerComponentSpanName(span, spanAttributes, rootSpanAttributes); + + // Enrich outgoing http.client spans targeting the Vercel Queues API (producer) + maybeEnrichQueueProducerSpan(span); } diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index eca9b586423f..343cfd8bb218 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -37,6 +37,7 @@ import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegrati import { handleOnSpanStart } from './handleOnSpanStart'; import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; import { maybeCompleteCronCheckIn } from './vercelCronsMonitoring'; +import { maybeCleanupQueueSpan } from './vercelQueuesMonitoring'; export * from '@sentry/node'; @@ -193,6 +194,7 @@ export function init(options: NodeOptions): NodeClient | undefined { client?.on('spanStart', handleOnSpanStart); client?.on('spanEnd', maybeCompleteCronCheckIn); + client?.on('spanEnd', maybeCleanupQueueSpan); getGlobalScope().addEventProcessor( Object.assign( diff --git a/packages/nextjs/src/server/vercelQueuesMonitoring.ts b/packages/nextjs/src/server/vercelQueuesMonitoring.ts new file mode 100644 index 000000000000..b29bd2a762fd --- /dev/null +++ b/packages/nextjs/src/server/vercelQueuesMonitoring.ts @@ -0,0 +1,127 @@ +import type { Span } from '@sentry/core'; +import { getIsolationScope, spanToJSON } from '@sentry/core'; + +// OTel Messaging semantic convention attribute keys +const ATTR_MESSAGING_SYSTEM = 'messaging.system'; +const ATTR_MESSAGING_DESTINATION_NAME = 'messaging.destination.name'; +const ATTR_MESSAGING_MESSAGE_ID = 'messaging.message.id'; +const ATTR_MESSAGING_OPERATION_NAME = 'messaging.operation.name'; +const ATTR_MESSAGING_CONSUMER_GROUP_NAME = 'messaging.consumer.group.name'; +const ATTR_MESSAGING_MESSAGE_DELIVERY_COUNT = 'messaging.message.delivery_count'; + +// Marker attribute to track enriched spans for cleanup +const ATTR_SENTRY_QUEUE_ENRICHED = 'sentry.queue.enriched'; + +/** + * Checks if the incoming request is a Vercel Queue consumer callback (push mode) + * and enriches the http.server span with OTel messaging semantic attributes. + * + * Vercel Queues push delivery sends a CloudEvent POST with the header: + * ce-type: com.vercel.queue.v2beta + * along with ce-vqs* headers carrying queue metadata. + */ +export function maybeEnrichQueueConsumerSpan(span: Span): void { + const headers = getIsolationScope().getScopeData().sdkProcessingMetadata?.normalizedRequest?.headers as + | Record + | undefined; + + if (!headers) { + return; + } + + const ceType = Array.isArray(headers['ce-type']) ? headers['ce-type'][0] : headers['ce-type']; + if (ceType !== 'com.vercel.queue.v2beta') { + return; + } + + const queueName = getHeader(headers, 'ce-vqsqueuename'); + const messageId = getHeader(headers, 'ce-vqsmessageid'); + const consumerGroup = getHeader(headers, 'ce-vqsconsumergroup'); + const deliveryCount = getHeader(headers, 'ce-vqsdeliverycount'); + + span.setAttribute(ATTR_MESSAGING_SYSTEM, 'vercel.queue'); + span.setAttribute(ATTR_MESSAGING_OPERATION_NAME, 'process'); + + if (queueName) { + span.setAttribute(ATTR_MESSAGING_DESTINATION_NAME, queueName); + } + + if (messageId) { + span.setAttribute(ATTR_MESSAGING_MESSAGE_ID, messageId); + } + + if (consumerGroup) { + span.setAttribute(ATTR_MESSAGING_CONSUMER_GROUP_NAME, consumerGroup); + } + + if (deliveryCount) { + const count = parseInt(deliveryCount, 10); + if (!isNaN(count)) { + span.setAttribute(ATTR_MESSAGING_MESSAGE_DELIVERY_COUNT, count); + } + } + + // Mark span so we can clean up marker on spanEnd + span.setAttribute(ATTR_SENTRY_QUEUE_ENRICHED, true); +} + +/** + * Checks if an outgoing http.client span targets the Vercel Queues API + * and enriches it with OTel messaging semantic attributes (producer side). + * + * The Vercel Queues API lives at *.vercel-queue.com/api/v3/topic/. + * We use domain-based detection to avoid false positives from user routes. + */ +export function maybeEnrichQueueProducerSpan(span: Span): void { + const spanData = spanToJSON(span).data; + + // http.client spans have url.full attribute + const urlFull = spanData?.['url.full'] as string | undefined; + if (!urlFull) { + return; + } + + let parsed: URL; + try { + parsed = new URL(urlFull); + } catch { + return; + } + + if (!parsed.hostname.endsWith('vercel-queue.com')) { + return; + } + + // Extract topic from path: /api/v3/topic/[/] + const topicMatch = parsed.pathname.match(/^\/api\/v3\/topic\/([^/]+)/); + if (!topicMatch) { + return; + } + + const topic = decodeURIComponent(topicMatch[1]!); + + span.setAttribute(ATTR_MESSAGING_SYSTEM, 'vercel.queue'); + span.setAttribute(ATTR_MESSAGING_DESTINATION_NAME, topic); + span.setAttribute(ATTR_MESSAGING_OPERATION_NAME, 'send'); + + // Mark span so we can clean up marker on spanEnd + span.setAttribute(ATTR_SENTRY_QUEUE_ENRICHED, true); +} + +/** + * Cleans up the internal marker attribute from enriched queue spans on end. + */ +export function maybeCleanupQueueSpan(span: Span): void { + const spanData = spanToJSON(span).data; + if (spanData?.[ATTR_SENTRY_QUEUE_ENRICHED]) { + span.setAttribute(ATTR_SENTRY_QUEUE_ENRICHED, undefined); + } +} + +function getHeader( + headers: Record, + name: string, +): string | undefined { + const value = headers[name]; + return Array.isArray(value) ? value[0] : value; +} diff --git a/packages/nextjs/test/server/vercelQueuesMonitoring.test.ts b/packages/nextjs/test/server/vercelQueuesMonitoring.test.ts new file mode 100644 index 000000000000..98885818486f --- /dev/null +++ b/packages/nextjs/test/server/vercelQueuesMonitoring.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let mockHeaders: Record | undefined; + +vi.mock('@sentry/core', () => ({ + getIsolationScope: () => ({ + getScopeData: () => ({ + sdkProcessingMetadata: { + normalizedRequest: mockHeaders !== undefined ? { headers: mockHeaders } : undefined, + }, + }), + }), + spanToJSON: (span: { _data: Record }) => ({ + data: span._data, + }), +})); + +import { + maybeCleanupQueueSpan, + maybeEnrichQueueConsumerSpan, + maybeEnrichQueueProducerSpan, +} from '../../src/server/vercelQueuesMonitoring'; + +function createMockSpan(data: Record = {}): { + _data: Record; + setAttribute: (key: string, value: unknown) => void; +} { + const _data = { ...data }; + return { + _data, + setAttribute: (key: string, value: unknown) => { + if (value === undefined) { + delete _data[key]; + } else { + _data[key] = value; + } + }, + }; +} + +describe('vercelQueuesMonitoring', () => { + beforeEach(() => { + mockHeaders = undefined; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('maybeEnrichQueueConsumerSpan', () => { + it('does nothing when there are no headers', () => { + mockHeaders = undefined; + const span = createMockSpan(); + maybeEnrichQueueConsumerSpan(span as any); + expect(span._data).toEqual({}); + }); + + it('does nothing when ce-type header is missing', () => { + mockHeaders = { 'content-type': 'application/json' }; + const span = createMockSpan(); + maybeEnrichQueueConsumerSpan(span as any); + expect(span._data).toEqual({}); + }); + + it('does nothing when ce-type is not com.vercel.queue.v2beta', () => { + mockHeaders = { 'ce-type': 'com.other.event' }; + const span = createMockSpan(); + maybeEnrichQueueConsumerSpan(span as any); + expect(span._data).toEqual({}); + }); + + it('enriches span with messaging attributes when ce-type matches', () => { + mockHeaders = { + 'ce-type': 'com.vercel.queue.v2beta', + 'ce-vqsqueuename': 'orders', + 'ce-vqsmessageid': 'msg-123', + 'ce-vqsconsumergroup': 'default', + 'ce-vqsdeliverycount': '3', + }; + const span = createMockSpan(); + maybeEnrichQueueConsumerSpan(span as any); + + expect(span._data['messaging.system']).toBe('vercel.queue'); + expect(span._data['messaging.operation.name']).toBe('process'); + expect(span._data['messaging.destination.name']).toBe('orders'); + expect(span._data['messaging.message.id']).toBe('msg-123'); + expect(span._data['messaging.consumer.group.name']).toBe('default'); + expect(span._data['messaging.message.delivery_count']).toBe(3); + expect(span._data['sentry.queue.enriched']).toBe(true); + }); + + it('handles missing optional headers gracefully', () => { + mockHeaders = { 'ce-type': 'com.vercel.queue.v2beta' }; + const span = createMockSpan(); + maybeEnrichQueueConsumerSpan(span as any); + + expect(span._data['messaging.system']).toBe('vercel.queue'); + expect(span._data['messaging.operation.name']).toBe('process'); + expect(span._data['messaging.destination.name']).toBeUndefined(); + expect(span._data['messaging.message.id']).toBeUndefined(); + }); + + it('ignores non-numeric delivery count', () => { + mockHeaders = { + 'ce-type': 'com.vercel.queue.v2beta', + 'ce-vqsdeliverycount': 'not-a-number', + }; + const span = createMockSpan(); + maybeEnrichQueueConsumerSpan(span as any); + + expect(span._data['messaging.message.delivery_count']).toBeUndefined(); + }); + }); + + describe('maybeEnrichQueueProducerSpan', () => { + it('does nothing when url.full is missing', () => { + const span = createMockSpan(); + maybeEnrichQueueProducerSpan(span as any); + expect(span._data).toEqual({}); + }); + + it('does nothing for non-vercel-queue URLs', () => { + const span = createMockSpan({ 'url.full': 'https://example.com/api/v3/topic/orders' }); + maybeEnrichQueueProducerSpan(span as any); + expect(span._data['messaging.system']).toBeUndefined(); + }); + + it('does nothing for vercel-queue.com URLs without topic path', () => { + const span = createMockSpan({ 'url.full': 'https://queue.vercel-queue.com/api/v3/other' }); + maybeEnrichQueueProducerSpan(span as any); + expect(span._data['messaging.system']).toBeUndefined(); + }); + + it('enriches span for vercel-queue.com topic URLs', () => { + const span = createMockSpan({ 'url.full': 'https://queue.vercel-queue.com/api/v3/topic/orders' }); + maybeEnrichQueueProducerSpan(span as any); + + expect(span._data['messaging.system']).toBe('vercel.queue'); + expect(span._data['messaging.destination.name']).toBe('orders'); + expect(span._data['messaging.operation.name']).toBe('send'); + expect(span._data['sentry.queue.enriched']).toBe(true); + }); + + it('handles URL-encoded topic names', () => { + const span = createMockSpan({ + 'url.full': 'https://queue.vercel-queue.com/api/v3/topic/my%20topic', + }); + maybeEnrichQueueProducerSpan(span as any); + + expect(span._data['messaging.destination.name']).toBe('my topic'); + }); + + it('extracts topic when URL has additional path segments', () => { + const span = createMockSpan({ + 'url.full': 'https://queue.vercel-queue.com/api/v3/topic/orders/msg-123', + }); + maybeEnrichQueueProducerSpan(span as any); + + expect(span._data['messaging.destination.name']).toBe('orders'); + }); + + it('handles invalid URLs gracefully', () => { + const span = createMockSpan({ 'url.full': 'not-a-url' }); + maybeEnrichQueueProducerSpan(span as any); + expect(span._data['messaging.system']).toBeUndefined(); + }); + }); + + describe('maybeCleanupQueueSpan', () => { + it('removes the enriched marker attribute', () => { + const span = createMockSpan({ + 'messaging.system': 'vercel.queue', + 'sentry.queue.enriched': true, + }); + maybeCleanupQueueSpan(span as any); + + expect(span._data['sentry.queue.enriched']).toBeUndefined(); + expect(span._data['messaging.system']).toBe('vercel.queue'); + }); + + it('does nothing for non-enriched spans', () => { + const span = createMockSpan({ 'some.attribute': 'value' }); + maybeCleanupQueueSpan(span as any); + expect(span._data).toEqual({ 'some.attribute': 'value' }); + }); + }); +}); From e9f53dc817741a4bd6bce9f91e30f7503f550be2 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 13 Mar 2026 14:21:48 +0100 Subject: [PATCH 2/4] fmt --- packages/nextjs/src/server/vercelQueuesMonitoring.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/nextjs/src/server/vercelQueuesMonitoring.ts b/packages/nextjs/src/server/vercelQueuesMonitoring.ts index b29bd2a762fd..8356ae60122e 100644 --- a/packages/nextjs/src/server/vercelQueuesMonitoring.ts +++ b/packages/nextjs/src/server/vercelQueuesMonitoring.ts @@ -118,10 +118,7 @@ export function maybeCleanupQueueSpan(span: Span): void { } } -function getHeader( - headers: Record, - name: string, -): string | undefined { +function getHeader(headers: Record, name: string): string | undefined { const value = headers[name]; return Array.isArray(value) ? value[0] : value; } From 77c446206a5624d7ebba48a67f39be598e6ed3d6 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 13 Mar 2026 14:32:55 +0100 Subject: [PATCH 3/4] fix codeql --- packages/nextjs/src/server/vercelQueuesMonitoring.ts | 2 +- packages/nextjs/test/server/vercelQueuesMonitoring.test.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/server/vercelQueuesMonitoring.ts b/packages/nextjs/src/server/vercelQueuesMonitoring.ts index 8356ae60122e..cfe367c46470 100644 --- a/packages/nextjs/src/server/vercelQueuesMonitoring.ts +++ b/packages/nextjs/src/server/vercelQueuesMonitoring.ts @@ -88,7 +88,7 @@ export function maybeEnrichQueueProducerSpan(span: Span): void { return; } - if (!parsed.hostname.endsWith('vercel-queue.com')) { + if (parsed.hostname !== 'vercel-queue.com' && !parsed.hostname.endsWith('.vercel-queue.com')) { return; } diff --git a/packages/nextjs/test/server/vercelQueuesMonitoring.test.ts b/packages/nextjs/test/server/vercelQueuesMonitoring.test.ts index 98885818486f..642e170ae3dd 100644 --- a/packages/nextjs/test/server/vercelQueuesMonitoring.test.ts +++ b/packages/nextjs/test/server/vercelQueuesMonitoring.test.ts @@ -125,6 +125,12 @@ describe('vercelQueuesMonitoring', () => { expect(span._data['messaging.system']).toBeUndefined(); }); + it('does nothing for hostname that is a suffix match but not a subdomain', () => { + const span = createMockSpan({ 'url.full': 'https://evil-vercel-queue.com/api/v3/topic/orders' }); + maybeEnrichQueueProducerSpan(span as any); + expect(span._data['messaging.system']).toBeUndefined(); + }); + it('does nothing for vercel-queue.com URLs without topic path', () => { const span = createMockSpan({ 'url.full': 'https://queue.vercel-queue.com/api/v3/other' }); maybeEnrichQueueProducerSpan(span as any); From 561afbc2fa34410282827ba65eaab94a292e74a7 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 13 Mar 2026 16:17:53 +0100 Subject: [PATCH 4/4] fix lint --- packages/nextjs/test/server/vercelQueuesMonitoring.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nextjs/test/server/vercelQueuesMonitoring.test.ts b/packages/nextjs/test/server/vercelQueuesMonitoring.test.ts index 642e170ae3dd..397e49943edf 100644 --- a/packages/nextjs/test/server/vercelQueuesMonitoring.test.ts +++ b/packages/nextjs/test/server/vercelQueuesMonitoring.test.ts @@ -30,6 +30,7 @@ function createMockSpan(data: Record = {}): { _data, setAttribute: (key: string, value: unknown) => { if (value === undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete _data[key]; } else { _data[key] = value;