Skip to content

Commit 4098450

Browse files
committed
draft queue instrumentation
1 parent 5b4bd5b commit 4098450

File tree

5 files changed

+331
-0
lines changed

5 files changed

+331
-0
lines changed

dev-packages/e2e-tests/test-applications/nextjs-16/tests/vercel-queue.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,12 @@ test('Should create transactions for queue producer and consumer', async ({ requ
4040
expect(consumerTransaction).toBeDefined();
4141
expect(consumerTransaction.contexts?.trace?.op).toBe('http.server');
4242
expect(consumerTransaction.contexts?.trace?.status).toBe('ok');
43+
44+
// 5. Verify the consumer span has messaging.* attributes from queue instrumentation.
45+
const consumerSpanData = consumerTransaction.contexts?.trace?.data;
46+
expect(consumerSpanData?.['messaging.system']).toBe('vercel.queue');
47+
expect(consumerSpanData?.['messaging.operation.name']).toBe('process');
48+
expect(consumerSpanData?.['messaging.destination.name']).toBe('orders');
49+
expect(consumerSpanData?.['messaging.message.id']).toBeTruthy();
50+
expect(consumerSpanData?.['messaging.consumer.group.name']).toBeTruthy();
4351
});

packages/nextjs/src/server/handleOnSpanStart.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes';
1616
import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests';
1717
import { maybeEnhanceServerComponentSpanName } from '../common/utils/tracingUtils';
1818
import { maybeStartCronCheckIn } from './vercelCronsMonitoring';
19+
import { maybeEnrichQueueConsumerSpan, maybeEnrichQueueProducerSpan } from './vercelQueuesMonitoring';
1920

2021
/**
2122
* Handles the on span start event for Next.js spans.
@@ -56,6 +57,9 @@ export function handleOnSpanStart(span: Span): void {
5657

5758
// Check if this is a Vercel cron request and start a check-in
5859
maybeStartCronCheckIn(rootSpan, route);
60+
61+
// Enrich queue consumer spans (Vercel Queue push delivery via CloudEvent)
62+
maybeEnrichQueueConsumerSpan(rootSpan);
5963
}
6064
}
6165

@@ -96,4 +100,7 @@ export function handleOnSpanStart(span: Span): void {
96100
}
97101

98102
maybeEnhanceServerComponentSpanName(span, spanAttributes, rootSpanAttributes);
103+
104+
// Enrich outgoing http.client spans targeting the Vercel Queues API (producer)
105+
maybeEnrichQueueProducerSpan(span);
99106
}

packages/nextjs/src/server/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegrati
3737
import { handleOnSpanStart } from './handleOnSpanStart';
3838
import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext';
3939
import { maybeCompleteCronCheckIn } from './vercelCronsMonitoring';
40+
import { maybeCleanupQueueSpan } from './vercelQueuesMonitoring';
4041

4142
export * from '@sentry/node';
4243

@@ -193,6 +194,7 @@ export function init(options: NodeOptions): NodeClient | undefined {
193194

194195
client?.on('spanStart', handleOnSpanStart);
195196
client?.on('spanEnd', maybeCompleteCronCheckIn);
197+
client?.on('spanEnd', maybeCleanupQueueSpan);
196198

197199
getGlobalScope().addEventProcessor(
198200
Object.assign(
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import type { Span } from '@sentry/core';
2+
import { getIsolationScope, spanToJSON } from '@sentry/core';
3+
4+
// OTel Messaging semantic convention attribute keys
5+
const ATTR_MESSAGING_SYSTEM = 'messaging.system';
6+
const ATTR_MESSAGING_DESTINATION_NAME = 'messaging.destination.name';
7+
const ATTR_MESSAGING_MESSAGE_ID = 'messaging.message.id';
8+
const ATTR_MESSAGING_OPERATION_NAME = 'messaging.operation.name';
9+
const ATTR_MESSAGING_CONSUMER_GROUP_NAME = 'messaging.consumer.group.name';
10+
const ATTR_MESSAGING_MESSAGE_DELIVERY_COUNT = 'messaging.message.delivery_count';
11+
12+
// Marker attribute to track enriched spans for cleanup
13+
const ATTR_SENTRY_QUEUE_ENRICHED = 'sentry.queue.enriched';
14+
15+
/**
16+
* Checks if the incoming request is a Vercel Queue consumer callback (push mode)
17+
* and enriches the http.server span with OTel messaging semantic attributes.
18+
*
19+
* Vercel Queues push delivery sends a CloudEvent POST with the header:
20+
* ce-type: com.vercel.queue.v2beta
21+
* along with ce-vqs* headers carrying queue metadata.
22+
*/
23+
export function maybeEnrichQueueConsumerSpan(span: Span): void {
24+
const headers = getIsolationScope().getScopeData().sdkProcessingMetadata?.normalizedRequest?.headers as
25+
| Record<string, string | string[] | undefined>
26+
| undefined;
27+
28+
if (!headers) {
29+
return;
30+
}
31+
32+
const ceType = Array.isArray(headers['ce-type']) ? headers['ce-type'][0] : headers['ce-type'];
33+
if (ceType !== 'com.vercel.queue.v2beta') {
34+
return;
35+
}
36+
37+
const queueName = getHeader(headers, 'ce-vqsqueuename');
38+
const messageId = getHeader(headers, 'ce-vqsmessageid');
39+
const consumerGroup = getHeader(headers, 'ce-vqsconsumergroup');
40+
const deliveryCount = getHeader(headers, 'ce-vqsdeliverycount');
41+
42+
span.setAttribute(ATTR_MESSAGING_SYSTEM, 'vercel.queue');
43+
span.setAttribute(ATTR_MESSAGING_OPERATION_NAME, 'process');
44+
45+
if (queueName) {
46+
span.setAttribute(ATTR_MESSAGING_DESTINATION_NAME, queueName);
47+
}
48+
49+
if (messageId) {
50+
span.setAttribute(ATTR_MESSAGING_MESSAGE_ID, messageId);
51+
}
52+
53+
if (consumerGroup) {
54+
span.setAttribute(ATTR_MESSAGING_CONSUMER_GROUP_NAME, consumerGroup);
55+
}
56+
57+
if (deliveryCount) {
58+
const count = parseInt(deliveryCount, 10);
59+
if (!isNaN(count)) {
60+
span.setAttribute(ATTR_MESSAGING_MESSAGE_DELIVERY_COUNT, count);
61+
}
62+
}
63+
64+
// Mark span so we can clean up marker on spanEnd
65+
span.setAttribute(ATTR_SENTRY_QUEUE_ENRICHED, true);
66+
}
67+
68+
/**
69+
* Checks if an outgoing http.client span targets the Vercel Queues API
70+
* and enriches it with OTel messaging semantic attributes (producer side).
71+
*
72+
* The Vercel Queues API lives at *.vercel-queue.com/api/v3/topic/<topic>.
73+
* We use domain-based detection to avoid false positives from user routes.
74+
*/
75+
export function maybeEnrichQueueProducerSpan(span: Span): void {
76+
const spanData = spanToJSON(span).data;
77+
78+
// http.client spans have url.full attribute
79+
const urlFull = spanData?.['url.full'] as string | undefined;
80+
if (!urlFull) {
81+
return;
82+
}
83+
84+
let parsed: URL;
85+
try {
86+
parsed = new URL(urlFull);
87+
} catch {
88+
return;
89+
}
90+
91+
if (!parsed.hostname.endsWith('vercel-queue.com')) {
92+
return;
93+
}
94+
95+
// Extract topic from path: /api/v3/topic/<topic>[/<messageId>]
96+
const topicMatch = parsed.pathname.match(/^\/api\/v3\/topic\/([^/]+)/);
97+
if (!topicMatch) {
98+
return;
99+
}
100+
101+
const topic = decodeURIComponent(topicMatch[1]!);
102+
103+
span.setAttribute(ATTR_MESSAGING_SYSTEM, 'vercel.queue');
104+
span.setAttribute(ATTR_MESSAGING_DESTINATION_NAME, topic);
105+
span.setAttribute(ATTR_MESSAGING_OPERATION_NAME, 'send');
106+
107+
// Mark span so we can clean up marker on spanEnd
108+
span.setAttribute(ATTR_SENTRY_QUEUE_ENRICHED, true);
109+
}
110+
111+
/**
112+
* Cleans up the internal marker attribute from enriched queue spans on end.
113+
*/
114+
export function maybeCleanupQueueSpan(span: Span): void {
115+
const spanData = spanToJSON(span).data;
116+
if (spanData?.[ATTR_SENTRY_QUEUE_ENRICHED]) {
117+
span.setAttribute(ATTR_SENTRY_QUEUE_ENRICHED, undefined);
118+
}
119+
}
120+
121+
function getHeader(
122+
headers: Record<string, string | string[] | undefined>,
123+
name: string,
124+
): string | undefined {
125+
const value = headers[name];
126+
return Array.isArray(value) ? value[0] : value;
127+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
let mockHeaders: Record<string, string | string[] | undefined> | undefined;
4+
5+
vi.mock('@sentry/core', () => ({
6+
getIsolationScope: () => ({
7+
getScopeData: () => ({
8+
sdkProcessingMetadata: {
9+
normalizedRequest: mockHeaders !== undefined ? { headers: mockHeaders } : undefined,
10+
},
11+
}),
12+
}),
13+
spanToJSON: (span: { _data: Record<string, unknown> }) => ({
14+
data: span._data,
15+
}),
16+
}));
17+
18+
import {
19+
maybeCleanupQueueSpan,
20+
maybeEnrichQueueConsumerSpan,
21+
maybeEnrichQueueProducerSpan,
22+
} from '../../src/server/vercelQueuesMonitoring';
23+
24+
function createMockSpan(data: Record<string, unknown> = {}): {
25+
_data: Record<string, unknown>;
26+
setAttribute: (key: string, value: unknown) => void;
27+
} {
28+
const _data = { ...data };
29+
return {
30+
_data,
31+
setAttribute: (key: string, value: unknown) => {
32+
if (value === undefined) {
33+
delete _data[key];
34+
} else {
35+
_data[key] = value;
36+
}
37+
},
38+
};
39+
}
40+
41+
describe('vercelQueuesMonitoring', () => {
42+
beforeEach(() => {
43+
mockHeaders = undefined;
44+
});
45+
46+
afterEach(() => {
47+
vi.clearAllMocks();
48+
});
49+
50+
describe('maybeEnrichQueueConsumerSpan', () => {
51+
it('does nothing when there are no headers', () => {
52+
mockHeaders = undefined;
53+
const span = createMockSpan();
54+
maybeEnrichQueueConsumerSpan(span as any);
55+
expect(span._data).toEqual({});
56+
});
57+
58+
it('does nothing when ce-type header is missing', () => {
59+
mockHeaders = { 'content-type': 'application/json' };
60+
const span = createMockSpan();
61+
maybeEnrichQueueConsumerSpan(span as any);
62+
expect(span._data).toEqual({});
63+
});
64+
65+
it('does nothing when ce-type is not com.vercel.queue.v2beta', () => {
66+
mockHeaders = { 'ce-type': 'com.other.event' };
67+
const span = createMockSpan();
68+
maybeEnrichQueueConsumerSpan(span as any);
69+
expect(span._data).toEqual({});
70+
});
71+
72+
it('enriches span with messaging attributes when ce-type matches', () => {
73+
mockHeaders = {
74+
'ce-type': 'com.vercel.queue.v2beta',
75+
'ce-vqsqueuename': 'orders',
76+
'ce-vqsmessageid': 'msg-123',
77+
'ce-vqsconsumergroup': 'default',
78+
'ce-vqsdeliverycount': '3',
79+
};
80+
const span = createMockSpan();
81+
maybeEnrichQueueConsumerSpan(span as any);
82+
83+
expect(span._data['messaging.system']).toBe('vercel.queue');
84+
expect(span._data['messaging.operation.name']).toBe('process');
85+
expect(span._data['messaging.destination.name']).toBe('orders');
86+
expect(span._data['messaging.message.id']).toBe('msg-123');
87+
expect(span._data['messaging.consumer.group.name']).toBe('default');
88+
expect(span._data['messaging.message.delivery_count']).toBe(3);
89+
expect(span._data['sentry.queue.enriched']).toBe(true);
90+
});
91+
92+
it('handles missing optional headers gracefully', () => {
93+
mockHeaders = { 'ce-type': 'com.vercel.queue.v2beta' };
94+
const span = createMockSpan();
95+
maybeEnrichQueueConsumerSpan(span as any);
96+
97+
expect(span._data['messaging.system']).toBe('vercel.queue');
98+
expect(span._data['messaging.operation.name']).toBe('process');
99+
expect(span._data['messaging.destination.name']).toBeUndefined();
100+
expect(span._data['messaging.message.id']).toBeUndefined();
101+
});
102+
103+
it('ignores non-numeric delivery count', () => {
104+
mockHeaders = {
105+
'ce-type': 'com.vercel.queue.v2beta',
106+
'ce-vqsdeliverycount': 'not-a-number',
107+
};
108+
const span = createMockSpan();
109+
maybeEnrichQueueConsumerSpan(span as any);
110+
111+
expect(span._data['messaging.message.delivery_count']).toBeUndefined();
112+
});
113+
});
114+
115+
describe('maybeEnrichQueueProducerSpan', () => {
116+
it('does nothing when url.full is missing', () => {
117+
const span = createMockSpan();
118+
maybeEnrichQueueProducerSpan(span as any);
119+
expect(span._data).toEqual({});
120+
});
121+
122+
it('does nothing for non-vercel-queue URLs', () => {
123+
const span = createMockSpan({ 'url.full': 'https://example.com/api/v3/topic/orders' });
124+
maybeEnrichQueueProducerSpan(span as any);
125+
expect(span._data['messaging.system']).toBeUndefined();
126+
});
127+
128+
it('does nothing for vercel-queue.com URLs without topic path', () => {
129+
const span = createMockSpan({ 'url.full': 'https://queue.vercel-queue.com/api/v3/other' });
130+
maybeEnrichQueueProducerSpan(span as any);
131+
expect(span._data['messaging.system']).toBeUndefined();
132+
});
133+
134+
it('enriches span for vercel-queue.com topic URLs', () => {
135+
const span = createMockSpan({ 'url.full': 'https://queue.vercel-queue.com/api/v3/topic/orders' });
136+
maybeEnrichQueueProducerSpan(span as any);
137+
138+
expect(span._data['messaging.system']).toBe('vercel.queue');
139+
expect(span._data['messaging.destination.name']).toBe('orders');
140+
expect(span._data['messaging.operation.name']).toBe('send');
141+
expect(span._data['sentry.queue.enriched']).toBe(true);
142+
});
143+
144+
it('handles URL-encoded topic names', () => {
145+
const span = createMockSpan({
146+
'url.full': 'https://queue.vercel-queue.com/api/v3/topic/my%20topic',
147+
});
148+
maybeEnrichQueueProducerSpan(span as any);
149+
150+
expect(span._data['messaging.destination.name']).toBe('my topic');
151+
});
152+
153+
it('extracts topic when URL has additional path segments', () => {
154+
const span = createMockSpan({
155+
'url.full': 'https://queue.vercel-queue.com/api/v3/topic/orders/msg-123',
156+
});
157+
maybeEnrichQueueProducerSpan(span as any);
158+
159+
expect(span._data['messaging.destination.name']).toBe('orders');
160+
});
161+
162+
it('handles invalid URLs gracefully', () => {
163+
const span = createMockSpan({ 'url.full': 'not-a-url' });
164+
maybeEnrichQueueProducerSpan(span as any);
165+
expect(span._data['messaging.system']).toBeUndefined();
166+
});
167+
});
168+
169+
describe('maybeCleanupQueueSpan', () => {
170+
it('removes the enriched marker attribute', () => {
171+
const span = createMockSpan({
172+
'messaging.system': 'vercel.queue',
173+
'sentry.queue.enriched': true,
174+
});
175+
maybeCleanupQueueSpan(span as any);
176+
177+
expect(span._data['sentry.queue.enriched']).toBeUndefined();
178+
expect(span._data['messaging.system']).toBe('vercel.queue');
179+
});
180+
181+
it('does nothing for non-enriched spans', () => {
182+
const span = createMockSpan({ 'some.attribute': 'value' });
183+
maybeCleanupQueueSpan(span as any);
184+
expect(span._data).toEqual({ 'some.attribute': 'value' });
185+
});
186+
});
187+
});

0 commit comments

Comments
 (0)