Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions dev-packages/cloudflare-integration-tests/suites/r2/index.ts
Original file line number Diff line number Diff line change
@@ -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<Env>,
);
222 changes: 222 additions & 0 deletions dev-packages/cloudflare-integration-tests/suites/r2/test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return envelope[1][0]![1] as Record<string, unknown>;
}

function findSpans(envelope: Envelope, description: string): Array<Record<string, unknown>> {
if (envelopeItemType(envelope) !== 'transaction') return [];
const tx = envelopeItem(envelope);
const spans = (tx.spans as Array<Record<string, unknown>>) || [];
return spans.filter(s => s.description === description);
}

function spanData(span: Record<string, unknown>): Record<string, unknown> {
return span.data as Record<string, unknown>;
}

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();
});
Original file line number Diff line number Diff line change
@@ -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",
},
],
}
Original file line number Diff line number Diff line change
@@ -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');
Comment thread
cursor[bot] marked this conversation as resolved.

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');
});
Loading
Loading