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>,
);
163 changes: 163 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,163 @@
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).toEqual(
expect.arrayContaining([
expect.objectContaining({
attributes: 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: 'string', 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: 'string', 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: 'string', 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: 'string', value: 'delete-key' },
[DB_OPERATION_NAME]: { type: 'string', value: 'delete' },
}),
}),
]),
);
})
.start(signal);

await runner.makeRequest('get', '/kv/delete');
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');
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
110 changes: 110 additions & 0 deletions packages/cloudflare/src/instrumentations/worker/instrumentKV.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { KVNamespace } from '@cloudflare/workers-types';
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, getClient, hasSpanStreamingEnabled, isObjectLike, startSpan } from '@sentry/core';

const ORIGIN = 'auto.cache.cloudflare.kv';

type KVOperation = 'get' | 'getWithMetadata' | 'put' | 'delete' | 'list';

const OPERATION_SPAN_OPS = {
get: CACHE_GET,
getWithMetadata: CACHE_GET,
put: CACHE_PUT,
delete: CACHE_REMOVE,
list: CACHE_GET,
} as const satisfies Record<KVOperation, string>;

function getCacheKeys(operation: KVOperation, args: unknown[]): string[] | undefined {
if (operation === 'list') {
return undefined;
}

const key = args[0];

if (Array.isArray(key)) {
return key.map(value => String(value));
}

return typeof key === 'string' ? [key] : key == null ? undefined : [String(key)];
}

function createSpanOptions(bindingName: string, operation: KVOperation, args: unknown[]) {
const cacheOperation = OPERATION_SPAN_OPS[operation];
const cacheOperationName = CACHE_OPERATION_NAMES[cacheOperation];
const keys = getCacheKeys(operation, args);
const client = getClient();

return {
name: client && hasSpanStreamingEnabled(client) ? cacheOperation : keys?.join(', ') || operation,
attributes: {
[SENTRY_OP]: cacheOperation,
[CACHE_OPERATION]: cacheOperationName,
[SENTRY_ORIGIN]: ORIGIN,
...(keys && keys.length > 0 && { [CACHE_KEY]: keys.length > 1 ? keys : keys[0] }),
[DB_OPERATION_NAME]: operation,
[DB_COLLECTION_NAME]: bindingName,
[DB_SYSTEM_NAME]: 'cloudflare-kv',
},
};
}

function resolveCacheHit(operation: KVOperation, result: unknown): boolean {
if (operation === 'list') {
return false;
}

if (result instanceof Map) {
return Array.from(result.values()).some(value => resolveCacheHit(operation, value));
}

if (operation === 'getWithMetadata' && isObjectLike(result) && 'value' in result) {
return result.value != null;
}

return result != null;
}

/**
* Wraps a Cloudflare Workers KV binding to create cache spans on namespace operations.
*
* Instrumented methods: get, getWithMetadata, put, delete, and list.
*/
export function instrumentKV<T extends KVNamespace>(kv: T, bindingName: string): T {
return new Proxy(kv, {
get(target, prop, receiver) {
if (prop === 'get' || prop === 'getWithMetadata' || prop === 'put' || prop === 'delete' || prop === 'list') {
const original = Reflect.get(target, prop, receiver);

if (typeof original !== 'function') {
return original;
}

return function (this: unknown, ...args: unknown[]) {
const operation = prop as KVOperation;

return startSpan(createSpanOptions(bindingName, operation, args), async span => {
const result = await Reflect.apply(original, target, args);

if (operation === 'get' || operation === 'getWithMetadata') {
span.setAttribute(CACHE_HIT, resolveCacheHit(operation, result));
}

return result;
});
};
}

return Reflect.get(target, prop, receiver);
},
});
}
Loading