diff --git a/packages/browser/README.md b/packages/browser/README.md index 7071911d..ab46f53e 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -90,6 +90,27 @@ console.log(result.value) // Flag value console.log(result.reason) // Evaluation reason ``` +### Using CoreProvider With Portable Configuration + +`CoreProvider` is an opt-in evaluation-only provider for applications that supply their own flags configuration, such as an SSR bootstrap or offline init payload. It does not fetch or poll configuration. + +For dynamic context, the generic configuration wire should contain rules-based flag configuration. Precomputed configuration can also be evaluated, but only for the matching context it was generated for. + +```javascript +import { configurationFromString, CoreProvider } from '@datadog/openfeature-browser' +import { OpenFeature } from '@openfeature/web-sdk' + +const configuration = configurationFromString('...flags configuration string...') +const provider = new CoreProvider({ configuration }) + +await OpenFeature.setProviderAndWait(provider) + +await OpenFeature.setContext({ targetingKey: 'user-123', plan: 'enterprise' }) + +const client = OpenFeature.getClient() +const enabled = client.getBooleanValue('new-checkout', false) +``` + ## End-user license agreement https://www.datadoghq.com/legal/eula diff --git a/packages/browser/src/evaluation.ts b/packages/browser/src/evaluation.ts deleted file mode 100644 index 48ae3d98..00000000 --- a/packages/browser/src/evaluation.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { - FlagsConfiguration, - FlagTypeToValue, - PrecomputedConfiguration, - PrecomputedFlagMetadata, -} from '@datadog/flagging-core' -import type { ErrorCode, EvaluationContext, FlagValueType, ResolutionDetails } from '@openfeature/web-sdk' - -export function evaluate( - flagsConfiguration: FlagsConfiguration, - type: T, - flagKey: string, - defaultValue: FlagTypeToValue, - context: EvaluationContext -): ResolutionDetails> { - if (flagsConfiguration.precomputed) { - return evaluatePrecomputed(flagsConfiguration.precomputed, type, flagKey, defaultValue, context) - } - - return { - value: defaultValue, - reason: 'DEFAULT', - } -} - -function evaluatePrecomputed( - precomputed: PrecomputedConfiguration, - type: T, - flagKey: string, - defaultValue: FlagTypeToValue, - _context: EvaluationContext -): ResolutionDetails> { - const flag = precomputed.response.data.attributes.flags[flagKey] - if (!flag) { - return { - value: defaultValue, - reason: 'ERROR', - errorCode: 'FLAG_NOT_FOUND' as ErrorCode, - } - } - - if (flag.variationType && variationTypeToOpenFeature(flag.variationType) !== type) { - return { - value: defaultValue, - reason: 'ERROR', - errorCode: 'TYPE_MISMATCH' as ErrorCode, - } - } - - return { - value: flag.variationValue as FlagTypeToValue, - variant: flag.variationKey, - flagMetadata: { - allocationKey: flag.allocationKey, - variationType: flag.variationType, - doLog: flag.doLog, - } as PrecomputedFlagMetadata, - reason: flag.reason, - } as ResolutionDetails> -} - -function variationTypeToOpenFeature(s: string): FlagValueType { - const typeMap: Record = { - string: 'string', - boolean: 'boolean', - number: 'number', - integer: 'number', - float: 'number', - object: 'object', - - BOOLEAN: 'boolean', - STRING: 'string', - NUMERIC: 'number', - INTEGER: 'number', - JSON: 'object', - } - - return typeMap[s] || s.toLowerCase() -} diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 9916ffab..9e4fec64 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -1,12 +1,17 @@ import { defineGlobal, getGlobalObject } from '@datadog/browser-core' +import { CoreProvider } from './openfeature/core-provider' import { DatadogProvider } from './openfeature/provider' export { configurationFromString, configurationToString } from '@datadog/flagging-core' export type { FlaggingInitConfiguration } from './domain/configuration' +export type { CoreProviderOptions } from './openfeature/core-provider' export { DatadogDevtools } from './openfeature/devtools-provider' -export { DatadogProvider } +export { CoreProvider, DatadogProvider } // Build environment placeholder for testing const _SDK_VERSION = __BUILD_ENV__SDK_VERSION__ -defineGlobal(getGlobalObject(), 'DD_FLAGGING' as keyof typeof globalThis, { Provider: DatadogProvider }) +defineGlobal(getGlobalObject(), 'DD_FLAGGING' as keyof typeof globalThis, { + Provider: DatadogProvider, + CoreProvider, +}) diff --git a/packages/browser/src/openfeature/core-provider.ts b/packages/browser/src/openfeature/core-provider.ts new file mode 100644 index 00000000..38063bd9 --- /dev/null +++ b/packages/browser/src/openfeature/core-provider.ts @@ -0,0 +1,132 @@ +import type { FlagsConfiguration, FlagTypeToValue } from '@datadog/flagging-core' +import { configMatchesContext, evaluate } from '@datadog/flagging-core' +import type { + EvaluationContext, + FlagValueType, + JsonValue, + Logger, + Paradigm, + Provider, + ProviderMetadata, + ResolutionDetails, +} from '@openfeature/web-sdk' +import { OpenFeatureEventEmitter, type ProviderEventEmitter, ProviderEvents } from '@openfeature/web-sdk' + +export interface CoreProviderOptions { + configuration: FlagsConfiguration +} + +export class CoreProvider implements Provider { + readonly metadata: ProviderMetadata = { + name: 'datadog-core', + } + readonly runsOn: Paradigm = 'client' + readonly events: ProviderEventEmitter + + private flagsConfiguration: FlagsConfiguration + private context: EvaluationContext = {} + + constructor(options: CoreProviderOptions) { + this.events = new OpenFeatureEventEmitter() + this.flagsConfiguration = options.configuration + } + + getConfiguration(): FlagsConfiguration { + return this.flagsConfiguration + } + + setConfiguration(configuration: FlagsConfiguration): void { + const hadEvaluatableConfiguration = this.canEvaluateCurrentContext() + this.flagsConfiguration = configuration + + const error = getConfigurationError(configuration, this.context) + if (error) { + this.events.emit(ProviderEvents.Error, { error }) + return + } + + this.events.emit(hadEvaluatableConfiguration ? ProviderEvents.ConfigurationChanged : ProviderEvents.Ready) + } + + async initialize(context: EvaluationContext = {}): Promise { + this.context = context + const error = getConfigurationError(this.flagsConfiguration, this.context) + if (error) { + throw error + } + } + + onContextChange(_oldContext: EvaluationContext, newContext: EvaluationContext): void { + this.context = newContext + const error = getConfigurationError(this.flagsConfiguration, this.context) + if (error) { + throw error + } + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails { + return this.resolve('boolean', flagKey, defaultValue, context, logger) + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails { + return this.resolve('string', flagKey, defaultValue, context, logger) + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails { + return this.resolve('number', flagKey, defaultValue, context, logger) + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails { + return this.resolve('object', flagKey, defaultValue, context, logger) as ResolutionDetails + } + + private resolve( + type: T, + flagKey: string, + defaultValue: FlagTypeToValue, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails> { + return evaluate(this.flagsConfiguration, type, flagKey, defaultValue, context, logger) + } + + private canEvaluateCurrentContext(): boolean { + return !getConfigurationError(this.flagsConfiguration, this.context) + } +} + +function hasEvaluatableConfiguration(configuration: FlagsConfiguration): boolean { + return !!(configuration.precomputed || configuration.rulesBased) +} + +function getConfigurationError(configuration: FlagsConfiguration, context: EvaluationContext): Error | undefined { + if (!hasEvaluatableConfiguration(configuration)) { + return new Error('No flags configuration has been set') + } + + if (!configuration.rulesBased && configuration.precomputed && !configMatchesContext(configuration, context)) { + return new Error('Precomputed flags configuration does not match the current context') + } + + return undefined +} diff --git a/packages/browser/src/openfeature/provider.ts b/packages/browser/src/openfeature/provider.ts index 3483c464..e647860f 100644 --- a/packages/browser/src/openfeature/provider.ts +++ b/packages/browser/src/openfeature/provider.ts @@ -1,6 +1,13 @@ -import { type AssignmentCache, configMatchesContext, type FlagsConfiguration } from '@datadog/flagging-core' +import { + type AssignmentCache, + configMatchesContext, + evaluate, + type FlagsConfiguration, + type FlagTypeToValue, +} from '@datadog/flagging-core' import type { EvaluationContext, + FlagValueType, Hook, JsonValue, Logger, @@ -23,7 +30,6 @@ import { type FlaggingInitConfiguration, validateAndBuildFlaggingConfiguration, } from '../domain/configuration' -import { evaluate } from '../evaluation' import { createExposureLoggingHook } from './exposures' import { createFlagEvalEVPHook } from './flagEvaluations' import { createRumTrackingHook } from './rumIntegration' @@ -67,6 +73,7 @@ export class DatadogProvider implements Provider { status: ProviderStatus private flagsConfiguration: FlagsConfiguration = {} + private context: EvaluationContext = {} private flagsCache: IndexedDBFlagsCache | undefined private exposureCache: AssignmentCache | undefined @@ -184,6 +191,7 @@ export class DatadogProvider implements Provider { // scheduling). this.flagsConfiguration = config + this.context = context this.status = fromCache ? ProviderStatus.STALE : ProviderStatus.READY this.events.emit(ProviderEvents.ConfigurationChanged) @@ -271,7 +279,7 @@ export class DatadogProvider implements Provider { context: EvaluationContext, _logger: Logger ): ResolutionDetails { - return evaluate(this.flagsConfiguration, 'boolean', flagKey, defaultValue, context) + return this.resolve('boolean', flagKey, defaultValue, context, _logger) } resolveStringEvaluation( @@ -280,7 +288,7 @@ export class DatadogProvider implements Provider { context: EvaluationContext, _logger: Logger ): ResolutionDetails { - return evaluate(this.flagsConfiguration, 'string', flagKey, defaultValue, context) + return this.resolve('string', flagKey, defaultValue, context, _logger) } resolveNumberEvaluation( @@ -289,7 +297,7 @@ export class DatadogProvider implements Provider { context: EvaluationContext, _logger: Logger ): ResolutionDetails { - return evaluate(this.flagsConfiguration, 'number', flagKey, defaultValue, context) + return this.resolve('number', flagKey, defaultValue, context, _logger) } resolveObjectEvaluation( @@ -304,6 +312,30 @@ export class DatadogProvider implements Provider { // type-sound way because there's no runtime information passed to // learn what type the user expects. So it's up to the user to // make sure they pass the appropriate type. - return evaluate(this.flagsConfiguration, 'object', flagKey, defaultValue, context) as ResolutionDetails + return this.resolve('object', flagKey, defaultValue, context, _logger) as ResolutionDetails + } + + private resolve( + type: T, + flagKey: string, + defaultValue: FlagTypeToValue, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails> { + if (!this.flagsConfiguration.precomputed && !this.flagsConfiguration.rulesBased) { + return { + value: defaultValue, + reason: 'DEFAULT', + } + } + + return evaluate(this.flagsConfiguration, type, flagKey, defaultValue, this.getEvaluationContext(context), logger) + } + + private getEvaluationContext(context: EvaluationContext): EvaluationContext { + if (Object.keys(context).length > 0 || Object.keys(this.context).length === 0) { + return context + } + return this.context } } diff --git a/packages/browser/test/data/precomputed-v1-wire.json b/packages/browser/test/data/precomputed-v1-wire.json index 39e82a21..a4c10f49 100644 --- a/packages/browser/test/data/precomputed-v1-wire.json +++ b/packages/browser/test/data/precomputed-v1-wire.json @@ -11,6 +11,6 @@ "lifetimeValue": 543.21 }, "fetchedAt": 1731939819456, - "response": "{\"data\":{\"id\":\"test_subject\",\"type\":\"precomputed-assignments\",\"attributes\":{\"createdAt\":1731939805123,\"environment\":{\"name\":\"prod\"},\"flags\":{\"string-flag\":{\"allocationKey\":\"allocation-123\",\"variationKey\":\"variation-123\",\"variationType\":\"STRING\",\"variationValue\":\"red\",\"extraLogging\":{\"experiment\":true},\"doLog\":true,\"reason\":\"TARGETING_MATCH\"},\"boolean-flag\":{\"allocationKey\":\"allocation-124\",\"variationKey\":\"variation-124\",\"variationType\":\"BOOLEAN\",\"variationValue\":true,\"extraLogging\":{\"experiment\":true},\"doLog\":true,\"reason\":\"TARGETING_MATCH\"},\"integer-flag\":{\"allocationKey\":\"allocation-125\",\"variationKey\":\"variation-125\",\"variationType\":\"NUMBER\",\"variationValue\":42,\"extraLogging\":{\"experiment\":true},\"doLog\":true,\"reason\":\"TARGETING_MATCH\"},\"numeric-flag\":{\"allocationKey\":\"allocation-126\",\"variationKey\":\"variation-126\",\"variationType\":\"NUMBER\",\"variationValue\":3.14,\"extraLogging\":{\"experiment\":true},\"doLog\":true,\"reason\":\"TARGETING_MATCH\"},\"json-flag\":{\"allocationKey\":\"allocation-127\",\"variationKey\":\"variation-127\",\"variationType\":\"OBJECT\",\"variationValue\":{\"key\":\"value\",\"prop\":123},\"extraLogging\":{\"experiment\":true},\"doLog\":true,\"reason\":\"TARGETING_MATCH\"}}}}}" + "response": "{\"data\":{\"id\":\"test_subject\",\"type\":\"precomputed-assignments\",\"attributes\":{\"createdAt\":1731939805123,\"environment\":{\"name\":\"prod\"},\"flags\":{\"string-flag\":{\"allocationKey\":\"allocation-123\",\"variationKey\":\"variation-123\",\"variationType\":\"STRING\",\"variationValue\":\"red\",\"extraLogging\":{\"experiment\":\"true\"},\"doLog\":true,\"reason\":\"TARGETING_MATCH\"},\"boolean-flag\":{\"allocationKey\":\"allocation-124\",\"variationKey\":\"variation-124\",\"variationType\":\"BOOLEAN\",\"variationValue\":true,\"extraLogging\":{\"experiment\":\"true\"},\"doLog\":true,\"reason\":\"TARGETING_MATCH\"},\"integer-flag\":{\"allocationKey\":\"allocation-125\",\"variationKey\":\"variation-125\",\"variationType\":\"NUMBER\",\"variationValue\":42,\"extraLogging\":{\"experiment\":\"true\"},\"doLog\":true,\"reason\":\"TARGETING_MATCH\"},\"numeric-flag\":{\"allocationKey\":\"allocation-126\",\"variationKey\":\"variation-126\",\"variationType\":\"NUMBER\",\"variationValue\":3.14,\"extraLogging\":{\"experiment\":\"true\"},\"doLog\":true,\"reason\":\"TARGETING_MATCH\"},\"json-flag\":{\"allocationKey\":\"allocation-127\",\"variationKey\":\"variation-127\",\"variationType\":\"OBJECT\",\"variationValue\":{\"key\":\"value\",\"prop\":123},\"extraLogging\":{\"experiment\":\"true\"},\"doLog\":true,\"reason\":\"TARGETING_MATCH\"}}}}}" } } diff --git a/packages/browser/test/data/precomputed-v1.json b/packages/browser/test/data/precomputed-v1.json index 8d09af81..0969928f 100644 --- a/packages/browser/test/data/precomputed-v1.json +++ b/packages/browser/test/data/precomputed-v1.json @@ -14,7 +14,7 @@ "variationType": "STRING", "variationValue": "red", "extraLogging": { - "experiment": true + "experiment": "true" }, "doLog": true, "reason": "TARGETING_MATCH" @@ -25,7 +25,7 @@ "variationType": "BOOLEAN", "variationValue": true, "extraLogging": { - "experiment": true + "experiment": "true" }, "doLog": true, "reason": "TARGETING_MATCH" @@ -36,7 +36,7 @@ "variationType": "NUMBER", "variationValue": 42, "extraLogging": { - "experiment": true + "experiment": "true" }, "doLog": true, "reason": "TARGETING_MATCH" @@ -47,7 +47,7 @@ "variationType": "NUMBER", "variationValue": 3.14, "extraLogging": { - "experiment": true + "experiment": "true" }, "doLog": true, "reason": "TARGETING_MATCH" @@ -58,7 +58,7 @@ "variationType": "OBJECT", "variationValue": { "key": "value", "prop": 123 }, "extraLogging": { - "experiment": true + "experiment": "true" }, "doLog": true, "reason": "TARGETING_MATCH" diff --git a/packages/browser/test/evaluation.spec.ts b/packages/browser/test/evaluation.spec.ts index 4f640249..3fd895d0 100644 --- a/packages/browser/test/evaluation.spec.ts +++ b/packages/browser/test/evaluation.spec.ts @@ -1,24 +1,76 @@ -import { configurationFromString } from '@datadog/flagging-core' +import { configurationFromString, evaluate, type FlagsConfiguration, OperatorType } from '@datadog/flagging-core' import type { ErrorCode } from '@openfeature/web-sdk' -import { evaluate } from '../src/evaluation' import configurationWire from './data/precomputed-v1-wire.json' const configuration = configurationFromString( // Adding stringify because import has parsed JSON JSON.stringify(configurationWire) ) +const matchingContext = configuration.precomputed?.context ?? {} + +const rulesBasedConfiguration: FlagsConfiguration = { + rulesBased: { + response: { + createdAt: '2026-07-06T23:01:56.822Z', + format: 'SERVER', + environment: { + name: 'prod', + }, + flags: { + 'dynamic-flag': { + key: 'dynamic-flag', + enabled: true, + variationType: 'STRING', + variations: { + enterprise: { key: 'enterprise', value: 'enabled' }, + fallback: { key: 'fallback', value: 'disabled' }, + }, + allocations: [ + { + key: 'enterprise-allocation', + rules: [ + { + conditions: [{ operator: OperatorType.ONE_OF, attribute: 'plan', value: ['enterprise'] }], + }, + ], + doLog: true, + splits: [ + { + variationKey: 'enterprise', + extraLogging: { experiment: 'dynamic-context' }, + shards: [{ salt: 'salt', ranges: [{ start: 0, end: 10000 }], totalShards: 10000 }], + }, + ], + }, + { + key: 'fallback-allocation', + doLog: false, + splits: [ + { + variationKey: 'fallback', + shards: [{ salt: 'salt', ranges: [{ start: 0, end: 10000 }], totalShards: 10000 }], + }, + ], + }, + ], + }, + }, + }, + }, +} describe('evaluate', () => { it('returns default for missing configuration', () => { const result = evaluate({}, 'boolean', 'boolean-flag', true, {}) expect(result).toEqual({ value: true, - reason: 'DEFAULT', + reason: 'ERROR', + errorCode: 'PROVIDER_NOT_READY' as ErrorCode, }) }) it('returns default for unknown flag', () => { - const result = evaluate(configuration, 'string', 'unknown-flag', 'default', {}) + const result = evaluate(configuration, 'string', 'unknown-flag', 'default', matchingContext) expect(result).toEqual({ value: 'default', reason: 'ERROR', @@ -27,7 +79,7 @@ describe('evaluate', () => { }) it('returns default without variant metadata for type mismatch', () => { - const result = evaluate(configuration, 'string', 'boolean-flag', 'default', {}) + const result = evaluate(configuration, 'string', 'boolean-flag', 'default', matchingContext) expect(result).toEqual({ value: 'default', reason: 'ERROR', @@ -38,7 +90,7 @@ describe('evaluate', () => { }) it('resolves boolean flag', () => { - const result = evaluate(configuration, 'boolean', 'boolean-flag', true, {}) + const result = evaluate(configuration, 'boolean', 'boolean-flag', true, matchingContext) expect(result).toEqual({ value: true, variant: 'variation-124', @@ -52,7 +104,7 @@ describe('evaluate', () => { }) it('resolves string flag', () => { - const result = evaluate(configuration, 'string', 'string-flag', 'default', {}) + const result = evaluate(configuration, 'string', 'string-flag', 'default', matchingContext) expect(result).toEqual({ value: 'red', variant: 'variation-123', @@ -66,7 +118,7 @@ describe('evaluate', () => { }) it('resolves object flag', () => { - const result = evaluate<'object'>(configuration, 'object', 'json-flag', { hello: 'world' }, {}) + const result = evaluate<'object'>(configuration, 'object', 'json-flag', { hello: 'world' }, matchingContext) expect(result).toEqual({ value: { key: 'value', prop: 123 }, variant: 'variation-127', @@ -78,4 +130,50 @@ describe('evaluate', () => { }, }) }) + + it('does not use precomputed configuration when context does not match', () => { + const result = evaluate(configuration, 'string', 'string-flag', 'default', { targetingKey: 'other-user' }) + + expect(result).toEqual({ + value: 'default', + reason: 'ERROR', + errorCode: 'INVALID_CONTEXT' as ErrorCode, + }) + }) + + it('evaluates rules-based configuration when present', () => { + const result = evaluate(rulesBasedConfiguration, 'string', 'dynamic-flag', 'default', { + targetingKey: 'user-1', + plan: 'enterprise', + }) + + expect(result).toMatchObject({ + value: 'enabled', + variant: 'enterprise', + reason: 'TARGETING_MATCH', + flagMetadata: { + allocationKey: 'enterprise-allocation', + doLog: true, + }, + }) + }) + + it('falls back to rules-based configuration when precomputed context does not match', () => { + const result = evaluate( + { + ...rulesBasedConfiguration, + precomputed: configuration.precomputed, + }, + 'string', + 'dynamic-flag', + 'default', + { targetingKey: 'other-user', plan: 'enterprise' } + ) + + expect(result).toMatchObject({ + value: 'enabled', + variant: 'enterprise', + reason: 'TARGETING_MATCH', + }) + }) }) diff --git a/packages/browser/test/openfeature/core-provider.spec.ts b/packages/browser/test/openfeature/core-provider.spec.ts new file mode 100644 index 00000000..a22a1b4c --- /dev/null +++ b/packages/browser/test/openfeature/core-provider.spec.ts @@ -0,0 +1,236 @@ +import { type FlagsConfiguration, OperatorType } from '@datadog/flagging-core' +import type { Logger } from '@openfeature/core' +import { ProviderEvents } from '@openfeature/web-sdk' +import { CoreProvider } from '../../src/openfeature/core-provider' + +const logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +} + +const rulesBasedConfiguration: FlagsConfiguration = { + rulesBased: { + response: { + createdAt: '2026-07-06T23:01:56.822Z', + format: 'SERVER', + environment: { + name: 'prod', + }, + flags: { + 'dynamic-flag': { + key: 'dynamic-flag', + enabled: true, + variationType: 'STRING', + variations: { + enterprise: { key: 'enterprise', value: 'enabled' }, + fallback: { key: 'fallback', value: 'disabled' }, + }, + allocations: [ + { + key: 'enterprise-allocation', + rules: [ + { + conditions: [{ operator: OperatorType.ONE_OF, attribute: 'plan', value: ['enterprise'] }], + }, + ], + doLog: true, + splits: [ + { + variationKey: 'enterprise', + extraLogging: { experiment: 'dynamic-context' }, + shards: [{ salt: 'salt', ranges: [{ start: 0, end: 10000 }], totalShards: 10000 }], + }, + ], + }, + { + key: 'fallback-allocation', + doLog: false, + splits: [ + { + variationKey: 'fallback', + shards: [{ salt: 'salt', ranges: [{ start: 0, end: 10000 }], totalShards: 10000 }], + }, + ], + }, + ], + }, + }, + }, + etag: 'rules-etag', + }, +} + +const precomputedConfiguration: FlagsConfiguration = { + precomputed: { + context: { targetingKey: 'static-user', plan: 'free' }, + response: { + data: { + attributes: { + createdAt: '2026-07-06T23:01:56.822Z', + flags: { + 'static-flag': { + allocationKey: 'static-allocation', + variationKey: 'static-variation', + variationType: 'string', + variationValue: 'static-value', + reason: 'TARGETING_MATCH', + doLog: true, + extraLogging: { experiment: 'static-context' }, + }, + }, + }, + }, + }, + }, +} + +describe('CoreProvider', () => { + it('has browser provider metadata', () => { + const provider = new CoreProvider({ configuration: rulesBasedConfiguration }) + + expect(provider.metadata).toEqual({ name: 'datadog-core' }) + expect(provider.runsOn).toBe('client') + }) + + it('evaluates rules locally with the supplied context', async () => { + const provider = new CoreProvider({ configuration: rulesBasedConfiguration }) + + await provider.initialize({ targetingKey: 'user-1', plan: 'free' }) + + expect( + provider.resolveStringEvaluation('dynamic-flag', 'default', { targetingKey: 'user-1', plan: 'free' }, logger) + ).toMatchObject({ + value: 'disabled', + variant: 'fallback', + reason: 'TARGETING_MATCH', + }) + + await provider.onContextChange( + { targetingKey: 'user-1', plan: 'free' }, + { targetingKey: 'user-1', plan: 'enterprise' } + ) + + expect( + provider.resolveStringEvaluation( + 'dynamic-flag', + 'default', + { targetingKey: 'user-1', plan: 'enterprise' }, + logger + ) + ).toMatchObject({ + value: 'enabled', + variant: 'enterprise', + reason: 'TARGETING_MATCH', + flagMetadata: { + allocationKey: 'enterprise-allocation', + doLog: true, + }, + }) + }) + + it('uses precomputed configuration only when the context matches', async () => { + const provider = new CoreProvider({ configuration: precomputedConfiguration }) + + await provider.initialize({ targetingKey: 'static-user', plan: 'free' }) + + expect( + provider.resolveStringEvaluation('static-flag', 'default', { targetingKey: 'static-user', plan: 'free' }, logger) + ).toMatchObject({ + value: 'static-value', + variant: 'static-variation', + reason: 'TARGETING_MATCH', + }) + }) + + it('throws for precomputed-only context changes that do not match the configuration', async () => { + const provider = new CoreProvider({ configuration: precomputedConfiguration }) + + await provider.initialize({ targetingKey: 'static-user', plan: 'free' }) + + expect(() => + provider.onContextChange( + { targetingKey: 'static-user', plan: 'free' }, + { targetingKey: 'other-user', plan: 'free' } + ) + ).toThrow('Precomputed flags configuration does not match the current context') + + expect( + provider.resolveStringEvaluation('static-flag', 'default', { targetingKey: 'other-user', plan: 'free' }, logger) + ).toEqual({ + value: 'default', + reason: 'ERROR', + errorCode: 'INVALID_CONTEXT', + }) + }) + + it('uses rules-based configuration when precomputed context does not match', async () => { + const provider = new CoreProvider({ + configuration: { + ...rulesBasedConfiguration, + precomputed: precomputedConfiguration.precomputed, + }, + }) + + await provider.initialize({ targetingKey: 'other-user', plan: 'enterprise' }) + + expect( + provider.resolveStringEvaluation( + 'dynamic-flag', + 'default', + { targetingKey: 'other-user', plan: 'enterprise' }, + logger + ) + ).toMatchObject({ + value: 'enabled', + variant: 'enterprise', + reason: 'TARGETING_MATCH', + }) + }) + + it('emits Ready when setConfiguration recovers from an invalid configuration', () => { + const provider = new CoreProvider({ configuration: {} }) + const readyHandler = jest.fn() + provider.events.addHandler(ProviderEvents.Ready, readyHandler) + + provider.setConfiguration(rulesBasedConfiguration) + + expect(readyHandler).toHaveBeenCalledTimes(1) + }) + + it('emits ConfigurationChanged for replacement configuration', () => { + const provider = new CoreProvider({ configuration: rulesBasedConfiguration }) + const changedHandler = jest.fn() + provider.events.addHandler(ProviderEvents.ConfigurationChanged, changedHandler) + + provider.setConfiguration({ + rulesBased: { + ...rulesBasedConfiguration.rulesBased!, + etag: 'new-rules-etag', + }, + }) + + expect(changedHandler).toHaveBeenCalledTimes(1) + }) + + it('emits Error when setConfiguration receives an invalid configuration', () => { + const provider = new CoreProvider({ configuration: rulesBasedConfiguration }) + const errorHandler = jest.fn() + provider.events.addHandler(ProviderEvents.Error, errorHandler) + + provider.setConfiguration({}) + + expect(errorHandler).toHaveBeenCalledTimes(1) + }) + + it('returns provider not ready when no evaluatable configuration is available', () => { + const provider = new CoreProvider({ configuration: {} }) + + expect(provider.resolveBooleanEvaluation('missing-flag', true, {}, logger)).toEqual({ + value: true, + reason: 'ERROR', + errorCode: 'PROVIDER_NOT_READY', + }) + }) +}) diff --git a/packages/browser/test/openfeature/provider.spec.ts b/packages/browser/test/openfeature/provider.spec.ts index 2c881dcb..f353bd06 100644 --- a/packages/browser/test/openfeature/provider.spec.ts +++ b/packages/browser/test/openfeature/provider.spec.ts @@ -32,7 +32,6 @@ describe('DatadogProvider', () => { describe('configuration validation', () => { beforeEach(() => { setupProvider() - OpenFeature.setProvider(provider) }) it('should throw error when ddog-gov.com site is provided', () => { @@ -138,7 +137,6 @@ describe('DatadogProvider', () => { describe('metadata', () => { beforeEach(() => { setupProvider() - OpenFeature.setProvider(provider) }) it('should have correct metadata', () => { @@ -328,7 +326,7 @@ describe('DatadogProvider', () => { variationKey: 'variation-123', variationType: 'STRING', variationValue: stringFlagValue, - extraLogging: { experiment: true }, + extraLogging: { experiment: 'true' }, doLog: true, reason: 'TARGETING_MATCH', }, diff --git a/packages/core/src/configuration/configuration.ts b/packages/core/src/configuration/configuration.ts index 47fb6a81..68a3a1e1 100644 --- a/packages/core/src/configuration/configuration.ts +++ b/packages/core/src/configuration/configuration.ts @@ -1,5 +1,6 @@ import type { TimeStamp } from '@datadog/js-core/time' import type { EvaluationContext, FlagValueType, JsonValue, ResolutionReason } from '@openfeature/core' +import type { UniversalFlagConfigurationV1 } from '../evaluation' /** * Internal flags configuration for DatadogProvider. @@ -7,6 +8,8 @@ import type { EvaluationContext, FlagValueType, JsonValue, ResolutionReason } fr export type FlagsConfiguration = { /** @internal */ precomputed?: PrecomputedConfiguration + /** @internal */ + rulesBased?: RulesBasedConfiguration } /** @internal */ @@ -14,6 +17,14 @@ export type PrecomputedConfiguration = { response: PrecomputedConfigurationResponse context?: EvaluationContext fetchedAt?: TimeStamp + etag?: string +} + +/** @internal */ +export type RulesBasedConfiguration = { + response: UniversalFlagConfigurationV1 + fetchedAt?: TimeStamp + etag?: string } // Fancy way to map FlagValueType to expected FlagValue. @@ -44,7 +55,7 @@ export type PrecomputedFlag = { variationValue: FlagTypeToValue reason: ResolutionReason doLog: boolean - extraLogging: Record + extraLogging: Record } /** @internal */ diff --git a/packages/core/src/configuration/wire.test.ts b/packages/core/src/configuration/wire.test.ts index 6e92e062..57b577de 100644 --- a/packages/core/src/configuration/wire.test.ts +++ b/packages/core/src/configuration/wire.test.ts @@ -1,3 +1,4 @@ +import type { TimeStamp } from '@datadog/js-core/time' import type { FlagsConfiguration } from './configuration' import { configurationFromString, configurationToString } from './wire' @@ -25,6 +26,51 @@ const configuration: FlagsConfiguration = { }, } +const rulesBasedConfiguration: FlagsConfiguration = { + rulesBased: { + response: { + createdAt: '2026-07-06T23:01:56.822Z', + format: 'SERVER', + environment: { + name: 'prod', + }, + flags: { + 'my-flag': { + key: 'my-flag', + enabled: true, + variationType: 'BOOLEAN', + variations: { + true: { + key: 'true', + value: true, + }, + }, + allocations: [ + { + key: 'alloc-1', + splits: [ + { + variationKey: 'true', + shards: [ + { + salt: 'salt', + ranges: [{ start: 0, end: 10000 }], + totalShards: 10000, + }, + ], + }, + ], + doLog: true, + }, + ], + }, + }, + }, + fetchedAt: 1731939819456 as TimeStamp, + etag: 'rules-etag', + }, +} + describe('configuration wire', () => { it('round-trips a precomputed configuration', () => { const restored = configurationFromString(configurationToString(configuration)) @@ -32,12 +78,24 @@ describe('configuration wire', () => { expect(restored).toEqual(configuration) }) + it('round-trips a rules-based configuration', () => { + const restored = configurationFromString(configurationToString(rulesBasedConfiguration)) + + expect(restored).toEqual(rulesBasedConfiguration) + }) + 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) }) + it('keeps rules-based flags readable after a round-trip', () => { + const restored = configurationFromString(configurationToString(rulesBasedConfiguration)) + + expect(restored.rulesBased?.response.flags['my-flag'].variations.true.value).toBe(true) + }) + it('serializes precomputed.response as a stringified response object, not the whole precomputed', () => { const wire = JSON.parse(configurationToString(configuration)) @@ -47,6 +105,12 @@ describe('configuration wire', () => { expect(JSON.parse(wire.precomputed.response)).toEqual(configuration.precomputed?.response) }) + it('serializes rulesBased.response as a stringified response object, not the whole rulesBased config', () => { + const wire = JSON.parse(configurationToString(rulesBasedConfiguration)) + + expect(JSON.parse(wire.rulesBased.response)).toEqual(rulesBasedConfiguration.rulesBased?.response) + }) + it('returns an empty configuration for an unknown version', () => { expect(configurationFromString(JSON.stringify({ version: 2 }))).toEqual({}) }) diff --git a/packages/core/src/configuration/wire.ts b/packages/core/src/configuration/wire.ts index 615f8922..ff8b3d66 100644 --- a/packages/core/src/configuration/wire.ts +++ b/packages/core/src/configuration/wire.ts @@ -8,6 +8,12 @@ type ConfigurationWire = { context?: EvaluationContext response: string fetchedAt?: TimeStamp + etag?: string + } + rulesBased?: { + response: string + fetchedAt?: TimeStamp + etag?: string } } @@ -30,6 +36,12 @@ export function configurationFromString(s: string): FlagsConfiguration { response: JSON.parse(wire.precomputed.response), } } + if (wire.rulesBased) { + configuration.rulesBased = { + ...wire.rulesBased, + response: JSON.parse(wire.rulesBased.response), + } + } return configuration } catch { @@ -53,6 +65,12 @@ export function configurationToString(configuration: FlagsConfiguration): string response: JSON.stringify(configuration.precomputed.response), } } + if (configuration.rulesBased) { + wire.rulesBased = { + ...configuration.rulesBased, + response: JSON.stringify(configuration.rulesBased.response), + } + } return JSON.stringify(wire) } diff --git a/packages/core/src/evaluation/evaluation.ts b/packages/core/src/evaluation/evaluation.ts index ff8e8be9..49e45c21 100644 --- a/packages/core/src/evaluation/evaluation.ts +++ b/packages/core/src/evaluation/evaluation.ts @@ -1,11 +1,62 @@ import { timeStampNow } from '@datadog/js-core/time' import type { ErrorCode, EvaluationContext, FlagValueType, Logger, ResolutionDetails } from '@openfeature/core' -import type { FlagTypeToValue } from '../configuration' +import { + configMatchesContext, + type FlagsConfiguration, + type FlagTypeToValue, + type PrecomputedConfiguration, + type PrecomputedFlagMetadata, +} from '../configuration' import { TargetingKeyMissingError } from './errors' import { evaluateForSubject } from './evaluateForSubject' import { createEvaluationTimestampMetadata } from './evaluationMetadata' import type { UniversalFlagConfigurationV1 } from './ufc-v1' +const NOOP_LOGGER: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +} + +export function evaluate( + flagsConfiguration: FlagsConfiguration, + type: T, + flagKey: string, + defaultValue: FlagTypeToValue, + context: EvaluationContext, + logger: Logger = NOOP_LOGGER +): ResolutionDetails> { + if (flagsConfiguration.precomputed && configMatchesContext(flagsConfiguration, context)) { + return evaluatePrecomputed(flagsConfiguration.precomputed, type, flagKey, defaultValue) + } + + if (flagsConfiguration.rulesBased) { + return evaluateRulesBasedConfiguration( + flagsConfiguration.rulesBased.response, + type, + flagKey, + defaultValue, + context, + logger + ) + } + + if (flagsConfiguration.precomputed) { + return { + value: defaultValue, + reason: 'ERROR', + errorCode: 'INVALID_CONTEXT' as ErrorCode, + } + } + + return { + value: defaultValue, + reason: 'ERROR', + errorCode: 'PROVIDER_NOT_READY' as ErrorCode, + } +} + export function evaluateRulesBasedConfiguration( config: UniversalFlagConfigurationV1 | undefined, type: T, @@ -63,3 +114,57 @@ export function evaluateRulesBasedConfiguration( } } } + +function evaluatePrecomputed( + precomputed: PrecomputedConfiguration, + type: T, + flagKey: string, + defaultValue: FlagTypeToValue +): ResolutionDetails> { + const flag = precomputed.response.data.attributes.flags[flagKey] + if (!flag) { + return { + value: defaultValue, + reason: 'ERROR', + errorCode: 'FLAG_NOT_FOUND' as ErrorCode, + } + } + + if (flag.variationType && variationTypeToOpenFeature(flag.variationType) !== type) { + return { + value: defaultValue, + reason: 'ERROR', + errorCode: 'TYPE_MISMATCH' as ErrorCode, + } + } + + return { + value: flag.variationValue as FlagTypeToValue, + variant: flag.variationKey, + flagMetadata: { + allocationKey: flag.allocationKey, + variationType: flag.variationType, + doLog: flag.doLog, + } as PrecomputedFlagMetadata, + reason: flag.reason, + } as ResolutionDetails> +} + +function variationTypeToOpenFeature(s: string): FlagValueType { + const typeMap: Record = { + string: 'string', + boolean: 'boolean', + number: 'number', + integer: 'number', + float: 'number', + object: 'object', + + BOOLEAN: 'boolean', + STRING: 'string', + NUMERIC: 'number', + INTEGER: 'number', + JSON: 'object', + } + + return typeMap[s] || s.toLowerCase() +} diff --git a/packages/core/test/evaluation/evaluateForSubject.spec.ts b/packages/core/test/evaluation/evaluateForSubject.spec.ts index bc3d1484..872671f3 100644 --- a/packages/core/test/evaluation/evaluateForSubject.spec.ts +++ b/packages/core/test/evaluation/evaluateForSubject.spec.ts @@ -151,6 +151,7 @@ describe('evaluateForSubject', () => { { variationKey: 'treatment', serialId, + extraLogging: { campaign: 'summer' }, shards: [ { salt: 'experiment-salt', @@ -177,6 +178,7 @@ describe('evaluateForSubject', () => { allocationKey: 'experiment-allocation', doLog: true, }) + expect(result.flagMetadata).not.toHaveProperty('extraLogging') }) })