Skip to content

✨ FFL-16 Precomputed flags evaluation #3580

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
044fd8c
✨ [FFL-24] add openfeature dependency and datadog provider
leoromanovsky May 22, 2025
59e15cf
remove openfeature/core
leoromanovsky May 27, 2025
ad0a754
refactor: move no-restricted-syntax closer to use
rasendubi May 26, 2025
975d58d
chore: mark @openfeature/core as side-effect free
rasendubi May 26, 2025
c8b780a
chore: add a comment on why classes are not allowed
rasendubi May 26, 2025
bab9a98
refactor: only depend on @openfeature/web-sdk
rasendubi May 26, 2025
143c64a
chore: properly use peer dependencies
rasendubi May 26, 2025
1044dc1
feat: precomputed configuration evaluation
rasendubi May 27, 2025
4d6c744
chore: remove duplicate dependency from LICENSE-3rdparty file
rasendubi May 28, 2025
6c7ab1b
doc: fix example key -> targetingKey
rasendubi May 28, 2025
cf67794
feat: expose API for initializing with precomputed configuration
rasendubi May 29, 2025
43c2239
feat: add configuration fetching
rasendubi May 29, 2025
18c4398
chore: update lock file
rasendubi May 29, 2025
9d77f2d
chore: revert change to check-licenses.js
rasendubi May 29, 2025
3af203f
docs: add example integration with RUM
rasendubi May 29, 2025
fe6b7e4
comply with JSON:API
rasendubi May 29, 2025
361d4f2
feat: use staging as default base url
rasendubi May 29, 2025
34a446e
fix linter errors
rasendubi May 29, 2025
143dcaf
chore: rename browser-flagging to openfeature-provider
rasendubi May 29, 2025
013e074
polyfill getGlobalObject
leoromanovsky May 29, 2025
e5103f7
webpack polyfill
leoromanovsky May 29, 2025
e855293
lint
leoromanovsky May 29, 2025
9a562e8
disable side effect
leoromanovsky May 29, 2025
a58b867
✅ change 'globalThis' to 'window' in @openfeature/web-sdk
BenoitZugmeyer May 30, 2025
9437999
Merge branch 'main' into rasendubi/ffe-openfeature
leoromanovsky Jun 3, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion LICENSE-3rdparty.csv
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ prod,react,MIT,Copyright (c) Facebook, Inc. and its affiliates.
prod,react-dom,MIT,Copyright (c) Facebook, Inc. and its affiliates.
dev,@eslint/js,MIT,Copyright OpenJS Foundation and other contributors, <www.openjsf.org>
dev,@jsdevtools/coverage-istanbul-loader,MIT,Copyright (c) 2015 James Messinger
dev,@openfeature/core,Apache-2.0,Copyright Linux Foundation
dev,@openfeature/web-sdk,Apache-2.0,Copyright Linux Foundation
dev,@playwright/test,Apache-2.0,Copyright Microsoft Corporation
dev,@types/chrome,MIT,Copyright Microsoft Corporation
dev,@types/connect-busboy,MIT,Copyright Microsoft Corporation
Expand Down Expand Up @@ -72,4 +74,4 @@ dev,webpack,MIT,Copyright JS Foundation and other contributors
dev,webpack-cli,MIT,Copyright JS Foundation and other contributors
dev,webpack-dev-middleware,MIT,Copyright JS Foundation and other contributors
dev,@swc/core,Apache-2.0,Copyright (c) SWC Contributors
dev,swc-loader,MIT,Copyright (c) SWC Contributors
dev,swc-loader,MIT,Copyright (c) SWC Contributors
4 changes: 4 additions & 0 deletions eslint-local-rules/disallowSideEffects.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const pathsWithSideEffect = new Set([
const packagesWithoutSideEffect = new Set([
'@datadog/browser-core',
'@datadog/browser-rum-core',
// @openfeature/core is mostly type definitions and enums. I have
// reviewed the whole library source code as of 2025-05-26 and it
// has no side effects.
'@openfeature/core',
'react',
'react-router-dom',
])
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,8 @@ export default tseslint.config(
'no-restricted-syntax': [
'error',
{
// Using classes seems to increase bundle size. See
// https://github.com/DataDog/browser-sdk/pull/2885
selector: 'ClassDeclaration',
message: 'Classes are not allowed. Use functions instead.',
},
Expand Down
47 changes: 47 additions & 0 deletions packages/flagging/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,50 @@
# Flagging SDK (Prerelease)

This package supports flagging and experimentation by performing evaluation in the browser.

## Initialize

```typescript
import { DatadogProvider } from '@datadog/openfeature-provider'

const datadogFlaggingProvider = new DatadogProvider()

// provide the subject
const subject = {
targetingKey: 'subject-key-1',
}
await OpenFeature.setContext(subject)

// initialize
await OpenFeature.setProviderAndWait(datadogFlaggingProvider)
```

## Evaluation

```typescript
const client = OpenFeature.getClient()

// provide the flag key and a default value which is returned for exceptional conditions.
const flagEval = client.getBooleanValue('<FLAG_KEY>', false)
```

## Integration with RUM feature flag tracking

```typescript
// Initialize RUM with experimental feature flags tracking
import { datadogRum } from '@datadog/browser-rum';

// Initialize Datadog Browser SDK
datadogRum.init({
...
enableExperimentalFeatures: ["feature_flags"],
...
});

// Add OpenFeature hook
OpenFeature.addHooks({
after(_hookContext: HookContext, details: EvaluationDetails<FlagValue>) {
datadogRum.addFeatureFlagEvaluation(details.flagKey, details.value)
}
})
```
9 changes: 7 additions & 2 deletions packages/flagging/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@datadog/browser-flagging",
"name": "@datadog/openfeature-provider",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: (as discussed) it could be nice to rename the package folder as well

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My notes from the conversation was we left it without consensus but I am ok revisiting it as the SDK scope becomes more clear; there might be more than just the openfeature provided being exported; perhaps where we will land is a core flagging package (here or in a shared javascript repository with react native) and openfeature provider as a wrapper on it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me.

"version": "6.8.0",
"license": "Apache-2.0",
"private": true,
Expand All @@ -18,13 +18,18 @@
"@datadog/browser-core": "6.8.0"
},
"peerDependencies": {
"@datadog/browser-rum": "6.8.0"
"@datadog/browser-rum": "6.8.0",
"@openfeature/web-sdk": "^1.5.0"
},
"peerDependenciesMeta": {
"@datadog/browser-rum": {
"optional": true
}
},
"devDependencies": {
"@openfeature/core": "1.8.0",
"@openfeature/web-sdk": "1.5.0"
},
"repository": {
"type": "git",
"url": "https://github.com/DataDog/browser-sdk.git",
Expand Down
46 changes: 46 additions & 0 deletions packages/flagging/src/configuration/configuration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { EvaluationContext, FlagValueType, JsonValue, ResolutionDetails } from '@openfeature/web-sdk'
/**
* Internal configuration for DatadogProvider.
*/
export type Configuration = {
/** @internal */
precomputed?: PrecomputedConfiguration
}

/** @internal */
export type PrecomputedConfiguration = {
response: PrecomputedConfigurationResponse
context?: EvaluationContext
fetchedAt?: UnixTimestamp
}

// Fancy way to map FlagValueType to expected FlagValue.
/** @internal */
export type FlagTypeToValue<T extends FlagValueType> = {
['boolean']: boolean
['string']: string
['number']: number
['object']: JsonValue
}[T]

/** @internal
* Timestamp in milliseconds since Unix Epoch.
*/
export type UnixTimestamp = number

/** @internal */
export type PrecomputedConfigurationResponse = {
data: {
attributes: {
/** When configuration was generated. */
createdAt: number
flags: Record<string, PrecomputedFlag>
}
}
}

/** @internal */
export type PrecomputedFlag<T extends FlagValueType = FlagValueType> = {
type: T
resolution: ResolutionDetails<FlagTypeToValue<T>>
}
2 changes: 2 additions & 0 deletions packages/flagging/src/configuration/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type * from './configuration'
export * from './wire'
58 changes: 58 additions & 0 deletions packages/flagging/src/configuration/wire.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { EvaluationContext } from '@openfeature/web-sdk'

import type { Configuration, UnixTimestamp } from './configuration'

type ConfigurationWire = {
version: 2
precomputed?: {
context?: EvaluationContext
response: string
fetchedAt?: UnixTimestamp
}
}

/**
* Create configuration from a string created with `configurationToString`.
*/
export function configurationFromString(s: string): Configuration {
try {
const wire: ConfigurationWire = JSON.parse(s)

if (wire.version !== 2) {
// Unknown version
return {}
}

const configuration: Configuration = {}
if (wire.precomputed) {
configuration.precomputed = {
...wire.precomputed,
response: JSON.parse(wire.precomputed.response),
}
}

return configuration
} catch {
return {}
}
}

/**
* Serialize configuration to string that can be deserialized with
* `configurationFromString`. The serialized string format is
* unspecified.
*/
export function configurationToString(configuration: Configuration): string {
const wire: ConfigurationWire = {
version: 2,
}

if (configuration.precomputed) {
wire.precomputed = {
...configuration.precomputed,
response: JSON.stringify(configuration.precomputed),
}
}

return JSON.stringify(wire)
}
13 changes: 9 additions & 4 deletions packages/flagging/src/entries/main.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { defineGlobal, getGlobalObject } from '@datadog/browser-core'
import { flagging as importedFlagging } from '../hello'

export const datadogFlagging = importedFlagging
import { DatadogProvider } from '../openfeature/provider'

export { DatadogProvider }
export { configurationFromString, configurationToString } from '../configuration'

interface BrowserWindow extends Window {
DD_FLAGGING?: typeof datadogFlagging
DD_FLAGGING?: {
Provider: typeof DatadogProvider
}
}
defineGlobal(getGlobalObject<BrowserWindow>(), 'DD_FLAGGING', datadogFlagging)

defineGlobal(getGlobalObject<BrowserWindow>(), 'DD_FLAGGING', { Provider: DatadogProvider })
52 changes: 52 additions & 0 deletions packages/flagging/src/evaluation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import configurationWire from '../test/data/precomputed-v2-wire.json'

import { configurationFromString } from './configuration'
import { evaluate } from './evaluation'

const configuration = configurationFromString(
// Adding stringify because import has parsed JSON
JSON.stringify(configurationWire)
)

describe('evaluate', () => {
it('returns default for missing configuration', () => {
const result = evaluate({}, 'boolean', 'boolean-flag', true, {})
expect(result).toEqual({
value: true,
reason: 'DEFAULT',
})
})

it('returns default for unknown flag', () => {
const result = evaluate(configuration, 'string', 'unknown-flag', 'default', {})
expect(result).toEqual({
value: 'default',
reason: 'ERROR',
errorCode: 'FLAG_NOT_FOUND' as any,
})
})

it('resolves string flag', () => {
const result = evaluate(configuration, 'string', 'string-flag', 'default', {})
expect(result).toEqual({
value: 'red',
variant: 'variation-123',
flagMetadata: {
allocationKey: 'allocation-123',
experiment: true,
},
})
})

it('resolves object flag', () => {
const result = evaluate<any>(configuration, 'object', 'json-flag', { hello: 'world' }, {})
expect(result).toEqual({
value: { key: 'value', prop: 123 },
variant: 'variation-127',
flagMetadata: {
allocationKey: 'allocation-127',
experiment: true,
},
})
})
})
47 changes: 47 additions & 0 deletions packages/flagging/src/evaluation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { ErrorCode, EvaluationContext, FlagValueType, ResolutionDetails } from '@openfeature/web-sdk'

import type { Configuration, PrecomputedConfiguration, FlagTypeToValue } from './configuration'

export function evaluate<T extends FlagValueType>(
configuration: Configuration,
type: T,
flagKey: string,
defaultValue: FlagTypeToValue<T>,
context: EvaluationContext
): ResolutionDetails<FlagTypeToValue<T>> {
if (configuration.precomputed) {
return evaluatePrecomputed(configuration.precomputed, type, flagKey, defaultValue, context)
}

return {
value: defaultValue,
reason: 'DEFAULT',
}
}

function evaluatePrecomputed<T extends FlagValueType>(
precomputed: PrecomputedConfiguration,
type: T,
flagKey: string,
defaultValue: FlagTypeToValue<T>,
_context: EvaluationContext
): ResolutionDetails<FlagTypeToValue<T>> {
const flag = precomputed.response.data.attributes.flags[flagKey]
if (!flag) {
return {
value: defaultValue,
reason: 'ERROR',
errorCode: 'FLAG_NOT_FOUND' as ErrorCode,
}
}

if (flag.type !== type) {
return {
value: defaultValue,
reason: 'ERROR',
errorCode: 'TYPE_MISMATCH' as ErrorCode,
}
}

return flag.resolution as ResolutionDetails<FlagTypeToValue<T>>
}
7 changes: 0 additions & 7 deletions packages/flagging/src/hello.ts

This file was deleted.

Loading