diff --git a/packages/browser/README.md b/packages/browser/README.md index 1b76e0e7..a17b0450 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -396,6 +396,10 @@ cross-origin requests, allow it in its CORS response before enabling it. The provider accepts both plaintext and obfuscated responses without an application configuration change. +Dependent precomputed assignments are negotiated through the URL query parameter +`cap_dependent-flags-variant-key=v1`. The provider adds it to every precompute +request, including requests sent through `flaggingProxy`. + Continue evaluating the original flag key. The shared core hashes it with the response's public salt before lookup. Flag values do not change. Evaluation details, exposure events, and RUM annotations retain the original flag key. Portable diff --git a/packages/browser/src/cache/indexeddb-flags-cache.ts b/packages/browser/src/cache/indexeddb-flags-cache.ts index 2820b53e..b5284030 100644 --- a/packages/browser/src/cache/indexeddb-flags-cache.ts +++ b/packages/browser/src/cache/indexeddb-flags-cache.ts @@ -32,7 +32,8 @@ export class IndexedDBFlagsCache { ): Promise { try { const legacyKey = buildConfigKey(this.clientToken, context) - const configKey = `${policyPrefix(optedOut)}-${legacyKey}${this.selectionSuffix}` + const configKey = `${policyPrefix(optedOut, 'v3')}-${legacyKey}${this.selectionSuffix}` + const previousConfigKey = `${policyPrefix(optedOut, 'v2')}-${legacyKey}${this.selectionSuffix}` const db = await openDB() try { const config = await new Promise((resolve, reject) => { @@ -44,24 +45,32 @@ export class IndexedDBFlagsCache { resolve(request.result as FlagsConfiguration) return } - if (optedOut || this.selectionSuffix) { - resolve(undefined) - return - } - // Read older plaintext entries on upgrade. Never expose encoded - // snapshots written by a prerelease SDK through the legacy key. - const legacy = store.get(legacyKey) - legacy.onerror = () => reject(legacy.error) - legacy.onsuccess = () => { - const config = legacy.result as FlagsConfiguration | undefined - const attributes = config?.precomputed?.response?.data?.attributes - resolve( - attributes && - (attributes.obfuscated === undefined || attributes.obfuscated === false) && - attributes.obfuscation === undefined - ? config - : undefined - ) + const previous = store.get(previousConfigKey) + previous.onerror = () => reject(previous.error) + previous.onsuccess = () => { + if (previous.result !== undefined) { + resolve(previous.result as FlagsConfiguration) + return + } + if (optedOut || this.selectionSuffix) { + resolve(undefined) + return + } + // Read older plaintext entries on upgrade. Never expose encoded + // snapshots written by a prerelease SDK through the legacy key. + const legacy = store.get(legacyKey) + legacy.onerror = () => reject(legacy.error) + legacy.onsuccess = () => { + const config = legacy.result as FlagsConfiguration | undefined + const attributes = config?.precomputed?.response?.data?.attributes + resolve( + attributes && + (attributes.obfuscated === undefined || attributes.obfuscated === false) && + attributes.obfuscation === undefined + ? config + : undefined + ) + } } } request.onerror = () => reject(request.error) @@ -80,9 +89,9 @@ export class IndexedDBFlagsCache { /** Fire-and-forget persist. Never throws. */ set(config: FlagsConfiguration, context: EvaluationContext, optedOut: ExperimentationOptOutPolicy = false): void { - // One namespace for new writes keeps plaintext rollback and encoded - // refreshes in the same slot without changing what older SDKs can read. - const prefix = policyPrefix(optedOut) + // New wire shapes use a new namespace so an older SDK cannot consume a + // snapshot it does not understand after an application rollback. + const prefix = policyPrefix(optedOut, 'v3') const configKey = `${prefix}-${buildConfigKey(this.clientToken, context)}${this.selectionSuffix}` openDB() .then((db) => { @@ -99,6 +108,7 @@ export class IndexedDBFlagsCache { } } -function policyPrefix(policy: ExperimentationOptOutPolicy): string { - return policy ? experimentationOptOutCacheKey(policy) : 'v2' +function policyPrefix(policy: ExperimentationOptOutPolicy, version: 'v2' | 'v3'): string { + if (version === 'v2') return policy ? experimentationOptOutCacheKey(policy) : version + return policy ? `${version}-${experimentationOptOutCacheKey(policy)}` : version } diff --git a/packages/browser/src/openfeature/core-provider-metadata.ts b/packages/browser/src/openfeature/core-provider-metadata.ts index 44070c9e..187a7b70 100644 --- a/packages/browser/src/openfeature/core-provider-metadata.ts +++ b/packages/browser/src/openfeature/core-provider-metadata.ts @@ -1,4 +1,5 @@ import type { EvaluationDetails, FlagValue, ResolutionDetails } from '@openfeature/web-sdk' +import { inheritPrecomputedDependencyEvaluations } from './precomputed-dependencies' const CORE_CONFIGURATION_ID_METADATA_KEY = '__dd_core_configuration_id' @@ -6,22 +7,25 @@ export function withCoreConfigurationId( details: ResolutionDetails, configurationId: string | undefined ): ResolutionDetails { - const allocationKey = details.flagMetadata?.allocationKey + const sourceMetadata = details.flagMetadata + const allocationKey = sourceMetadata?.allocationKey if ( + !sourceMetadata || configurationId === undefined || - details.flagMetadata?.doLog !== true || typeof allocationKey !== 'string' || details.variant == null ) { return details } + const flagMetadata = { + ...sourceMetadata, + [CORE_CONFIGURATION_ID_METADATA_KEY]: configurationId, + } + inheritPrecomputedDependencyEvaluations(sourceMetadata, flagMetadata) return { ...details, - flagMetadata: { - ...details.flagMetadata, - [CORE_CONFIGURATION_ID_METADATA_KEY]: configurationId, - }, + flagMetadata, } } diff --git a/packages/browser/src/openfeature/core-provider.ts b/packages/browser/src/openfeature/core-provider.ts index 1b4e98ef..7bb066a9 100644 --- a/packages/browser/src/openfeature/core-provider.ts +++ b/packages/browser/src/openfeature/core-provider.ts @@ -31,6 +31,7 @@ import { } from './experimentation-opt-out' import { registerDatadogProvider, supportsExposureAdmission } from './exposure-scope' import { stampExposureCacheVerdict } from './exposures' +import { getPrecomputedDependencyEvaluations } from './precomputed-dependencies' import { DatadogProviderBase } from './provider-base' export interface DatadogCoreProviderConfiguration @@ -51,7 +52,11 @@ export class DatadogCoreProvider extends DatadogProviderBase { readonly hooks: Hook[] = [ { finally: (context, details) => { - if (supportsExposureAdmission(context)) completeExposureDecision(details) + if (!supportsExposureAdmission(context)) return + for (const dependency of getPrecomputedDependencyEvaluations(details)) { + if (dependency.isPresentInAssignment) completeExposureDecision(dependency) + } + completeExposureDecision(details) }, }, ] diff --git a/packages/browser/src/openfeature/exposure-hook.ts b/packages/browser/src/openfeature/exposure-hook.ts index 9b981168..7dd6cc52 100644 --- a/packages/browser/src/openfeature/exposure-hook.ts +++ b/packages/browser/src/openfeature/exposure-hook.ts @@ -14,6 +14,7 @@ import type { } from '@openfeature/web-sdk' import { getEvaluationTrackingContext } from './evaluation-telemetry-policy' import { supportsExposureAdmission } from './exposure-scope' +import { getPrecomputedDependencyEvaluations } from './precomputed-dependencies' const METADATA_ALLOCATION_KEY = '__dd_allocation_key' const METADATA_DO_LOG = '__dd_do_log' @@ -61,14 +62,13 @@ class ExposureEvaluationImpl implements ExposureEvaluation { readonly details: EvaluationDetails private readonly evaluationContext: EvaluationContext | undefined - constructor(hookContext: HookContext, details: EvaluationDetails) { + constructor(hookContext: HookContext, details: EvaluationDetails, trackingContext?: EvaluationContext) { this.flagKey = hookContext?.flagKey ?? details.flagKey this.details = details - // Prefer the context the provider actually evaluated (for example the - // RUM-enriched subject); fall back to the hook context for unbound results. + // The caller resolves the provider-bound context once for the whole graph. // A wrapper may expose an unreadable context; the exposure decision below never reads it. try { - this.evaluationContext = cloneExposureContext(getEvaluationTrackingContext(details, () => hookContext?.context)) + this.evaluationContext = cloneExposureContext(trackingContext) } catch { this.evaluationContext = undefined } @@ -180,18 +180,56 @@ export class ExposureHook implements Hook { /** Runs in the final hook stage, so a later hook's error is seen. */ finally(hookContext: HookContext, evaluationDetails: EvaluationDetails): void { if (evaluationDetails == null || !supportsExposureAdmission(hookContext)) return + let evaluations: Array< + EvaluationDetails | (Omit, 'value'> & { value?: FlagValue }) + > try { - // The callback must read the final verdict, whether or not a provider or - // tracking hook finalized the decision first. - completeExposureDecision(evaluationDetails) - this.callback(new ExposureEvaluationImpl(hookContext, evaluationDetails)) + const succeeded = + evaluationDetails.errorCode === undefined && + evaluationDetails.reason !== 'ERROR' && + evaluationDetails.variant != null + evaluations = succeeded + ? [ + ...getPrecomputedDependencyEvaluations(evaluationDetails).filter( + (dependency) => dependency.isPresentInAssignment + ), + evaluationDetails, + ] + : [evaluationDetails] } catch (error) { - if (this.warned) { - console.debug('Exposure callback failed', error) - } else { - this.warned = true - console.warn('Exposure callback failed; later failures are logged at debug level', error) + this.logCallbackFailure(error) + return + } + let trackingContext: EvaluationContext | undefined + try { + trackingContext = getEvaluationTrackingContext(evaluationDetails, () => hookContext.context) + } catch { + // An unreadable caller context must not suppress an otherwise valid callback. + } + for (const evaluation of evaluations) { + try { + // The callback must read the final verdict, whether or not a provider or + // tracking hook finalized the decision first. + completeExposureDecision(evaluation) + this.callback( + new ExposureEvaluationImpl( + evaluation === evaluationDetails ? hookContext : ({ flagKey: evaluation.flagKey } as HookContext), + evaluation as EvaluationDetails, + trackingContext + ) + ) + } catch (error) { + this.logCallbackFailure(error) } } } + + private logCallbackFailure(error: unknown): void { + if (this.warned) { + console.debug('Exposure callback failed', error) + } else { + this.warned = true + console.warn('Exposure callback failed; later failures are logged at debug level', error) + } + } } diff --git a/packages/browser/src/openfeature/exposures.ts b/packages/browser/src/openfeature/exposures.ts index fd86a5cf..c90a7cfb 100644 --- a/packages/browser/src/openfeature/exposures.ts +++ b/packages/browser/src/openfeature/exposures.ts @@ -29,6 +29,7 @@ import { startExposuresBatch } from '../transport/startExposuresBatch' import { getCoreConfigurationId } from './core-provider-metadata' import { getEvaluationTrackingContext } from './evaluation-telemetry-policy' import { supportsExposureAdmission } from './exposure-scope' +import { getPrecomputedDependencyEvaluations } from './precomputed-dependencies' import type { DatadogTrackingHook, ManagedTrackingHook } from './tracking' import { createTrackingHookController, runTrackingLifecycleOperation } from './tracking' @@ -79,49 +80,53 @@ export function createExposureLoggingHook( return } if (!supportsExposureAdmission(hookContext)) return - // Complete the provider decision before either destination consumes it. - const sharedDecision = completeExposureDecision(details) - const exposureEvent = createExposureEvent( - getEvaluationTrackingContext(details, () => getEvaluationContext(hookContext.context)), - details - ) - if (!exposureEvent) { - return - } - const exposureCacheEntry = getExposureCacheEntry(exposureEvent, details) + const context = getEvaluationTrackingContext(details, () => getEvaluationContext(hookContext.context)) + const evaluations = [ + ...getPrecomputedDependencyEvaluations(details).filter((dependency) => dependency.isPresentInAssignment), + details, + ] + const timestamp = timeStampNow() + const url = window?.location?.href + for (const evaluation of evaluations) { + try { + const exposureDetails = evaluation as EvaluationDetails + // Complete the provider decision before either destination consumes it. + const sharedDecision = completeExposureDecision(exposureDetails) + const exposureEvent = createExposureEvent(context, exposureDetails) + if (!exposureEvent) continue + // Dependency exposures belong to the same immutable configuration + // snapshot as the root result. + const exposureCacheEntry = getExposureCacheEntry(exposureEvent, details) + const stampedVerdict = exposureDetails.flagMetadata?.__dd_exposure_cache_hit + const hasLoggedAssignment = + stampedVerdict === undefined ? exposureCache.has(exposureCacheEntry) : stampedVerdict + if (hasLoggedAssignment) continue - // The verdict is final once completed. Fall back to the cache when the - // result carried no shared decision. - const stampedVerdict = details.flagMetadata?.__dd_exposure_cache_hit - const hasLoggedAssignment = stampedVerdict === undefined ? exposureCache.has(exposureCacheEntry) : stampedVerdict - if (hasLoggedAssignment) { - return - } - - try { - if (exposuresBatch) { - const url = window?.location?.href - const timestamp = timeStampNow() - // Features stay off the cache entry: a features change must not turn a - // repeat exposure into a new one. - const features = featuresWithPrefix(details.flagMetadata, METADATA_EXPOSURE_FEATURE_PREFIX) - const exposureEventWithTimestamp: ExposureEventWithTimestamp = { - ...exposureEvent, - ...(features && { features }), - ...(configuration.service ? { service: configuration.service } : {}), - rum: { - ...(configuration.applicationId && { application: { id: configuration.applicationId } }), - ...(url && { view: { url } }), - }, - timestamp, + if (exposuresBatch) { + // Features stay off the cache entry: a features change must not turn a + // repeat exposure into a new one. + const features = featuresWithPrefix(exposureDetails.flagMetadata, METADATA_EXPOSURE_FEATURE_PREFIX) + const exposureEventWithTimestamp: ExposureEventWithTimestamp = { + ...exposureEvent, + ...(features && { features }), + ...(configuration.service ? { service: configuration.service } : {}), + rum: { + ...(configuration.applicationId && { application: { id: configuration.applicationId } }), + ...(url && { view: { url } }), + }, + timestamp, + } + exposuresBatch.add(exposureEventWithTimestamp as unknown as Context) } - exposuresBatch.add(exposureEventWithTimestamp as unknown as Context) + // A shared root decision already recorded the root assignment. Other + // entries are recorded only after batch.add() succeeds, or immediately + // in record-only mode where there is no batch. + if (!sharedDecision) exposureCache.set(exposureCacheEntry) + } catch (error) { + addTelemetryDebug('Error adding exposure to batch', { + 'error.message': error instanceof Error ? error.message : String(error), + }) } - if (!sharedDecision) exposureCache.set(exposureCacheEntry) - } catch (error) { - addTelemetryDebug('Error adding exposure to batch', { - 'error.message': error instanceof Error ? error.message : String(error), - }) } }, } @@ -177,24 +182,43 @@ export function stampExposureCacheVerdict( exposureCache: AssignmentCache | undefined ): void { const metadata = details.flagMetadata - if ( - !metadata || - metadata.__dd_do_log !== true || - details.errorCode || - details.reason === 'ERROR' || - details.variant == null - ) { + if (!metadata || details.errorCode || details.reason === 'ERROR' || details.variant == null) { return } const detailsWithMetadata: EvaluationDetails = { ...details, flagMetadata: metadata } - const exposureEvent = createExposureEvent( - getEvaluationTrackingContext(detailsWithMetadata, () => fallbackContext), - detailsWithMetadata - ) - if (!exposureEvent) { + const context = getEvaluationTrackingContext(detailsWithMetadata, () => fallbackContext) + if (exposureCache) { + for (const dependency of getPrecomputedDependencyEvaluations(detailsWithMetadata)) { + if (dependency.isPresentInAssignment) { + prepareEvaluationExposureDecision( + dependency as EvaluationDetails, + context, + detailsWithMetadata, + exposureCache + ) + } + } + prepareEvaluationExposureDecision(detailsWithMetadata, context, detailsWithMetadata, exposureCache) + } +} + +function prepareEvaluationExposureDecision( + evaluation: EvaluationDetails, + context: EvaluationContext, + rootDetails: EvaluationDetails, + exposureCache: AssignmentCache +): void { + const metadata = evaluation.flagMetadata + if ( + metadata?.__dd_do_log !== true || + evaluation.errorCode || + evaluation.reason === 'ERROR' || + evaluation.variant == null + ) { return } - if (exposureCache) { - prepareExposureDecision(metadata, exposureCache, getExposureCacheEntry(exposureEvent, detailsWithMetadata)) + const exposureEvent = createExposureEvent(context, evaluation) + if (exposureEvent) { + prepareExposureDecision(metadata, exposureCache, getExposureCacheEntry(exposureEvent, rootDetails)) } } diff --git a/packages/browser/src/openfeature/flagEvaluations.ts b/packages/browser/src/openfeature/flagEvaluations.ts index 47cd406c..4ec9a004 100644 --- a/packages/browser/src/openfeature/flagEvaluations.ts +++ b/packages/browser/src/openfeature/flagEvaluations.ts @@ -15,6 +15,7 @@ import { withEvaluationTelemetryPolicy, } from './evaluation-telemetry-policy' import { createFlagEvaluationTransport, type FlagEvaluationTransport } from './flagEvaluationTransport' +import { getPrecomputedDependencyEvaluations } from './precomputed-dependencies' import type { DatadogTrackingHooks, DatadogTrackingHooksOptions, ManagedTrackingHook } from './tracking' import { composeDatadogTrackingHooks, createTrackingHookController } from './tracking' @@ -26,7 +27,10 @@ export function createFlagEvalEVPHook( ): ManagedTrackingHook { const transport = sharedTransport ?? createFlagEvaluationTransport(configuration) const flagEvaluationBatch = transport.batch - + const completedDependencies = new WeakMap< + HookContext, + { context: EvaluationContext; evaluations: ReturnType } + >() const aggregator = new FlagEvaluationAggregator( configuration.flagEvaluationTrackingInterval, (events: FlagEvaluationEvent[]) => { @@ -76,8 +80,22 @@ export function createFlagEvalEVPHook( if (!sharedTransport) transport.stop() } }, + // Provider hooks run before application after hooks. Retain prerequisite + // telemetry in case a later hook replaces the successful root result. + after: (hookContext: HookContext, details: EvaluationDetails) => { + try { + const evaluations = getPrecomputedDependencyEvaluations(details) + if (evaluations.length === 0) return + const context = getEvaluationTrackingContext(details, () => getEvaluationContext(hookContext.context)) + completedDependencies.set(hookContext, { context, evaluations }) + } catch { + // Telemetry capture must not turn a successful evaluation into an error. + } + }, // Failed evaluations skip `after`; `finally` receives the result returned to the application. finally: (hookContext: HookContext, details: EvaluationDetails) => { + const completed = completedDependencies.get(hookContext) + completedDependencies.delete(hookContext) try { // OpenFeature can copy a customer hook's error.code without validation. // Restrict telemetry to standard OpenFeature error codes. @@ -85,11 +103,17 @@ export function createFlagEvalEVPHook( details.errorCode === undefined || Object.values(ErrorCode).includes(details.errorCode) ? details.errorCode : ErrorCode.GENERAL - aggregator.addEvaluation( - getEvaluationTrackingContext(details, () => getEvaluationContext(hookContext.context)), - details, - errorCode - ) + const context = + completed?.context ?? getEvaluationTrackingContext(details, () => getEvaluationContext(hookContext.context)) + const dependencies = getPrecomputedDependencyEvaluations(details) + for (const dependency of dependencies.length > 0 ? dependencies : (completed?.evaluations ?? [])) { + const dependencyErrorCode = + dependency.errorCode === undefined || Object.values(ErrorCode).includes(dependency.errorCode) + ? dependency.errorCode + : ErrorCode.GENERAL + aggregator.addEvaluation(context, dependency as EvaluationDetails, dependencyErrorCode) + } + aggregator.addEvaluation(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/precomputed-dependencies.ts b/packages/browser/src/openfeature/precomputed-dependencies.ts new file mode 100644 index 00000000..1fab16a9 --- /dev/null +++ b/packages/browser/src/openfeature/precomputed-dependencies.ts @@ -0,0 +1,31 @@ +import type { EvaluationDetails, FlagMetadata, FlagValue } from '@openfeature/web-sdk' + +const DEPENDENCY_EVALUATIONS = Symbol.for('@datadog/flagging/precomputed-dependency-evaluations') + +export type PrecomputedDependencyEvaluationDetails = Omit, 'value'> & { + value?: FlagValue + isPresentInAssignment?: boolean +} + +type MetadataWithDependencyEvaluations = FlagMetadata & { + [DEPENDENCY_EVALUATIONS]?: readonly PrecomputedDependencyEvaluationDetails[] +} + +export function getPrecomputedDependencyEvaluations(details: { + flagMetadata?: FlagMetadata +}): readonly PrecomputedDependencyEvaluationDetails[] { + return details.flagMetadata + ? ((details.flagMetadata as MetadataWithDependencyEvaluations)[DEPENDENCY_EVALUATIONS] ?? []) + : [] +} + +export function inheritPrecomputedDependencyEvaluations(from: FlagMetadata, to: FlagMetadata): void { + const dependencies = (from as MetadataWithDependencyEvaluations)[DEPENDENCY_EVALUATIONS] + if (!dependencies) return + Object.defineProperty(to, DEPENDENCY_EVALUATIONS, { + configurable: false, + enumerable: false, + value: dependencies, + writable: false, + }) +} diff --git a/packages/browser/src/openfeature/rumIntegration.ts b/packages/browser/src/openfeature/rumIntegration.ts index 52ad0e5b..b8700092 100644 --- a/packages/browser/src/openfeature/rumIntegration.ts +++ b/packages/browser/src/openfeature/rumIntegration.ts @@ -1,6 +1,7 @@ import { type Context, getGlobalObject } from '@datadog/browser-core' import type { EvaluationContext, EvaluationDetails, FlagValue, Hook, HookContext } from '@openfeature/web-sdk' import { type EvaluationTelemetryPolicy, withEvaluationTelemetryPolicy } from './evaluation-telemetry-policy' +import { getPrecomputedDependencyEvaluations } from './precomputed-dependencies' import type { DatadogTrackingHook } from './tracking' export interface DDRum { @@ -48,7 +49,19 @@ export function createRumTrackingHook(policy?: EvaluationTelemetryPolicy): Hook return } const globalObject = getGlobalObject<{ DD_RUM?: DDRum }>() - globalObject.DD_RUM?.addFeatureFlagEvaluation?.(details.flagKey, details.variant) + const record = (flagKey: string, variant: string) => { + try { + globalObject.DD_RUM?.addFeatureFlagEvaluation?.(flagKey, variant) + } catch { + // One RUM failure must not suppress the remaining dependency graph. + } + } + for (const dependency of getPrecomputedDependencyEvaluations(details)) { + if (dependency.isPresentInAssignment && dependency.variant != null) { + record(dependency.flagKey, dependency.variant) + } + } + record(details.flagKey, details.variant) }, }, policy diff --git a/packages/browser/src/openfeature/tracking.ts b/packages/browser/src/openfeature/tracking.ts index e2206052..9c83450c 100644 --- a/packages/browser/src/openfeature/tracking.ts +++ b/packages/browser/src/openfeature/tracking.ts @@ -35,7 +35,11 @@ export function createTrackingHookController( return { hooks: [ - { before: (...args) => activeHook?.before?.(...args), finally: (...args) => activeHook?.finally?.(...args) }, + { + before: (...args) => activeHook?.before?.(...args), + after: (...args) => activeHook?.after?.(...args), + finally: (...args) => activeHook?.finally?.(...args), + }, ], initialize: () => { // Serialize setup and teardown so shutdown also waits for a pending cache read. diff --git a/packages/browser/src/transport/fetchConfiguration.ts b/packages/browser/src/transport/fetchConfiguration.ts index cfec46f6..2a7d8724 100644 --- a/packages/browser/src/transport/fetchConfiguration.ts +++ b/packages/browser/src/transport/fetchConfiguration.ts @@ -26,6 +26,7 @@ const sourcePayload = { sdk_version: __BUILD_ENV__SDK_VERSION__, } const capabilitiesHeader = [...SUPPORTED_FLAGS_CAPABILITIES].sort().join(',') +const DEPENDENT_FLAGS_CAPABILITY_QUERY = 'cap_dependent-flags-variant-key' type JSONAPIError = { errors: { @@ -99,6 +100,9 @@ export function buildConfigurationUrl(options: ConfigurationRequestOptions, endp } url.searchParams.set('dd_env', options.env || '') + if (endpoint === 'precomputed') { + url.searchParams.set(DEPENDENT_FLAGS_CAPABILITY_QUERY, 'v1') + } return url } diff --git a/packages/browser/test/cache/indexeddb-flags-cache.spec.ts b/packages/browser/test/cache/indexeddb-flags-cache.spec.ts index c116070b..bddb33e4 100644 --- a/packages/browser/test/cache/indexeddb-flags-cache.spec.ts +++ b/packages/browser/test/cache/indexeddb-flags-cache.spec.ts @@ -29,7 +29,8 @@ const testConfig: FlagsConfiguration = { const context = { targetingKey: 'user-123' } const legacyKey = `flags-config-${buildStorageKeySuffix('test-client-token')}-${getMD5Hash(JSON.stringify(context))}` -const currentKey = `v2-${legacyKey}` +const previousKey = `v2-${legacyKey}` +const currentKey = `v3-${legacyKey}` const encodedConfig: FlagsConfiguration = { precomputed: { ...testConfig.precomputed!, @@ -160,6 +161,16 @@ describe('IndexedDBFlagsCache', () => { }) describe('older SDK compatibility', () => { + it('reads the previous cache namespace without writing new snapshots into it', async () => { + await writeEntry(previousKey, testConfig) + expect(await cache.get(context)).toEqual(testConfig) + + cache.set(encodedConfig, context) + await flushAsync() + expect(await readEntry(currentKey)).toEqual(encodedConfig) + expect(await readEntry(previousKey)).toEqual(testConfig) + }) + it('reads legacy plaintext on upgrade without overwriting it with encoded keys', async () => { await writeEntry(legacyKey, testConfig) expect(await cache.get(context)).toEqual(testConfig) @@ -175,6 +186,7 @@ describe('IndexedDBFlagsCache', () => { cache.set(encodedConfig, context) await flushAsync() expect(await readEntry(currentKey)).toEqual(encodedConfig) + expect(await readEntry(previousKey)).toBeUndefined() expect(await readEntry(legacyKey)).toBeUndefined() }) diff --git a/packages/browser/test/openfeature/core-provider-exposure-cache.spec.ts b/packages/browser/test/openfeature/core-provider-exposure-cache.spec.ts index f1ca0645..5c83993d 100644 --- a/packages/browser/test/openfeature/core-provider-exposure-cache.spec.ts +++ b/packages/browser/test/openfeature/core-provider-exposure-cache.spec.ts @@ -113,6 +113,40 @@ function rulesConfiguration({ return configurationFromRulesBinary(new Uint8Array(configuration)) } +function precomputedDependentConfiguration({ rootAllocationKey = 'root-allocation' } = {}): FlagsConfiguration { + return { + precomputed: { + context, + response: { + data: { + attributes: { + createdAt: '2026-10-26T00:00:00Z', + flags: { + parent: { + allocationKey: 'parent-allocation', + variationKey: 'parent-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + }, + root: { + allocationKey: rootAllocationKey, + variationKey: 'root-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: false, + dependencyEvaluations: [{ flagKey: 'parent', isPresentInAssignment: true }], + }, + }, + }, + }, + }, + }, + } +} + const context = { targetingKey: 'rules-user' } const exposureCalls = (fetchMock: jest.Mock) => @@ -204,6 +238,35 @@ describe('DatadogCoreProvider with a shared exposure cache', () => { ]) }) + it('finalizes selected dependencies without requiring a tracking hook', async () => { + const tracking = createDatadogExposureLoggingHook(trackingConfig) + await tracking.initialize() + const provider = new DatadogCoreProvider({ exposureCache: tracking.exposureCache }) + provider.setConfiguration(precomputedDependentConfiguration()) + await OpenFeature.setProviderAndWait(DOMAIN, provider, context) + const client = OpenFeature.getClient(DOMAIN) + + expect(client.getBooleanDetails('root', false).flagMetadata?.__dd_exposure_cache_hit).toBeUndefined() + expect(client.getBooleanDetails('parent', false).flagMetadata?.__dd_exposure_cache_hit).toBe(true) + await tracking.shutdown() + }) + + it('scopes dependencies selected by a silent root to the active configuration', async () => { + const tracking = createDatadogExposureLoggingHook(trackingConfig) + await tracking.initialize() + const provider = new DatadogCoreProvider({ exposureCache: tracking.exposureCache }) + provider.setConfiguration(precomputedDependentConfiguration()) + await OpenFeature.setProviderAndWait(DOMAIN, provider, context) + const client = OpenFeature.getClient(DOMAIN) + + client.getBooleanDetails('root', false) + expect(client.getBooleanDetails('parent', false).flagMetadata?.__dd_exposure_cache_hit).toBe(true) + + provider.setConfiguration(precomputedDependentConfiguration({ rootAllocationKey: 'replacement-root-allocation' })) + expect(client.getBooleanDetails('parent', false).flagMetadata?.__dd_exposure_cache_hit).toBe(false) + await tracking.shutdown() + }) + it.each([ ['MultiProvider', (provider: Provider) => new MultiProvider([{ provider }])], [ diff --git a/packages/browser/test/openfeature/exposure-hook.spec.ts b/packages/browser/test/openfeature/exposure-hook.spec.ts index 11c46e4a..15b20cf0 100644 --- a/packages/browser/test/openfeature/exposure-hook.spec.ts +++ b/packages/browser/test/openfeature/exposure-hook.spec.ts @@ -1,5 +1,6 @@ import { getGlobalObject, INTAKE_SITE_STAGING } from '@datadog/browser-core' -import { OpenFeature } from '@openfeature/web-sdk' +import { FlagEvaluationAggregator } from '@datadog/flagging-core' +import { ErrorCode, type EvaluationContext, type HookContext, OpenFeature } from '@openfeature/web-sdk' import { IDBFactory } from 'fake-indexeddb' import type { FlaggingInitConfiguration } from '../../src/domain/configuration' import { type ExposureEvaluation, ExposureHook } from '../../src/openfeature/exposure-hook' @@ -58,13 +59,64 @@ describe('ExposureHook', () => { reason: 'TARGETING_MATCH', doLog: false, }, + 'dependency-parent': { + allocationKey: 'parent-allocation', + variationKey: 'parent-on', + variationType: 'BOOLEAN', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + features: [ + { key: 'dependency.hook', value: 'parent', destinations: ['HOOK'] }, + { key: 'dependency.exposure', value: 2, destinations: ['EXPOSURE'] }, + { key: 'dependency.evaluation', value: true, destinations: ['EVALUATION'] }, + ], + }, + 'dependency-silent-parent': { + allocationKey: 'silent-parent-allocation', + variationKey: 'silent-parent-on', + variationType: 'BOOLEAN', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: false, + }, + 'dependency-rejected-parent': { + allocationKey: 'rejected-parent-allocation', + variationKey: 'rejected-parent-on', + variationType: 'BOOLEAN', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + }, + 'dependent-root': { + allocationKey: 'dependent-root-allocation', + variationKey: 'root-on', + variationType: 'BOOLEAN', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + dependencyEvaluations: [ + { flagKey: 'dependency-parent', isPresentInAssignment: true }, + { flagKey: 'dependency-silent-parent', isPresentInAssignment: true }, + { flagKey: 'dependency-rejected-parent', isPresentInAssignment: false }, + ], + }, + 'dependent-silent-root': { + allocationKey: 'dependent-silent-root-allocation', + variationKey: 'root-on', + variationType: 'BOOLEAN', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: false, + dependencyEvaluations: [{ flagKey: 'dependency-parent', isPresentInAssignment: true }], + }, }, }, }, }), }) - async function startProvider() { + async function startProvider(evaluationContext: EvaluationContext = context) { fetchMock.mockImplementation((url: string) => { if (url.toString().includes('exposures')) { return Promise.resolve({ ok: true, status: 200 }) @@ -72,8 +124,9 @@ describe('ExposureHook', () => { return Promise.resolve(precomputedResponse()) }) const provider = new DatadogProvider(baseProviderConfig) - await OpenFeature.setContext(context) + await OpenFeature.setContext(evaluationContext) await OpenFeature.setProviderAndWait(provider) + return provider } beforeAll(() => { @@ -179,6 +232,109 @@ describe('ExposureHook', () => { expect(evaluations[1].shouldSend()).toBe(true) }) + it('reports selected dependencies before the root with the shared exposure decisions', async () => { + await startProvider() + const evaluations: ExposureEvaluation[] = [] + OpenFeature.addHooks(new ExposureHook((evaluation) => evaluations.push(evaluation))) + const client = OpenFeature.getClient() + + client.getBooleanDetails('dependent-root', false) + + expect(evaluations.map((evaluation) => evaluation.flagKey)).toEqual([ + 'dependency-parent', + 'dependency-silent-parent', + 'dependent-root', + ]) + expect(evaluations.map((evaluation) => evaluation.isExposure())).toEqual([true, false, true]) + expect(evaluations.map((evaluation) => evaluation.shouldSend())).toEqual([true, false, true]) + expect(evaluations[0].details.value).toBe(true) + expect(evaluations[0].getFeatures()).toEqual({ 'dependency.hook': 'parent' }) + }) + + it('uses the root provider-bound RUM subject for dependency callbacks', async () => { + getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM = { + addFeatureFlagEvaluation: jest.fn(), + getUser: () => ({ id: 'rum-user', plan: 'pro' }), + } + await startProvider({}) + const evaluations: ExposureEvaluation[] = [] + OpenFeature.addHooks(new ExposureHook((evaluation) => evaluations.push(evaluation))) + + OpenFeature.getClient().getBooleanDetails('dependent-root', false) + + expect(evaluations.map((evaluation) => evaluation.targetingKey)).toEqual(['rum-user', 'rum-user', 'rum-user']) + expect(evaluations[0].context).toMatchObject({ targetingKey: 'rum-user', plan: 'pro' }) + }) + + it('delivers an unbound evaluation when the hook context is unreadable', async () => { + const provider = await startProvider() + const callback = jest.fn() + const hookContext = { flagKey: 'unbound', providerMetadata: provider.metadata } as HookContext + Object.defineProperty(hookContext, 'context', { + get: () => { + throw new Error('context unavailable') + }, + }) + + new ExposureHook(callback).finally(hookContext, { + flagKey: 'unbound', + value: true, + variant: 'on', + reason: 'STATIC', + flagMetadata: {}, + }) + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback.mock.calls[0][0].context).toBeUndefined() + }) + + it('continues dependency and root callbacks after one callback throws', async () => { + await startProvider() + const flagKeys: string[] = [] + OpenFeature.addHooks( + new ExposureHook((evaluation) => { + flagKeys.push(evaluation.flagKey) + if (evaluation.flagKey === 'dependency-parent') throw new Error('callback failed') + }) + ) + + OpenFeature.getClient().getBooleanDetails('dependent-root', false) + + expect(flagKeys).toEqual(['dependency-parent', 'dependency-silent-parent', 'dependent-root']) + }) + + it('keeps reached dependency telemetry when an application after hook rejects the root', async () => { + await startProvider() + const addEvaluation = jest.spyOn(FlagEvaluationAggregator.prototype, 'addEvaluation') + OpenFeature.addHooks({ + after: () => { + throw new Error('application hook failed') + }, + }) + + const details = OpenFeature.getClient().getBooleanDetails('dependent-root', false) + + expect(details.errorCode).toBe(ErrorCode.GENERAL) + expect(addEvaluation.mock.calls.map(([, evaluation, error]) => [evaluation.flagKey, error])).toEqual([ + ['dependency-parent', undefined], + ['dependency-silent-parent', undefined], + ['dependency-rejected-parent', undefined], + ['dependent-root', ErrorCode.GENERAL], + ]) + }) + + it('reports an exposed dependency when the selected root does not log', async () => { + await startProvider() + const evaluations: ExposureEvaluation[] = [] + OpenFeature.addHooks(new ExposureHook((evaluation) => evaluations.push(evaluation))) + + OpenFeature.getClient().getBooleanDetails('dependent-silent-root', false) + + expect(evaluations.map((evaluation) => evaluation.flagKey)).toEqual(['dependency-parent', 'dependent-silent-root']) + expect(evaluations.map((evaluation) => evaluation.isExposure())).toEqual([true, false]) + expect(evaluations.map((evaluation) => evaluation.shouldSend())).toEqual([true, false]) + }) + it('keeps customer edits to the context out of later evaluations', async () => { await startProvider() await OpenFeature.setContext({ targetingKey: 'hook-user', profile: { plan: 'pro' } }) diff --git a/packages/browser/test/openfeature/exposures.spec.ts b/packages/browser/test/openfeature/exposures.spec.ts index e7860cd5..93d34ab2 100644 --- a/packages/browser/test/openfeature/exposures.spec.ts +++ b/packages/browser/test/openfeature/exposures.spec.ts @@ -1,9 +1,11 @@ import { getGlobalObject, INTAKE_SITE_STAGING } from '@datadog/browser-core' -import { ErrorCode, type HookContext, OpenFeature } from '@openfeature/web-sdk' +import type { PrecomputedFlag } from '@datadog/flagging-core' +import { ErrorCode, type EvaluationDetails, type HookContext, OpenFeature } from '@openfeature/web-sdk' import { IDBFactory } from 'fake-indexeddb' +import { bindPrecomputedDependencyEvaluations } from '../../../core/src/evaluation/precomputed-dependencies' import type { FlaggingInitConfiguration } from '../../src/domain/configuration' import { DatadogCoreProvider } from '../../src/openfeature/core-provider' -import { createDatadogExposureLoggingHook } from '../../src/openfeature/exposures' +import { createDatadogExposureLoggingHook, stampExposureCacheVerdict } from '../../src/openfeature/exposures' import { DatadogProvider } from '../../src/openfeature/provider' import type { DDRum } from '../../src/openfeature/rumIntegration' import { nextWrite } from '../cache/indexeddb-test-helpers' @@ -28,6 +30,34 @@ describe('Exposures End-to-End', () => { env: 'test', site: INTAKE_SITE_STAGING, } + const dependencyFlags = { + parent: { + allocationKey: 'parent-allocation', + variationKey: 'parent-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + serialId: 12, + features: [{ key: 'dependency.exposure', value: 'parent', destinations: ['EXPOSURE'] }], + }, + rejected: { + allocationKey: 'rejected-allocation', + variationKey: 'rejected-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + }, + noExposure: { + allocationKey: 'no-exposure-allocation', + variationKey: 'no-exposure-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: false, + }, + } satisfies Record beforeAll(() => { originalFetch = global.fetch @@ -64,22 +94,33 @@ describe('Exposures End-to-End', () => { await tracking.initialize() try { const hook = tracking.hooks[0] - expect(hook.after).toBeUndefined() + expect(hook.after).toBeDefined() expect(hook.finally).toBeDefined() - hook.finally!( - { - context: { targetingKey: 'test-user' }, - providerMetadata: new DatadogCoreProvider().metadata, - } as HookContext, - { - flagKey: 'test-flag', - value: false, - variant: 'variant-a', - reason: 'TARGETING_MATCH', - flagMetadata: { allocationKey: 'allocation-a', doLog: true }, - ...fallbackDetails, - } + const flagMetadata = { allocationKey: 'allocation-a', doLog: true } + bindPrecomputedDependencyEvaluations( + flagMetadata, + [ + { + flagKey: 'parent', + isPresentInAssignment: true, + }, + ], + dependencyFlags ) + const hookContext = { + context: { targetingKey: 'test-user' }, + providerMetadata: new DatadogCoreProvider().metadata, + } as HookContext + const details: EvaluationDetails = { + flagKey: 'test-flag', + value: false, + variant: 'variant-a', + reason: 'TARGETING_MATCH', + flagMetadata, + ...fallbackDetails, + } + hook.after!(hookContext, details) + hook.finally!(hookContext, details) } finally { await tracking.shutdown?.() } @@ -87,6 +128,98 @@ describe('Exposures End-to-End', () => { } ) + it('emits dependency exposures before the root only after the graph succeeds', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200 }) + const tracking = createDatadogExposureLoggingHook(baseProviderConfig) + await tracking.initialize() + try { + const flagMetadata = { allocationKey: 'root-allocation', doLog: true } + bindPrecomputedDependencyEvaluations( + flagMetadata, + [ + { + flagKey: 'parent', + isPresentInAssignment: true, + }, + { flagKey: 'rejected', isPresentInAssignment: false }, + { flagKey: 'noExposure', isPresentInAssignment: true }, + ], + dependencyFlags + ) + const rootDetails = { + flagKey: 'root', + value: true, + variant: 'root-on', + reason: 'TARGETING_MATCH' as const, + flagMetadata, + } + stampExposureCacheVerdict(rootDetails, { targetingKey: 'subject' }, tracking.exposureCache) + tracking.hooks[0].finally!( + { + context: { targetingKey: 'subject' }, + providerMetadata: new DatadogCoreProvider().metadata, + } as HookContext, + rootDetails + ) + triggerBatch() + + const events = parseExposureEvents(getExposuresCalls()[0][1].body) + expect(events.map((event) => event.flag.key)).toEqual(['parent', 'root']) + expect(events[0]).toMatchObject({ + allocation: { key: 'parent-allocation' }, + variant: { key: 'parent-on' }, + serial_id: 12, + features: { 'dependency.exposure': 'parent' }, + }) + } finally { + await tracking.shutdown?.() + } + }) + + it('continues later dependency and root exposure publication after one evaluation throws', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200 }) + const tracking = createDatadogExposureLoggingHook(baseProviderConfig) + await tracking.initialize() + try { + const cache = tracking.exposureCache! + const hasSpy = jest + .spyOn(cache, 'has') + .mockImplementationOnce(() => { + throw new Error('cache unavailable') + }) + .mockReturnValue(false) + const setSpy = jest.spyOn(cache, 'set') + const flagMetadata = { allocationKey: 'root-allocation', doLog: true } + bindPrecomputedDependencyEvaluations( + flagMetadata, + [ + { flagKey: 'parent', isPresentInAssignment: true }, + { flagKey: 'rejected', isPresentInAssignment: true }, + ], + dependencyFlags + ) + + tracking.hooks[0].finally!( + { + context: { targetingKey: 'subject' }, + providerMetadata: new DatadogCoreProvider().metadata, + } as HookContext, + { + flagKey: 'root', + value: true, + variant: 'root-on', + reason: 'TARGETING_MATCH', + flagMetadata, + } + ) + + expect(hasSpy).toHaveBeenCalledTimes(3) + expect(setSpy.mock.calls.map(([entry]) => entry.flag.key)).toEqual(['rejected', 'root']) + } finally { + await tracking.shutdown?.() + } + }) + 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 813568f2..7af2ae16 100644 --- a/packages/browser/test/openfeature/flagEvaluations.spec.ts +++ b/packages/browser/test/openfeature/flagEvaluations.spec.ts @@ -1,5 +1,6 @@ -import { FlagEvaluationAggregator } from '@datadog/flagging-core' +import { FlagEvaluationAggregator, type PrecomputedFlag } from '@datadog/flagging-core' import { ErrorCode, type EvaluationDetails, type HookContext } from '@openfeature/web-sdk' +import { bindPrecomputedDependencyEvaluations } from '../../../core/src/evaluation/precomputed-dependencies' import { validateAndBuildFlaggingTrackingConfiguration } from '../../src/domain/configuration' import { createFlagEvalEVPHook } from '../../src/openfeature/flagEvaluations' @@ -48,7 +49,7 @@ describe('createFlagEvalEVPHook', () => { hook = createFlagEvalEVPHook(mockConfiguration) expect(hook).toBeDefined() - expect(hook.after).toBeUndefined() + expect(hook.after).toBeDefined() expect(hook.finally).toBeDefined() }) @@ -119,4 +120,101 @@ describe('createFlagEvalEVPHook', () => { expect(addEvaluationSpy).toHaveBeenCalledTimes(1) expect(addEvaluationSpy).toHaveBeenCalledWith(effectiveContext, mockDetails, expectedError) }) + + it('tracks ordered dependency evaluations before the root evaluation', () => { + const flagMetadata = { allocationKey: 'root-allocation' } + const flags = { + parent: { + allocationKey: 'parent-allocation', + variationKey: 'parent-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + }, + rejected: { + allocationKey: 'rejected-allocation', + variationKey: 'rejected-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + }, + } satisfies Record + bindPrecomputedDependencyEvaluations( + flagMetadata, + [ + { + flagKey: 'parent', + isPresentInAssignment: true, + }, + { flagKey: 'rejected', isPresentInAssignment: false }, + { flagKey: 'no-assignment-parent', reason: 'ERROR', errorCode: 'GENERAL' }, + { flagKey: 'missing-parent', reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' }, + { flagKey: 'failed-ancestor', reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' }, + ], + flags, + undefined, + undefined, + { rootFlagKey: '', allowDependencyErrors: true } + ) + const details: EvaluationDetails = { + flagKey: 'root', + value: false, + reason: 'ERROR', + errorCode: ErrorCode.GENERAL, + flagMetadata, + } + const context = { targetingKey: 'subject' } + const addEvaluationSpy = jest.spyOn(FlagEvaluationAggregator.prototype, 'addEvaluation') + hook = createFlagEvalEVPHook(mockConfiguration) + + hook.finally?.({ context } as HookContext, details) + + expect(addEvaluationSpy.mock.calls.map(([, evaluation, error]) => [evaluation.flagKey, error])).toEqual([ + ['parent', undefined], + ['rejected', undefined], + ['no-assignment-parent', ErrorCode.GENERAL], + ['missing-parent', ErrorCode.FLAG_NOT_FOUND], + ['failed-ancestor', ErrorCode.FLAG_NOT_FOUND], + ['root', ErrorCode.GENERAL], + ]) + }) + + it('retains prerequisite telemetry when a later after hook replaces the root result', () => { + const flagMetadata = {} + bindPrecomputedDependencyEvaluations(flagMetadata, [{ flagKey: 'parent', isPresentInAssignment: true }], { + parent: { + allocationKey: 'parent-allocation', + variationKey: 'parent-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + }, + }) + const hookContext = { context: { targetingKey: 'subject' } } as HookContext + const addEvaluationSpy = jest.spyOn(FlagEvaluationAggregator.prototype, 'addEvaluation') + hook = createFlagEvalEVPHook(mockConfiguration) + + hook.after?.(hookContext, { + flagKey: 'root', + value: true, + variant: 'root-on', + reason: 'TARGETING_MATCH', + flagMetadata, + }) + hook.finally?.(hookContext, { + flagKey: 'root', + value: false, + reason: 'ERROR', + errorCode: ErrorCode.GENERAL, + flagMetadata: {}, + }) + + expect(addEvaluationSpy.mock.calls.map(([, evaluation, error]) => [evaluation.flagKey, error])).toEqual([ + ['parent', undefined], + ['root', ErrorCode.GENERAL], + ]) + }) }) diff --git a/packages/browser/test/openfeature/obfuscation.spec.ts b/packages/browser/test/openfeature/obfuscation.spec.ts index 8f167f31..0fa44600 100644 --- a/packages/browser/test/openfeature/obfuscation.spec.ts +++ b/packages/browser/test/openfeature/obfuscation.spec.ts @@ -91,7 +91,8 @@ describe('browser flag-key obfuscation', () => { reason: 'TARGETING_MATCH', flagMetadata: { allocationKey: 'allocation-1', doLog: true, __dd_split_serial_id: 123 }, }) - const request = fetchMock.mock.calls[0][1] + const [url, request] = fetchMock.mock.calls[0] + expect(new URL(url).searchParams.get('cap_dependent-flags-variant-key')).toBe('v1') expect(request.headers['X-DD-FEATURE-FLAGS-CAPABILITIES']).toBe('assignment-encoding-flag-key-256-v1') expect(JSON.parse(request.body).data.attributes).not.toHaveProperty('supported_capabilities') expect(JSON.parse(request.body).data.attributes).toMatchObject({ diff --git a/packages/browser/test/openfeature/precomputed-corpus.spec.ts b/packages/browser/test/openfeature/precomputed-corpus.spec.ts new file mode 100644 index 00000000..9e202629 --- /dev/null +++ b/packages/browser/test/openfeature/precomputed-corpus.spec.ts @@ -0,0 +1,209 @@ +import fs from 'node:fs' +import path from 'node:path' +import { getGlobalObject } from '@datadog/browser-core' +import type { FlagEvaluationEvent } from '@datadog/flagging-core' +import type { EvaluationDetails, JsonValue } from '@openfeature/core' +import { OpenFeature } from '@openfeature/web-sdk' +import { IDBFactory } from 'fake-indexeddb' +import { DatadogProvider } from '../../src/openfeature/provider' +import type { DDRum } from '../../src/openfeature/rumIntegration' + +type PlatformFilter = Array<{ platform: string }> +type Controlled = { _skip?: PlatformFilter; _include?: PlatformFilter } +type FixtureEvaluation = Controlled & { + flag: string + variationType: 'boolean' | 'string' | 'integer' | 'float' | 'object' + defaultValue: JsonValue + expectedResult: { value: JsonValue; reason?: string | null; errorCode?: string | null; variantKey?: string | null } +} +type EventMatcher = Controlled & { _count: number; [key: string]: unknown } +type PrecomputedFixture = Controlled & { + name: string + context: { targetingKey: string | null; attributes: Record } + response: object + evaluations: FixtureEvaluation[] + expectations: { + noUnmatchedEvents?: boolean + orderedEvents?: boolean + exposures?: EventMatcher[] + evaluationEvents?: EventMatcher[] + } +} + +const fixtureRoot = path.resolve(__dirname, '../../../core/test/ffe-system-test-data/precomputed-assignments/cases') +// Existing unscoped cases predate the Web runner and have known Web telemetry +// expectation drift. Start with cases that explicitly opt into Web, without +// keying the runner to dependent flags or any fixture name. +const fixtures = discoverFixtures(fixtureRoot).filter((fixture) => + fixture._include?.some(({ platform }) => platform === 'web') +) + +describe.each(fixtures)('precomputed corpus: $name', (fixture) => { + const domain = `precomputed-corpus-${fixture.name}` + let originalFetch: typeof globalThis.fetch + let fetchMock: jest.Mock + + beforeEach(async () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-10-21T00:00:00Z')) + originalFetch = globalThis.fetch + fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200 }) + globalThis.fetch = fetchMock + globalThis.indexedDB = new IDBFactory() + localStorage.clear() + delete getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM + await OpenFeature.clearProviders() + await OpenFeature.clearContext() + OpenFeature.clearHandlers() + OpenFeature.clearHooks() + }) + + afterEach(async () => { + await OpenFeature.clearProviders() + await OpenFeature.clearContext() + OpenFeature.clearHandlers() + OpenFeature.clearHooks() + globalThis.fetch = originalFetch + jest.useRealTimers() + }) + + it('matches evaluation results and telemetry expectations', async () => { + const provider = new DatadogProvider({ + clientToken: 'fixture-client-token', + env: 'fixture', + flagEvaluationTrackingInterval: 1000, + flagConfigurationFetch: jest.fn().mockResolvedValue({ + ok: true, + json: async () => fixture.response, + }), + }) + const context = { + ...(fixture.context.targetingKey === null ? {} : { targetingKey: fixture.context.targetingKey }), + ...fixture.context.attributes, + } + await OpenFeature.setProviderAndWait(domain, provider, context) + const client = OpenFeature.getClient(domain) + + for (const evaluation of fixture.evaluations.filter(appliesToWeb)) { + const details = evaluate(client, evaluation) + expect(details.value).toEqual(evaluation.expectedResult.value) + if ('reason' in evaluation.expectedResult) expect(details.reason ?? null).toBe(evaluation.expectedResult.reason) + if ('errorCode' in evaluation.expectedResult) + expect(details.errorCode ?? null).toBe(evaluation.expectedResult.errorCode) + if ('variantKey' in evaluation.expectedResult) + expect(details.variant ?? null).toBe(evaluation.expectedResult.variantKey) + } + + jest.advanceTimersByTime(31_000) + const exposures = requestEvents('/exposures') + const evaluations = requestEvents('/flagevaluation') as FlagEvaluationEvent[] + if (fixture.expectations.exposures) { + assertMatchers( + exposures, + fixture.expectations.exposures, + fixture.expectations.noUnmatchedEvents, + fixture.expectations.orderedEvents + ) + } + if (fixture.expectations.evaluationEvents) { + assertMatchers( + evaluations, + fixture.expectations.evaluationEvents, + fixture.expectations.noUnmatchedEvents, + fixture.expectations.orderedEvents + ) + } + }) + + function requestEvents(endpoint: string): unknown[] { + return fetchMock.mock.calls + .filter(([url]) => url.toString().includes(endpoint)) + .flatMap(([, request]) => + request.body + .trim() + .split('\n') + .filter(Boolean) + .map((line: string) => JSON.parse(line)) + ) + } +}) + +function discoverFixtures(directory: string): PrecomputedFixture[] { + return fs + .readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const child = path.join(directory, entry.name) + return entry.isDirectory() + ? discoverFixtures(child) + : entry.name.endsWith('.json') + ? [JSON.parse(fs.readFileSync(child, 'utf8')) as PrecomputedFixture] + : [] + }) + .sort((left, right) => left.name.localeCompare(right.name)) +} + +function appliesToWeb(value: Controlled): boolean { + if (value._skip?.some(({ platform }) => platform === 'web')) return false + return value._include === undefined || value._include.some(({ platform }) => platform === 'web') +} + +function evaluate( + client: ReturnType, + evaluation: FixtureEvaluation +): EvaluationDetails { + switch (evaluation.variationType) { + case 'boolean': + return client.getBooleanDetails(evaluation.flag, evaluation.defaultValue as boolean) + case 'string': + return client.getStringDetails(evaluation.flag, evaluation.defaultValue as string) + case 'integer': + case 'float': + return client.getNumberDetails(evaluation.flag, evaluation.defaultValue as number) + case 'object': + return client.getObjectDetails(evaluation.flag, evaluation.defaultValue) + } +} + +function assertMatchers( + events: unknown[], + matchers: EventMatcher[], + noUnmatchedEvents = false, + orderedEvents = false +): void { + const applicableMatchers = matchers.filter(appliesToWeb) + // An event family can be explicitly skipped for a platform. Do not + // reinterpret that as an assertion that the platform emits zero events. + if (matchers.length > 0 && applicableMatchers.length === 0) return + const remaining = [...events] + for (const matcher of applicableMatchers) { + const expected = Object.fromEntries(Object.entries(matcher).filter(([key]) => !key.startsWith('_'))) + for (let count = 0; count < matcher._count; count++) { + if (orderedEvents) { + expect(isSubset(remaining.shift(), expected)).toBe(true) + continue + } + const index = remaining.findIndex((event) => isSubset(event, expected)) + expect(index).toBeGreaterThanOrEqual(0) + remaining.splice(index, 1) + } + } + if (noUnmatchedEvents) expect(remaining).toEqual([]) +} + +function isSubset(actual: unknown, expected: unknown): boolean { + if (expected === null) return actual == null + if (Array.isArray(expected)) { + return ( + Array.isArray(actual) && + expected.length === actual.length && + expected.every((value, i) => isSubset(actual[i], value)) + ) + } + if (typeof expected === 'object') { + if (typeof actual !== 'object' || actual === null) return false + return Object.entries(expected as Record).every(([key, value]) => + isSubset((actual as Record)[key], value) + ) + } + return Object.is(actual, expected) +} diff --git a/packages/browser/test/openfeature/provider.spec.ts b/packages/browser/test/openfeature/provider.spec.ts index 14244723..3f7aae3c 100644 --- a/packages/browser/test/openfeature/provider.spec.ts +++ b/packages/browser/test/openfeature/provider.spec.ts @@ -278,7 +278,9 @@ describe('DatadogProvider', () => { // Check that fetch was called with the correct URL and method expect(fetchMock).toHaveBeenCalled() const [url, requestOptions] = fetchMock.mock.calls[0] - expect(url.toString()).toBe(`https://preview.ff-cdn.datad0g.com/precompute-assignments?dd_env=test`) + expect(url.toString()).toBe( + `https://preview.ff-cdn.datad0g.com/precompute-assignments?dd_env=test&cap_dependent-flags-variant-key=v1` + ) expect(requestOptions.method).toBe('POST') // Verify headers were set correctly @@ -609,7 +611,7 @@ describe('DatadogProvider', () => { expect(globalFetch).not.toHaveBeenCalled() expect(customFetch).toHaveBeenCalledWith( - 'https://preview.ff-cdn.datad0g.com/precompute-assignments?dd_env=test', + 'https://preview.ff-cdn.datad0g.com/precompute-assignments?dd_env=test&cap_dependent-flags-variant-key=v1', expect.objectContaining({ method: 'POST', headers: { diff --git a/packages/browser/test/openfeature/rumIntegration.spec.ts b/packages/browser/test/openfeature/rumIntegration.spec.ts index b298a53e..b4772f02 100644 --- a/packages/browser/test/openfeature/rumIntegration.spec.ts +++ b/packages/browser/test/openfeature/rumIntegration.spec.ts @@ -1,5 +1,7 @@ import { getGlobalObject } from '@datadog/browser-core' +import type { PrecomputedFlag } from '@datadog/flagging-core' import { ErrorCode, type EvaluationDetails, type FlagValue, type HookContext } from '@openfeature/web-sdk' +import { bindPrecomputedDependencyEvaluations } from '../../../core/src/evaluation/precomputed-dependencies' import type { DDRum } from '../../src/openfeature/rumIntegration' import { createRumTrackingHook, enrichEvaluationContextWithRumUser } from '../../src/openfeature/rumIntegration' @@ -26,6 +28,88 @@ describe('createRumTrackingHook', () => { expect(mockAddFeatureFlagEvaluation).toHaveBeenCalledWith('test-flag', 'variant-a') }) + it('tracks only dependencies present in the selected assignment before the root', () => { + const mockAddFeatureFlagEvaluation = jest.fn() + getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM = { addFeatureFlagEvaluation: mockAddFeatureFlagEvaluation } + const details = makeDetails('root', 'root-on', true) + details.flagMetadata = {} + const flags = { + selected: { + allocationKey: 'selected-allocation', + variationKey: 'selected-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: false, + }, + rejected: { + allocationKey: 'rejected-allocation', + variationKey: 'rejected-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + }, + } satisfies Record + bindPrecomputedDependencyEvaluations( + details.flagMetadata, + [ + { flagKey: 'selected', isPresentInAssignment: true }, + { flagKey: 'rejected', isPresentInAssignment: false }, + ], + flags + ) + + createRumTrackingHook().finally!(mockHookContext, details) + + expect(mockAddFeatureFlagEvaluation.mock.calls).toEqual([ + ['selected', 'selected-on'], + ['root', 'root-on'], + ]) + }) + + it('continues dependencies and the root when one RUM recording throws', () => { + const addFeatureFlagEvaluation = jest.fn().mockImplementationOnce(() => { + throw new Error('RUM unavailable') + }) + getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM = { addFeatureFlagEvaluation } + const details = makeDetails('root', 'root-on', true) + details.flagMetadata = {} + bindPrecomputedDependencyEvaluations( + details.flagMetadata, + [ + { flagKey: 'first', isPresentInAssignment: true }, + { flagKey: 'second', isPresentInAssignment: true }, + ], + { + first: { + allocationKey: 'first-allocation', + variationKey: 'first-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: false, + }, + second: { + allocationKey: 'second-allocation', + variationKey: 'second-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: false, + }, + } + ) + + createRumTrackingHook().finally!(mockHookContext, details) + + expect(addFeatureFlagEvaluation.mock.calls).toEqual([ + ['first', 'first-on'], + ['second', 'second-on'], + ['root', 'root-on'], + ]) + }) + it('should be a no-op when DD_RUM is absent', () => { const hook = createRumTrackingHook() expect(() => hook.finally!(mockHookContext, makeDetails('test-flag', 'variant-a', 'foo-bar-baz'))).not.toThrow() diff --git a/packages/browser/test/openfeature/tracking-lifecycle.spec.ts b/packages/browser/test/openfeature/tracking-lifecycle.spec.ts index 471b6bae..7085df33 100644 --- a/packages/browser/test/openfeature/tracking-lifecycle.spec.ts +++ b/packages/browser/test/openfeature/tracking-lifecycle.spec.ts @@ -1,5 +1,13 @@ -import { OpenFeature, ProviderEvents, ProviderStatus } from '@openfeature/web-sdk' +import { + type EvaluationDetails, + type FlagValue, + type HookContext, + OpenFeature, + ProviderEvents, + ProviderStatus, +} from '@openfeature/web-sdk' import * as indexeddbStore from '../../src/cache/indexeddb-store' +import { createTrackingHookController } from '../../src/openfeature/tracking' import { composeDatadogTrackingHooks, configurationFromString, @@ -100,6 +108,19 @@ describe('tracking resource lifecycle', () => { } ) + it('forwards the after stage to an initialized managed hook', async () => { + const after = jest.fn() + const controller = createTrackingHookController(() => ({ after, shutdown: jest.fn() })) + controllers.push(controller) + await controller.initialize() + const context = {} as HookContext + const details = {} as EvaluationDetails + + controller.hooks[0].after?.(context, details) + + expect(after).toHaveBeenCalledWith(context, details) + }) + it.each(['exposure', 'evaluation'] as const)( 'cleans up %s resources when the final proxy call throws', async (kind) => { diff --git a/packages/browser/test/transport/fetchConfiguration.spec.ts b/packages/browser/test/transport/fetchConfiguration.spec.ts index 4af2d52d..06b1835a 100644 --- a/packages/browser/test/transport/fetchConfiguration.spec.ts +++ b/packages/browser/test/transport/fetchConfiguration.spec.ts @@ -85,27 +85,27 @@ describe('createFlagsConfigurationFetcher', () => { { description: 'HTTP protocol', flaggingProxy: 'http://localhost:8080', - expectedUrl: 'http://localhost:8080/?dd_env=test', + expectedUrl: 'http://localhost:8080/?dd_env=test&cap_dependent-flags-variant-key=v1', }, { description: 'HTTPS protocol', flaggingProxy: 'https://proxy.example.com', - expectedUrl: 'https://proxy.example.com/?dd_env=test', + expectedUrl: 'https://proxy.example.com/?dd_env=test&cap_dependent-flags-variant-key=v1', }, { description: 'HTTPS protocol with path', flaggingProxy: 'https://proxy.example.com/api/flags', - expectedUrl: 'https://proxy.example.com/api/flags?dd_env=test', + expectedUrl: 'https://proxy.example.com/api/flags?dd_env=test&cap_dependent-flags-variant-key=v1', }, { description: 'HTTP protocol with port and path', flaggingProxy: 'http://localhost:3000/proxy', - expectedUrl: 'http://localhost:3000/proxy?dd_env=test', + expectedUrl: 'http://localhost:3000/proxy?dd_env=test&cap_dependent-flags-variant-key=v1', }, { description: 'HTTP protocol with port, path and params', flaggingProxy: 'http://localhost:3000/proxy?foo=bar', - expectedUrl: 'http://localhost:3000/proxy?foo=bar&dd_env=test', + expectedUrl: 'http://localhost:3000/proxy?foo=bar&dd_env=test&cap_dependent-flags-variant-key=v1', }, ] @@ -129,22 +129,22 @@ describe('createFlagsConfigurationFetcher', () => { { description: 'domain only', flaggingProxy: 'proxy.example.com', - expectedUrl: 'https://proxy.example.com/?dd_env=test', + expectedUrl: 'https://proxy.example.com/?dd_env=test&cap_dependent-flags-variant-key=v1', }, { description: 'domain with port', flaggingProxy: 'proxy.example.com:8080', - expectedUrl: 'https://proxy.example.com:8080/?dd_env=test', + expectedUrl: 'https://proxy.example.com:8080/?dd_env=test&cap_dependent-flags-variant-key=v1', }, { description: 'localhost with port', flaggingProxy: 'localhost:3000', - expectedUrl: 'https://localhost:3000/?dd_env=test', + expectedUrl: 'https://localhost:3000/?dd_env=test&cap_dependent-flags-variant-key=v1', }, { description: 'domain with path', flaggingProxy: 'proxy.example.com/api', - expectedUrl: 'https://proxy.example.com/api?dd_env=test', + expectedUrl: 'https://proxy.example.com/api?dd_env=test&cap_dependent-flags-variant-key=v1', }, ] @@ -168,17 +168,20 @@ describe('createFlagsConfigurationFetcher', () => { { description: 'default site', config: baseConfig, - expectedUrl: 'https://preview.ff-cdn.datadoghq.com/precompute-assignments?dd_env=test', + expectedUrl: + 'https://preview.ff-cdn.datadoghq.com/precompute-assignments?dd_env=test&cap_dependent-flags-variant-key=v1', }, { description: 'specific site', config: { ...baseConfig, site: 'datadoghq.eu' as const }, - expectedUrl: 'https://preview.ff-cdn.datadoghq.eu/precompute-assignments?dd_env=test', + expectedUrl: + 'https://preview.ff-cdn.datadoghq.eu/precompute-assignments?dd_env=test&cap_dependent-flags-variant-key=v1', }, { description: 'US3 site', config: { ...baseConfig, site: 'us3.datadoghq.com' as const }, - expectedUrl: 'https://preview.ff-cdn.us3.datadoghq.com/precompute-assignments?dd_env=test', + expectedUrl: + 'https://preview.ff-cdn.us3.datadoghq.com/precompute-assignments?dd_env=test&cap_dependent-flags-variant-key=v1', }, ] @@ -543,7 +546,7 @@ describe('createFlagsConfigurationFetcher', () => { key_patterns: ['ffe-*'], }) expect(mockFetch.mock.calls[0][0]).toBe( - 'https://preview.ff-cdn.datadoghq.com/precompute-assignments?dd_env=test' + 'https://preview.ff-cdn.datadoghq.com/precompute-assignments?dd_env=test&cap_dependent-flags-variant-key=v1' ) expect(displayError).not.toHaveBeenCalled() expect(addTelemetryDebug).not.toHaveBeenCalled() @@ -558,7 +561,9 @@ describe('createFlagsConfigurationFetcher', () => { await fetcher(mockContext) - expect(mockFetch.mock.calls[0][0]).toBe('https://proxy.example.com/?dd_env=test') + expect(mockFetch.mock.calls[0][0]).toBe( + 'https://proxy.example.com/?dd_env=test&cap_dependent-flags-variant-key=v1' + ) expect(requestBody().data.attributes.flag_selection).toEqual({ tags: ['beta'], key_patterns: [] }) }) diff --git a/packages/core/README.md b/packages/core/README.md index c07a9b74..e872e8fd 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -51,6 +51,29 @@ receiving configuration. Existing low-level evaluation calls still accept raw co and prepare each flag once on first use. Replace changed flags instead of mutating prepared flags in place. Preparation does not change the serialized configuration. +## Precomputed dependent assignments + +Successful dependency records contain only `flagKey` and `isPresentInAssignment`; their +assignment details come from the dependency's top-level flag entry. Root-relative dependency +failures remain inline error records and never include `isPresentInAssignment`. + +`attributes.flags` contains only complete assignments. Terminal root failures are carried in +the sibling `attributes.flagEvaluationErrors` map under the failed flag key. The two maps are +mutually exclusive by key. A capable client resolves an entry from `flagEvaluationErrors` with +the caller's default while retaining its `reason`, `errorCode`, and dependency telemetry. Older +clients ignore the sibling map and therefore treat the failed flag as absent; no placeholder +assignment value is fabricated. + +For obfuscated responses, keys in both maps are hashed. Each `dependencyEvaluations[].flagKey` +is authenticated ciphertext bound to the plaintext root key. The capable client decrypts the +reference with the caller-supplied root key, hashes the recovered plaintext prerequisite key, +and uses that digest for the top-level lookup. Plain responses keep plaintext dependency keys. + +When a present prerequisite produces no assignment, the root is terminally returned as +`reason: "ERROR"` with `errorCode: "GENERAL"`, and the prerequisite is an inline dependency +error. The server does not continue to a later root fallback, and the unassigned prerequisite +does not receive a standalone top-level flag entry. + ## Adding a public entry point `packages/core/package.json` **`exports` is the single source of truth** for public paths. diff --git a/packages/core/src/configuration/precompute-configuration.test.ts b/packages/core/src/configuration/precompute-configuration.test.ts index e922deec..947a04ad 100644 --- a/packages/core/src/configuration/precompute-configuration.test.ts +++ b/packages/core/src/configuration/precompute-configuration.test.ts @@ -462,10 +462,14 @@ describe('precomputeConfiguration', () => { [{ flagKey: 'unsafeInteger', errorCode: 'PARSE_ERROR' }], [{ flagKey: 'badJson', errorCode: 'PARSE_ERROR' }], ]) - for (const key of ['unassigned', 'unsupported', 'unsafeInteger', 'badJson']) { + expect(evaluate(result, 'boolean', 'unassigned', false, context)).toMatchObject({ + value: false, + errorCode: 'FLAG_NOT_FOUND', + }) + for (const key of ['unsupported', 'unsafeInteger', 'badJson']) { expect(evaluate(result, 'boolean', key, false, context)).toMatchObject({ value: false, - errorCode: 'FLAG_NOT_FOUND', + errorCode: 'PARSE_ERROR', }) } }) @@ -720,7 +724,7 @@ describe('precomputeConfiguration', () => { expect(evaluate(result, 'boolean', 'sharded', false, subject)).toMatchObject({ value: false, reason: 'ERROR', - errorCode: 'FLAG_NOT_FOUND', + errorCode: 'INVALID_CONTEXT', }) expect(onEvaluationError).toHaveBeenCalledTimes(1) expect(onEvaluationError).toHaveBeenCalledWith({ flagKey: 'sharded', errorCode: 'INVALID_CONTEXT' }) diff --git a/packages/core/src/configuration/wire.test.ts b/packages/core/src/configuration/wire.test.ts index 6de5b665..d9586aed 100644 --- a/packages/core/src/configuration/wire.test.ts +++ b/packages/core/src/configuration/wire.test.ts @@ -114,7 +114,8 @@ describe('configuration wire', () => { it('keeps flags readable after a round-trip', () => { const restored = configurationFromString(configurationToString(configuration)) - expect(restored.precomputed?.response.data.attributes.flags['my-flag'].variationValue).toBe(true) + const flag = restored.precomputed?.response.data.attributes.flags['my-flag'] + expect(flag && 'variationValue' in flag ? flag.variationValue : undefined).toBe(true) }) it('serializes only the precomputed response object', () => { diff --git a/packages/core/src/evaluation/precomputed-dependencies.ts b/packages/core/src/evaluation/precomputed-dependencies.ts new file mode 100644 index 00000000..fa0a6310 --- /dev/null +++ b/packages/core/src/evaluation/precomputed-dependencies.ts @@ -0,0 +1,129 @@ +import type { ErrorCode, EvaluationDetails, FlagMetadata, FlagValue, Logger } from '@openfeature/core' +import type { PrecomputedDependencyEvaluation, PrecomputedFlag } from '../configuration' +import { createDependencyFlagKeyDecryptor } from '../configuration/dependency-key-encryption' +import type { FlagKeyObfuscation } from '../configuration/flag-key-obfuscation' +import { encodePrecomputedFlagKey } from '../configuration/flag-key-obfuscation' +import { getOwnProperty } from './getOwnProperty' +import { createSplitFeatureMetadata, HAS_EVALUATION_FEATURES_METADATA_KEY } from './split-features' + +const NOOP_LOGGER: Logger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} } + +export type PrecomputedDependencyEvaluationDetails = Omit, 'value'> & { + value?: FlagValue + /** Successful dependencies only; errors are reached-evaluation telemetry. */ + isPresentInAssignment?: boolean +} + +const DEPENDENCY_EVALUATIONS = Symbol.for('@datadog/flagging/precomputed-dependency-evaluations') + +type MetadataWithDependencyEvaluations = FlagMetadata & { + [DEPENDENCY_EVALUATIONS]?: readonly PrecomputedDependencyEvaluationDetails[] +} + +export function bindPrecomputedDependencyEvaluations( + metadata: FlagMetadata, + dependencies: readonly PrecomputedDependencyEvaluation[] | undefined, + flags: Record, + flagErrors?: Record, + logger: Logger = NOOP_LOGGER, + identity?: { rootFlagKey: string; encoding?: FlagKeyObfuscation; allowDependencyErrors?: boolean } +): string | undefined { + if (!dependencies?.length) return undefined + const resolved: PrecomputedDependencyEvaluationDetails[] = [] + const decryptDependencyFlagKey = identity?.encoding + ? createDependencyFlagKeyDecryptor(identity.rootFlagKey, identity.encoding) + : undefined + for (const dependency of dependencies) { + const details = toEvaluationDetails( + dependency, + flags, + identity?.encoding, + decryptDependencyFlagKey, + flagErrors, + logger + ) + if ('bindingError' in details) return details.bindingError + if (!identity?.allowDependencyErrors && details.reason === 'ERROR') { + return 'Successful root assignment contains a failed dependency evaluation' + } + resolved.push(details) + } + Object.defineProperty(metadata, DEPENDENCY_EVALUATIONS, { + configurable: false, + enumerable: false, + value: Object.freeze(resolved), + writable: false, + }) + return undefined +} + +export function getPrecomputedDependencyEvaluations(details: { + flagMetadata?: FlagMetadata +}): readonly PrecomputedDependencyEvaluationDetails[] { + return details.flagMetadata + ? ((details.flagMetadata as MetadataWithDependencyEvaluations)[DEPENDENCY_EVALUATIONS] ?? []) + : [] +} + +function toEvaluationDetails( + dependency: PrecomputedDependencyEvaluation, + flags: Record, + encoding: FlagKeyObfuscation | undefined, + decryptDependencyFlagKey: ((encryptedFlagKey: string) => { key: string } | { error: string }) | undefined, + flagErrors: Record | undefined, + logger: Logger +): PrecomputedDependencyEvaluationDetails | { bindingError: string } { + const decrypted = decryptDependencyFlagKey ? decryptDependencyFlagKey(dependency.flagKey) : { key: dependency.flagKey } + if ('error' in decrypted) { + return { bindingError: decrypted.error } + } + const lookup = encoding ? encodePrecomputedFlagKey(decrypted.key, encoding) : { key: decrypted.key } + if ('error' in lookup) { + return { bindingError: lookup.error } + } + if (isDependencyError(dependency)) { + return { + flagKey: decrypted.key, + reason: 'ERROR', + errorCode: dependency.errorCode as ErrorCode, + ...(dependency.errorMessage === undefined ? {} : { errorMessage: dependency.errorMessage }), + flagMetadata: {}, + } + } + const flag = getOwnProperty(flags, lookup.key) + const flagError = flagErrors ? getOwnProperty(flagErrors, lookup.key) : undefined + if ( + flagError !== undefined || + !flag || + typeof flag.allocationKey !== 'string' || + typeof flag.variationKey !== 'string' || + typeof flag.reason !== 'string' || + flag.reason === 'ERROR' || + typeof flag.doLog !== 'boolean' + ) { + return { bindingError: flagError ?? 'Successful dependency evaluation is missing its top-level assignment' } + } + const { entries: featureEntries, hasEvaluationFeatures } = createSplitFeatureMetadata(flag.features, logger) + return { + flagKey: decrypted.key, + value: flag.variationValue, + variant: flag.variationKey, + reason: flag.reason, + isPresentInAssignment: dependency.isPresentInAssignment, + flagMetadata: { + __dd_allocation_key: flag.allocationKey, + __dd_do_log: flag.doLog, + allocationKey: flag.allocationKey, + doLog: flag.doLog, + ...(typeof flag.serialId === 'number' ? { __dd_split_serial_id: flag.serialId } : {}), + ...featureEntries, + ...(hasEvaluationFeatures ? { [HAS_EVALUATION_FEATURES_METADATA_KEY]: true } : {}), + }, + } +} + +function isDependencyError( + dependency: PrecomputedDependencyEvaluation +): dependency is Extract { + return 'reason' in dependency && dependency.reason === 'ERROR' && typeof dependency.errorCode === 'string' +} diff --git a/packages/core/src/evaluation/precomputed-evaluation.ts b/packages/core/src/evaluation/precomputed-evaluation.ts index d2122828..6e25ddd4 100644 --- a/packages/core/src/evaluation/precomputed-evaluation.ts +++ b/packages/core/src/evaluation/precomputed-evaluation.ts @@ -14,6 +14,7 @@ import { INVALID_EXPERIMENTATION_OPT_OUT, } from './experimentation-opt-out' import { getOwnProperty } from './getOwnProperty' +import { bindPrecomputedDependencyEvaluations } from './precomputed-dependencies' import { createSplitFeatureMetadata, HAS_EVALUATION_FEATURES_METADATA_KEY } from './split-features' const NOOP_LOGGER: Logger = { @@ -144,7 +145,44 @@ function evaluatePrecomputedFlag( } } + const evaluationError = attributes.flagEvaluationErrors + ? getOwnProperty(attributes.flagEvaluationErrors, lookup.key) + : undefined const flag = getOwnProperty(attributes.flags, lookup.key) + if (evaluationError && flag) { + return { + value: defaultValue, + reason: 'ERROR', + errorCode: 'PARSE_ERROR' as ErrorCode, + errorMessage: 'A precomputed flag cannot have both an assignment and an evaluation error', + } + } + if (evaluationError) { + const flagMetadata = {} + const dependencyError = bindPrecomputedDependencyEvaluations( + flagMetadata, + evaluationError.dependencyEvaluations, + attributes.flags, + precomputed.flagErrors, + logger, + { rootFlagKey: flagKey, encoding: encoding.encoding, allowDependencyErrors: true } + ) + if (dependencyError) { + return { + value: defaultValue, + reason: 'ERROR', + errorCode: 'PARSE_ERROR' as ErrorCode, + errorMessage: dependencyError, + } + } + return { + value: defaultValue, + reason: evaluationError.reason, + errorCode: evaluationError.errorCode as ErrorCode, + ...(evaluationError.errorMessage === undefined ? {} : { errorMessage: evaluationError.errorMessage }), + ...(evaluationError.dependencyEvaluations?.length ? { flagMetadata } : {}), + } + } if (!flag) { return { value: defaultValue, @@ -154,27 +192,62 @@ function evaluatePrecomputedFlag( } if (flag.variationType && variationTypeToOpenFeature(flag.variationType) !== type) { + const flagMetadata = {} + const dependencyError = bindPrecomputedDependencyEvaluations( + flagMetadata, + flag.dependencyEvaluations, + attributes.flags, + precomputed.flagErrors, + logger, + { rootFlagKey: flagKey, encoding: encoding.encoding } + ) + if (dependencyError) { + return { + value: defaultValue, + reason: 'ERROR', + errorCode: 'PARSE_ERROR' as ErrorCode, + errorMessage: dependencyError, + } + } return { value: defaultValue, reason: 'ERROR', errorCode: 'TYPE_MISMATCH' as ErrorCode, + ...(flag.dependencyEvaluations?.length ? { flagMetadata } : {}), } } const { entries: featureEntries, hasEvaluationFeatures } = createSplitFeatureMetadata(flag.features, logger) + const flagMetadata = { + __dd_allocation_key: flag.allocationKey, + __dd_do_log: flag.doLog, + allocationKey: flag.allocationKey, + variationType: flag.variationType, + doLog: flag.doLog, + ...(typeof flag.serialId === 'number' ? { __dd_split_serial_id: flag.serialId } : {}), + ...featureEntries, + ...(hasEvaluationFeatures ? { [HAS_EVALUATION_FEATURES_METADATA_KEY]: true } : {}), + } as PrecomputedFlagMetadata + const dependencyError = bindPrecomputedDependencyEvaluations( + flagMetadata, + flag.dependencyEvaluations, + attributes.flags, + precomputed.flagErrors, + logger, + { rootFlagKey: flagKey, encoding: encoding.encoding } + ) + if (dependencyError) { + return { + value: defaultValue, + reason: 'ERROR', + errorCode: 'PARSE_ERROR' as ErrorCode, + errorMessage: dependencyError, + } + } return { value: flag.variationValue as FlagTypeToValue, variant: flag.variationKey, - flagMetadata: { - __dd_allocation_key: flag.allocationKey, - __dd_do_log: flag.doLog, - ...(typeof flag.serialId === 'number' ? { __dd_split_serial_id: flag.serialId } : {}), - allocationKey: flag.allocationKey, - variationType: flag.variationType, - doLog: flag.doLog, - ...featureEntries, - ...(hasEvaluationFeatures ? { [HAS_EVALUATION_FEATURES_METADATA_KEY]: true } : {}), - } as PrecomputedFlagMetadata, + flagMetadata, reason: flag.reason, } as ResolutionDetails> } diff --git a/packages/core/test/evaluation/flag-key-obfuscation.spec.ts b/packages/core/test/evaluation/flag-key-obfuscation.spec.ts index be80f972..dbab910a 100644 --- a/packages/core/test/evaluation/flag-key-obfuscation.spec.ts +++ b/packages/core/test/evaluation/flag-key-obfuscation.spec.ts @@ -6,7 +6,9 @@ import { type FlagsConfiguration, parsePrecomputedConfigurationResponse, } from '../../src/configuration' +import { decryptDependencyFlagKey } from '../../src/configuration/dependency-key-encryption' import { encodePrecomputedFlagKey, type FlagKeyObfuscation } from '../../src/configuration/flag-key-obfuscation' +import { getPrecomputedDependencyEvaluations } from '../../src/evaluation/precomputed-dependencies' import { evaluatePrecomputedConfiguration } from '../../src/evaluation/precomputed-evaluation' import * as sha256 from '../../src/evaluation/sha256' @@ -21,7 +23,7 @@ const assignment = { doLog: true, serialId: 123, } -const descriptor = { scheme: 'flag-key-sha256-v1', salt } +const descriptor: FlagKeyObfuscation = { scheme: 'flag-key-sha256-v1', salt } // These vectors also run in the Rust edge encoder. Expected digests are not // generated by the SDK implementation under test. @@ -37,8 +39,22 @@ const vectors = [ ['', '072d985b427f536ad0a11b2d4c5e0f7e6f0ff6d23e779e082f75599ce3fe3eba'], ] -function response(flags: Record, encoding: Record = {}) { - return { data: { attributes: { createdAt: '2026-09-30T00:00:00Z', format: 'PRECOMPUTED', ...encoding, flags } } } +function response( + flags: Record, + encoding: Record = {}, + flagEvaluationErrors?: Record +) { + return { + data: { + attributes: { + createdAt: '2026-09-30T00:00:00Z', + format: 'PRECOMPUTED', + ...encoding, + flags, + ...(flagEvaluationErrors ? { flagEvaluationErrors } : {}), + }, + }, + } } function decode(value: unknown): FlagsConfiguration { @@ -56,6 +72,16 @@ function hash(key: string, publicSalt = salt): string { } describe('precomputed flag-key obfuscation', () => { + it('decrypts the shared edge and fixture dependency-key vector', () => { + expect( + decryptDependencyFlagKey( + 'Eb_cST-0-FZKdvcpKLHbDDG_CX2hK4Xfrw7h5o-47Gp6GyFntOGTZO3wmLg', + 'checkout-redesign', + descriptor + ) + ).toEqual({ key: 'new-payment-flow' }) + }) + afterEach(() => jest.restoreAllMocks()) it('caches repeated lookups separately for concurrent configurations', () => { @@ -174,6 +200,75 @@ describe('precomputed flag-key obfuscation', () => { expect(evaluatePrecomputedConfiguration(encoded, 'boolean', 'valid', false, context).value).toBe(true) }) + it('resolves an obfuscated evaluation error with the caller default', () => { + const parentKey = hash('parent') + const rootKey = hash('root') + const encryptedParentKey = 'Ldgr0eitlOxeK6kY8mkfq-dX-sb2Ea5RwRT9pAwTldy4ww' + const encoded = decode( + response( + { [parentKey]: assignment }, + { obfuscated: true, obfuscation: descriptor }, + { + [rootKey]: { + reason: 'ERROR', + errorCode: 'GENERAL', + dependencyEvaluations: [{ flagKey: encryptedParentKey, isPresentInAssignment: true }], + }, + } + ) + ) + + const result = evaluatePrecomputedConfiguration(encoded, 'boolean', 'root', false, context) + expect(result).toMatchObject({ value: false, reason: 'ERROR', errorCode: 'GENERAL' }) + expect(getPrecomputedDependencyEvaluations(result)).toEqual([ + expect.objectContaining({ flagKey: 'parent', value: true, variant: assignment.variationKey }), + ]) + }) + + it('fails authentication when an encrypted dependency is moved to another root', () => { + const encoded = decode( + response( + { + [hash('parent')]: assignment, + [hash('other-root')]: { + ...assignment, + dependencyEvaluations: [ + { flagKey: 'Ldgr0eitlOxeK6kY8mkfq-dX-sb2Ea5RwRT9pAwTldy4ww', isPresentInAssignment: true }, + ], + }, + }, + { obfuscated: true, obfuscation: descriptor } + ) + ) + + const result = evaluatePrecomputedConfiguration(encoded, 'boolean', 'other-root', false, context) + expect(result).toMatchObject({ value: false, reason: 'ERROR', errorCode: 'PARSE_ERROR' }) + expect(getPrecomputedDependencyEvaluations(result)).toEqual([]) + }) + + it('validates keys in the obfuscated error map and dependency list', () => { + const error = { reason: 'ERROR', errorCode: 'GENERAL' } + expect( + parsePrecomputedConfigurationResponse( + response({}, { obfuscated: true, obfuscation: descriptor }, { plaintext: error }) + ) + ).toHaveProperty('error') + expect( + parsePrecomputedConfigurationResponse( + response( + {}, + { obfuscated: true, obfuscation: descriptor }, + { + [hash('root')]: { ...error, dependencyEvaluations: [{ flagKey: 'parent', isPresentInAssignment: true }] }, + } + ) + ) + ).toEqual({ + response: expect.anything(), + flagErrors: { [hash('root')]: 'Invalid precomputed flag evaluation error' }, + }) + }) + it('preserves the encoding through portable configuration round trips', () => { const original = decode(response({ [hash('flag')]: assignment }, { obfuscated: true, obfuscation: descriptor })) const restored = configurationFromString(configurationToString(original)) diff --git a/packages/core/test/evaluation/precomputed-dependencies.spec.ts b/packages/core/test/evaluation/precomputed-dependencies.spec.ts new file mode 100644 index 00000000..da97257a --- /dev/null +++ b/packages/core/test/evaluation/precomputed-dependencies.spec.ts @@ -0,0 +1,480 @@ +import { + type FlagsConfiguration, + type PrecomputedConfigurationResponse, + type PrecomputedFlag, +} from '../../src/configuration' +import { encodePrecomputedFlagKey, type FlagKeyObfuscation } from '../../src/configuration/flag-key-obfuscation' +import { parsePrecomputedConfigurationResponse } from '../../src/configuration/wire-validation' +import { getPrecomputedDependencyEvaluations } from '../../src/evaluation/precomputed-dependencies' +import { evaluatePrecomputedConfiguration } from '../../src/evaluation/precomputed-evaluation' + +const context = { targetingKey: 'subject' } + +const parentFlag = { + allocationKey: 'parent-allocation', + variationKey: 'parent-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + serialId: 42, +} satisfies PrecomputedFlag + +function configuration( + flag: Record, + additionalFlags: Record = { parent: parentFlag } +): FlagsConfiguration { + const isEvaluationError = flag.reason === 'ERROR' && !('variationValue' in flag) + return { + precomputed: { + context, + response: { + data: { + attributes: { + createdAt: '2026-10-21T00:00:00Z', + flags: { ...additionalFlags, ...(isEvaluationError ? {} : { root: flag }) }, + ...(isEvaluationError ? { flagEvaluationErrors: { root: flag } } : {}), + }, + }, + } as unknown as PrecomputedConfigurationResponse, + }, + } +} + +describe('precomputed dependent flags', () => { + const successfulDependency = { + flagKey: 'parent', + isPresentInAssignment: true, + } + + it('returns the root assignment and preserves its ordered dependency evaluations', () => { + const result = evaluatePrecomputedConfiguration( + configuration({ + allocationKey: 'root-allocation', + variationKey: 'root-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + dependencyEvaluations: [successfulDependency], + }), + 'boolean', + 'root', + false, + context + ) + + expect(result).toMatchObject({ value: true, variant: 'root-on', reason: 'TARGETING_MATCH' }) + expect(getPrecomputedDependencyEvaluations(result)).toEqual([ + { + flagKey: 'parent', + value: true, + variant: 'parent-on', + reason: 'TARGETING_MATCH', + isPresentInAssignment: true, + flagMetadata: { + __dd_allocation_key: 'parent-allocation', + __dd_do_log: true, + allocationKey: 'parent-allocation', + doLog: true, + __dd_split_serial_id: 42, + }, + }, + ]) + }) + + it('fails closed when a successful root contains a failed dependency evaluation', () => { + const result = evaluatePrecomputedConfiguration( + configuration({ + allocationKey: 'root-allocation', + variationKey: 'root-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + dependencyEvaluations: [{ flagKey: 'missing-parent', reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' }], + }), + 'boolean', + 'root', + false, + context + ) + + expect(result).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'PARSE_ERROR', + errorMessage: 'Successful root assignment contains a failed dependency evaluation', + }) + }) + + it('treats nullable error fields on a successful dependency as absent', () => { + const payload = configuration({ + allocationKey: 'root-allocation', + variationKey: 'root-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + dependencyEvaluations: [ + { + flagKey: 'parent', + isPresentInAssignment: true, + reason: null, + errorCode: null, + errorMessage: null, + }, + ], + }).precomputed!.response + const parsed = parsePrecomputedConfigurationResponse(payload) + if ('error' in parsed) throw new Error(parsed.error) + + const result = evaluatePrecomputedConfiguration( + { precomputed: { context, response: parsed.response, flagErrors: parsed.flagErrors } }, + 'boolean', + 'root', + false, + context + ) + + expect(getPrecomputedDependencyEvaluations(result)).toEqual([ + expect.objectContaining({ flagKey: 'parent', value: true, variant: 'parent-on', reason: 'TARGETING_MATCH' }), + ]) + }) + + it('decrypts dependency identities before hashing them for top-level lookup', () => { + const obfuscation: FlagKeyObfuscation = { + scheme: 'flag-key-sha256-v1', + salt: '00112233445566778899aabbccddeeff', + } + const parentKey = encodePrecomputedFlagKey('parent', obfuscation) + const rootKey = encodePrecomputedFlagKey('root', obfuscation) + if ('error' in parentKey || 'error' in rootKey) throw new Error('test keys must encode') + const flags: FlagsConfiguration = { + precomputed: { + context, + response: { + data: { + attributes: { + createdAt: '2026-10-21T00:00:00Z', + obfuscated: true, + obfuscation, + flags: { + [parentKey.key]: parentFlag, + [rootKey.key]: { + allocationKey: 'root-allocation', + variationKey: 'root-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + dependencyEvaluations: [ + { + flagKey: 'Dnh0eaLWDiAaLkwLG6hAV7Q-rU7_xMvXou2PWK3dCjz9rg', + isPresentInAssignment: true, + }, + ], + }, + } satisfies Record, + }, + }, + }, + }, + } + + const result = evaluatePrecomputedConfiguration(flags, 'boolean', 'root', false, context) + + expect(getPrecomputedDependencyEvaluations(result)).toEqual([ + expect.objectContaining({ flagKey: 'parent', value: true, variant: 'parent-on' }), + ]) + }) + + it('preserves each dependency split feature for its destination', () => { + const result = evaluatePrecomputedConfiguration( + configuration( + { + allocationKey: 'root-allocation', + variationKey: 'root-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + dependencyEvaluations: [successfulDependency], + }, + { + parent: { + ...parentFlag, + features: [ + { key: 'hook', value: 'h', destinations: ['HOOK'] }, + { key: 'exposure', value: 2, destinations: ['EXPOSURE'] }, + { key: 'evaluation', value: true, destinations: ['EVALUATION'] }, + ], + }, + } + ), + 'boolean', + 'root', + false, + context + ) + + expect(getPrecomputedDependencyEvaluations(result)[0].flagMetadata).toMatchObject({ + '__dd_feature.hook': 'h', + '__dd_exposure_feature.exposure': 2, + '__dd_evaluation_feature.evaluation': true, + __dd_has_evaluation_features: true, + }) + }) + + it('resolves successful details from the top-level assignment and retains rejected-path membership', () => { + const result = evaluatePrecomputedConfiguration( + configuration({ + allocationKey: 'root-allocation', + variationKey: 'root-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + dependencyEvaluations: [{ flagKey: 'parent', isPresentInAssignment: false }], + }), + 'boolean', + 'root', + false, + context + ) + + expect(getPrecomputedDependencyEvaluations(result)).toEqual([ + expect.objectContaining({ + flagKey: 'parent', + variant: 'parent-on', + isPresentInAssignment: false, + flagMetadata: expect.objectContaining({ allocationKey: 'parent-allocation', doLog: true }), + }), + ]) + }) + + it.each([true, false])( + 'fails the root when a successful dependency reference is unresolved (selected: %s)', + (isPresentInAssignment) => { + const result = evaluatePrecomputedConfiguration( + configuration( + { + allocationKey: 'root-allocation', + variationKey: 'root-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + dependencyEvaluations: [{ flagKey: 'absent-parent', isPresentInAssignment }], + }, + {} + ), + 'boolean', + 'root', + false, + context + ) + + expect(result).toMatchObject({ value: false, reason: 'ERROR', errorCode: 'PARSE_ERROR' }) + expect(getPrecomputedDependencyEvaluations(result)).toEqual([]) + } + ) + + it('does not reconstruct a successful dependency from a quarantined top-level assignment', () => { + const root = { + allocationKey: 'root-allocation', + variationKey: 'root-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + dependencyEvaluations: [{ flagKey: 'parent', isPresentInAssignment: true }], + } + const payload = configuration(root, { parent: { ...parentFlag, variationValue: 'not-a-boolean' } }).precomputed! + .response + const parsed = parsePrecomputedConfigurationResponse(payload) + if ('error' in parsed) throw new Error(parsed.error) + const result = evaluatePrecomputedConfiguration( + { precomputed: { context, response: parsed.response, flagErrors: parsed.flagErrors } }, + 'boolean', + 'root', + false, + context + ) + + expect(result).toMatchObject({ value: false, reason: 'ERROR', errorCode: 'PARSE_ERROR' }) + expect(getPrecomputedDependencyEvaluations(result)).toEqual([]) + }) + + it('does not reconstruct an ERROR assignment as a successful dependency', () => { + const root = { + allocationKey: 'root-allocation', + variationKey: 'root-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + dependencyEvaluations: [successfulDependency], + } + const invalidParent = { ...parentFlag, reason: 'ERROR' } + + const direct = evaluatePrecomputedConfiguration( + configuration(root, { parent: invalidParent }), + 'boolean', + 'root', + false, + context + ) + expect(direct).toMatchObject({ value: false, reason: 'ERROR', errorCode: 'PARSE_ERROR' }) + expect(getPrecomputedDependencyEvaluations(direct)).toEqual([]) + + const payload = configuration(root, { parent: invalidParent }).precomputed!.response + const parsed = parsePrecomputedConfigurationResponse(payload) + if ('error' in parsed) throw new Error(parsed.error) + expect(parsed.flagErrors).toEqual({ parent: 'Invalid precomputed flag configuration' }) + }) + + it('returns the caller default while preserving reached dependency errors', () => { + const result = evaluatePrecomputedConfiguration( + configuration({ + reason: 'ERROR', + errorCode: 'GENERAL', + dependencyEvaluations: [ + successfulDependency, + { flagKey: 'missing-parent', reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' }, + { flagKey: 'failed-ancestor', reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' }, + ], + }), + 'boolean', + 'root', + false, + context + ) + + expect(result).toMatchObject({ value: false, reason: 'ERROR', errorCode: 'GENERAL' }) + expect(getPrecomputedDependencyEvaluations(result)).toEqual([ + expect.objectContaining({ flagKey: 'parent', variant: 'parent-on' }), + expect.objectContaining({ flagKey: 'missing-parent', reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' }), + expect.objectContaining({ flagKey: 'failed-ancestor', reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' }), + ]) + }) + + it('preserves reached dependencies when the caller uses the wrong typed API', () => { + const result = evaluatePrecomputedConfiguration( + configuration({ + allocationKey: 'root-allocation', + variationKey: 'root-on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + dependencyEvaluations: [successfulDependency], + }), + 'string', + 'root', + 'default', + context + ) + + expect(result).toMatchObject({ value: 'default', reason: 'ERROR', errorCode: 'TYPE_MISMATCH' }) + expect(getPrecomputedDependencyEvaluations(result)).toEqual([ + expect.objectContaining({ flagKey: 'parent', variant: 'parent-on' }), + ]) + }) + + it('preserves a terminal no-assignment prerequisite error without inventing a top-level assignment', () => { + const flags = configuration( + { + reason: 'ERROR', + errorCode: 'GENERAL', + dependencyEvaluations: [{ flagKey: 'no-assignment-parent', reason: 'ERROR', errorCode: 'GENERAL' }], + }, + {} + ) + const root = evaluatePrecomputedConfiguration(flags, 'boolean', 'root', false, context) + const standalone = evaluatePrecomputedConfiguration(flags, 'boolean', 'no-assignment-parent', false, context) + + expect(root).toMatchObject({ value: false, reason: 'ERROR', errorCode: 'GENERAL' }) + expect(getPrecomputedDependencyEvaluations(root)).toEqual([ + { + flagKey: 'no-assignment-parent', + reason: 'ERROR', + errorCode: 'GENERAL', + flagMetadata: {}, + }, + ]) + expect(standalone).toMatchObject({ value: false, reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' }) + }) + + it.each([ + { reason: 'ERROR' }, + { reason: 'ERROR', errorCode: 12 }, + { reason: 'TARGETING_MATCH', errorCode: 'GENERAL' }, + { reason: 'ERROR', errorCode: 'GENERAL', dependencyEvaluations: {} }, + { reason: 'ERROR', errorCode: 'GENERAL', dependencyEvaluations: [{ flagKey: 'parent', reason: 'ERROR' }] }, + { + reason: 'ERROR', + errorCode: 'GENERAL', + dependencyEvaluations: [ + { flagKey: 'parent', reason: 'ERROR', errorCode: 'GENERAL', isPresentInAssignment: false }, + ], + }, + { + reason: 'ERROR', + errorCode: 'GENERAL', + dependencyEvaluations: [{ flagKey: 'parent', isPresentInAssignment: 'yes' }], + }, + { + reason: 'ERROR', + errorCode: 'GENERAL', + dependencyEvaluations: [ + { + flagKey: 'parent', + allocationKey: 'duplicated-allocation', + variationKey: 'duplicated-variation', + reason: 'TARGETING_MATCH', + doLog: true, + }, + ], + }, + { + reason: 'ERROR', + errorCode: 'GENERAL', + dependencyEvaluations: [ + { flagKey: 'parent', isPresentInAssignment: true }, + { flagKey: 'parent', isPresentInAssignment: false }, + ], + }, + ])('isolates malformed root or dependency records: %j', (flag) => { + const payload = configuration(flag).precomputed!.response + expect(parsePrecomputedConfigurationResponse(payload)).toEqual({ + response: payload, + flagErrors: { + root: + flag.reason === 'ERROR' + ? 'Invalid precomputed flag evaluation error' + : 'Invalid precomputed flag configuration', + }, + }) + }) + + it('rejects a flag key present in both assignments and evaluation errors', () => { + const response = configuration(parentFlag).precomputed!.response + const payload = { + ...response, + data: { + ...response.data, + attributes: { + ...response.data.attributes, + flagEvaluationErrors: { + root: { reason: 'ERROR', errorCode: 'GENERAL', dependencyEvaluations: [] }, + }, + }, + }, + } + + expect(parsePrecomputedConfigurationResponse(payload)).toEqual({ + error: 'A precomputed flag cannot have both an assignment and an evaluation error', + }) + }) +}) diff --git a/packages/core/test/ffe-system-test-data b/packages/core/test/ffe-system-test-data index c8cc8da2..ff462c02 160000 --- a/packages/core/test/ffe-system-test-data +++ b/packages/core/test/ffe-system-test-data @@ -1 +1 @@ -Subproject commit c8cc8da239341748dc8c30c2145c0f0b5432c958 +Subproject commit ff462c022403c7e3fb2509bbe47b04434d2c3dc3