From 7803e69b9533be7b35ace3f8a49424167452c241 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Sun, 18 Oct 2026 09:43:33 +0530 Subject: [PATCH 1/6] feat(cloudflare): instrument Workers KV bindings --- .../suites/kv/index.ts | 58 +++++++ .../suites/kv/test.ts | 147 ++++++++++++++++++ .../suites/kv/wrangler.jsonc | 12 ++ .../instrumentations/worker/instrumentEnv.ts | 9 ++ .../instrumentations/worker/instrumentKV.ts | 101 ++++++++++++ packages/cloudflare/src/utils/isBinding.ts | 17 +- .../instrumentations/instrumentEnv.test.ts | 52 +++++++ .../worker/instrumentKV.test.ts | 138 ++++++++++++++++ .../cloudflare/test/utils/isBinding.test.ts | 38 +++++ 9 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/kv/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/kv/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/kv/wrangler.jsonc create mode 100644 packages/cloudflare/src/instrumentations/worker/instrumentKV.ts create mode 100644 packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/index.ts b/dev-packages/cloudflare-integration-tests/suites/kv/index.ts new file mode 100644 index 000000000000..cae7fafc4abf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/index.ts @@ -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, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/test.ts b/dev-packages/cloudflare-integration-tests/suites/kv/test.ts new file mode 100644 index 000000000000..ecf1ef993a0b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/test.ts @@ -0,0 +1,147 @@ +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: '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' }), + 'cache.hit': { type: 'boolean', value: true }, + }), + }), + ]), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/bulk-get'); + await runner.completed(); +}); + +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: '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: '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: 'get' }, + 'db.operation.name': { type: 'string', value: 'list' }, + }), + }), + ]), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/kv/list'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/kv/wrangler.jsonc new file mode 100644 index 000000000000..14b15cd17aa4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/kv/wrangler.jsonc @@ -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", + }, + ], +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index 33b02b9bd74f..af41244d117f 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -6,6 +6,7 @@ import { isD1Database, isDurableObjectNamespace, isJSRPC, + isKVNamespace, isQueue, isR2Bucket, } from '../../utils/isBinding'; @@ -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'; @@ -71,6 +73,13 @@ export function instrumentEnv>(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); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts b/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts new file mode 100644 index 000000000000..d6b1a702a15f --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts @@ -0,0 +1,101 @@ +import type { KVNamespace } from '@cloudflare/workers-types'; +import { CACHE_HIT, CACHE_KEY, CACHE_OPERATION, 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; + +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(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); + }, + }); +} diff --git a/packages/cloudflare/src/utils/isBinding.ts b/packages/cloudflare/src/utils/isBinding.ts index e675807b8fd7..1747d7346905 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 { Ai, D1Database, DurableObjectNamespace, Queue, R2Bucket } from '@cloudflare/workers-types'; +import type { Ai, D1Database, DurableObjectNamespace, KVNamespace, Queue, R2Bucket } from '@cloudflare/workers-types'; /** * Checks if a value is a JSRPC proxy (service binding). @@ -96,6 +96,21 @@ export function isAiBinding(item: unknown): item is Ai { ); } +/** + * Duck-type check for Workers KV namespace bindings. + * KVNamespace has `get`, `put`, `list`, and `getWithMetadata` methods. + */ +export function isKVNamespace(item: unknown): item is KVNamespace { + return ( + item != null && + isNotJSRPC(item) && + typeof item.get === 'function' && + typeof item.put === 'function' && + typeof item.list === 'function' && + typeof item.getWithMetadata === 'function' + ); +} + /** * Duck-type check for R2 Bucket bindings. * R2Bucket has `head`, `put`, and `createMultipartUpload` methods. diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index 9219091d53bd..3d5269ecb8a5 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -222,6 +222,58 @@ describe('instrumentEnv', () => { expect(send.mock.calls[0]?.[0]).toBe('hello'); }); + it('wraps Workers KV bindings in a proxy', async () => { + const get = vi.fn().mockResolvedValue('value'); + const put = vi.fn().mockResolvedValue(undefined); + const list = vi.fn().mockResolvedValue({ keys: [], list_complete: true }); + const getWithMetadata = vi.fn().mockResolvedValue({ value: 'value', metadata: null }); + const kv = { get, put, list, getWithMetadata, delete: vi.fn() }; + const env = { MY_KV: kv }; + const instrumented = instrumentEnv(env); + + const wrapped = instrumented.MY_KV as typeof kv; + expect(wrapped).not.toBe(kv); + + await wrapped.get('test-key', 'json'); + expect(get).toHaveBeenCalledWith('test-key', 'json'); + + await wrapped.put('test-key', 'value'); + expect(put).toHaveBeenCalledWith('test-key', 'value'); + }); + + it('caches the wrapped Workers KV binding across repeated access', () => { + const kv = { get: vi.fn(), put: vi.fn(), list: vi.fn(), getWithMetadata: vi.fn() }; + const env = { MY_KV: kv }; + const instrumented = instrumentEnv(env); + + expect(instrumented.MY_KV).toBe(instrumented.MY_KV); + }); + + it('instruments Workers KV bindings independently from R2 bindings', () => { + const kv = { get: vi.fn(), put: vi.fn(), list: vi.fn(), getWithMetadata: vi.fn() }; + const r2 = { head: vi.fn(), put: vi.fn(), createMultipartUpload: vi.fn() }; + const env = { MY_KV: kv, MY_BUCKET: r2 }; + const instrumented = instrumentEnv(env); + + expect(instrumented.MY_KV).not.toBe(kv); + expect(instrumented.MY_BUCKET).not.toBe(r2); + }); + + it('does not instrument a JSRPC proxy as Workers KV', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env); + + expect(instrumented.SERVICE).not.toBe(jsrpcProxy); + }); + it('caches the wrapped Queue binding across repeated access', () => { const queue = { send: vi.fn(), sendBatch: vi.fn() }; const env = { MY_QUEUE: queue }; diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts new file mode 100644 index 000000000000..08aeb45883e6 --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts @@ -0,0 +1,138 @@ +import type { KVNamespace } from '@cloudflare/workers-types'; +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { instrumentKV } from '../../../src/instrumentations/worker/instrumentKV'; + +function createMockKVNamespace(): KVNamespace { + return { + get: vi.fn().mockResolvedValue('value'), + getWithMetadata: vi.fn().mockResolvedValue({ value: 'value', metadata: null }), + put: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue({ keys: [], list_complete: true }), + } as unknown as KVNamespace; +} + +describe('instrumentKV', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + + test('forwards get with the type argument', async () => { + const kv = createMockKVNamespace(); + const wrapped = instrumentKV(kv, 'MY_KV'); + + const result = await wrapped.get('test-key', 'json'); + + expect(result).toBe('value'); + expect(kv.get).toHaveBeenCalledWith('test-key', 'json'); + }); + + test('starts a cache.get span with KV attributes', async () => { + const kv = createMockKVNamespace(); + const wrapped = instrumentKV(kv, 'MY_KV'); + + await wrapped.get('test-key'); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': 'cache.get', + 'sentry.origin': 'auto.cache.cloudflare.kv', + 'cache.operation': 'get', + 'cache.key': 'test-key', + 'db.operation.name': 'get', + 'db.collection.name': 'MY_KV', + 'db.system.name': 'cloudflare.kv', + }), + }), + expect.any(Function), + ); + }); + + test('handles bulk get without changing the underlying argument shape', async () => { + const kv = createMockKVNamespace(); + const get = vi.fn().mockResolvedValue(new Map([['first', 'one'], ['second', 'two']])); + kv.get = get as unknown as KVNamespace['get']; + const wrapped = instrumentKV(kv, 'MY_KV'); + + const keys = ['first', 'second']; + const result = await wrapped.get(keys, 'text'); + + expect(result).toBeInstanceOf(Map); + expect(get).toHaveBeenCalledWith(keys, 'text'); + expect(startSpanSpy.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'cache.key': ['first', 'second'], + }), + }), + ); + }); + + test('handles getWithMetadata as a cache read', async () => { + const kv = createMockKVNamespace(); + const wrapped = instrumentKV(kv, 'MY_KV'); + + await wrapped.getWithMetadata('test-key', 'json'); + + expect(kv.getWithMetadata).toHaveBeenCalledWith('test-key', 'json'); + expect(startSpanSpy.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': 'cache.get', + 'cache.key': 'test-key', + 'db.operation.name': 'getWithMetadata', + }), + }), + ); + }); + + test('instruments put, delete, and list operations', async () => { + const kv = createMockKVNamespace(); + const wrapped = instrumentKV(kv, 'MY_KV'); + + await wrapped.put('put-key', 'value'); + await wrapped.delete('delete-key'); + await wrapped.list({ prefix: 'test-' }); + + expect(startSpanSpy).toHaveBeenCalledTimes(3); + expect(startSpanSpy.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': 'cache.put', + 'cache.key': 'put-key', + 'db.operation.name': 'put', + }), + }), + ); + expect(startSpanSpy.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': 'cache.remove', + 'cache.key': 'delete-key', + 'db.operation.name': 'delete', + }), + }), + ); + expect(startSpanSpy.mock.calls[2]?.[0]).toEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': 'cache.get', + 'db.operation.name': 'list', + }), + }), + ); + }); + + test('forwards unknown property accesses transparently', () => { + const kv = Object.assign(createMockKVNamespace(), { + customMethod: vi.fn().mockReturnValue('ok'), + }) as KVNamespace & { customMethod: () => string }; + + const wrapped = instrumentKV(kv, 'MY_KV'); + expect(wrapped.customMethod()).toBe('ok'); + }); +}); diff --git a/packages/cloudflare/test/utils/isBinding.test.ts b/packages/cloudflare/test/utils/isBinding.test.ts index 28d44fd9936d..e7cc2c76886d 100644 --- a/packages/cloudflare/test/utils/isBinding.test.ts +++ b/packages/cloudflare/test/utils/isBinding.test.ts @@ -210,3 +210,41 @@ describe('isD1Database', () => { expect(isD1Database(jsrpcProxy)).toBe(false); }); }); + + +describe('isKVNamespace', () => { + it('returns true for an object with get, put, list, and getWithMetadata methods', () => { + const kv = { + get: async () => null, + put: async () => {}, + list: async () => ({ keys: [], list_complete: true }), + getWithMetadata: async () => ({ value: null, metadata: null }), + }; + expect(isKVNamespace(kv)).toBe(true); + }); + + it('returns false when getWithMetadata is missing', () => { + expect(isKVNamespace({ get: () => {}, put: () => {}, list: () => {} })).toBe(false); + }); + + it('returns false when a required method is not a function', () => { + expect(isKVNamespace({ get: 'nope', put: () => {}, list: () => {}, getWithMetadata: () => {} })).toBe(false); + }); + + it('returns false for null and undefined', () => { + expect(isKVNamespace(null)).toBe(false); + expect(isKVNamespace(undefined)).toBe(false); + }); + + it('returns false for a JSRPC proxy even though it has all KV methods', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + expect(isKVNamespace(jsrpcProxy)).toBe(false); + }); +}); From 7c1a267eb739c3b7c10d45ffab721405e7ba76dc Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Sun, 18 Oct 2026 09:45:01 +0530 Subject: [PATCH 2/6] test(cloudflare): fix KV binding test import --- .../cloudflare/src/instrumentations/worker/instrumentEnv.ts | 1 + packages/cloudflare/test/utils/isBinding.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index af41244d117f..18cf562d95be 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -33,6 +33,7 @@ const instrumentedBindings = new WeakMap(); * - 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) * diff --git a/packages/cloudflare/test/utils/isBinding.test.ts b/packages/cloudflare/test/utils/isBinding.test.ts index e7cc2c76886d..ed92e2ccafbf 100644 --- a/packages/cloudflare/test/utils/isBinding.test.ts +++ b/packages/cloudflare/test/utils/isBinding.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue } from '../../src/utils/isBinding'; +import { isD1Database, isDurableObjectNamespace, isJSRPC, isKVNamespace, isQueue } from '../../src/utils/isBinding'; describe('isJSRPC', () => { it('returns false for a plain object', () => { From 1a91897e6a8497e6e451d306cde5b0e46f6e5897 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Sun, 18 Oct 2026 09:45:37 +0530 Subject: [PATCH 3/6] test(cloudflare): align KV database system attribute --- dev-packages/cloudflare-integration-tests/suites/kv/test.ts | 2 +- packages/cloudflare/src/instrumentations/worker/instrumentKV.ts | 2 +- .../test/instrumentations/worker/instrumentKV.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/test.ts b/dev-packages/cloudflare-integration-tests/suites/kv/test.ts index ecf1ef993a0b..d9bf8bc8c507 100644 --- a/dev-packages/cloudflare-integration-tests/suites/kv/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/kv/test.ts @@ -17,7 +17,7 @@ it('emits cache.get for a KV read with key and hit attributes', async ({ signal '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' }, + 'db.system.name': { type: 'string', value: 'cloudflare-kv' }, }), }), ]), diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts b/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts index d6b1a702a15f..e0344707c3af 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts @@ -44,7 +44,7 @@ function createSpanOptions(bindingName: string, operation: KVOperation, args: un ...(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', + 'db.system.name': 'cloudflare-kv', }, }; } diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts index 08aeb45883e6..98894d11fa53 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts @@ -45,7 +45,7 @@ describe('instrumentKV', () => { 'cache.key': 'test-key', 'db.operation.name': 'get', 'db.collection.name': 'MY_KV', - 'db.system.name': 'cloudflare.kv', + 'db.system.name': 'cloudflare-kv', }), }), expect.any(Function), From c23d2c8157969ecead1c50488c25653cb8dce8ec Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Sun, 18 Oct 2026 10:04:08 +0530 Subject: [PATCH 4/6] test(cloudflare): use conventions in KV instrumentation --- .../suites/kv/test.ts | 73 +++++++++++-------- .../instrumentations/worker/instrumentKV.ts | 17 ++++- .../worker/instrumentKV.test.ts | 48 +++++++----- 3 files changed, 86 insertions(+), 52 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/test.ts b/dev-packages/cloudflare-integration-tests/suites/kv/test.ts index d9bf8bc8c507..ea28f8aea9c1 100644 --- a/dev-packages/cloudflare-integration-tests/suites/kv/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/kv/test.ts @@ -1,3 +1,15 @@ +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'; @@ -5,19 +17,19 @@ 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'); + 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: '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' }, + [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' }, }), }), ]), @@ -33,15 +45,18 @@ it('emits cache.get for a bulk KV read with all keys in cache.key', async ({ sig const runner = createRunner(__dirname) .expect(envelope => { const spans = getSpansFromEnvelope(envelope).filter( - span => span.name === 'cache.get' && span.attributes['db.operation.name']?.value === 'get', + 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' }), - 'cache.hit': { type: 'boolean', value: true }, + [CACHE_KEY]: expect.objectContaining({ + type: 'array', + value: expect.arrayContaining(['bulk-a', 'bulk-b']), + }), + [CACHE_HIT]: { type: 'boolean', value: true }, }), }), ]), @@ -57,14 +72,14 @@ 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', + 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 }, + [CACHE_KEY]: { type: 'string', value: 'metadata-key' }, + [CACHE_HIT]: { type: 'boolean', value: true }, }), }), ]); @@ -78,15 +93,15 @@ it('emits cache.get for getWithMetadata', async ({ signal }) => { 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'); + 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: 'put' }, - 'cache.key': { type: 'string', value: 'put-key' }, - 'db.operation.name': { type: 'string', value: 'put' }, + [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' }, }), }), ]), @@ -101,15 +116,15 @@ it('emits cache.put for KV writes', async ({ signal }) => { 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'); + 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: 'remove' }, - 'cache.key': { type: 'string', value: 'delete-key' }, - 'db.operation.name': { type: 'string', value: 'delete' }, + [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' }, }), }), ]), @@ -125,16 +140,16 @@ 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', + 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: 'get' }, - 'db.operation.name': { type: 'string', value: 'list' }, + [SENTRY_OP]: { type: 'string', value: CACHE_GET }, + [CACHE_OPERATION]: { type: 'string', value: CACHE_OPERATION_NAMES[CACHE_GET] }, + [DB_OPERATION_NAME]: { type: 'string', value: 'list' }, }), }), ]), diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts b/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts index e0344707c3af..c070cd6974d4 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentKV.ts @@ -1,5 +1,14 @@ import type { KVNamespace } from '@cloudflare/workers-types'; -import { CACHE_HIT, CACHE_KEY, CACHE_OPERATION, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +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'; @@ -42,9 +51,9 @@ function createSpanOptions(bindingName: string, operation: KVOperation, args: un [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', + [DB_OPERATION_NAME]: operation, + [DB_COLLECTION_NAME]: bindingName, + [DB_SYSTEM_NAME]: 'cloudflare-kv', }, }; } diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts index 98894d11fa53..aaa0a68a0da2 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts @@ -1,4 +1,14 @@ import type { KVNamespace } from '@cloudflare/workers-types'; +import { + 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 * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { instrumentKV } from '../../../src/instrumentations/worker/instrumentKV'; @@ -39,13 +49,13 @@ describe('instrumentKV', () => { expect(startSpanSpy).toHaveBeenCalledWith( expect.objectContaining({ attributes: expect.objectContaining({ - 'sentry.op': 'cache.get', - 'sentry.origin': 'auto.cache.cloudflare.kv', - 'cache.operation': 'get', - 'cache.key': 'test-key', - 'db.operation.name': 'get', - 'db.collection.name': 'MY_KV', - 'db.system.name': 'cloudflare-kv', + [SENTRY_OP]: CACHE_GET, + [SENTRY_ORIGIN]: 'auto.cache.cloudflare.kv', + [CACHE_OPERATION]: SentryCore.CACHE_OPERATION_NAMES[CACHE_GET], + [CACHE_KEY]: 'test-key', + [DB_OPERATION_NAME]: 'get', + [DB_COLLECTION_NAME]: 'MY_KV', + [DB_SYSTEM_NAME]: 'cloudflare-kv', }), }), expect.any(Function), @@ -66,7 +76,7 @@ describe('instrumentKV', () => { expect(startSpanSpy.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ attributes: expect.objectContaining({ - 'cache.key': ['first', 'second'], + [CACHE_KEY]: ['first', 'second'], }), }), ); @@ -82,9 +92,9 @@ describe('instrumentKV', () => { expect(startSpanSpy.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ attributes: expect.objectContaining({ - 'sentry.op': 'cache.get', - 'cache.key': 'test-key', - 'db.operation.name': 'getWithMetadata', + [SENTRY_OP]: CACHE_GET, + [CACHE_KEY]: 'test-key', + [DB_OPERATION_NAME]: 'getWithMetadata', }), }), ); @@ -102,26 +112,26 @@ describe('instrumentKV', () => { expect(startSpanSpy.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ attributes: expect.objectContaining({ - 'sentry.op': 'cache.put', - 'cache.key': 'put-key', - 'db.operation.name': 'put', + [SENTRY_OP]: CACHE_PUT, + [CACHE_KEY]: 'put-key', + [DB_OPERATION_NAME]: 'put', }), }), ); expect(startSpanSpy.mock.calls[1]?.[0]).toEqual( expect.objectContaining({ attributes: expect.objectContaining({ - 'sentry.op': 'cache.remove', - 'cache.key': 'delete-key', - 'db.operation.name': 'delete', + [SENTRY_OP]: CACHE_REMOVE, + [CACHE_KEY]: 'delete-key', + [DB_OPERATION_NAME]: 'delete', }), }), ); expect(startSpanSpy.mock.calls[2]?.[0]).toEqual( expect.objectContaining({ attributes: expect.objectContaining({ - 'sentry.op': 'cache.get', - 'db.operation.name': 'list', + [SENTRY_OP]: CACHE_GET, + [DB_OPERATION_NAME]: 'list', }), }), ); From 85572810d1d9269dab99c34e4c1b81c996b7abfd Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Sun, 18 Oct 2026 10:16:53 +0530 Subject: [PATCH 5/6] test(cloudflare): tighten Workers KV regression assertions --- .../suites/kv/test.ts | 1 + .../test/instrumentations/instrumentEnv.test.ts | 15 --------------- .../instrumentations/worker/instrumentKV.test.ts | 1 + 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/kv/test.ts b/dev-packages/cloudflare-integration-tests/suites/kv/test.ts index ea28f8aea9c1..6629fa22f97a 100644 --- a/dev-packages/cloudflare-integration-tests/suites/kv/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/kv/test.ts @@ -154,6 +154,7 @@ it('emits cache.get for KV list operations', async ({ signal }) => { }), ]), ); + expect(spans[0]?.attributes[CACHE_KEY]).toBeUndefined(); }) .start(signal); diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index 3d5269ecb8a5..297ea7d3db02 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -259,21 +259,6 @@ describe('instrumentEnv', () => { expect(instrumented.MY_BUCKET).not.toBe(r2); }); - it('does not instrument a JSRPC proxy as Workers KV', () => { - const jsrpcProxy = new Proxy( - {}, - { - get(_target, _prop) { - return () => {}; - }, - }, - ); - const env = { SERVICE: jsrpcProxy }; - const instrumented = instrumentEnv(env); - - expect(instrumented.SERVICE).not.toBe(jsrpcProxy); - }); - it('caches the wrapped Queue binding across repeated access', () => { const queue = { send: vi.fn(), sendBatch: vi.fn() }; const env = { MY_QUEUE: queue }; diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts index aaa0a68a0da2..f16da72d5a48 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts @@ -135,6 +135,7 @@ describe('instrumentKV', () => { }), }), ); + expect(startSpanSpy.mock.calls[2]?.[0].attributes?.[CACHE_KEY]).toBeUndefined(); }); test('forwards unknown property accesses transparently', () => { From 3bdf95a5516b7281c5080dec8355e4bc24928393 Mon Sep 17 00:00:00 2001 From: Faizan Saiyed Date: Mon, 19 Oct 2026 16:03:01 +0530 Subject: [PATCH 6/6] fix(cloudflare): clean up KV test formatting and lint --- .../test/instrumentations/instrumentEnv.test.ts | 2 +- .../instrumentations/worker/instrumentKV.test.ts | 3 ++- packages/cloudflare/test/utils/isBinding.test.ts | 13 ++++++++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index 297ea7d3db02..9374842fa83b 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -231,7 +231,7 @@ describe('instrumentEnv', () => { const env = { MY_KV: kv }; const instrumented = instrumentEnv(env); - const wrapped = instrumented.MY_KV as typeof kv; + const wrapped = instrumented.MY_KV; expect(wrapped).not.toBe(kv); await wrapped.get('test-key', 'json'); diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts index f16da72d5a48..66177c7ed2f0 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentKV.test.ts @@ -135,7 +135,8 @@ describe('instrumentKV', () => { }), }), ); - expect(startSpanSpy.mock.calls[2]?.[0].attributes?.[CACHE_KEY]).toBeUndefined(); + const listSpanAttributes = startSpanSpy.mock.calls[2]?.[0].attributes; + expect(listSpanAttributes?.[CACHE_KEY]).toBeUndefined(); }); test('forwards unknown property accesses transparently', () => { diff --git a/packages/cloudflare/test/utils/isBinding.test.ts b/packages/cloudflare/test/utils/isBinding.test.ts index ed92e2ccafbf..ad4fbe4d5606 100644 --- a/packages/cloudflare/test/utils/isBinding.test.ts +++ b/packages/cloudflare/test/utils/isBinding.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { isD1Database, isDurableObjectNamespace, isJSRPC, isKVNamespace, isQueue } from '../../src/utils/isBinding'; +import { + isD1Database, + isDurableObjectNamespace, + isJSRPC, + isKVNamespace, + isQueue, +} from '../../src/utils/isBinding'; describe('isJSRPC', () => { it('returns false for a plain object', () => { @@ -211,7 +217,6 @@ describe('isD1Database', () => { }); }); - describe('isKVNamespace', () => { it('returns true for an object with get, put, list, and getWithMetadata methods', () => { const kv = { @@ -228,7 +233,9 @@ describe('isKVNamespace', () => { }); it('returns false when a required method is not a function', () => { - expect(isKVNamespace({ get: 'nope', put: () => {}, list: () => {}, getWithMetadata: () => {} })).toBe(false); + expect( + isKVNamespace({ get: 'nope', put: () => {}, list: () => {}, getWithMetadata: () => {} }), + ).toBe(false); }); it('returns false for null and undefined', () => {