diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index 3a386f0bb59d..888a84bc00f6 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -1,5 +1,6 @@ import type { CloudflareOptions } from '../../client'; -import { isDurableObjectNamespace, isJSRPC, isQueue } from '../../utils/isBinding'; +import { isDurableObjectNamespace, isFlagship, isJSRPC, isQueue } from '../../utils/isBinding'; +import { instrumentFlagship } from './instrumentFlagship'; import { appendRpcMeta } from '../../utils/rpcMeta'; import { getEffectiveRpcPropagation } from '../../utils/rpcOptions'; import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace'; @@ -54,6 +55,12 @@ export function instrumentEnv>(env: Env, opt return instrumented; } + if (isFlagship(item)) { + const instrumented = instrumentFlagship(item); + instrumentedBindings.set(item, instrumented); + return instrumented; + } + if (!rpcPropagation) { return item; } @@ -70,7 +77,7 @@ export function instrumentEnv>(env: Env, opt const value = Reflect.get(target, p); if (p === 'fetch' && typeof value === 'function') { - return instrumentFetcher((...args) => Reflect.apply(value, target, args)); + return instrumentFetcher((...args: unknown[]) => Reflect.apply(value, target, args)); } if (typeof value === 'function' && typeof p === 'string' && !STUB_NON_RPC_METHODS.has(p)) { diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentFlagship.ts b/packages/cloudflare/src/instrumentations/worker/instrumentFlagship.ts new file mode 100644 index 000000000000..0ee4e4938658 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentFlagship.ts @@ -0,0 +1,65 @@ +import { + _INTERNAL_addFeatureFlagToActiveSpan, + _INTERNAL_insertFlagToScope, +} from '@sentry/core'; + +const EVALUATION_METHODS = new Set([ + 'get', + 'getBooleanValue', + 'getStringValue', + 'getNumberValue', + 'getObjectValue', + 'getBooleanDetails', + 'getStringDetails', + 'getNumberDetails', + 'getObjectDetails', +]); + +type FlagshipEvaluationDetails = { + flagKey: string; + value: unknown; +}; + +function isEvaluationDetails(value: unknown): value is FlagshipEvaluationDetails { + return ( + value != null && + typeof value === 'object' && + 'flagKey' in value && + typeof (value as FlagshipEvaluationDetails).flagKey === 'string' && + 'value' in value + ); +} + +function recordFlagEvaluation(flagKey: string, value: unknown): void { + _INTERNAL_insertFlagToScope(flagKey, value); + _INTERNAL_addFeatureFlagToActiveSpan(flagKey, value); +} +export function instrumentFlagship(flagship: T): T { + return new Proxy(flagship, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + + if (typeof prop !== 'string' || !EVALUATION_METHODS.has(prop) || typeof value !== 'function') { + return value; + } + + const original = value as (...args: unknown[]) => unknown; + + return async (...args: unknown[]) => { + const result = await Reflect.apply(original, target, args); + + if (prop.endsWith('Details') && isEvaluationDetails(result)) { + recordFlagEvaluation(result.flagKey, result.value); + return result; + } + + const flagKey = args[0]; + if (typeof flagKey === 'string') { + recordFlagEvaluation(flagKey, result); + } + + return result; + }; + }, + }); +} diff --git a/packages/cloudflare/src/utils/isBinding.ts b/packages/cloudflare/src/utils/isBinding.ts index 5ced12c78389..e69e5b2cc441 100644 --- a/packages/cloudflare/src/utils/isBinding.ts +++ b/packages/cloudflare/src/utils/isBinding.ts @@ -67,3 +67,12 @@ export function isDurableObjectNamespace(item: unknown): item is DurableObjectNa export function isQueue(item: unknown): item is Queue { return item != null && isNotJSRPC(item) && typeof item.send === 'function' && typeof item.sendBatch === 'function'; } +export function isFlagship(item: unknown): boolean { + return ( + item != null && + isNotJSRPC(item) && + typeof (item as Record).getBooleanValue === 'function' && + typeof (item as Record).getStringValue === 'function' && + typeof (item as Record).getBooleanDetails === 'function' + ); +} diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentFlagship.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentFlagship.test.ts new file mode 100644 index 000000000000..1fbe6ef8e89a --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentFlagship.test.ts @@ -0,0 +1,116 @@ +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentFlagship } from '../../../src/instrumentations/worker/instrumentFlagship'; + +function createMockFlagship() { + return { + get: vi.fn().mockResolvedValue(true), + getBooleanValue: vi.fn().mockResolvedValue(true), + getStringValue: vi.fn().mockResolvedValue('variant-a'), + getNumberValue: vi.fn().mockResolvedValue(42), + getObjectValue: vi.fn().mockResolvedValue({ enabled: true }), + getBooleanDetails: vi.fn().mockResolvedValue({ flagKey: 'dark-mode', value: true }), + getStringDetails: vi.fn().mockResolvedValue({ flagKey: 'checkout-flow', value: 'v2' }), + getNumberDetails: vi.fn().mockResolvedValue({ flagKey: 'max-retries', value: 5 }), + getObjectDetails: vi.fn().mockResolvedValue({ flagKey: 'theme-config', value: { size: 16 } }), + }; +} + +describe('instrumentFlagship', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('forwards evaluation calls to the underlying binding', async () => { + const flagship = createMockFlagship(); + const wrapped = instrumentFlagship(flagship); + + await wrapped.getBooleanValue('new-checkout', false, { userId: 'user-42' }); + + expect(flagship.getBooleanValue).toHaveBeenCalledWith('new-checkout', false, { userId: 'user-42' }); + }); + + it('records boolean values from getBooleanValue on the active span and scope', async () => { + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({ setAttribute: vi.fn() } as any); + vi.spyOn(SentryCore, 'spanToJSON').mockReturnValue({ data: {} } as any); + const insertSpy = vi.spyOn(SentryCore, '_INTERNAL_insertFlagToScope'); + const spanSpy = vi.spyOn(SentryCore, '_INTERNAL_addFeatureFlagToActiveSpan'); + + const flagship = createMockFlagship(); + const wrapped = instrumentFlagship(flagship); + + await wrapped.getBooleanValue('new-checkout', false); + + expect(insertSpy).toHaveBeenCalledWith('new-checkout', true); + expect(spanSpy).toHaveBeenCalledWith('new-checkout', true); + }); + + it('does not record non-boolean values from typed value methods', async () => { + const insertSpy = vi.spyOn(SentryCore, '_INTERNAL_insertFlagToScope'); + const spanSpy = vi.spyOn(SentryCore, '_INTERNAL_addFeatureFlagToActiveSpan'); + + const flagship = createMockFlagship(); + const wrapped = instrumentFlagship(flagship); + + await wrapped.getStringValue('checkout-flow', 'v1'); + await wrapped.getNumberValue('max-retries', 3); + await wrapped.getObjectValue('theme-config', { size: 14 }); + + expect(insertSpy).not.toHaveBeenCalled(); + expect(spanSpy).not.toHaveBeenCalled(); + }); + + it('records boolean values from get() when the result is boolean', async () => { + const insertSpy = vi.spyOn(SentryCore, '_INTERNAL_insertFlagToScope'); + const spanSpy = vi.spyOn(SentryCore, '_INTERNAL_addFeatureFlagToActiveSpan'); + + const flagship = createMockFlagship(); + flagship.get.mockResolvedValueOnce('not-a-boolean'); + const wrapped = instrumentFlagship(flagship); + + await wrapped.get('string-flag', 'default'); + expect(insertSpy).not.toHaveBeenCalled(); + expect(spanSpy).not.toHaveBeenCalled(); + + await wrapped.get('bool-flag', false); + expect(insertSpy).toHaveBeenCalledWith('bool-flag', true); + expect(spanSpy).toHaveBeenCalledWith('bool-flag', true); + }); + + it('records boolean values from details methods using the returned metadata', async () => { + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({ setAttribute: vi.fn() } as any); + vi.spyOn(SentryCore, 'spanToJSON').mockReturnValue({ data: {} } as any); + const insertSpy = vi.spyOn(SentryCore, '_INTERNAL_insertFlagToScope'); + const spanSpy = vi.spyOn(SentryCore, '_INTERNAL_addFeatureFlagToActiveSpan'); + + const flagship = createMockFlagship(); + const wrapped = instrumentFlagship(flagship); + + await wrapped.getBooleanDetails('dark-mode', false); + + expect(insertSpy).toHaveBeenCalledWith('dark-mode', true); + expect(spanSpy).toHaveBeenCalledWith('dark-mode', true); + }); + + it('does not record non-boolean values from details methods', async () => { + const insertSpy = vi.spyOn(SentryCore, '_INTERNAL_insertFlagToScope'); + const spanSpy = vi.spyOn(SentryCore, '_INTERNAL_addFeatureFlagToActiveSpan'); + + const flagship = createMockFlagship(); + const wrapped = instrumentFlagship(flagship); + + await wrapped.getStringDetails('checkout-flow', 'v1'); + await wrapped.getNumberDetails('max-retries', 3); + await wrapped.getObjectDetails('theme-config', { size: 14 }); + + expect(insertSpy).not.toHaveBeenCalled(); + expect(spanSpy).not.toHaveBeenCalled(); + }); + + it('passes through non-evaluation properties unchanged', () => { + const flagship = { ...createMockFlagship(), appId: 'app-123' }; + const wrapped = instrumentFlagship(flagship); + + expect(wrapped.appId).toBe('app-123'); + }); +});