Skip to content
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>,
);
189 changes: 189 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,189 @@
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(
expect.objectContaining({
[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