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
58 changes: 58 additions & 0 deletions dev-packages/cloudflare-integration-tests/suites/kv/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { KVNamespace } from '@cloudflare/workers-types';
import * as Sentry from '@sentry/cloudflare';

interface Env {
SENTRY_DSN: string;
MY_KV: KVNamespace;
}

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 === '/kv/get') {
await env.MY_KV.put('get-key', 'get-value');
const value = await env.MY_KV.get('get-key');
return new Response(value);
}

if (url.pathname === '/kv/bulk-get') {
await env.MY_KV.put('bulk-a', 'a');
await env.MY_KV.put('bulk-b', 'b');
const values = await env.MY_KV.get(['bulk-a', 'bulk-b']);
return Response.json(Object.fromEntries(values));
}

if (url.pathname === '/kv/get-with-metadata') {
await env.MY_KV.put('metadata-key', 'metadata-value', { metadata: { source: 'test' } });
const value = await env.MY_KV.getWithMetadata('metadata-key');
return Response.json(value);
}

if (url.pathname === '/kv/put') {
await env.MY_KV.put('put-key', 'put-value');
return new Response('OK');
}

if (url.pathname === '/kv/list') {
await env.MY_KV.put('list-a', 'a');
await env.MY_KV.put('list-b', 'b');
await env.MY_KV.list({ prefix: 'list-' });
return new Response('OK');
}

if (url.pathname === '/kv/delete') {
await env.MY_KV.put('delete-key', 'gone');
await env.MY_KV.delete('delete-key');
return new Response('OK');
}

return new Response('not found', { status: 404 });
},
} as ExportedHandler<Env>,
);
187 changes: 187 additions & 0 deletions dev-packages/cloudflare-integration-tests/suites/kv/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import {
CACHE_HIT,
CACHE_KEY,
CACHE_OPERATION,
DB_COLLECTION_NAME,
DB_OPERATION_NAME,
DB_SYSTEM_NAME,
SENTRY_OP,
SENTRY_ORIGIN,
} from '@sentry/conventions/attributes';
import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op';
import { CACHE_OPERATION_NAMES } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../runner';
import { getSpansFromEnvelope } from '../../spanUtils';

it('emits cache.get for a KV read with key and hit attributes', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const spans = getSpansFromEnvelope(envelope).filter(span => span.name === CACHE_GET);
expect(spans).toHaveLength(1);
expect(spans[0]?.attributes).toEqual({
[SENTRY_OP]: { type: 'string', value: CACHE_GET },
[SENTRY_ORIGIN]: { type: 'string', value: 'auto.cache.cloudflare.kv' },
[CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] },
[CACHE_KEY]: { type: 'array', value: ['get-key'] },
[CACHE_HIT]: { type: 'boolean', value: true },
[DB_OPERATION_NAME]: { type: 'string', value: 'get' },
[DB_COLLECTION_NAME]: { type: 'string', value: 'MY_KV' },
[DB_SYSTEM_NAME]: { type: 'string', value: 'cloudflare-kv' },
});
})
.start(signal);

await runner.makeRequest('get', '/kv/get');
await runner.completed();
});

it('emits cache.get for a bulk KV read with all keys in cache.key', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const spans = getSpansFromEnvelope(envelope).filter(
span => span.name === CACHE_GET && span.attributes[DB_OPERATION_NAME]?.value === 'get',
);

expect(spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
attributes: expect.objectContaining({
[CACHE_KEY]: expect.objectContaining({
type: 'array',
value: expect.arrayContaining(['bulk-a', 'bulk-b']),
}),
[CACHE_HIT]: { type: 'boolean', value: true },
}),
}),
]),
);
})
.start(signal);

await runner.makeRequest('get', '/kv/bulk-get');
await runner.completed();
});
Comment thread
cursor[bot] marked this conversation as resolved.

it('emits cache.get for getWithMetadata', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const spans = getSpansFromEnvelope(envelope).filter(
span => span.name === CACHE_GET && span.attributes[DB_OPERATION_NAME]?.value === 'getWithMetadata',
);

expect(spans).toEqual([
expect.objectContaining({
attributes: expect.objectContaining({
[CACHE_KEY]: { type: 'array', value: ['metadata-key'] },
[CACHE_HIT]: { type: 'boolean', value: true },
}),
}),
]);
})
.start(signal);

await runner.makeRequest('get', '/kv/get-with-metadata');
await runner.completed();
});

it('emits cache.put for KV writes', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const spans = getSpansFromEnvelope(envelope).filter(span => span.name === CACHE_PUT);
expect(spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
attributes: expect.objectContaining({
[SENTRY_OP]: { type: 'string', value: CACHE_PUT },
[CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_PUT] },
[CACHE_KEY]: { type: 'array', value: ['put-key'] },
[DB_OPERATION_NAME]: { type: 'string', value: 'put' },
}),
}),
]),
);
})
.start(signal);

await runner.makeRequest('get', '/kv/put');
await runner.completed();
});

it('emits cache.remove for KV deletes', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const spans = getSpansFromEnvelope(envelope).filter(span => span.name === CACHE_REMOVE);
expect(spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
attributes: expect.objectContaining({
[SENTRY_OP]: { type: 'string', value: CACHE_REMOVE },
[CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_REMOVE] },
[CACHE_KEY]: { type: 'array', value: ['delete-key'] },
[DB_OPERATION_NAME]: { type: 'string', value: 'delete' },
}),
}),
]),
);
})
.start(signal);

await runner.makeRequest('get', '/kv/delete');
await runner.completed();
});

it('emits separate spans for separate KV requests', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const spans = getSpansFromEnvelope(envelope).filter(span => span.name === CACHE_GET);
expect(spans).toHaveLength(1);
expect(spans[0]?.attributes).toEqual(
expect.objectContaining({
[DB_OPERATION_NAME]: { type: 'string', value: 'get' },
[CACHE_KEY]: { type: 'array', value: ['get-key'] },
}),
);
})
.expect(envelope => {
const spans = getSpansFromEnvelope(envelope).filter(span => span.name === CACHE_PUT);
expect(spans).toHaveLength(1);
expect(spans[0]?.attributes).toEqual(
expect.objectContaining({
[DB_OPERATION_NAME]: { type: 'string', value: 'put' },
[CACHE_KEY]: { type: 'array', value: ['put-key'] },
}),
);
})
.start(signal);

await runner.makeRequest('get', '/kv/get');
await runner.makeRequest('get', '/kv/put');
await runner.completed();
});

it('emits cache.get for KV list operations', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const spans = getSpansFromEnvelope(envelope).filter(
span => span.name === CACHE_GET && span.attributes[DB_OPERATION_NAME]?.value === 'list',
);

expect(spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
attributes: expect.objectContaining({
[SENTRY_OP]: { type: 'string', value: CACHE_GET },
[CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] },
[DB_OPERATION_NAME]: { type: 'string', value: 'list' },
}),
}),
]),
);
expect(spans[0]?.attributes[CACHE_KEY]).toBeUndefined();
})
.start(signal);

await runner.makeRequest('get', '/kv/list');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a test where you call at least 2 endpoints? Not that something is off, but would be nice to just double check that two requests are for sure sent in separate spans

await runner.completed();
});
Comment thread
cursor[bot] marked this conversation as resolved.
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"],
"kv_namespaces": [
{
"binding": "MY_KV",
"id": "00000000000000000000000000000000",
},
],
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isD1Database,
isDurableObjectNamespace,
isJSRPC,
isKVNamespace,
isQueue,
isR2Bucket,
} from '../../utils/isBinding';
Expand All @@ -14,6 +15,7 @@ import { appendRpcMeta } from '../../utils/rpcMeta';
import { createRpcPropagationResolver } from '../../utils/rpcPropagation';
import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace';
import { instrumentFetcher } from './instrumentFetcher';
import { instrumentKV } from './instrumentKV';
import { instrumentQueueProducer } from './instrumentQueueProducer';
import { instrumentR2Bucket } from './instrumentR2';

Expand All @@ -31,6 +33,7 @@ const instrumentedBindings = new WeakMap<object, unknown>();
* - DurableObjectNamespace (via `idFromName` duck-typing)
* - Service bindings / JSRPC proxies
* - Queue producers (via `send` + `sendBatch` duck-typing)
* - Workers KV namespaces (via `get` + `put` + `list` + `getWithMetadata` duck-typing)
* - R2 Buckets (via `head` + `put` + `createMultipartUpload` duck-typing)
* - Workers AI (via `run` + `gateway` + `toMarkdown` duck-typing)
*
Expand Down Expand Up @@ -71,6 +74,13 @@ export function instrumentEnv<Env extends Record<string, unknown>>(env: Env, opt
return instrumented;
}

if (isKVNamespace(item)) {
const bindingName = typeof prop === 'string' ? prop : String(prop);
const instrumented = instrumentKV(item, bindingName);
instrumentedBindings.set(item, instrumented);
return instrumented;
}

if (isR2Bucket(item)) {
const bindingName = typeof prop === 'string' ? prop : String(prop);
const instrumented = instrumentR2Bucket(item, bindingName);
Expand Down
Loading