Skip to content

Commit 3671f29

Browse files
JPeer264claude
andcommitted
feat(cloudflare): Add R2 bucket auto-instrumentation
Auto-detect R2 bucket bindings via duck-typing in instrumentEnv and wrap all operations with spans. Span names and attributes align with Cloudflare's built-in tracing conventions (r2_get, r2_put, etc.). Instrumented methods: get, head, put, delete, list, createMultipartUpload, resumeMultipartUpload, and the resulting multipart upload operations (uploadPart, abort, complete). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 555dbe1 commit 3671f29

8 files changed

Lines changed: 903 additions & 4 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { R2Bucket } from '@cloudflare/workers-types';
2+
import * as Sentry from '@sentry/cloudflare';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
MY_BUCKET: R2Bucket;
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 === '/r2/put-get') {
19+
await env.MY_BUCKET.put('test-key.txt', 'test-value');
20+
const obj = await env.MY_BUCKET.get('test-key.txt');
21+
const text = await obj?.text();
22+
return new Response(text);
23+
}
24+
25+
if (url.pathname === '/r2/head') {
26+
await env.MY_BUCKET.put('head-key.txt', 'hello');
27+
await env.MY_BUCKET.head('head-key.txt');
28+
return new Response('OK');
29+
}
30+
31+
if (url.pathname === '/r2/list') {
32+
await env.MY_BUCKET.list({ prefix: 'test-' });
33+
return new Response('OK');
34+
}
35+
36+
if (url.pathname === '/r2/delete') {
37+
await env.MY_BUCKET.put('delete-me.txt', 'gone');
38+
await env.MY_BUCKET.delete('delete-me.txt');
39+
return new Response('OK');
40+
}
41+
42+
if (url.pathname === '/r2/multipart') {
43+
const upload = await env.MY_BUCKET.createMultipartUpload('multipart.bin');
44+
const data = new Uint8Array(5 * 1024 * 1024 + 1).fill(65);
45+
const part1 = await upload.uploadPart(1, data);
46+
const part2 = await upload.uploadPart(2, 'final');
47+
await upload.complete([part1, part2]);
48+
return new Response('OK');
49+
}
50+
51+
return new Response('not found', { status: 404 });
52+
},
53+
} as ExportedHandler<Env>,
54+
);
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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 findSpans(envelope: Envelope, description: string): Array<Record<string, unknown>> {
14+
if (envelopeItemType(envelope) !== 'transaction') return [];
15+
const tx = envelopeItem(envelope);
16+
const spans = (tx.spans as Array<Record<string, unknown>>) || [];
17+
return spans.filter(s => s.description === description);
18+
}
19+
20+
function spanData(span: Record<string, unknown>): Record<string, unknown> {
21+
return span.data as Record<string, unknown>;
22+
}
23+
24+
it('emits r2_put and r2_get spans with correct attributes', async ({ signal }) => {
25+
const runner = createRunner(__dirname)
26+
.expect((envelope: Envelope) => {
27+
const putSpans = findSpans(envelope, 'r2_put');
28+
expect(putSpans).toHaveLength(1);
29+
const putData = spanData(putSpans[0]!);
30+
expect({
31+
op: putSpans[0]!.op,
32+
description: putSpans[0]!.description,
33+
'cloudflare.r2.operation': putData['cloudflare.r2.operation'],
34+
'cloudflare.r2.bucket': putData['cloudflare.r2.bucket'],
35+
'cloudflare.r2.request.key': putData['cloudflare.r2.request.key'],
36+
'sentry.origin': putData['sentry.origin'],
37+
}).toEqual({
38+
op: 'cloud.r2',
39+
description: 'r2_put',
40+
'cloudflare.r2.operation': 'PutObject',
41+
'cloudflare.r2.bucket': 'MY_BUCKET',
42+
'cloudflare.r2.request.key': 'test-key.txt',
43+
'sentry.origin': 'auto.faas.cloudflare.r2',
44+
});
45+
46+
const getSpans = findSpans(envelope, 'r2_get');
47+
expect(getSpans).toHaveLength(1);
48+
const getData = spanData(getSpans[0]!);
49+
expect({
50+
op: getSpans[0]!.op,
51+
description: getSpans[0]!.description,
52+
'cloudflare.r2.operation': getData['cloudflare.r2.operation'],
53+
'cloudflare.r2.bucket': getData['cloudflare.r2.bucket'],
54+
'cloudflare.r2.request.key': getData['cloudflare.r2.request.key'],
55+
'sentry.origin': getData['sentry.origin'],
56+
}).toEqual({
57+
op: 'cloud.r2',
58+
description: 'r2_get',
59+
'cloudflare.r2.operation': 'GetObject',
60+
'cloudflare.r2.bucket': 'MY_BUCKET',
61+
'cloudflare.r2.request.key': 'test-key.txt',
62+
'sentry.origin': 'auto.faas.cloudflare.r2',
63+
});
64+
})
65+
.start(signal);
66+
67+
await runner.makeRequest('get', '/r2/put-get');
68+
await runner.completed();
69+
});
70+
71+
it('emits an r2_head span', async ({ signal }) => {
72+
const runner = createRunner(__dirname)
73+
.expect((envelope: Envelope) => {
74+
const spans = findSpans(envelope, 'r2_head');
75+
expect(spans).toHaveLength(1);
76+
const data = spanData(spans[0]!);
77+
expect({
78+
op: spans[0]!.op,
79+
description: spans[0]!.description,
80+
'cloudflare.r2.operation': data['cloudflare.r2.operation'],
81+
'cloudflare.r2.bucket': data['cloudflare.r2.bucket'],
82+
'cloudflare.r2.request.key': data['cloudflare.r2.request.key'],
83+
'sentry.origin': data['sentry.origin'],
84+
}).toEqual({
85+
op: 'cloud.r2',
86+
description: 'r2_head',
87+
'cloudflare.r2.operation': 'HeadObject',
88+
'cloudflare.r2.bucket': 'MY_BUCKET',
89+
'cloudflare.r2.request.key': 'head-key.txt',
90+
'sentry.origin': 'auto.faas.cloudflare.r2',
91+
});
92+
})
93+
.start(signal);
94+
95+
await runner.makeRequest('get', '/r2/head');
96+
await runner.completed();
97+
});
98+
99+
it('emits an r2_list span without a key attribute', async ({ signal }) => {
100+
const runner = createRunner(__dirname)
101+
.expect((envelope: Envelope) => {
102+
const spans = findSpans(envelope, 'r2_list');
103+
expect(spans).toHaveLength(1);
104+
const data = spanData(spans[0]!);
105+
expect({
106+
op: spans[0]!.op,
107+
description: spans[0]!.description,
108+
'cloudflare.r2.operation': data['cloudflare.r2.operation'],
109+
'cloudflare.r2.bucket': data['cloudflare.r2.bucket'],
110+
'cloudflare.r2.request.key': data['cloudflare.r2.request.key'],
111+
'sentry.origin': data['sentry.origin'],
112+
}).toEqual({
113+
op: 'cloud.r2',
114+
description: 'r2_list',
115+
'cloudflare.r2.operation': 'ListObjects',
116+
'cloudflare.r2.bucket': 'MY_BUCKET',
117+
'cloudflare.r2.request.key': undefined,
118+
'sentry.origin': 'auto.faas.cloudflare.r2',
119+
});
120+
})
121+
.start(signal);
122+
123+
await runner.makeRequest('get', '/r2/list');
124+
await runner.completed();
125+
});
126+
127+
it('emits an r2_delete span', async ({ signal }) => {
128+
const runner = createRunner(__dirname)
129+
.expect((envelope: Envelope) => {
130+
const spans = findSpans(envelope, 'r2_delete');
131+
expect(spans).toHaveLength(1);
132+
const data = spanData(spans[0]!);
133+
expect({
134+
op: spans[0]!.op,
135+
description: spans[0]!.description,
136+
'cloudflare.r2.operation': data['cloudflare.r2.operation'],
137+
'cloudflare.r2.bucket': data['cloudflare.r2.bucket'],
138+
'cloudflare.r2.request.key': data['cloudflare.r2.request.key'],
139+
'sentry.origin': data['sentry.origin'],
140+
}).toEqual({
141+
op: 'cloud.r2',
142+
description: 'r2_delete',
143+
'cloudflare.r2.operation': 'DeleteObject',
144+
'cloudflare.r2.bucket': 'MY_BUCKET',
145+
'cloudflare.r2.request.key': 'delete-me.txt',
146+
'sentry.origin': 'auto.faas.cloudflare.r2',
147+
});
148+
})
149+
.start(signal);
150+
151+
await runner.makeRequest('get', '/r2/delete');
152+
await runner.completed();
153+
});
154+
155+
it('emits spans for each multipart upload operation', async ({ signal }) => {
156+
const runner = createRunner(__dirname)
157+
.expect((envelope: Envelope) => {
158+
const createSpans = findSpans(envelope, 'r2_createMultipartUpload');
159+
expect(createSpans).toHaveLength(1);
160+
const createData = spanData(createSpans[0]!);
161+
expect({
162+
op: createSpans[0]!.op,
163+
description: createSpans[0]!.description,
164+
'cloudflare.r2.operation': createData['cloudflare.r2.operation'],
165+
'cloudflare.r2.bucket': createData['cloudflare.r2.bucket'],
166+
'cloudflare.r2.request.key': createData['cloudflare.r2.request.key'],
167+
'sentry.origin': createData['sentry.origin'],
168+
}).toEqual({
169+
op: 'cloud.r2',
170+
description: 'r2_createMultipartUpload',
171+
'cloudflare.r2.operation': 'CreateMultipartUpload',
172+
'cloudflare.r2.bucket': 'MY_BUCKET',
173+
'cloudflare.r2.request.key': 'multipart.bin',
174+
'sentry.origin': 'auto.faas.cloudflare.r2',
175+
});
176+
177+
const uploadPartSpans = findSpans(envelope, 'r2_uploadPart');
178+
expect(uploadPartSpans).toHaveLength(2);
179+
const part0Data = spanData(uploadPartSpans[0]!);
180+
expect({
181+
op: uploadPartSpans[0]!.op,
182+
description: uploadPartSpans[0]!.description,
183+
'cloudflare.r2.operation': part0Data['cloudflare.r2.operation'],
184+
'cloudflare.r2.bucket': part0Data['cloudflare.r2.bucket'],
185+
'cloudflare.r2.request.key': part0Data['cloudflare.r2.request.key'],
186+
'cloudflare.r2.request.part_number': part0Data['cloudflare.r2.request.part_number'],
187+
'sentry.origin': part0Data['sentry.origin'],
188+
}).toEqual({
189+
op: 'cloud.r2',
190+
description: 'r2_uploadPart',
191+
'cloudflare.r2.operation': 'UploadPart',
192+
'cloudflare.r2.bucket': 'MY_BUCKET',
193+
'cloudflare.r2.request.key': 'multipart.bin',
194+
'cloudflare.r2.request.part_number': 1,
195+
'sentry.origin': 'auto.faas.cloudflare.r2',
196+
});
197+
expect(spanData(uploadPartSpans[1]!)['cloudflare.r2.request.part_number']).toBe(2);
198+
199+
const completeSpans = findSpans(envelope, 'r2_completeMultipartUpload');
200+
expect(completeSpans).toHaveLength(1);
201+
const completeData = spanData(completeSpans[0]!);
202+
expect({
203+
op: completeSpans[0]!.op,
204+
description: completeSpans[0]!.description,
205+
'cloudflare.r2.operation': completeData['cloudflare.r2.operation'],
206+
'cloudflare.r2.bucket': completeData['cloudflare.r2.bucket'],
207+
'cloudflare.r2.request.key': completeData['cloudflare.r2.request.key'],
208+
'sentry.origin': completeData['sentry.origin'],
209+
}).toEqual({
210+
op: 'cloud.r2',
211+
description: 'r2_completeMultipartUpload',
212+
'cloudflare.r2.operation': 'CompleteMultipartUpload',
213+
'cloudflare.r2.bucket': 'MY_BUCKET',
214+
'cloudflare.r2.request.key': 'multipart.bin',
215+
'sentry.origin': 'auto.faas.cloudflare.r2',
216+
});
217+
})
218+
.start(signal);
219+
220+
await runner.makeRequest('get', '/r2/multipart');
221+
await runner.completed();
222+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "worker-name",
3+
"compatibility_date": "2025-06-17",
4+
"main": "index.ts",
5+
"compatibility_flags": ["nodejs_compat"],
6+
"r2_buckets": [
7+
{
8+
"binding": "MY_BUCKET",
9+
"bucket_name": "test-bucket",
10+
},
11+
],
12+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('R2 put and get create spans', async ({ baseURL }) => {
5+
const transactionWaiter = waitForTransaction('cloudflare-workers', event => {
6+
return event.spans?.some(span => span.description === 'r2_put') ?? false;
7+
});
8+
9+
const response = await fetch(`${baseURL}/r2/put-get`);
10+
expect(response.status).toBe(200);
11+
await expect(response.text()).resolves.toBe('test-value');
12+
13+
const transaction = await transactionWaiter;
14+
15+
const putSpan = transaction.spans?.find(span => span.description === 'r2_put');
16+
expect(putSpan).toBeDefined();
17+
expect(putSpan?.op).toBe('cloud.r2');
18+
expect(putSpan?.data?.['cloudflare.r2.operation']).toBe('PutObject');
19+
expect(putSpan?.data?.['cloudflare.r2.bucket']).toBe('MY_BUCKET');
20+
expect(putSpan?.data?.['cloudflare.r2.request.key']).toBe('test-key.txt');
21+
expect(putSpan?.data?.['sentry.origin']).toBe('auto.faas.cloudflare.r2');
22+
23+
const getSpan = transaction.spans?.find(span => span.description === 'r2_get');
24+
expect(getSpan).toBeDefined();
25+
expect(getSpan?.op).toBe('cloud.r2');
26+
expect(getSpan?.data?.['cloudflare.r2.operation']).toBe('GetObject');
27+
expect(getSpan?.data?.['cloudflare.r2.request.key']).toBe('test-key.txt');
28+
});
29+
30+
test('R2 head creates a span', async ({ baseURL }) => {
31+
const transactionWaiter = waitForTransaction('cloudflare-workers', event => {
32+
return event.spans?.some(span => span.description === 'r2_head') ?? false;
33+
});
34+
35+
const response = await fetch(`${baseURL}/r2/head`);
36+
expect(response.status).toBe(200);
37+
38+
const transaction = await transactionWaiter;
39+
40+
const headSpan = transaction.spans?.find(span => span.description === 'r2_head');
41+
expect(headSpan).toBeDefined();
42+
expect(headSpan?.op).toBe('cloud.r2');
43+
expect(headSpan?.data?.['cloudflare.r2.operation']).toBe('HeadObject');
44+
expect(headSpan?.data?.['cloudflare.r2.request.key']).toBe('head-key.txt');
45+
});
46+
47+
test('R2 list creates a span', async ({ baseURL }) => {
48+
const transactionWaiter = waitForTransaction('cloudflare-workers', event => {
49+
return event.spans?.some(span => span.description === 'r2_list') ?? false;
50+
});
51+
52+
const response = await fetch(`${baseURL}/r2/list`);
53+
expect(response.status).toBe(200);
54+
55+
const transaction = await transactionWaiter;
56+
57+
const listSpan = transaction.spans?.find(span => span.description === 'r2_list');
58+
expect(listSpan).toBeDefined();
59+
expect(listSpan?.op).toBe('cloud.r2');
60+
expect(listSpan?.data?.['cloudflare.r2.operation']).toBe('ListObjects');
61+
expect(listSpan?.data?.['cloudflare.r2.request.key']).toBeUndefined();
62+
});
63+
64+
test('R2 delete creates a span', async ({ baseURL }) => {
65+
const transactionWaiter = waitForTransaction('cloudflare-workers', event => {
66+
return event.spans?.some(span => span.description === 'r2_delete') ?? false;
67+
});
68+
69+
const response = await fetch(`${baseURL}/r2/delete`);
70+
expect(response.status).toBe(200);
71+
72+
const transaction = await transactionWaiter;
73+
74+
const deleteSpan = transaction.spans?.find(span => span.description === 'r2_delete');
75+
expect(deleteSpan).toBeDefined();
76+
expect(deleteSpan?.op).toBe('cloud.r2');
77+
expect(deleteSpan?.data?.['cloudflare.r2.operation']).toBe('DeleteObject');
78+
expect(deleteSpan?.data?.['cloudflare.r2.request.key']).toBe('delete-me.txt');
79+
});
80+
81+
test('R2 multipart upload creates spans for each operation', async ({ baseURL }) => {
82+
const transactionWaiter = waitForTransaction('cloudflare-workers', event => {
83+
return event.spans?.some(span => span.description === 'r2_createMultipartUpload') ?? false;
84+
});
85+
86+
const response = await fetch(`${baseURL}/r2/multipart`);
87+
expect(response.status).toBe(200);
88+
89+
const transaction = await transactionWaiter;
90+
91+
const createSpan = transaction.spans?.find(span => span.description === 'r2_createMultipartUpload');
92+
expect(createSpan).toBeDefined();
93+
expect(createSpan?.op).toBe('cloud.r2');
94+
expect(createSpan?.data?.['cloudflare.r2.operation']).toBe('CreateMultipartUpload');
95+
expect(createSpan?.data?.['cloudflare.r2.request.key']).toBe('multipart.bin');
96+
97+
const uploadPartSpans = transaction.spans?.filter(span => span.description === 'r2_uploadPart');
98+
expect(uploadPartSpans).toHaveLength(2);
99+
expect(uploadPartSpans?.[0]?.data?.['cloudflare.r2.operation']).toBe('UploadPart');
100+
expect(uploadPartSpans?.[0]?.data?.['cloudflare.r2.request.key']).toBe('multipart.bin');
101+
expect(uploadPartSpans?.[0]?.data?.['cloudflare.r2.request.part_number']).toBe(1);
102+
expect(uploadPartSpans?.[1]?.data?.['cloudflare.r2.request.part_number']).toBe(2);
103+
104+
const completeSpan = transaction.spans?.find(span => span.description === 'r2_completeMultipartUpload');
105+
expect(completeSpan).toBeDefined();
106+
expect(completeSpan?.data?.['cloudflare.r2.operation']).toBe('CompleteMultipartUpload');
107+
expect(completeSpan?.data?.['cloudflare.r2.request.key']).toBe('multipart.bin');
108+
});

0 commit comments

Comments
 (0)