diff --git a/dev-packages/cloudflare-integration-tests/suites/r2/index.ts b/dev-packages/cloudflare-integration-tests/suites/r2/index.ts new file mode 100644 index 000000000000..7f1da7308dba --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/r2/index.ts @@ -0,0 +1,54 @@ +import type { R2Bucket } from '@cloudflare/workers-types'; +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + MY_BUCKET: R2Bucket; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/r2/put-get') { + await env.MY_BUCKET.put('test-key.txt', 'test-value'); + const obj = await env.MY_BUCKET.get('test-key.txt'); + const text = await obj?.text(); + return new Response(text); + } + + if (url.pathname === '/r2/head') { + await env.MY_BUCKET.put('head-key.txt', 'hello'); + await env.MY_BUCKET.head('head-key.txt'); + return new Response('OK'); + } + + if (url.pathname === '/r2/list') { + await env.MY_BUCKET.list({ prefix: 'test-' }); + return new Response('OK'); + } + + if (url.pathname === '/r2/delete') { + await env.MY_BUCKET.put('delete-me.txt', 'gone'); + await env.MY_BUCKET.delete('delete-me.txt'); + return new Response('OK'); + } + + if (url.pathname === '/r2/multipart') { + const upload = await env.MY_BUCKET.createMultipartUpload('multipart.bin'); + const data = new Uint8Array(5 * 1024 * 1024 + 1).fill(65); + const part1 = await upload.uploadPart(1, data); + const part2 = await upload.uploadPart(2, 'final'); + await upload.complete([part1, part2]); + return new Response('OK'); + } + + return new Response('not found', { status: 404 }); + }, + } as ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/r2/test.ts b/dev-packages/cloudflare-integration-tests/suites/r2/test.ts new file mode 100644 index 000000000000..d04f39b92f25 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/r2/test.ts @@ -0,0 +1,222 @@ +import type { Envelope } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../runner'; + +function envelopeItemType(envelope: Envelope): string | undefined { + return envelope[1][0]?.[0]?.type as string | undefined; +} + +function envelopeItem(envelope: Envelope): Record { + return envelope[1][0]![1] as Record; +} + +function findSpans(envelope: Envelope, description: string): Array> { + if (envelopeItemType(envelope) !== 'transaction') return []; + const tx = envelopeItem(envelope); + const spans = (tx.spans as Array>) || []; + return spans.filter(s => s.description === description); +} + +function spanData(span: Record): Record { + return span.data as Record; +} + +it('emits r2_put and r2_get spans with correct attributes', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect((envelope: Envelope) => { + const putSpans = findSpans(envelope, 'r2_put'); + expect(putSpans).toHaveLength(1); + const putData = spanData(putSpans[0]!); + expect({ + op: putSpans[0]!.op, + description: putSpans[0]!.description, + 'cloudflare.r2.operation': putData['cloudflare.r2.operation'], + 'cloudflare.r2.bucket': putData['cloudflare.r2.bucket'], + 'cloudflare.r2.request.key': putData['cloudflare.r2.request.key'], + 'sentry.origin': putData['sentry.origin'], + }).toEqual({ + op: 'cloud.r2', + description: 'r2_put', + 'cloudflare.r2.operation': 'PutObject', + 'cloudflare.r2.bucket': 'MY_BUCKET', + 'cloudflare.r2.request.key': 'test-key.txt', + 'sentry.origin': 'auto.faas.cloudflare.r2', + }); + + const getSpans = findSpans(envelope, 'r2_get'); + expect(getSpans).toHaveLength(1); + const getData = spanData(getSpans[0]!); + expect({ + op: getSpans[0]!.op, + description: getSpans[0]!.description, + 'cloudflare.r2.operation': getData['cloudflare.r2.operation'], + 'cloudflare.r2.bucket': getData['cloudflare.r2.bucket'], + 'cloudflare.r2.request.key': getData['cloudflare.r2.request.key'], + 'sentry.origin': getData['sentry.origin'], + }).toEqual({ + op: 'cloud.r2', + description: 'r2_get', + 'cloudflare.r2.operation': 'GetObject', + 'cloudflare.r2.bucket': 'MY_BUCKET', + 'cloudflare.r2.request.key': 'test-key.txt', + 'sentry.origin': 'auto.faas.cloudflare.r2', + }); + }) + .start(signal); + + await runner.makeRequest('get', '/r2/put-get'); + await runner.completed(); +}); + +it('emits an r2_head span', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect((envelope: Envelope) => { + const spans = findSpans(envelope, 'r2_head'); + expect(spans).toHaveLength(1); + const data = spanData(spans[0]!); + expect({ + op: spans[0]!.op, + description: spans[0]!.description, + 'cloudflare.r2.operation': data['cloudflare.r2.operation'], + 'cloudflare.r2.bucket': data['cloudflare.r2.bucket'], + 'cloudflare.r2.request.key': data['cloudflare.r2.request.key'], + 'sentry.origin': data['sentry.origin'], + }).toEqual({ + op: 'cloud.r2', + description: 'r2_head', + 'cloudflare.r2.operation': 'HeadObject', + 'cloudflare.r2.bucket': 'MY_BUCKET', + 'cloudflare.r2.request.key': 'head-key.txt', + 'sentry.origin': 'auto.faas.cloudflare.r2', + }); + }) + .start(signal); + + await runner.makeRequest('get', '/r2/head'); + await runner.completed(); +}); + +it('emits an r2_list span without a key attribute', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect((envelope: Envelope) => { + const spans = findSpans(envelope, 'r2_list'); + expect(spans).toHaveLength(1); + const data = spanData(spans[0]!); + expect({ + op: spans[0]!.op, + description: spans[0]!.description, + 'cloudflare.r2.operation': data['cloudflare.r2.operation'], + 'cloudflare.r2.bucket': data['cloudflare.r2.bucket'], + 'cloudflare.r2.request.key': data['cloudflare.r2.request.key'], + 'sentry.origin': data['sentry.origin'], + }).toEqual({ + op: 'cloud.r2', + description: 'r2_list', + 'cloudflare.r2.operation': 'ListObjects', + 'cloudflare.r2.bucket': 'MY_BUCKET', + 'cloudflare.r2.request.key': undefined, + 'sentry.origin': 'auto.faas.cloudflare.r2', + }); + }) + .start(signal); + + await runner.makeRequest('get', '/r2/list'); + await runner.completed(); +}); + +it('emits an r2_delete span', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect((envelope: Envelope) => { + const spans = findSpans(envelope, 'r2_delete'); + expect(spans).toHaveLength(1); + const data = spanData(spans[0]!); + expect({ + op: spans[0]!.op, + description: spans[0]!.description, + 'cloudflare.r2.operation': data['cloudflare.r2.operation'], + 'cloudflare.r2.bucket': data['cloudflare.r2.bucket'], + 'cloudflare.r2.request.key': data['cloudflare.r2.request.key'], + 'sentry.origin': data['sentry.origin'], + }).toEqual({ + op: 'cloud.r2', + description: 'r2_delete', + 'cloudflare.r2.operation': 'DeleteObject', + 'cloudflare.r2.bucket': 'MY_BUCKET', + 'cloudflare.r2.request.key': 'delete-me.txt', + 'sentry.origin': 'auto.faas.cloudflare.r2', + }); + }) + .start(signal); + + await runner.makeRequest('get', '/r2/delete'); + await runner.completed(); +}); + +it('emits spans for each multipart upload operation', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect((envelope: Envelope) => { + const createSpans = findSpans(envelope, 'r2_createMultipartUpload'); + expect(createSpans).toHaveLength(1); + const createData = spanData(createSpans[0]!); + expect({ + op: createSpans[0]!.op, + description: createSpans[0]!.description, + 'cloudflare.r2.operation': createData['cloudflare.r2.operation'], + 'cloudflare.r2.bucket': createData['cloudflare.r2.bucket'], + 'cloudflare.r2.request.key': createData['cloudflare.r2.request.key'], + 'sentry.origin': createData['sentry.origin'], + }).toEqual({ + op: 'cloud.r2', + description: 'r2_createMultipartUpload', + 'cloudflare.r2.operation': 'CreateMultipartUpload', + 'cloudflare.r2.bucket': 'MY_BUCKET', + 'cloudflare.r2.request.key': 'multipart.bin', + 'sentry.origin': 'auto.faas.cloudflare.r2', + }); + + const uploadPartSpans = findSpans(envelope, 'r2_uploadPart'); + expect(uploadPartSpans).toHaveLength(2); + const part0Data = spanData(uploadPartSpans[0]!); + expect({ + op: uploadPartSpans[0]!.op, + description: uploadPartSpans[0]!.description, + 'cloudflare.r2.operation': part0Data['cloudflare.r2.operation'], + 'cloudflare.r2.bucket': part0Data['cloudflare.r2.bucket'], + 'cloudflare.r2.request.key': part0Data['cloudflare.r2.request.key'], + 'cloudflare.r2.request.part_number': part0Data['cloudflare.r2.request.part_number'], + 'sentry.origin': part0Data['sentry.origin'], + }).toEqual({ + op: 'cloud.r2', + description: 'r2_uploadPart', + 'cloudflare.r2.operation': 'UploadPart', + 'cloudflare.r2.bucket': 'MY_BUCKET', + 'cloudflare.r2.request.key': 'multipart.bin', + 'cloudflare.r2.request.part_number': 1, + 'sentry.origin': 'auto.faas.cloudflare.r2', + }); + expect(spanData(uploadPartSpans[1]!)['cloudflare.r2.request.part_number']).toBe(2); + + const completeSpans = findSpans(envelope, 'r2_completeMultipartUpload'); + expect(completeSpans).toHaveLength(1); + const completeData = spanData(completeSpans[0]!); + expect({ + op: completeSpans[0]!.op, + description: completeSpans[0]!.description, + 'cloudflare.r2.operation': completeData['cloudflare.r2.operation'], + 'cloudflare.r2.bucket': completeData['cloudflare.r2.bucket'], + 'cloudflare.r2.request.key': completeData['cloudflare.r2.request.key'], + 'sentry.origin': completeData['sentry.origin'], + }).toEqual({ + op: 'cloud.r2', + description: 'r2_completeMultipartUpload', + 'cloudflare.r2.operation': 'CompleteMultipartUpload', + 'cloudflare.r2.bucket': 'MY_BUCKET', + 'cloudflare.r2.request.key': 'multipart.bin', + 'sentry.origin': 'auto.faas.cloudflare.r2', + }); + }) + .start(signal); + + await runner.makeRequest('get', '/r2/multipart'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/r2/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/r2/wrangler.jsonc new file mode 100644 index 000000000000..e177f1f793ec --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/r2/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], + "r2_buckets": [ + { + "binding": "MY_BUCKET", + "bucket_name": "test-bucket", + }, + ], +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/r2.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/r2.test.ts new file mode 100644 index 000000000000..4c993c5c8417 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/r2.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('R2 put and get create spans', async ({ baseURL }) => { + const transactionWaiter = waitForTransaction('cloudflare-workers', event => { + return event.spans?.some(span => span.description === 'r2_put') ?? false; + }); + + const response = await fetch(`${baseURL}/r2/put-get`); + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe('test-value'); + + const transaction = await transactionWaiter; + + const putSpan = transaction.spans?.find(span => span.description === 'r2_put'); + expect(putSpan).toBeDefined(); + expect(putSpan?.op).toBe('cloud.r2'); + expect(putSpan?.data?.['cloudflare.r2.operation']).toBe('PutObject'); + expect(putSpan?.data?.['cloudflare.r2.bucket']).toBe('MY_BUCKET'); + expect(putSpan?.data?.['cloudflare.r2.request.key']).toBe('test-key.txt'); + expect(putSpan?.data?.['sentry.origin']).toBe('auto.faas.cloudflare.r2'); + + const getSpan = transaction.spans?.find(span => span.description === 'r2_get'); + expect(getSpan).toBeDefined(); + expect(getSpan?.op).toBe('cloud.r2'); + expect(getSpan?.data?.['cloudflare.r2.operation']).toBe('GetObject'); + expect(getSpan?.data?.['cloudflare.r2.request.key']).toBe('test-key.txt'); +}); + +test('R2 head creates a span', async ({ baseURL }) => { + const transactionWaiter = waitForTransaction('cloudflare-workers', event => { + return event.spans?.some(span => span.description === 'r2_head') ?? false; + }); + + const response = await fetch(`${baseURL}/r2/head`); + expect(response.status).toBe(200); + + const transaction = await transactionWaiter; + + const headSpan = transaction.spans?.find(span => span.description === 'r2_head'); + expect(headSpan).toBeDefined(); + expect(headSpan?.op).toBe('cloud.r2'); + expect(headSpan?.data?.['cloudflare.r2.operation']).toBe('HeadObject'); + expect(headSpan?.data?.['cloudflare.r2.request.key']).toBe('head-key.txt'); +}); + +test('R2 list creates a span', async ({ baseURL }) => { + const transactionWaiter = waitForTransaction('cloudflare-workers', event => { + return event.spans?.some(span => span.description === 'r2_list') ?? false; + }); + + const response = await fetch(`${baseURL}/r2/list`); + expect(response.status).toBe(200); + + const transaction = await transactionWaiter; + + const listSpan = transaction.spans?.find(span => span.description === 'r2_list'); + expect(listSpan).toBeDefined(); + expect(listSpan?.op).toBe('cloud.r2'); + expect(listSpan?.data?.['cloudflare.r2.operation']).toBe('ListObjects'); + expect(listSpan?.data?.['cloudflare.r2.request.key']).toBeUndefined(); +}); + +test('R2 delete creates a span', async ({ baseURL }) => { + const transactionWaiter = waitForTransaction('cloudflare-workers', event => { + return event.spans?.some(span => span.description === 'r2_delete') ?? false; + }); + + const response = await fetch(`${baseURL}/r2/delete`); + expect(response.status).toBe(200); + + const transaction = await transactionWaiter; + + const deleteSpan = transaction.spans?.find(span => span.description === 'r2_delete'); + expect(deleteSpan).toBeDefined(); + expect(deleteSpan?.op).toBe('cloud.r2'); + expect(deleteSpan?.data?.['cloudflare.r2.operation']).toBe('DeleteObject'); + expect(deleteSpan?.data?.['cloudflare.r2.request.key']).toBe('delete-me.txt'); +}); + +test('R2 multipart upload creates spans for each operation', async ({ baseURL }) => { + const transactionWaiter = waitForTransaction('cloudflare-workers', event => { + return event.spans?.some(span => span.description === 'r2_createMultipartUpload') ?? false; + }); + + const response = await fetch(`${baseURL}/r2/multipart`); + expect(response.status).toBe(200); + + const transaction = await transactionWaiter; + + const createSpan = transaction.spans?.find(span => span.description === 'r2_createMultipartUpload'); + expect(createSpan).toBeDefined(); + expect(createSpan?.op).toBe('cloud.r2'); + expect(createSpan?.data?.['cloudflare.r2.operation']).toBe('CreateMultipartUpload'); + expect(createSpan?.data?.['cloudflare.r2.request.key']).toBe('multipart.bin'); + + const uploadPartSpans = transaction.spans?.filter(span => span.description === 'r2_uploadPart'); + expect(uploadPartSpans).toHaveLength(2); + expect(uploadPartSpans?.[0]?.data?.['cloudflare.r2.operation']).toBe('UploadPart'); + expect(uploadPartSpans?.[0]?.data?.['cloudflare.r2.request.key']).toBe('multipart.bin'); + expect(uploadPartSpans?.[0]?.data?.['cloudflare.r2.request.part_number']).toBe(1); + expect(uploadPartSpans?.[1]?.data?.['cloudflare.r2.request.part_number']).toBe(2); + + const completeSpan = transaction.spans?.find(span => span.description === 'r2_completeMultipartUpload'); + expect(completeSpan).toBeDefined(); + expect(completeSpan?.data?.['cloudflare.r2.operation']).toBe('CompleteMultipartUpload'); + expect(completeSpan?.data?.['cloudflare.r2.request.key']).toBe('multipart.bin'); +}); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index cbf3925a0397..393072718ab0 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -1,11 +1,12 @@ import type { CloudflareOptions } from '../../client'; -import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue } from '../../utils/isBinding'; +import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue, isR2Bucket } from '../../utils/isBinding'; import { instrumentD1 } from './instrumentD1'; import { appendRpcMeta } from '../../utils/rpcMeta'; import { getEffectiveRpcPropagation } from '../../utils/rpcOptions'; import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace'; import { instrumentFetcher } from './instrumentFetcher'; import { instrumentQueueProducer } from './instrumentQueueProducer'; +import { instrumentR2Bucket } from './instrumentR2'; function isProxyable(item: unknown): item is object { return item !== null && (typeof item === 'object' || typeof item === 'function'); @@ -21,8 +22,7 @@ const instrumentedBindings = new WeakMap(); * - DurableObjectNamespace (via `idFromName` duck-typing) * - Service bindings / JSRPC proxies * - Queue producers (via `send` + `sendBatch` duck-typing) - * - * Extensible for future binding types (KV, D1, etc.). + * - R2 Buckets (via `head` + `put` + `createMultipartUpload` duck-typing) * * @param env - The Cloudflare env object to instrument * @param options - Optional CloudflareOptions to control RPC trace propagation @@ -61,6 +61,13 @@ export function instrumentEnv>(env: Env, opt return instrumented; } + if (isR2Bucket(item)) { + const bindingName = typeof prop === 'string' ? prop : String(prop); + const instrumented = instrumentR2Bucket(item, bindingName); + instrumentedBindings.set(item, instrumented); + return instrumented; + } + if (!rpcPropagation) { return item; } diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentR2.ts b/packages/cloudflare/src/instrumentations/worker/instrumentR2.ts new file mode 100644 index 000000000000..2ac624048496 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentR2.ts @@ -0,0 +1,137 @@ +import type { R2Bucket, R2ListOptions, R2MultipartUpload } from '@cloudflare/workers-types'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; + +const ORIGIN = 'auto.faas.cloudflare.r2'; + +const R2_OPERATIONS = { + get: { spanName: 'r2_get', operation: 'GetObject' }, + head: { spanName: 'r2_head', operation: 'HeadObject' }, + put: { spanName: 'r2_put', operation: 'PutObject' }, + delete: { spanName: 'r2_delete', operation: 'DeleteObject' }, + list: { spanName: 'r2_list', operation: 'ListObjects' }, + createMultipartUpload: { spanName: 'r2_createMultipartUpload', operation: 'CreateMultipartUpload' }, + uploadPart: { spanName: 'r2_uploadPart', operation: 'UploadPart' }, + abortMultipartUpload: { spanName: 'r2_abortMultipartUpload', operation: 'AbortMultipartUpload' }, + completeMultipartUpload: { spanName: 'r2_completeMultipartUpload', operation: 'CompleteMultipartUpload' }, +} as const; + +type R2OperationKey = keyof typeof R2_OPERATIONS; + +function isR2ListOptions(key: unknown): key is R2ListOptions { + return typeof key === 'object' && key !== null && !Array.isArray(key); +} + +function createSpanOptions(bindingName: string, op: R2OperationKey, key?: string | string[] | R2ListOptions) { + const { spanName, operation } = R2_OPERATIONS[op]; + const requestKey = Array.isArray(key) ? key.join(', ') : typeof key === 'string' ? key : undefined; + + return { + op: 'cloud.r2', + name: spanName, + attributes: { + 'cloudflare.r2.operation': operation, + 'cloudflare.r2.bucket': bindingName, + ...(requestKey !== undefined && { 'cloudflare.r2.request.key': requestKey }), + ...(isR2ListOptions(key) && key.prefix !== undefined && { 'cloudflare.r2.request.prefix': key.prefix }), + ...(isR2ListOptions(key) && key.delimiter !== undefined && { 'cloudflare.r2.request.delimiter': key.delimiter }), + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cloud.r2', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + }, + }; +} + +function instrumentR2MultipartUpload(upload: R2MultipartUpload, bindingName: string): R2MultipartUpload { + const { key } = upload; + + return new Proxy(upload, { + get(target, prop, receiver) { + if (prop === 'uploadPart') { + const original = Reflect.get(target, prop, receiver); + + return function (this: unknown, ...args: Parameters) { + const [partNumber] = args; + + return startSpan( + { + ...createSpanOptions(bindingName, 'uploadPart', key), + attributes: { + ...createSpanOptions(bindingName, 'uploadPart', key).attributes, + 'cloudflare.r2.request.part_number': partNumber, + }, + }, + () => Reflect.apply(original, target, args), + ); + }; + } + + if (prop === 'abort') { + const original = Reflect.get(target, prop, receiver); + + return function (this: unknown) { + return startSpan(createSpanOptions(bindingName, 'abortMultipartUpload', key), () => + Reflect.apply(original, target, []), + ); + }; + } + + if (prop === 'complete') { + const original = Reflect.get(target, prop, receiver); + + return function (this: unknown, ...args: Parameters) { + return startSpan(createSpanOptions(bindingName, 'completeMultipartUpload', key), () => + Reflect.apply(original, target, args), + ); + }; + } + + return Reflect.get(target, prop, receiver); + }, + }); +} + +/** + * Wraps a Cloudflare R2 Bucket binding to create spans on bucket operations. + * + * Instrumented methods: get, head, put, delete, list, createMultipartUpload, + * resumeMultipartUpload (and the resulting multipart upload operations). + */ +export function instrumentR2Bucket(bucket: T, bindingName: string): T { + return new Proxy(bucket, { + get(target, prop, receiver) { + if (prop === 'get' || prop === 'head' || prop === 'put' || prop === 'delete' || prop === 'list') { + const original = Reflect.get(target, prop, receiver); + + return function (this: unknown, ...args: Parameters) { + const [key] = args; + + return startSpan(createSpanOptions(bindingName, prop, key), () => Reflect.apply(original, target, args)); + }; + } + + if (prop === 'createMultipartUpload') { + const original = Reflect.get(target, prop, receiver) as R2Bucket['createMultipartUpload']; + + return function (this: unknown, ...args: Parameters) { + const [key] = args; + + return startSpan(createSpanOptions(bindingName, 'createMultipartUpload', key), async () => { + const upload = await Reflect.apply(original, target, args); + return instrumentR2MultipartUpload(upload, bindingName); + }); + }; + } + + if (prop === 'resumeMultipartUpload') { + const original = Reflect.get(target, prop, receiver); + + return function (this: unknown, ...args: Parameters) { + const upload = Reflect.apply(original, target, args); + + return instrumentR2MultipartUpload(upload, bindingName); + }; + } + + return Reflect.get(target, prop, receiver); + }, + }); +} diff --git a/packages/cloudflare/src/utils/isBinding.ts b/packages/cloudflare/src/utils/isBinding.ts index 9386a58ce88a..88832f375f21 100644 --- a/packages/cloudflare/src/utils/isBinding.ts +++ b/packages/cloudflare/src/utils/isBinding.ts @@ -31,7 +31,7 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import type { D1Database, DurableObjectNamespace, Queue } from '@cloudflare/workers-types'; +import type { D1Database, DurableObjectNamespace, Queue, R2Bucket } from '@cloudflare/workers-types'; /** * Checks if a value is a JSRPC proxy (service binding). @@ -81,3 +81,17 @@ export function isD1Database(item: unknown): item is D1Database { typeof item.exec === 'function' ); } + +/** + * Duck-type check for R2 Bucket bindings. + * R2Bucket has `head`, `put`, and `createMultipartUpload` methods. + */ +export function isR2Bucket(item: unknown): item is R2Bucket { + return ( + item != null && + isNotJSRPC(item) && + typeof item.head === 'function' && + typeof item.put === 'function' && + typeof item.createMultipartUpload === 'function' + ); +} diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentR2.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentR2.test.ts new file mode 100644 index 000000000000..3a632ac20e6e --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentR2.test.ts @@ -0,0 +1,345 @@ +import type { R2Bucket, R2MultipartUpload } from '@cloudflare/workers-types'; +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { instrumentR2Bucket } from '../../../src/instrumentations/worker/instrumentR2'; + +const MOCK_R2_OBJECT = { + key: 'my-file.txt', + version: 'v1', + size: 100, + etag: 'abc', + httpEtag: '"abc"', + checksums: {}, + uploaded: new Date(), + storageClass: 'Standard', + writeHttpMetadata: vi.fn(), +}; + +const MOCK_R2_OBJECT_BODY = { + ...MOCK_R2_OBJECT, + body: new ReadableStream(), + bodyUsed: false, + arrayBuffer: vi.fn(), + bytes: vi.fn(), + text: vi.fn(), + json: vi.fn(), + blob: vi.fn(), +}; + +const MOCK_R2_OBJECTS = { + objects: [MOCK_R2_OBJECT], + truncated: false, + delimitedPrefixes: [], +}; + +const MOCK_UPLOADED_PART = { partNumber: 1, etag: 'part-etag' }; + +function createMockMultipartUpload(key = 'my-file.txt'): R2MultipartUpload { + return { + key, + uploadId: 'upload-123', + uploadPart: vi.fn().mockResolvedValue(MOCK_UPLOADED_PART), + abort: vi.fn().mockResolvedValue(undefined), + complete: vi.fn().mockResolvedValue(MOCK_R2_OBJECT), + }; +} + +function createMockR2Bucket(): R2Bucket { + return { + head: vi.fn().mockResolvedValue(MOCK_R2_OBJECT), + get: vi.fn().mockResolvedValue(MOCK_R2_OBJECT_BODY), + put: vi.fn().mockResolvedValue(MOCK_R2_OBJECT), + delete: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue(MOCK_R2_OBJECTS), + createMultipartUpload: vi.fn().mockImplementation((key: string) => Promise.resolve(createMockMultipartUpload(key))), + resumeMultipartUpload: vi.fn().mockImplementation((key: string) => createMockMultipartUpload(key)), + } as unknown as R2Bucket; +} + +describe('instrumentR2Bucket', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + + describe('get', () => { + test('forwards the call and returns the result', async () => { + const bucket = createMockR2Bucket(); + const wrapped = instrumentR2Bucket(bucket, 'MY_BUCKET'); + + const result = await wrapped.get('my-file.txt'); + expect(result).toBe(MOCK_R2_OBJECT_BODY); + expect(bucket.get).toHaveBeenCalledTimes(1); + }); + + test('starts a span with correct attributes', async () => { + const wrapped = instrumentR2Bucket(createMockR2Bucket(), 'MY_BUCKET'); + await wrapped.get('my-file.txt'); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + op: 'cloud.r2', + name: 'r2_get', + attributes: expect.objectContaining({ + 'cloudflare.r2.operation': 'GetObject', + 'cloudflare.r2.bucket': 'MY_BUCKET', + 'cloudflare.r2.request.key': 'my-file.txt', + 'sentry.op': 'cloud.r2', + 'sentry.origin': 'auto.faas.cloudflare.r2', + }), + }), + expect.any(Function), + ); + }); + }); + + describe('head', () => { + test('forwards the call and returns the result', async () => { + const bucket = createMockR2Bucket(); + const wrapped = instrumentR2Bucket(bucket, 'MY_BUCKET'); + + const result = await wrapped.head('my-file.txt'); + expect(result).toBe(MOCK_R2_OBJECT); + expect(bucket.head).toHaveBeenCalledTimes(1); + }); + + test('starts a span with correct attributes', async () => { + const wrapped = instrumentR2Bucket(createMockR2Bucket(), 'MY_BUCKET'); + await wrapped.head('my-file.txt'); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + op: 'cloud.r2', + name: 'r2_head', + attributes: expect.objectContaining({ + 'cloudflare.r2.operation': 'HeadObject', + 'cloudflare.r2.request.key': 'my-file.txt', + }), + }), + expect.any(Function), + ); + }); + }); + + describe('put', () => { + test('forwards the call and returns the result', async () => { + const bucket = createMockR2Bucket(); + const wrapped = instrumentR2Bucket(bucket, 'MY_BUCKET'); + + const result = await wrapped.put('my-file.txt', 'hello'); + expect(result).toBe(MOCK_R2_OBJECT); + expect(bucket.put).toHaveBeenCalledTimes(1); + expect(bucket.put).toHaveBeenCalledWith('my-file.txt', 'hello'); + }); + + test('starts a span with correct attributes', async () => { + const wrapped = instrumentR2Bucket(createMockR2Bucket(), 'MY_BUCKET'); + await wrapped.put('upload/photo.jpg', new ArrayBuffer(42)); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + op: 'cloud.r2', + name: 'r2_put', + attributes: expect.objectContaining({ + 'cloudflare.r2.operation': 'PutObject', + 'cloudflare.r2.request.key': 'upload/photo.jpg', + }), + }), + expect.any(Function), + ); + }); + }); + + describe('delete', () => { + test('forwards the call with a single key', async () => { + const bucket = createMockR2Bucket(); + const wrapped = instrumentR2Bucket(bucket, 'MY_BUCKET'); + + await wrapped.delete('my-file.txt'); + expect(bucket.delete).toHaveBeenCalledTimes(1); + }); + + test('starts a span with the key', async () => { + const wrapped = instrumentR2Bucket(createMockR2Bucket(), 'MY_BUCKET'); + await wrapped.delete('my-file.txt'); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + op: 'cloud.r2', + name: 'r2_delete', + attributes: expect.objectContaining({ + 'cloudflare.r2.operation': 'DeleteObject', + 'cloudflare.r2.request.key': 'my-file.txt', + }), + }), + expect.any(Function), + ); + }); + + test('joins multiple keys in the attribute', async () => { + const wrapped = instrumentR2Bucket(createMockR2Bucket(), 'MY_BUCKET'); + await wrapped.delete(['a.txt', 'b.txt']); + + const attrs = startSpanSpy.mock.calls[0]![0].attributes!; + expect(attrs['cloudflare.r2.request.key']).toBe('a.txt, b.txt'); + }); + }); + + describe('list', () => { + test('forwards the call and returns the result', async () => { + const bucket = createMockR2Bucket(); + const wrapped = instrumentR2Bucket(bucket, 'MY_BUCKET'); + + const result = await wrapped.list(); + expect(result).toBe(MOCK_R2_OBJECTS); + }); + + test('starts a span without a key attribute', async () => { + const wrapped = instrumentR2Bucket(createMockR2Bucket(), 'MY_BUCKET'); + await wrapped.list({ prefix: 'uploads/' }); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + op: 'cloud.r2', + name: 'r2_list', + attributes: expect.objectContaining({ + 'cloudflare.r2.operation': 'ListObjects', + }), + }), + expect.any(Function), + ); + expect(startSpanSpy.mock.calls[0]![0].attributes!['cloudflare.r2.request.key']).toBeUndefined(); + }); + }); + + describe('createMultipartUpload', () => { + test('forwards the call and returns an instrumented upload', async () => { + const bucket = createMockR2Bucket(); + const wrapped = instrumentR2Bucket(bucket, 'MY_BUCKET'); + + const upload = await wrapped.createMultipartUpload('big-file.bin'); + expect(upload.key).toBe('big-file.bin'); + expect(upload.uploadId).toBe('upload-123'); + expect(bucket.createMultipartUpload).toHaveBeenCalledTimes(1); + }); + + test('starts a span for the createMultipartUpload call', async () => { + const wrapped = instrumentR2Bucket(createMockR2Bucket(), 'MY_BUCKET'); + await wrapped.createMultipartUpload('big-file.bin'); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + op: 'cloud.r2', + name: 'r2_createMultipartUpload', + attributes: expect.objectContaining({ + 'cloudflare.r2.operation': 'CreateMultipartUpload', + 'cloudflare.r2.request.key': 'big-file.bin', + }), + }), + expect.any(Function), + ); + }); + + test('instruments the returned multipart upload', async () => { + const wrapped = instrumentR2Bucket(createMockR2Bucket(), 'MY_BUCKET'); + const upload = await wrapped.createMultipartUpload('big-file.bin'); + + startSpanSpy.mockClear(); + await upload.uploadPart(1, 'part-data'); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + name: 'r2_uploadPart', + attributes: expect.objectContaining({ + 'cloudflare.r2.request.key': 'big-file.bin', + 'cloudflare.r2.request.part_number': 1, + }), + }), + expect.any(Function), + ); + }); + }); + + describe('resumeMultipartUpload', () => { + test('forwards the call and returns an instrumented upload', () => { + const bucket = createMockR2Bucket(); + const wrapped = instrumentR2Bucket(bucket, 'MY_BUCKET'); + + const upload = wrapped.resumeMultipartUpload('my-file.txt', 'upload-123'); + expect(upload.key).toBe('my-file.txt'); + expect(upload.uploadId).toBe('upload-123'); + expect(bucket.resumeMultipartUpload).toHaveBeenCalledTimes(1); + }); + + test('does not start a span for resumeMultipartUpload itself', () => { + const wrapped = instrumentR2Bucket(createMockR2Bucket(), 'MY_BUCKET'); + wrapped.resumeMultipartUpload('my-file.txt', 'upload-123'); + + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + + test('instruments the returned multipart upload operations', async () => { + const wrapped = instrumentR2Bucket(createMockR2Bucket(), 'MY_BUCKET'); + const upload = wrapped.resumeMultipartUpload('my-file.txt', 'upload-123'); + + await upload.abort(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + name: 'r2_abortMultipartUpload', + attributes: expect.objectContaining({ + 'cloudflare.r2.request.key': 'my-file.txt', + }), + }), + expect.any(Function), + ); + }); + }); + + describe('multipart upload operations', () => { + test('uploadPart returns the uploaded part', async () => { + const wrapped = instrumentR2Bucket(createMockR2Bucket(), 'MY_BUCKET'); + const upload = wrapped.resumeMultipartUpload('my-file.txt', 'upload-123'); + + const part = await upload.uploadPart(1, 'data'); + expect(part).toEqual(MOCK_UPLOADED_PART); + }); + + test('complete returns the final R2Object', async () => { + const wrapped = instrumentR2Bucket(createMockR2Bucket(), 'MY_BUCKET'); + const upload = wrapped.resumeMultipartUpload('my-file.txt', 'upload-123'); + + const result = await upload.complete([MOCK_UPLOADED_PART]); + expect(result).toBe(MOCK_R2_OBJECT); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + name: 'r2_completeMultipartUpload', + attributes: expect.objectContaining({ + 'cloudflare.r2.operation': 'CompleteMultipartUpload', + 'cloudflare.r2.request.key': 'my-file.txt', + }), + }), + expect.any(Function), + ); + }); + }); + + test('forwards unknown property accesses transparently', () => { + const bucket = Object.assign(createMockR2Bucket(), { + customMethod: vi.fn().mockReturnValue('hi'), + }) as unknown as R2Bucket & { customMethod: () => string }; + const wrapped = instrumentR2Bucket(bucket, 'MY_BUCKET') as R2Bucket & { customMethod: () => string }; + expect(wrapped.customMethod()).toBe('hi'); + }); +});