diff --git a/packages/browser/src/openfeature/exposures.ts b/packages/browser/src/openfeature/exposures.ts index 9d533b52..292bef34 100644 --- a/packages/browser/src/openfeature/exposures.ts +++ b/packages/browser/src/openfeature/exposures.ts @@ -44,7 +44,10 @@ export function createExposureLoggingHook( return { shutdown: () => exposuresBatch.stop(), - after: (hookContext: HookContext, details: EvaluationDetails) => { + finally: (hookContext: HookContext, details: EvaluationDetails) => { + if (details.errorCode || details.reason === 'ERROR' || details.variant == null) { + return + } const timestamp = timeStampNow() const exposureEvent = createExposureEvent(getEvaluationContext(hookContext.context), details) if (!exposureEvent) { diff --git a/packages/browser/src/openfeature/flagEvaluations.ts b/packages/browser/src/openfeature/flagEvaluations.ts index c683ff97..3889f29f 100644 --- a/packages/browser/src/openfeature/flagEvaluations.ts +++ b/packages/browser/src/openfeature/flagEvaluations.ts @@ -9,7 +9,13 @@ import { Observable, } from '@datadog/browser-core' import { FlagEvaluationAggregator, type FlagEvaluationEvent } from '@datadog/flagging-core' -import type { EvaluationContext, EvaluationDetails, FlagValue, HookContext } from '@openfeature/web-sdk' +import { + ErrorCode, + type EvaluationContext, + type EvaluationDetails, + type FlagValue, + type HookContext, +} from '@openfeature/web-sdk' import type { FlaggingTrackingConfiguration } from '../domain/configuration' import { validateAndBuildFlaggingTrackingConfiguration } from '../domain/configuration' import type { DatadogTrackingHooks, DatadogTrackingHooksOptions, ManagedTrackingHook } from './tracking' @@ -77,9 +83,16 @@ export function createFlagEvalEVPHook( flagEvaluationBatch.stop() } }, - after: (hookContext: HookContext, details: EvaluationDetails) => { + // Failed evaluations skip `after`; `finally` receives the result returned to the application. + finally: (hookContext: HookContext, details: EvaluationDetails) => { try { - aggregator.addEvaluation(getEvaluationContext(hookContext.context), details) + // OpenFeature can copy a customer hook's error.code without validation. + // Restrict telemetry to standard OpenFeature error codes. + const errorCode = + details.errorCode === undefined || Object.values(ErrorCode).includes(details.errorCode) + ? details.errorCode + : ErrorCode.GENERAL + aggregator.addEvaluation(getEvaluationContext(hookContext.context), details, errorCode) } catch (error) { addTelemetryDebug('Error adding evaluation to aggregator', { 'error.message': error instanceof Error ? error.message : String(error), diff --git a/packages/browser/src/openfeature/provider-tracking.ts b/packages/browser/src/openfeature/provider-tracking.ts index 956fafc5..9565db9c 100644 --- a/packages/browser/src/openfeature/provider-tracking.ts +++ b/packages/browser/src/openfeature/provider-tracking.ts @@ -56,14 +56,14 @@ export function createProviderTracking({ } function withTrackingContext(hook: Hook, getTrackingContext: (context: EvaluationContext) => EvaluationContext): Hook { - if (!hook.after) { + if (!hook.finally) { return hook } return { ...hook, - after: (hookContext, details, hookHints) => - hook.after?.( + finally: (hookContext, details, hookHints) => + hook.finally?.( { ...hookContext, context: getTrackingContext(hookContext.context), diff --git a/packages/browser/src/openfeature/provider.ts b/packages/browser/src/openfeature/provider.ts index 25a7b241..4203973a 100644 --- a/packages/browser/src/openfeature/provider.ts +++ b/packages/browser/src/openfeature/provider.ts @@ -70,7 +70,9 @@ export class DatadogProvider extends DatadogProviderBase { // result before calling the resolver and subsequent hooks. Return this stored context, not a // fresh RUM lookup, so targeting, flag configuration, and telemetry stay on the same identity. /** Effective context associated with the active flags configuration. */ - private evaluationContext: EvaluationContext = {} + private evaluationContext: EvaluationContext | undefined + /** Request context used for tracking until a configuration has been accepted. */ + private requestedEvaluationContext: EvaluationContext = {} status: ProviderStatus @@ -108,7 +110,7 @@ export class DatadogProvider extends DatadogProviderBase { options, configuration: this.configuration, enabledByDefault: true, - getTrackingContext: () => this.evaluationContext, + getTrackingContext: () => this.evaluationContext ?? this.requestedEvaluationContext, }) this.hooks = this.tracking.hooks this.exposureCache = this.tracking.exposureCache @@ -140,6 +142,7 @@ export class DatadogProvider extends DatadogProviderBase { private setContext(context: EvaluationContext): Promise { const evaluationContext = this.isRumIntegrationEnabled ? enrichEvaluationContextWithRumUser(context) : context + this.requestedEvaluationContext = evaluationContext if (this.status === ProviderStatus.NOT_READY) { // we're initializing, no status changes necessary @@ -280,7 +283,7 @@ export class DatadogProvider extends DatadogProviderBase { type, flagKey, defaultValue, - this.evaluationContext + this.evaluationContext ?? {} ) } } diff --git a/packages/browser/src/openfeature/rumIntegration.ts b/packages/browser/src/openfeature/rumIntegration.ts index c321a648..5e45e7c0 100644 --- a/packages/browser/src/openfeature/rumIntegration.ts +++ b/packages/browser/src/openfeature/rumIntegration.ts @@ -41,8 +41,8 @@ export function enrichEvaluationContextWithRumUser(context: EvaluationContext): export function createRumTrackingHook(): Hook { return { - after: (_hookContext: HookContext, details: EvaluationDetails) => { - if (details.variant == null) { + finally: (_hookContext: HookContext, details: EvaluationDetails) => { + if (details.errorCode || details.reason === 'ERROR' || details.variant == null) { return } const globalObject = getGlobalObject<{ DD_RUM?: DDRum }>() diff --git a/packages/browser/src/openfeature/tracking.ts b/packages/browser/src/openfeature/tracking.ts index 9334f2d8..6d6c7353 100644 --- a/packages/browser/src/openfeature/tracking.ts +++ b/packages/browser/src/openfeature/tracking.ts @@ -29,7 +29,7 @@ export function createTrackingHookController( let lifecycle = Promise.resolve() return { - hooks: [{ after: (...args) => activeHook?.after?.(...args) }], + hooks: [{ finally: (...args) => activeHook?.finally?.(...args) }], initialize: () => { // Serialize setup and teardown so shutdown also waits for a pending cache read. lifecycle = lifecycle.then(() => diff --git a/packages/browser/test/openfeature/evaluation-tracking.spec.ts b/packages/browser/test/openfeature/evaluation-tracking.spec.ts new file mode 100644 index 00000000..2b421f91 --- /dev/null +++ b/packages/browser/test/openfeature/evaluation-tracking.spec.ts @@ -0,0 +1,315 @@ +import { getGlobalObject } from '@datadog/browser-core' +import type { FlagEvaluationEvent } from '@datadog/flagging-core' +import { OpenFeature } from '@openfeature/web-sdk' +import type { DDRum } from '../../src/openfeature/rumIntegration' +import { + composeDatadogTrackingHooks, + configurationFromString, + createDatadogEvaluationLoggingHook, + createDatadogExposureLoggingHook, + createDatadogRumTrackingHook, + DatadogCoreProvider, + DatadogProvider, + type DatadogTrackingHooks, +} from '../../src/rules-based' +import precomputedResponse from '../data/precomputed-v1.json' +import rulesWire from '../data/rules-v1-wire.json' + +const domain = 'evaluation-tracking' +const options = { clientToken: 'tracking-token', env: 'test', flagEvaluationTrackingInterval: 1000 } + +describe.each(['core', 'online'] as const)('%s provider evaluation tracking', (kind) => { + const flagKey = kind === 'core' ? 'test-flag' : 'boolean-flag' + const variant = kind === 'core' ? 'on' : 'variation-124' + const rumEvaluation = jest.fn() + let client: ReturnType + let tracking: DatadogTrackingHooks | undefined + let originalFetch: typeof globalThis.fetch + let fetchMock: jest.Mock + + beforeEach(async () => { + jest.useFakeTimers() + localStorage.clear() + originalFetch = globalThis.fetch + fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200 }) + globalThis.fetch = fetchMock + client = OpenFeature.getClient(domain) + rumEvaluation.mockReset() + const rumUser = { id: 'rum-user', user_email: 'rum@example.com' } + getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM = { + addFeatureFlagEvaluation: rumEvaluation, + getUser: () => rumUser, + } + + if (kind === 'core') { + const provider = new DatadogCoreProvider() + provider.setConfiguration(configurationFromString(JSON.stringify(rulesWire))) + await OpenFeature.setProviderAndWait(domain, provider, { targetingKey: 'core-user', country: 'US' }) + tracking = composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(options), + createDatadogEvaluationLoggingHook(options), + createDatadogRumTrackingHook() + ) + await tracking.initialize() + client.addHooks(...tracking.hooks) + } else { + const provider = new DatadogProvider({ + ...options, + flagConfigurationFetch: jest.fn().mockResolvedValue({ ok: true, json: async () => precomputedResponse }), + }) + await OpenFeature.setProviderAndWait(domain, provider, { country: 'US' }) + } + + // Tracking must use the context of the active configuration, not a fresh RUM lookup. + rumUser.id = 'changed-rum-user' + }) + + afterEach(async () => { + client.clearHooks() + OpenFeature.clearHooks() + await tracking?.shutdown() + tracking = undefined + await OpenFeature.clearProviders() + await OpenFeature.clearContext() + delete getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM + globalThis.fetch = originalFetch + jest.useRealTimers() + }) + + it('counts each successful evaluation once', () => { + expect(client.getBooleanValue(flagKey, false)).toBe(true) + expect(client.getBooleanValue(flagKey, false)).toBe(true) + jest.advanceTimersByTime(31_000) + + const events = evaluationEvents() + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + flag: { key: flagKey }, + variant: { key: variant }, + evaluation_count: 2, + runtime_default_used: false, + ...expectedContext(), + }) + expect(events[0].error).toBeUndefined() + expect(rumEvaluation).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls.filter(([url]) => url.includes('/exposures'))).toHaveLength(1) + }) + + it.each(['FLAG_NOT_FOUND', 'TYPE_MISMATCH', 'GENERAL'] as const)( + 'tracks %s fallbacks without exposure or RUM events', + (errorCode) => { + if (errorCode === 'GENERAL') { + client.addHooks({ + before: () => { + throw new Error('before hook failed for private@example.com') + }, + }) + } + const evaluatedKey = errorCode === 'FLAG_NOT_FOUND' ? 'missing-flag' : flagKey + const evaluate = () => + errorCode === 'TYPE_MISMATCH' + ? client.getStringDetails(evaluatedKey, 'fallback') + : client.getBooleanDetails(evaluatedKey, false) + const details = evaluate() + expect(details).toMatchObject({ + value: errorCode === 'TYPE_MISMATCH' ? 'fallback' : false, + reason: 'ERROR', + errorCode, + }) + evaluate() + jest.advanceTimersByTime(31_000) + + const events = evaluationEvents() + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + flag: { key: evaluatedKey }, + evaluation_count: 2, + runtime_default_used: true, + error: { message: errorCode }, + ...expectedContext(), + }) + expect(events[0].variant).toBeUndefined() + expect(JSON.stringify(events)).not.toContain('private@example.com') + expect(rumEvaluation).not.toHaveBeenCalled() + expect(fetchMock.mock.calls.filter(([url]) => url.includes('/exposures'))).toHaveLength(0) + } + ) + + it('keeps successful and failed evaluations of the same flag separate', () => { + client.getBooleanValue(flagKey, false) + client.getStringDetails(flagKey, 'fallback') + jest.advanceTimersByTime(31_000) + + expect(evaluationEvents()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + flag: { key: flagKey }, + variant: { key: variant }, + evaluation_count: 1, + runtime_default_used: false, + }), + expect.objectContaining({ + flag: { key: flagKey }, + evaluation_count: 1, + runtime_default_used: true, + error: { message: 'TYPE_MISMATCH' }, + }), + ]) + ) + expect(evaluationEvents()).toHaveLength(2) + }) + + it('keeps different failures of the same flag and context separate', () => { + client.getStringDetails(flagKey, 'fallback') + client.addHooks({ + before: () => { + throw new Error('before hook failed') + }, + }) + client.getBooleanDetails(flagKey, false) + client.getBooleanDetails(flagKey, false) + jest.advanceTimersByTime(31_000) + + expect(evaluationEvents()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + flag: { key: flagKey }, + evaluation_count: 1, + runtime_default_used: true, + error: { message: 'TYPE_MISMATCH' }, + ...expectedContext(), + }), + expect.objectContaining({ + flag: { key: flagKey }, + evaluation_count: 2, + runtime_default_used: true, + error: { message: 'GENERAL' }, + ...expectedContext(), + }), + ]) + ) + expect(evaluationEvents()).toHaveLength(2) + expect(rumEvaluation).not.toHaveBeenCalled() + expect(fetchMock.mock.calls.filter(([url]) => url.includes('/exposures'))).toHaveLength(0) + }) + + describe.each(['before', 'after'] as const)('errors from %s hooks', (stage) => { + it.each(['private@example.com', 123])('normalizes custom error code %j in telemetry only', (code) => { + client.addHooks({ + [stage]: () => { + throw Object.assign(new Error('Hook failed for private@example.com'), { code }) + }, + }) + + expect(client.getBooleanDetails(flagKey, false)).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: code, + errorMessage: 'Hook failed for private@example.com', + }) + jest.advanceTimersByTime(31_000) + + const events = evaluationEvents() + expect(events).toEqual([ + expect.objectContaining({ + flag: { key: flagKey }, + evaluation_count: 1, + runtime_default_used: true, + error: { message: 'GENERAL' }, + ...expectedContext(), + }), + ]) + expect(JSON.stringify(events)).not.toContain('private@example.com') + expect(rumEvaluation).not.toHaveBeenCalled() + expect(fetchMock.mock.calls.filter(([url]) => url.includes('/exposures'))).toHaveLength(0) + }) + }) + + it('does not track a discarded assignment or deduplicate its later successful exposure', () => { + // Global after hooks run after provider and client after hooks. + OpenFeature.addHooks({ + after: () => { + throw new Error('after hook failed') + }, + }) + + expect(client.getBooleanDetails(flagKey, false)).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'GENERAL', + }) + jest.advanceTimersByTime(31_000) + + expect(evaluationEvents()).toEqual([ + expect.objectContaining({ + flag: { key: flagKey }, + evaluation_count: 1, + runtime_default_used: true, + error: { message: 'GENERAL' }, + }), + ]) + expect(rumEvaluation).not.toHaveBeenCalled() + expect(fetchMock.mock.calls.filter(([url]) => url.includes('/exposures'))).toHaveLength(0) + + OpenFeature.clearHooks() + fetchMock.mockClear() + expect(client.getBooleanValue(flagKey, false)).toBe(true) + jest.advanceTimersByTime(31_000) + + expect(rumEvaluation).toHaveBeenCalledWith(flagKey, variant) + expect(fetchMock.mock.calls.filter(([url]) => url.includes('/exposures'))).toHaveLength(1) + expect(evaluationEvents()).toEqual([ + expect.objectContaining({ + flag: { key: flagKey }, + variant: { key: variant }, + evaluation_count: 1, + runtime_default_used: false, + }), + ]) + }) + + it('preserves the successful result and other tracking when RUM tracking throws', () => { + rumEvaluation.mockImplementationOnce(() => { + throw new Error('RUM unavailable') + }) + const finallyHook = jest.fn() + OpenFeature.addHooks({ finally: finallyHook }) + + const details = client.getBooleanDetails(flagKey, false) + expect(details).toMatchObject({ value: true, variant }) + expect(details.errorCode).toBeUndefined() + expect(finallyHook).toHaveBeenCalledTimes(1) + expect(finallyHook.mock.calls[0][1]).toBe(details) + jest.advanceTimersByTime(31_000) + + expect(evaluationEvents()).toEqual([ + expect.objectContaining({ + evaluation_count: 1, + runtime_default_used: false, + variant: { key: variant }, + }), + ]) + expect(evaluationEvents()[0].error).toBeUndefined() + expect(fetchMock.mock.calls.filter(([url]) => url.includes('/exposures'))).toHaveLength(1) + }) + + function evaluationEvents(): FlagEvaluationEvent[] { + return fetchMock.mock.calls + .filter(([url]) => url.includes('/flagevaluation')) + .flatMap(([, request]) => + request.body + .trim() + .split('\n') + .map((line: string) => JSON.parse(line)) + ) + } + + function expectedContext() { + return { + targeting_key: kind === 'core' ? 'core-user' : 'rum-user', + context: expect.objectContaining({ + evaluation: { country: 'US', ...(kind === 'online' && { user_email: 'rum@example.com' }) }, + }), + } + } +}) diff --git a/packages/browser/test/openfeature/exposures.spec.ts b/packages/browser/test/openfeature/exposures.spec.ts index ecaea69d..0fed27b8 100644 --- a/packages/browser/test/openfeature/exposures.spec.ts +++ b/packages/browser/test/openfeature/exposures.spec.ts @@ -1,6 +1,7 @@ import { getGlobalObject, INTAKE_SITE_STAGING } from '@datadog/browser-core' -import { OpenFeature } from '@openfeature/web-sdk' +import { ErrorCode, type HookContext, OpenFeature } from '@openfeature/web-sdk' import type { FlaggingInitConfiguration } from '../../src/domain/configuration' +import { createDatadogExposureLoggingHook } from '../../src/openfeature/exposures' import { DatadogProvider } from '../../src/openfeature/provider' import type { DDRum } from '../../src/openfeature/rumIntegration' import precomputedServerResponse from '../data/precomputed-v1.json' @@ -53,6 +54,30 @@ describe('Exposures End-to-End', () => { jest.setSystemTime(new Date('2025-08-04T17:00:00.000Z')) }) + it.each([{ errorCode: ErrorCode.GENERAL }, { reason: 'ERROR' }, { variant: undefined }])( + 'does not track errors or runtime defaults in finally: %j', + async (fallbackDetails) => { + const tracking = createDatadogExposureLoggingHook(baseProviderConfig) + await tracking.initialize() + try { + const hook = tracking.hooks[0] + expect(hook.after).toBeUndefined() + expect(hook.finally).toBeDefined() + hook.finally!({ context: { targetingKey: 'test-user' } } as HookContext, { + flagKey: 'test-flag', + value: false, + variant: 'variant-a', + reason: 'TARGETING_MATCH', + flagMetadata: { allocationKey: 'allocation-a', doLog: true }, + ...fallbackDetails, + }) + } finally { + await tracking.shutdown?.() + } + expect(fetchMock).not.toHaveBeenCalled() + } + ) + it('should send exposure events to correct endpoint with proper payload', async () => { // Mock server responses fetchMock.mockImplementation((url: string) => { diff --git a/packages/browser/test/openfeature/flagEvaluations.spec.ts b/packages/browser/test/openfeature/flagEvaluations.spec.ts index cc1716ae..813568f2 100644 --- a/packages/browser/test/openfeature/flagEvaluations.spec.ts +++ b/packages/browser/test/openfeature/flagEvaluations.spec.ts @@ -1,51 +1,67 @@ import { FlagEvaluationAggregator } from '@datadog/flagging-core' -import type { EvaluationDetails, HookContext } from '@openfeature/web-sdk' -import type { FlaggingConfiguration } from '../../src/domain/configuration' +import { ErrorCode, type EvaluationDetails, type HookContext } from '@openfeature/web-sdk' +import { validateAndBuildFlaggingTrackingConfiguration } from '../../src/domain/configuration' import { createFlagEvalEVPHook } from '../../src/openfeature/flagEvaluations' -const mockConfiguration: FlaggingConfiguration = { +const mockConfiguration = validateAndBuildFlaggingTrackingConfiguration({ + clientToken: 'tracking-token', flagEvaluationTrackingInterval: 1000, applicationId: 'test-app-id', - fetchFlagsConfiguration: jest.fn(), service: 'test-service', - exposuresEndpointBuilder: jest.fn() as any, - flagEvaluationEndpointBuilder: jest.fn() as any, - // Add required Configuration properties - site: 'datadoghq.com', - version: '1.0.0', - sessionSampleRate: 100, - telemetrySampleRate: 20, - // `as unknown as FlaggingConfiguration` is intentional: FlaggingConfiguration inherits many required - // fields from Configuration/TransportConfiguration (beforeSend, logsEndpointBuilder, sdkVersion, etc.) - // that createFlagEvalEVPHook never reads. All @datadog/browser-core imports used by - // createFlagEvalEVPHook are mocked at the module level below, so missing fields don't - // cause runtime failures. -} as unknown as FlaggingConfiguration +})! jest.mock('@datadog/browser-core', () => ({ + ...jest.requireActual('@datadog/browser-core'), addTelemetryDebug: jest.fn(), createBatch: jest.fn(() => ({ add: jest.fn(), + stop: jest.fn(), })), createFlushController: jest.fn(), createHttpRequest: jest.fn(), createIdentityEncoder: jest.fn(), createPageMayExitObservable: jest.fn(() => ({ - subscribe: jest.fn(), + subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })), })), - Observable: jest.fn().mockImplementation(() => ({})), - dateNow: jest.fn(() => 1234567890), + Observable: jest.fn().mockImplementation(() => ({ notify: jest.fn() })), })) describe('createFlagEvalEVPHook', () => { + let hook: ReturnType | undefined + + beforeEach(() => { + jest.useFakeTimers() + hook = undefined + }) + + afterEach(() => { + try { + hook?.shutdown() + expect(jest.getTimerCount()).toBe(0) + } finally { + jest.restoreAllMocks() + jest.useRealTimers() + } + }) + it('should create a hook that tracks flag evaluations', () => { - const hook = createFlagEvalEVPHook(mockConfiguration) + hook = createFlagEvalEVPHook(mockConfiguration) expect(hook).toBeDefined() - expect(hook.after).toBeDefined() + expect(hook.after).toBeUndefined() + expect(hook.finally).toBeDefined() }) - it('should handle evaluation tracking in after hook', () => { + it.each([ + { errorCode: undefined, errorMessage: undefined, expectedError: undefined }, + ...Object.values(ErrorCode).map((errorCode) => ({ + errorCode, + errorMessage: 'Invalid user private@example.com', + expectedError: errorCode, + })), + { errorCode: ErrorCode.TYPE_MISMATCH, errorMessage: '', expectedError: ErrorCode.TYPE_MISMATCH }, + { errorCode: ErrorCode.PROVIDER_NOT_READY, errorMessage: undefined, expectedError: ErrorCode.PROVIDER_NOT_READY }, + ])('tracks evaluation details in finally: $expectedError', ({ errorCode, errorMessage, expectedError }) => { const mockContext: HookContext = { flagKey: 'test-flag', defaultValue: true, @@ -80,8 +96,10 @@ describe('createFlagEvalEVPHook', () => { const mockDetails: EvaluationDetails = { flagKey: 'test-flag', value: true, - variant: 'variant-a', - reason: 'TARGETING_MATCH', + variant: errorCode ? undefined : 'variant-a', + reason: errorCode ? 'ERROR' : 'TARGETING_MATCH', + errorCode, + errorMessage, flagMetadata: { allocationKey: 'allocation-123', targetingRuleKey: 'rule-456', @@ -93,12 +111,12 @@ describe('createFlagEvalEVPHook', () => { user_email: 'rum@example.com', } const addEvaluationSpy = jest.spyOn(FlagEvaluationAggregator.prototype, 'addEvaluation') - const hook = createFlagEvalEVPHook(mockConfiguration, () => effectiveContext) + hook = createFlagEvalEVPHook(mockConfiguration, () => effectiveContext) expect(() => { - hook.after?.(mockContext, mockDetails) + hook?.finally?.(mockContext, mockDetails) }).not.toThrow() - expect(addEvaluationSpy).toHaveBeenCalledWith(effectiveContext, mockDetails) - addEvaluationSpy.mockRestore() + expect(addEvaluationSpy).toHaveBeenCalledTimes(1) + expect(addEvaluationSpy).toHaveBeenCalledWith(effectiveContext, mockDetails, expectedError) }) }) diff --git a/packages/browser/test/openfeature/provider-tracking.spec.ts b/packages/browser/test/openfeature/provider-tracking.spec.ts new file mode 100644 index 00000000..7fbf78c0 --- /dev/null +++ b/packages/browser/test/openfeature/provider-tracking.spec.ts @@ -0,0 +1,133 @@ +import { getGlobalObject } from '@datadog/browser-core' +import type { FlagEvaluationEvent } from '@datadog/flagging-core' +import { OpenFeature, ProviderStatus } from '@openfeature/web-sdk' +import { DatadogProvider } from '../../src/openfeature/provider' +import type { DDRum } from '../../src/openfeature/rumIntegration' +import precomputedResponse from '../data/precomputed-v1.json' +import { createDeferred } from '../transport/fetchTestUtils' + +const domain = 'provider-tracking-context' +const options = { clientToken: 'tracking-token', env: 'test', flagEvaluationTrackingInterval: 1000 } +const response = { ok: true, json: async () => precomputedResponse } + +describe('online provider tracking context', () => { + let originalFetch: typeof globalThis.fetch + let fetchMock: jest.Mock + + beforeEach(() => { + jest.useFakeTimers() + localStorage.clear() + originalFetch = globalThis.fetch + fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200 }) + globalThis.fetch = fetchMock + }) + + afterEach(async () => { + await OpenFeature.clearProviders() + await OpenFeature.clearContext() + delete getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM + globalThis.fetch = originalFetch + jest.useRealTimers() + }) + + it.each([false, true])( + 'uses the startup context before configuration loads (RUM enabled: %s)', + async (rumEnabled) => { + const pending = createDeferred() + const rumUser = { id: 'startup-rum-user', user_email: 'rum@example.com' } + const rumEvaluation = jest.fn() + getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM = { + getUser: () => rumUser, + addFeatureFlagEvaluation: rumEvaluation, + } + const provider = new DatadogProvider({ + ...options, + enableRumFeatureFlagTracking: rumEnabled, + flagConfigurationFetch: jest.fn().mockReturnValue(pending.promise), + }) + const context = { country: 'US', ...(!rumEnabled && { targetingKey: 'startup-user' }) } + const ready = OpenFeature.setProviderAndWait(domain, provider, context) + const client = OpenFeature.getClient(domain) + const expectedContext = { + targeting_key: rumEnabled ? 'startup-rum-user' : 'startup-user', + context: expect.objectContaining({ + evaluation: { country: 'US', ...(rumEnabled && { user_email: 'rum@example.com' }) }, + }), + } + + try { + await jest.advanceTimersByTimeAsync(0) + rumUser.id = 'changed-rum-user' + expect(client.getBooleanDetails('boolean-flag', false).errorCode).toBe('PROVIDER_NOT_READY') + jest.advanceTimersByTime(31_000) + + expect(evaluationEvents()).toEqual([ + expect.objectContaining({ + ...expectedContext, + evaluation_count: 1, + runtime_default_used: true, + error: { message: 'PROVIDER_NOT_READY' }, + }), + ]) + expect(rumEvaluation).not.toHaveBeenCalled() + expect(fetchMock.mock.calls.filter(([url]) => url.includes('/exposures'))).toHaveLength(0) + } finally { + pending.resolve(response) + await ready + } + + fetchMock.mockClear() + expect(client.getBooleanValue('boolean-flag', false)).toBe(true) + jest.advanceTimersByTime(31_000) + expect(evaluationEvents()).toEqual([expect.objectContaining({ ...expectedContext, runtime_default_used: false })]) + expect(evaluationEvents()[0].error).toBeUndefined() + } + ) + + it('keeps the active context during a pending update and switches when the new configuration is accepted', async () => { + const pending = createDeferred() + const provider = new DatadogProvider({ + ...options, + enableRumFeatureFlagTracking: false, + flagConfigurationFetch: jest.fn().mockResolvedValueOnce(response).mockReturnValue(pending.promise), + }) + await OpenFeature.setProviderAndWait(domain, provider, { targetingKey: 'first-user', country: 'US' }) + const client = OpenFeature.getClient(domain) + const updating = OpenFeature.setContext(domain, { targetingKey: 'next-user', country: 'CA' }) + + try { + expect(client.providerStatus).toBe(ProviderStatus.RECONCILING) + expect(client.getBooleanValue('boolean-flag', false)).toBe(true) + expect(client.getStringDetails('boolean-flag', 'fallback').errorCode).toBe('TYPE_MISMATCH') + jest.advanceTimersByTime(31_000) + + expect(evaluationEvents()).toHaveLength(2) + for (const event of evaluationEvents()) { + expect(event).toMatchObject({ targeting_key: 'first-user', context: { evaluation: { country: 'US' } } }) + } + } finally { + pending.resolve(response) + await updating + } + + fetchMock.mockClear() + client.getBooleanValue('boolean-flag', false) + client.getStringDetails('boolean-flag', 'fallback') + jest.advanceTimersByTime(31_000) + expect(evaluationEvents()).toHaveLength(2) + for (const event of evaluationEvents()) { + expect(event).toMatchObject({ targeting_key: 'next-user', context: { evaluation: { country: 'CA' } } }) + } + }) + + function evaluationEvents(): FlagEvaluationEvent[] { + return fetchMock.mock.calls + .filter(([url]) => url.includes('/flagevaluation')) + .flatMap(([, request]) => + request.body + .trim() + .split('\n') + .map((line: string) => JSON.parse(line)) + ) + } +}) diff --git a/packages/browser/test/openfeature/rumIntegration.spec.ts b/packages/browser/test/openfeature/rumIntegration.spec.ts index 48cc9375..b298a53e 100644 --- a/packages/browser/test/openfeature/rumIntegration.spec.ts +++ b/packages/browser/test/openfeature/rumIntegration.spec.ts @@ -1,5 +1,5 @@ import { getGlobalObject } from '@datadog/browser-core' -import type { EvaluationDetails, FlagValue, HookContext } from '@openfeature/web-sdk' +import { ErrorCode, type EvaluationDetails, type FlagValue, type HookContext } from '@openfeature/web-sdk' import type { DDRum } from '../../src/openfeature/rumIntegration' import { createRumTrackingHook, enrichEvaluationContextWithRumUser } from '../../src/openfeature/rumIntegration' @@ -20,21 +20,22 @@ describe('createRumTrackingHook', () => { globalObject.DD_RUM = { addFeatureFlagEvaluation: mockAddFeatureFlagEvaluation } const hook = createRumTrackingHook() - hook.after!(mockHookContext, makeDetails('test-flag', 'variant-a', true)) + expect(hook.after).toBeUndefined() + hook.finally!(mockHookContext, makeDetails('test-flag', 'variant-a', true)) expect(mockAddFeatureFlagEvaluation).toHaveBeenCalledWith('test-flag', 'variant-a') }) it('should be a no-op when DD_RUM is absent', () => { const hook = createRumTrackingHook() - expect(() => hook.after!(mockHookContext, makeDetails('test-flag', 'variant-a', 'foo-bar-baz'))).not.toThrow() + expect(() => hook.finally!(mockHookContext, makeDetails('test-flag', 'variant-a', 'foo-bar-baz'))).not.toThrow() }) it('should detect DD_RUM that loads after hook creation (lazy detection)', () => { const hook = createRumTrackingHook() // First call: DD_RUM not yet loaded - hook.after!(mockHookContext, makeDetails('flag-1', 'variant-key-a', 'foo-bar-baz')) + hook.finally!(mockHookContext, makeDetails('flag-1', 'variant-key-a', 'foo-bar-baz')) // Now DD_RUM loads const mockAddFeatureFlagEvaluation = jest.fn() @@ -42,7 +43,7 @@ describe('createRumTrackingHook', () => { globalObject.DD_RUM = { addFeatureFlagEvaluation: mockAddFeatureFlagEvaluation } // Second call: should pick up DD_RUM - hook.after!(mockHookContext, makeDetails('flag-2', 'variant-key-b', 'qux-quux-quuz')) + hook.finally!(mockHookContext, makeDetails('flag-2', 'variant-key-b', 'qux-quux-quuz')) expect(mockAddFeatureFlagEvaluation).toHaveBeenCalledTimes(1) expect(mockAddFeatureFlagEvaluation).toHaveBeenCalledWith('flag-2', 'variant-key-b') @@ -54,7 +55,7 @@ describe('createRumTrackingHook', () => { globalObject.DD_RUM = { addFeatureFlagEvaluation: mockAddFeatureFlagEvaluation } const hook = createRumTrackingHook() - hook.after!(mockHookContext, { + hook.finally!(mockHookContext, { flagKey: 'test-flag', variant: undefined, value: 'default', @@ -63,12 +64,29 @@ describe('createRumTrackingHook', () => { expect(mockAddFeatureFlagEvaluation).not.toHaveBeenCalled() }) + it.each([{ errorCode: ErrorCode.GENERAL }, { reason: 'ERROR' }])( + 'does not track errors even when a variant is present: %j', + (errorDetails) => { + const mockAddFeatureFlagEvaluation = jest.fn() + getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM = { + addFeatureFlagEvaluation: mockAddFeatureFlagEvaluation, + } + + createRumTrackingHook().finally!(mockHookContext, { + ...makeDetails('test-flag', 'variant-a', true), + ...errorDetails, + }) + + expect(mockAddFeatureFlagEvaluation).not.toHaveBeenCalled() + } + ) + it('should be a no-op when DD_RUM exists but lacks addFeatureFlagEvaluation', () => { const globalObject = getGlobalObject<{ DD_RUM?: Partial }>() globalObject.DD_RUM = {} as DDRum const hook = createRumTrackingHook() - expect(() => hook.after!(mockHookContext, makeDetails('test-flag', 'variant-key-a', 'foo-bar-baz'))).not.toThrow() + expect(() => hook.finally!(mockHookContext, makeDetails('test-flag', 'variant-key-a', 'foo-bar-baz'))).not.toThrow() }) it('should pass the variant key, not the value', () => { @@ -79,7 +97,7 @@ describe('createRumTrackingHook', () => { const hook = createRumTrackingHook() const details = makeDetails('my-flag', 'variant-key-a', true) - hook.after!(mockHookContext, details) + hook.finally!(mockHookContext, details) expect(mockAddFeatureFlagEvaluation).toHaveBeenCalledWith('my-flag', 'variant-key-a') }) diff --git a/packages/browser/test/openfeature/tracking-lifecycle.spec.ts b/packages/browser/test/openfeature/tracking-lifecycle.spec.ts index b6e26e0c..c6b2c1c5 100644 --- a/packages/browser/test/openfeature/tracking-lifecycle.spec.ts +++ b/packages/browser/test/openfeature/tracking-lifecycle.spec.ts @@ -76,25 +76,28 @@ describe('tracking resource lifecycle', () => { expect(jest.getTimerCount()).toBe(0) }) - it('disables registered evaluation hooks after shutdown and can initialize them again', async () => { - const controller = createDatadogEvaluationLoggingHook(options) - controllers.push(controller) - const client = await createClient() - client.addHooks(...controller.hooks) - await controller.initialize() - client.getBooleanValue('test-flag', false) - await controller.shutdown() - fetchMock.mockClear() + it.each(['test-flag', 'missing-flag'])( + 'disables evaluation tracking for %s after shutdown and can initialize it again', + async (flagKey) => { + const controller = createDatadogEvaluationLoggingHook(options) + controllers.push(controller) + const client = await createClient() + client.addHooks(...controller.hooks) + await controller.initialize() + client.getBooleanValue(flagKey, false) + await controller.shutdown() + fetchMock.mockClear() - client.getBooleanValue('test-flag', false) - jest.advanceTimersByTime(60_000) - expect(fetchMock).not.toHaveBeenCalled() + client.getBooleanValue(flagKey, false) + jest.advanceTimersByTime(60_000) + expect(fetchMock).not.toHaveBeenCalled() - await controller.initialize() - client.getBooleanValue('test-flag', false) - jest.advanceTimersByTime(31_000) - expect(fetchMock).toHaveBeenCalledTimes(1) - }) + await controller.initialize() + client.getBooleanValue(flagKey, false) + jest.advanceTimersByTime(31_000) + expect(fetchMock).toHaveBeenCalledTimes(1) + } + ) it.each(['exposure', 'evaluation'] as const)( 'cleans up %s resources when the final proxy call throws',