Skip to content

Commit 1024839

Browse files
JPeer264claude
andauthored
feat(cloudflare): Add R2 bucket auto-instrumentation (#21327)
closes #20836 closes [JS-2448](https://linear.app/getsentry/issue/JS-2448) This implements R2 spans. All attributes were taken from Cloudflare itself. The only thing which does not exist in Cloudflare and is added here (inspired by [AWS S3](https://opentelemetry.io/docs/specs/semconv/object-stores/s3/)), is `cloudflare.r2.bucket` and gives information of the binding itself. This should be merged after the [Sentry conventions PR](getsentry/sentry-conventions#413) and the [Sentry docs PR](getsentry/sentry-docs#18410) (or at least after approval, in case some OPs change) --- AI generated text: 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 beb181a commit 1024839

8 files changed

Lines changed: 809 additions & 6 deletions

File tree

.size-limit.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ module.exports = [
451451
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
452452
gzip: false,
453453
brotli: false,
454-
limit: '176 KiB',
454+
limit: '180 KiB',
455455
disablePlugins: ['@size-limit/webpack'],
456456
webpack: false,
457457
modifyEsbuildConfig: function (config) {
@@ -471,7 +471,7 @@ module.exports = [
471471
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
472472
gzip: false,
473473
brotli: false,
474-
limit: '432 KiB',
474+
limit: '450 KiB',
475475
disablePlugins: ['@size-limit/webpack'],
476476
webpack: false,
477477
modifyEsbuildConfig: function (config) {
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: 'object.put',
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: 'object.get',
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: 'object.head',
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: 'object.list',
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: 'object.delete',
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: 'object.multipart_upload.create',
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: 'object.upload_part',
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: 'object.multipart_upload.complete',
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+
}

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { CloudflareOptions } from '../../client';
2-
import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue } from '../../utils/isBinding';
2+
import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue, isR2Bucket } from '../../utils/isBinding';
33
import { instrumentD1 } from './instrumentD1';
44
import { appendRpcMeta } from '../../utils/rpcMeta';
55
import { getEffectiveRpcPropagation } from '../../utils/rpcOptions';
66
import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace';
77
import { instrumentFetcher } from './instrumentFetcher';
88
import { instrumentQueueProducer } from './instrumentQueueProducer';
9+
import { instrumentR2Bucket } from './instrumentR2';
910

1011
function isProxyable(item: unknown): item is object {
1112
return item !== null && (typeof item === 'object' || typeof item === 'function');
@@ -21,8 +22,7 @@ const instrumentedBindings = new WeakMap<object, unknown>();
2122
* - DurableObjectNamespace (via `idFromName` duck-typing)
2223
* - Service bindings / JSRPC proxies
2324
* - Queue producers (via `send` + `sendBatch` duck-typing)
24-
*
25-
* Extensible for future binding types (KV, D1, etc.).
25+
* - R2 Buckets (via `head` + `put` + `createMultipartUpload` duck-typing)
2626
*
2727
* @param env - The Cloudflare env object to instrument
2828
* @param options - Optional CloudflareOptions to control RPC trace propagation
@@ -61,6 +61,13 @@ export function instrumentEnv<Env extends Record<string, unknown>>(env: Env, opt
6161
return instrumented;
6262
}
6363

64+
if (isR2Bucket(item)) {
65+
const bindingName = typeof prop === 'string' ? prop : String(prop);
66+
const instrumented = instrumentR2Bucket(item, bindingName);
67+
instrumentedBindings.set(item, instrumented);
68+
return instrumented;
69+
}
70+
6471
if (!rpcPropagation) {
6572
return item;
6673
}

0 commit comments

Comments
 (0)