Skip to content

Commit b9b0533

Browse files
committed
feat(cloudflare): Support tracing for queue producer
1 parent 4c053b6 commit b9b0533

11 files changed

Lines changed: 573 additions & 14 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { MessageBatch, Queue } from '@cloudflare/workers-types';
2+
import * as Sentry from '@sentry/cloudflare';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
MY_QUEUE: Queue<{ trigger?: 'error'; payload?: string }>;
7+
}
8+
9+
export default Sentry.withSentry(
10+
(env: Env) => ({
11+
dsn: env.SENTRY_DSN,
12+
tracesSampleRate: 1,
13+
}),
14+
{
15+
async fetch(request, env) {
16+
const url = new URL(request.url);
17+
18+
if (url.pathname === '/enqueue/error') {
19+
await env.MY_QUEUE.send({ trigger: 'error' });
20+
return new Response('enqueued error');
21+
}
22+
23+
if (url.pathname === '/enqueue/ok') {
24+
await env.MY_QUEUE.send({ payload: 'hello' });
25+
return new Response('enqueued ok');
26+
}
27+
28+
if (url.pathname === '/enqueue/batch') {
29+
await env.MY_QUEUE.sendBatch([
30+
{ body: { payload: 'one' } },
31+
{ body: { payload: 'two' } },
32+
{ body: { payload: 'three' } },
33+
]);
34+
return new Response('enqueued batch');
35+
}
36+
37+
return new Response('not found', { status: 404 });
38+
},
39+
async queue(batch: MessageBatch<{ trigger?: 'error'; payload?: string }>) {
40+
for (const message of batch.messages) {
41+
if (message.body.trigger === 'error') {
42+
throw new Error('Boom from queue handler');
43+
}
44+
}
45+
},
46+
} as ExportedHandler<Env>,
47+
);
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import type { Envelope } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../runner';
4+
5+
function envelopeItemType(envelope: Envelope): string | undefined {
6+
return envelope[1][0]?.[0]?.type as string | undefined;
7+
}
8+
9+
function envelopeItem(envelope: Envelope): Record<string, unknown> {
10+
return envelope[1][0]![1] as Record<string, unknown>;
11+
}
12+
13+
function findPublishSpan(envelope: Envelope): Record<string, unknown> | undefined {
14+
if (envelopeItemType(envelope) !== 'transaction') return undefined;
15+
const tx = envelopeItem(envelope);
16+
const spans = (tx.spans as Array<Record<string, unknown>>) || [];
17+
return spans.find(s => (s.op as string) === 'queue.publish');
18+
}
19+
20+
function isConsumerTransaction(envelope: Envelope): boolean {
21+
if (envelopeItemType(envelope) !== 'transaction') return false;
22+
const tx = envelopeItem(envelope);
23+
return tx.transaction === 'process test-queue';
24+
}
25+
26+
it('captures errors thrown by the queue handler with the correct mechanism', async ({ signal }) => {
27+
const runner = createRunner(__dirname)
28+
.ignore('transaction')
29+
.expect((envelope: Envelope) => {
30+
expect(envelopeItemType(envelope)).toBe('event');
31+
const event = envelopeItem(envelope);
32+
expect(event).toMatchObject({
33+
level: 'error',
34+
exception: {
35+
values: [
36+
{
37+
type: 'Error',
38+
value: 'Boom from queue handler',
39+
mechanism: { type: 'auto.faas.cloudflare.queue', handled: false },
40+
},
41+
],
42+
},
43+
});
44+
})
45+
.start(signal);
46+
47+
await runner.makeRequest('post', '/enqueue/error');
48+
await runner.completed();
49+
});
50+
51+
it('emits a queue.publish span on env.MY_QUEUE.send and a queue.process transaction on the consumer', async ({
52+
signal,
53+
}) => {
54+
const runner = createRunner(__dirname)
55+
.unordered()
56+
.expect((envelope: Envelope) => {
57+
// Producer transaction must contain a queue.publish child span
58+
const publishSpan = findPublishSpan(envelope);
59+
expect(publishSpan).toBeDefined();
60+
expect(publishSpan).toMatchObject({
61+
op: 'queue.publish',
62+
description: 'send MY_QUEUE',
63+
data: expect.objectContaining({
64+
'messaging.system': 'cloudflare',
65+
'messaging.destination.name': 'MY_QUEUE',
66+
'messaging.operation.type': 'send',
67+
'messaging.operation.name': 'send',
68+
'sentry.origin': 'auto.faas.cloudflare.queue',
69+
}),
70+
});
71+
})
72+
.expect((envelope: Envelope) => {
73+
expect(isConsumerTransaction(envelope)).toBe(true);
74+
const tx = envelopeItem(envelope);
75+
const trace = (tx.contexts as Record<string, Record<string, unknown>>).trace as Record<string, unknown>;
76+
expect(trace).toMatchObject({
77+
op: 'queue.process',
78+
origin: 'auto.faas.cloudflare.queue',
79+
data: expect.objectContaining({
80+
'messaging.system': 'cloudflare',
81+
'messaging.destination.name': 'test-queue',
82+
'messaging.operation.type': 'process',
83+
'messaging.operation.name': 'process',
84+
'messaging.batch.message_count': 1,
85+
'faas.trigger': 'pubsub',
86+
}),
87+
});
88+
})
89+
.start(signal);
90+
91+
await runner.makeRequest('post', '/enqueue/ok');
92+
await runner.completed();
93+
});
94+
95+
it('emits a queue.publish span with batch attributes on env.MY_QUEUE.sendBatch', async ({ signal }) => {
96+
const runner = createRunner(__dirname)
97+
.unordered()
98+
.expect((envelope: Envelope) => {
99+
const publishSpan = findPublishSpan(envelope);
100+
expect(publishSpan).toBeDefined();
101+
expect(publishSpan).toMatchObject({
102+
op: 'queue.publish',
103+
description: 'send MY_QUEUE',
104+
data: expect.objectContaining({
105+
'messaging.system': 'cloudflare',
106+
'messaging.destination.name': 'MY_QUEUE',
107+
'messaging.operation.type': 'send',
108+
'messaging.operation.name': 'send',
109+
'messaging.batch.message_count': 3,
110+
'sentry.origin': 'auto.faas.cloudflare.queue',
111+
}),
112+
});
113+
})
114+
.expect((envelope: Envelope) => {
115+
expect(isConsumerTransaction(envelope)).toBe(true);
116+
const tx = envelopeItem(envelope);
117+
const trace = (tx.contexts as Record<string, Record<string, unknown>>).trace as Record<string, unknown>;
118+
expect(trace).toMatchObject({
119+
data: expect.objectContaining({
120+
'messaging.batch.message_count': 3,
121+
}),
122+
});
123+
})
124+
.start(signal);
125+
126+
await runner.makeRequest('post', '/enqueue/batch');
127+
await runner.completed();
128+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "worker-name",
3+
"compatibility_date": "2025-06-17",
4+
"main": "index.ts",
5+
"compatibility_flags": ["nodejs_compat"],
6+
"queues": {
7+
"producers": [
8+
{
9+
"queue": "test-queue",
10+
"binding": "MY_QUEUE",
11+
},
12+
],
13+
"consumers": [
14+
{
15+
"queue": "test-queue",
16+
"max_batch_size": 10,
17+
"max_batch_timeout": 1,
18+
"max_retries": 0,
19+
},
20+
],
21+
},
22+
}

packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { CloudflareOptions } from '../../client';
2-
import { isDurableObjectNamespace, isJSRPC } from '../../utils/isBinding';
2+
import { isDurableObjectNamespace, isJSRPC, isQueue } from '../../utils/isBinding';
33
import { appendRpcMeta } from '../../utils/rpcMeta';
44
import { getEffectiveRpcPropagation } from '../../utils/rpcOptions';
55
import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace';
66
import { instrumentFetcher } from './instrumentFetcher';
7+
import { instrumentQueueProducer } from './instrumentQueueProducer';
78

89
function isProxyable(item: unknown): item is object {
910
return item !== null && (typeof item === 'object' || typeof item === 'function');
@@ -17,9 +18,10 @@ const instrumentedBindings = new WeakMap<object, unknown>();
1718
*
1819
* Currently detects:
1920
* - DurableObjectNamespace (via `idFromName` duck-typing)
20-
* - Service bindings / JSRPC proxies (wraps `fetch` for trace propagation)
21+
* - Service bindings / JSRPC proxies
22+
* - Queue producers (via `send` + `sendBatch` duck-typing)
2123
*
22-
* Extensible for future binding types (KV, D1, Queue, etc.).
24+
* Extensible for future binding types (KV, D1, etc.).
2325
*
2426
* @param env - The Cloudflare env object to instrument
2527
* @param options - Optional CloudflareOptions to control RPC trace propagation
@@ -31,12 +33,6 @@ export function instrumentEnv<Env extends Record<string, unknown>>(env: Env, opt
3133

3234
const rpcPropagation = options ? getEffectiveRpcPropagation(options) : false;
3335

34-
// As of now only trace propagation is used for the instrumentEnv
35-
// so this is an optimization to avoid wrapping the env in a proxy if trace propagation is disabled
36-
if (!rpcPropagation) {
37-
return env;
38-
}
39-
4036
return new Proxy(env, {
4137
get(target, prop, receiver) {
4238
const item = Reflect.get(target, prop, receiver);
@@ -51,6 +47,17 @@ export function instrumentEnv<Env extends Record<string, unknown>>(env: Env, opt
5147
return cached;
5248
}
5349

50+
if (isQueue(item)) {
51+
const bindingName = typeof prop === 'string' ? prop : String(prop);
52+
const instrumented = instrumentQueueProducer(item, bindingName);
53+
instrumentedBindings.set(item, instrumented);
54+
return instrumented;
55+
}
56+
57+
if (!rpcPropagation) {
58+
return item;
59+
}
60+
5461
if (isDurableObjectNamespace(item)) {
5562
const instrumented = instrumentDurableObjectNamespace(item);
5663
instrumentedBindings.set(item, instrumented);

packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ function wrapQueueHandler(
4242
'faas.trigger': 'pubsub',
4343
'messaging.destination.name': batch.queue,
4444
'messaging.system': 'cloudflare',
45+
'messaging.operation.type': 'process',
46+
'messaging.operation.name': 'process',
4547
'messaging.batch.message_count': batch.messages.length,
4648
'messaging.message.retry.count': batch.messages.reduce((acc, message) => acc + message.attempts - 1, 0),
4749
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.process',
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { MessageSendRequest, Queue, QueueSendBatchOptions, QueueSendOptions } from '@cloudflare/workers-types';
2+
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
3+
4+
const ORIGIN = 'auto.faas.cloudflare.queue';
5+
6+
function startPublishSpan<T>(
7+
options: {
8+
bindingName: string;
9+
bodySize: number | undefined;
10+
messageCount?: number;
11+
},
12+
callback: () => T,
13+
): T {
14+
const { bindingName, bodySize, messageCount } = options;
15+
16+
return startSpan(
17+
{
18+
op: 'queue.publish',
19+
name: `send ${bindingName}`,
20+
attributes: {
21+
'messaging.system': 'cloudflare',
22+
'messaging.destination.name': bindingName,
23+
'messaging.operation.type': 'send',
24+
'messaging.operation.name': 'send',
25+
...(messageCount !== undefined && { 'messaging.batch.message_count': messageCount }),
26+
'messaging.message.body.size': bodySize,
27+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'queue.publish',
28+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
29+
},
30+
},
31+
callback,
32+
);
33+
}
34+
35+
function getBodySize(body: unknown): number | undefined {
36+
if (body == null) {
37+
return undefined;
38+
}
39+
40+
if (typeof body === 'string') {
41+
return new TextEncoder().encode(body).byteLength;
42+
}
43+
44+
if (body instanceof ArrayBuffer) {
45+
return body.byteLength;
46+
}
47+
48+
if (ArrayBuffer.isView(body)) {
49+
return body.byteLength;
50+
}
51+
52+
try {
53+
return new TextEncoder().encode(JSON.stringify(body)).byteLength;
54+
} catch {
55+
return undefined;
56+
}
57+
}
58+
59+
/**
60+
* Wraps a Queue producer binding to create `queue.publish` spans on
61+
* `send` and `sendBatch` calls.
62+
*
63+
* The queue's own name is not available on the binding object, so we use
64+
* the env binding key (e.g. `MY_QUEUE`) as `messaging.destination.name`.
65+
*/
66+
export function instrumentQueueProducer<T extends Queue>(queue: T, bindingName: string): T {
67+
return new Proxy(queue, {
68+
get(target, prop, receiver) {
69+
if (prop === 'send') {
70+
const original = Reflect.get(target, prop, receiver) as Queue['send'];
71+
72+
return function (this: unknown, message: unknown, options?: QueueSendOptions): Promise<void> {
73+
return startPublishSpan({ bindingName, bodySize: getBodySize(message) }, () =>
74+
Reflect.apply(original, target, [message, options]),
75+
);
76+
};
77+
}
78+
79+
if (prop === 'sendBatch') {
80+
const original = Reflect.get(target, prop, receiver) as Queue['sendBatch'];
81+
return function (
82+
this: unknown,
83+
messages: Iterable<MessageSendRequest>,
84+
options?: QueueSendBatchOptions,
85+
): Promise<void> {
86+
const messageArray = Array.from(messages);
87+
const totalBodySize = messageArray.reduce<number>((acc, m) => {
88+
const size = getBodySize(m.body);
89+
return size === undefined ? acc : acc + size;
90+
}, 0);
91+
92+
return startPublishSpan({ bindingName, bodySize: totalBodySize, messageCount: messageArray.length }, () =>
93+
Reflect.apply(original, target, [messageArray, options]),
94+
);
95+
};
96+
}
97+
98+
return Reflect.get(target, prop, receiver);
99+
},
100+
});
101+
}

packages/cloudflare/src/utils/isBinding.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
3232
*/
3333

34-
import type { DurableObjectNamespace } from '@cloudflare/workers-types';
34+
import type { DurableObjectNamespace, Queue } from '@cloudflare/workers-types';
3535

3636
/**
3737
* Checks if a value is a JSRPC proxy (service binding).
@@ -59,3 +59,11 @@ const isNotJSRPC = (item: unknown): item is Record<string, unknown> => !isJSRPC(
5959
export function isDurableObjectNamespace(item: unknown): item is DurableObjectNamespace {
6060
return item != null && isNotJSRPC(item) && typeof item.idFromName === 'function';
6161
}
62+
63+
/**
64+
* Duck-type check for Queue producer bindings.
65+
* Queue has `send` and `sendBatch` async methods.
66+
*/
67+
export function isQueue(item: unknown): item is Queue {
68+
return item != null && isNotJSRPC(item) && typeof item.send === 'function' && typeof item.sendBatch === 'function';
69+
}

0 commit comments

Comments
 (0)