From 752b828bdd784e9791212a954478de8c574bf765 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 16:49:24 -0400 Subject: [PATCH 01/64] docs: add plan for provider.setConfiguration offline init (FFL-2666) Adds provider_set_configuration.md describing the plan to expose a JS setConfiguration path so a customer-supplied precomputed ConfigurationWire loads and evaluates exactly like CDN-fetched assignments. Planning only, no implementation. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 123 ++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 provider_set_configuration.md diff --git a/provider_set_configuration.md b/provider_set_configuration.md new file mode 100644 index 000000000..15ed07994 --- /dev/null +++ b/provider_set_configuration.md @@ -0,0 +1,123 @@ +# `provider.setConfiguration` — Offline Init for React Native Feature Flags + +**Jira:** [FFL-2666](https://datadoghq.atlassian.net/browse/FFL-2666) +**Status:** Planning (no implementation yet) + +## Goal + +Let a customer who has fetched a flag **configuration themselves** load it into the +React Native SDK and have flag evaluation behave **exactly** as it does today when the +SDK fetches precomputed assignments from the edge CDN. The core operation is: + +``` +configurationFromString(wire) -> provider.setConfiguration(configuration) -> evaluate(...) +``` + +Scope for this task: **precomputed (static context)** configuration only. The API is +designed to leave room for a future **rules-based (UFC / dynamic)** branch without +reshaping it. + +## Background: how flags work today + +1. `DdFlags.enable(config)` → native `enable`. +2. `DdFlags.getClient(name)` → a JS `FlagsClient` (`packages/core/src/flags/FlagsClient.ts`). +3. `flagsClient.setEvaluationContext(ctx)` → native `setEvaluationContext(...)`. **Setting + the context is what triggers the CDN fetch** of precomputed assignments. Native + fetches + parses and returns a **serialized snapshot** (`Record`). +4. JS caches the snapshot in `flagsCache`. **All evaluation already happens in JS** against + that cache. +5. `trackEvaluation` (exposure / RUM logging) is a per-flag, stateless call back to native. + +So the native read path only does *fetch → parse → return a snapshot*, and native exposure +tracking reconstructs what it needs from the per-flag data JS passes it. This means offline +init can be done **entirely in JS with no native changes**: parse a supplied configuration +into the same `FlagCacheEntry` map and populate `flagsCache`. + +## Approach (decisions) + +- **Parse the `ConfigurationWire` string in JS**, populating the existing `flagsCache`. +- **Input** is a `ConfigurationWire` string, consumed via `configurationFromString(wire)`. +- **No Swift/Kotlin changes** — evaluation and per-flag exposure tracking already run off + JS-provided data. +- **Expose the API on both** the core `FlagsClient` and the `DatadogOpenFeatureProvider`. +- The customer still calls `setEvaluationContext` themselves; `setConfiguration` must + **verify the precomputed config matches the active context** (see below). + +## Wire format (confirmed) + +**ConfigurationWire v2** ([ConfigurationWire](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141725646/ConfigurationWire)): + +```ts +type ConfigurationWire = string // JSON-serialized +type Configuration = { + version: 2 + precomputed?: { + response: string // JSON-encoded PrecomputedConfiguration + context?: EvaluationContext // the context the assignments were computed for + fetchedAt?: number + etag?: string + } + // no `server`/rules branch defined yet — keep the type extensible for it +} +``` + +**Inner `precomputed.response` → PrecomputedConfiguration** +([PrecomputedConfiguration format](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141791092/PrecomputedConfiguration+format)): + +```ts +{ + id: string + data: { attributes: { + createdAt: number + expiresAt?: number + flags: Record + }} +} +``` + +Key facts that de-risk the JS approach: + +- **Obfuscation is not supported** in the DD precomputed format — parsing is plain + JSON → object mapping (no key hashing, no base64/salt decoding). +- `context` and the active context are both plain (`targetingKey` + attributes) — matching + is a normalized deep-equality. +- `PrecomputedFlag` → `FlagCacheEntry` is nearly 1:1 for the evaluation path. Two + tracking-only fields (`doLog`, `variationValue`; new format exposes + `flagMetadata.experiment`) need a mapping decision, confirmed with the flags backend team. + +## Context matching (order-independent) + +Customers may call `setConfiguration` and `setEvaluationContext` in either order. The +`FlagsClient` holds the **loaded configuration** (carrying its embedded `context`) and the +**active evaluation context** independently. The servable `flagsCache` is only populated +when the two **match**; the match is re-validated on **both** calls. On mismatch, values are +not served. + +## Work breakdown + +| Subtask | Summary | +| :------ | :------ | +| [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v2, validate, fail predictably; extensible for rules) | +| [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode `PrecomputedConfiguration` → `FlagCacheEntry` map (plain JSON) | +| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching | +| [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | +| [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | +| [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Tests (parse/round-trip, decode, context match/mismatch, events) | + +## Open items (non-blocking) + +- `doLog` / `variationValue` / `experiment` mapping for native exposure tracking (FFL-2687). +- Precomputed context-mismatch behavior: default value vs `PROVIDER_NOT_READY` vs error + (RFC open question). +- `setConfiguration` sync vs `Promise`-returning (JS-only work is synchronous, but a Promise + keeps parity with `setEvaluationContext` and forward-compat for rules). From 9fe8db000916bb63a30339ffd83df93657a8aa33 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 18:56:19 -0400 Subject: [PATCH 02/64] docs(plan): correct wire format to version 1 and add server branch ConfigurationWire is version 1 (per updated spec and shipped wire.ts), and the envelope now reserves a server/rules branch alongside precomputed. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 15ed07994..2e80404f0 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -45,19 +45,23 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. ## Wire format (confirmed) -**ConfigurationWire v2** ([ConfigurationWire](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141725646/ConfigurationWire)): +**ConfigurationWire v1** ([ConfigurationWire](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141725646/ConfigurationWire)): ```ts type ConfigurationWire = string // JSON-serialized type Configuration = { - version: 2 + version: 1 precomputed?: { response: string // JSON-encoded PrecomputedConfiguration context?: EvaluationContext // the context the assignments were computed for fetchedAt?: number etag?: string } - // no `server`/rules branch defined yet — keep the type extensible for it + server?: { // rules-based (UFC) config — out of scope for MVP + response: string // JSON-encoded server configuration + fetchedAt?: number + etag?: string + } } ``` @@ -107,7 +111,7 @@ not served. | Subtask | Summary | | :------ | :------ | -| [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v2, validate, fail predictably; extensible for rules) | +| [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1, validate, fail predictably; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode `PrecomputedConfiguration` → `FlagCacheEntry` map (plain JSON) | | [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | From a9af1e8d8c7f1dbe4d1503433c79d3729bd41563 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 18:56:49 -0400 Subject: [PATCH 03/64] docs(plan): make configurationFromString lenient, not throwing Parser returns an empty config on invalid input or unknown version, matching the shipped wire.ts. Predictable failure surfaces at the provider layer. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 2e80404f0..5d1b6d27a 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -95,6 +95,11 @@ Key facts that de-risk the JS approach: JSON → object mapping (no key hashing, no base64/salt decoding). - `context` and the active context are both plain (`targetingKey` + attributes) — matching is a normalized deep-equality. +- **`configurationFromString` is lenient** — it returns an empty config (`{}`) on a parse + error or unknown `version` rather than throwing, matching the shipped + `openfeature-js-client` `wire.ts`. Predictable failure surfaces at the + `setConfiguration`/provider layer (empty/absent precomputed → provider stays not-ready / + emits `PROVIDER_ERROR`), not as a thrown parse error. - `PrecomputedFlag` → `FlagCacheEntry` is nearly 1:1 for the evaluation path. Two tracking-only fields (`doLog`, `variationValue`; new format exposes `flagMetadata.experiment`) need a mapping decision, confirmed with the flags backend team. @@ -111,7 +116,7 @@ not served. | Subtask | Summary | | :------ | :------ | -| [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1, validate, fail predictably; extensible for `server`/rules) | +| [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode `PrecomputedConfiguration` → `FlagCacheEntry` map (plain JSON) | | [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | From 1ae02ea3ff91e5a138f9cfa098e7bd52413fb11d Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 18:57:29 -0400 Subject: [PATCH 04/64] docs(plan): assume shipped PrecomputedFlag shape, note format discrepancy Adopt the openfeature-js-client PrecomputedFlag shape (matches RN's FlagCacheEntry, decoder ~1:1) instead of the OpenFeature-aligned proposal page. Flags the two-format discrepancy to confirm with the flags team. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 47 +++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 5d1b6d27a..9f4bc4df0 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -65,30 +65,36 @@ type Configuration = { } ``` -**Inner `precomputed.response` → PrecomputedConfiguration** -([PrecomputedConfiguration format](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141791092/PrecomputedConfiguration+format)): +**Inner `precomputed.response` → PrecomputedConfiguration** — **ASSUMED**: the shipped +`openfeature-js-client` `PrecomputedFlag` shape +(`packages/core/src/configuration/configuration.ts`), which matches RN's existing +`FlagCacheEntry`: ```ts { - id: string data: { attributes: { - createdAt: number - expiresAt?: number + createdAt: string flags: Record }> }} } ``` +> ⚠️ **Two documented formats exist.** The Confluence *PrecomputedConfiguration format* +> page ([5141791092](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141791092/PrecomputedConfiguration+format)) +> describes an OpenFeature-aligned shape (`type` + `resolution.flagMetadata.experiment`). +> The **shipped** `openfeature-js-client` uses the `PrecomputedFlag` shape above, which +> matches RN's `FlagCacheEntry`. **We assume the shipped `PrecomputedFlag` shape.** Confirm +> with the flags backend team which the CDN actually returns, and whether `variationValue` +> is the typed value or a string. + Key facts that de-risk the JS approach: - **Obfuscation is not supported** in the DD precomputed format — parsing is plain @@ -100,9 +106,10 @@ Key facts that de-risk the JS approach: `openfeature-js-client` `wire.ts`. Predictable failure surfaces at the `setConfiguration`/provider layer (empty/absent precomputed → provider stays not-ready / emits `PROVIDER_ERROR`), not as a thrown parse error. -- `PrecomputedFlag` → `FlagCacheEntry` is nearly 1:1 for the evaluation path. Two - tracking-only fields (`doLog`, `variationValue`; new format exposes - `flagMetadata.experiment`) need a mapping decision, confirmed with the flags backend team. +- The assumed `PrecomputedFlag` shape maps **~1:1** onto RN's existing `FlagCacheEntry` + (`allocationKey`, `variationKey`, `variationType`, `variationValue`, `reason`, `doLog`, + `extraLogging`), so the decoder is near-trivial and the earlier `doLog`/`variationValue` + uncertainty is resolved by the shipped format. ## Context matching (order-independent) @@ -117,7 +124,7 @@ not served. | Subtask | Summary | | :------ | :------ | | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | -| [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode `PrecomputedConfiguration` → `FlagCacheEntry` map (plain JSON) | +| [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — ~1:1, plain JSON | | [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | @@ -125,7 +132,9 @@ not served. ## Open items (non-blocking) -- `doLog` / `variationValue` / `experiment` mapping for native exposure tracking (FFL-2687). +- Confirm the CDN's actual precomputed shape against the two documented formats, and whether + `variationValue` is the typed value or a string (FFL-2687). Plan assumes the shipped + `PrecomputedFlag` shape. - Precomputed context-mismatch behavior: default value vs `PROVIDER_NOT_READY` vs error (RFC open question). - `setConfiguration` sync vs `Promise`-returning (JS-only work is synchronous, but a Promise From e2c86cba3e22167ece43522649c6261601815b3f Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 18:58:09 -0400 Subject: [PATCH 05/64] docs(plan): port reference helpers (wire + configMatchesContext) vs depend openfeature-js-client is not a dependency here, so port its pure wire.ts and configMatchesContext helpers into RN core to preserve the native RUM/exposure path. Documents the context-agnostic matching nuance. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 9f4bc4df0..e66e7c16b 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -42,6 +42,11 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. - **Expose the API on both** the core `FlagsClient` and the `DatadogOpenFeatureProvider`. - The customer still calls `setEvaluationContext` themselves; `setConfiguration` must **verify the precomputed config matches the active context** (see below). +- **Port, don't depend.** `openfeature-js-client` is not a dependency here (only upstream + `@openfeature/core` + `@openfeature/web-sdk` are). Port its small pure helpers — + `wire.ts` (`configurationFromString`/`configurationToString`) and `configMatchesContext` — + into RN core rather than depending on the package, whose provider/exposure-logging would + bypass RN's native RUM/exposure path. ## Wire format (confirmed) @@ -119,13 +124,17 @@ Customers may call `setConfiguration` and `setEvaluationContext` in either order when the two **match**; the match is re-validated on **both** calls. On mismatch, values are not served. +This is a port of the reference `configMatchesContext` (deep-equality on `targetingKey` + +attributes), including its nuance: **a config with no embedded `context` is context-agnostic +and matches any evaluation context; a stored context must match exactly.** + ## Work breakdown | Subtask | Summary | | :------ | :------ | | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — ~1:1, plain JSON | -| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching | +| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`) | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | | [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Tests (parse/round-trip, decode, context match/mismatch, events) | From 90ab9f8ef12e803d2a6d642c7677b3ec9c397341 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 19:07:49 -0400 Subject: [PATCH 06/64] docs(plan): drop certainty-asserting section headings Nothing here is confirmed or decided yet, so rename the Approach and Wire format headings to avoid implying finality. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index e66e7c16b..028c50f70 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -33,7 +33,7 @@ tracking reconstructs what it needs from the per-flag data JS passes it. This me init can be done **entirely in JS with no native changes**: parse a supplied configuration into the same `FlagCacheEntry` map and populate `flagsCache`. -## Approach (decisions) +## Approach - **Parse the `ConfigurationWire` string in JS**, populating the existing `flagsCache`. - **Input** is a `ConfigurationWire` string, consumed via `configurationFromString(wire)`. @@ -48,7 +48,7 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. into RN core rather than depending on the package, whose provider/exposure-logging would bypass RN's native RUM/exposure path. -## Wire format (confirmed) +## Wire format **ConfigurationWire v1** ([ConfigurationWire](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141725646/ConfigurationWire)): From 27227f598772fe4c58109428c3d44e8cf687a494 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 19:08:52 -0400 Subject: [PATCH 07/64] docs(plan): ground precomputed shape in a sample CDN response A staging /precompute-assignments sample (example.json) shows the PrecomputedFlag-style shape (typed variationValue, RFC3339 createdAt, obfuscated:false), matching RN's FlagCacheEntry. Update the format block and notes to reference it and soften remaining certainty wording. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 68 ++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 028c50f70..f8cf5a7a4 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -70,40 +70,51 @@ type Configuration = { } ``` -**Inner `precomputed.response` → PrecomputedConfiguration** — **ASSUMED**: the shipped -`openfeature-js-client` `PrecomputedFlag` shape -(`packages/core/src/configuration/configuration.ts`), which matches RN's existing -`FlagCacheEntry`: +**Inner `precomputed.response` → PrecomputedConfiguration.** A sample staging CDN response +(`POST /precompute-assignments?dd_env=staging`, saved locally as `example.json`) shows the +`PrecomputedFlag`-style shape used by the shipped `openfeature-js-client`, which lines up +with RN's existing `FlagCacheEntry`: ```ts { - data: { attributes: { - createdAt: string - flags: Record - }> - }} + data: { + id: string + type: 'precomputed-assignments' + attributes: { + obfuscated: boolean // false in the sample + createdAt: string // RFC3339 timestamp (string, not a number) + format: 'PRECOMPUTED' + environment: { name: string } + flags: Record + serialId?: number + }> + } + } } ``` > ⚠️ **Two documented formats exist.** The Confluence *PrecomputedConfiguration format* > page ([5141791092](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141791092/PrecomputedConfiguration+format)) > describes an OpenFeature-aligned shape (`type` + `resolution.flagMetadata.experiment`). -> The **shipped** `openfeature-js-client` uses the `PrecomputedFlag` shape above, which -> matches RN's `FlagCacheEntry`. **We assume the shipped `PrecomputedFlag` shape.** Confirm -> with the flags backend team which the CDN actually returns, and whether `variationValue` -> is the typed value or a string. +> The sample CDN response and the shipped `openfeature-js-client` instead use the +> `PrecomputedFlag`-style shape above, which matches RN's `FlagCacheEntry`. **This plan +> assumes the `PrecomputedFlag`-style shape.** Since `variationValue` is the **typed** value, +> the decoder maps it to RN's `FlagCacheEntry.value` and derives the string +> `variationValue`/`variationType` that native Android exposure tracking expects. Still worth +> checking with the flags team whether the shape is stable across environments and versions. Key facts that de-risk the JS approach: -- **Obfuscation is not supported** in the DD precomputed format — parsing is plain - JSON → object mapping (no key hashing, no base64/salt decoding). +- **Obfuscation is not supported** in the DD precomputed format (the sample response carries + `obfuscated: false`) — parsing is plain JSON → object mapping (no key hashing, no + base64/salt decoding). - `context` and the active context are both plain (`targetingKey` + attributes) — matching is a normalized deep-equality. - **`configurationFromString` is lenient** — it returns an empty config (`{}`) on a parse @@ -111,10 +122,11 @@ Key facts that de-risk the JS approach: `openfeature-js-client` `wire.ts`. Predictable failure surfaces at the `setConfiguration`/provider layer (empty/absent precomputed → provider stays not-ready / emits `PROVIDER_ERROR`), not as a thrown parse error. -- The assumed `PrecomputedFlag` shape maps **~1:1** onto RN's existing `FlagCacheEntry` +- The `PrecomputedFlag`-style shape maps **~1:1** onto RN's existing `FlagCacheEntry` (`allocationKey`, `variationKey`, `variationType`, `variationValue`, `reason`, `doLog`, - `extraLogging`), so the decoder is near-trivial and the earlier `doLog`/`variationValue` - uncertainty is resolved by the shipped format. + `extraLogging`), so the decoder is near-trivial — the sample shows `doLog` and the per-flag + fields present directly. The one transform is the typed `variationValue` → RN's typed + `value` plus a stringified `variationValue`. ## Context matching (order-independent) @@ -141,9 +153,9 @@ and matches any evaluation context; a stored context must match exactly.** ## Open items (non-blocking) -- Confirm the CDN's actual precomputed shape against the two documented formats, and whether - `variationValue` is the typed value or a string (FFL-2687). Plan assumes the shipped - `PrecomputedFlag` shape. +- Check with the flags team whether the `PrecomputedFlag`-style shape seen in the sample + (`example.json`) is stable across environments/versions, vs the OpenFeature-aligned proposal + page (FFL-2687). - Precomputed context-mismatch behavior: default value vs `PROVIDER_NOT_READY` vs error (RFC open question). - `setConfiguration` sync vs `Promise`-returning (JS-only work is synchronous, but a Promise From 07328de9bb94cad3ba1c9744914b1abe9e098da3 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 21:07:44 -0400 Subject: [PATCH 08/64] docs(plan): document native tracking path and setEvaluationContext fetch Explains that native exposure/RUM tracking is per-flag and parameter-driven (no dependency on a prior CDN fetch), so offline init stays in JS. Notes that FlagsClient.setEvaluationContext currently triggers a native CDN fetch and overwrites the cache, so it must branch when an offline config is loaded (FFL-2688). Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 36 +++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index f8cf5a7a4..e61cb1799 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -41,7 +41,7 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. JS-provided data. - **Expose the API on both** the core `FlagsClient` and the `DatadogOpenFeatureProvider`. - The customer still calls `setEvaluationContext` themselves; `setConfiguration` must - **verify the precomputed config matches the active context** (see below). + **check that the precomputed config matches the active context** (see below). - **Port, don't depend.** `openfeature-js-client` is not a dependency here (only upstream `@openfeature/core` + `@openfeature/web-sdk` are). Port its small pure helpers — `wire.ts` (`configurationFromString`/`configurationToString`) and `configMatchesContext` — @@ -140,13 +140,45 @@ This is a port of the reference `configMatchesContext` (deep-equality on `target attributes), including its nuance: **a config with no embedded `context` is context-agnostic and matches any evaluation context; a stored context must match exactly.** +## Native tracking path and `setEvaluationContext` + +Two native code paths shape how much of this can stay in JS. + +**Exposure / RUM tracking is per-flag and parameter-driven.** RN's `trackEvaluation` bridges to +the native SDK's internal tracking entry point (iOS +`FlagsClient.sendFlagEvaluation(key:assignment:context:)`, Android +`_FlagsInternalProxy.trackFlagSnapshotEvaluation(key, flag, context)`). On iOS that calls +`exposureLogger.logExposure`, `evaluationLogger.logEvaluation`, and +`rumFlagEvaluationReporter.sendFlagEvaluation` — each takes the `key`, `assignment`, and +`context` as arguments and none reads the native repository's stored context or fetched +assignments (`ExposureLogger` gates on `assignment.doLog`, dedups in memory, and writes through +the core feature scope). Android's `ExposureEventsProcessor` is constructed from a record writer +plus a time provider and builds each event from the passed flag + context. So tracking runs off +the flag data and context that JS supplies, as long as `DdFlags.enable()` and `getClient()` have +run to create the client. That is what keeps offline init in JS with no Swift/Kotlin changes. + +**`setEvaluationContext` currently triggers a native CDN fetch.** RN's JS +`FlagsClient.setEvaluationContext` calls native `setEvaluationContext`, which fetches precomputed +assignments from the CDN and then overwrites `flagsCache` with the result: + +```ts +const result = await this.nativeFlags.setEvaluationContext(...); // CDN fetch +this.evaluationContext = processedContext; +this.flagsCache = result; // overwrites loaded config +``` + +In the offline flow the customer still calls `setEvaluationContext` (needed for context matching +and to hand a context to `trackEvaluation`), so this path has to branch when an offline +configuration is present: record the context without invoking the native fetch and without +overwriting the config-populated cache. This is a JS-only change and lives in FFL-2688. + ## Work breakdown | Subtask | Summary | | :------ | :------ | | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — ~1:1, plain JSON | -| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`) | +| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`); branch `setEvaluationContext` so it does not trigger the native CDN fetch / overwrite the loaded cache when an offline config is present | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | | [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Tests (parse/round-trip, decode, context match/mismatch, events) | From 5d400622ec5825f58dc95ba10ee1b8d84723c30e Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 08:45:24 -0400 Subject: [PATCH 09/64] docs(plan): address review feedback Add a Non-goals section (fetchPrecomputedConfiguration, precomputeConfiguration, rules-based eval out of scope), note the fetch source could be the Datadog/Fastly edge CDN or a customer proxy, and specify context mismatch as PROVIDER_ERROR plus an INVALID_CONTEXT evaluation error code. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 39 +++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index e61cb1799..be6483aac 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -13,10 +13,27 @@ SDK fetches precomputed assignments from the edge CDN. The core operation is: configurationFromString(wire) -> provider.setConfiguration(configuration) -> evaluate(...) ``` -Scope for this task: **precomputed (static context)** configuration only. The API is -designed to leave room for a future **rules-based (UFC / dynamic)** branch without +Scope for this task: **precomputed, static single-context (one user)** configuration only. +The API is designed to leave room for a future **rules-based (UFC / dynamic)** branch without reshaping it. +## Non-goals (out of scope for now) + +The goal here is only `setConfiguration()` for a **static, single-context (one user)** +precomputed configuration. The following are explicitly out of scope for this work and can be +later: + +- **`fetchPrecomputedConfiguration(...)`** — a convenience helper that fetches precomputed + assignments over HTTP (from the Datadog/Fastly edge CDN, or from the customer's own + service/proxy) and returns a `FlagsConfiguration`. Customers in this work fetch the + configuration themselves; a JS-level fetch helper (auth, endpoint, ETag/`304`) is a separate + convenience feature for customers who would rather not handle the HTTP fetch, and is later + work. +- **`precomputeConfiguration(...)`** — server-side conversion of rules into a client precomputed + configuration (Node.js-only in the RFC). Not relevant to the RN client. +- **Rules-based / dynamic (UFC) on-device evaluation** — the wire `server` branch. The type + stays extensible for it, but it is not implemented here. + ## Background: how flags work today 1. `DdFlags.enable(config)` → native `enable`. @@ -133,8 +150,17 @@ Key facts that de-risk the JS approach: Customers may call `setConfiguration` and `setEvaluationContext` in either order. The `FlagsClient` holds the **loaded configuration** (carrying its embedded `context`) and the **active evaluation context** independently. The servable `flagsCache` is only populated -when the two **match**; the match is re-validated on **both** calls. On mismatch, values are -not served. +when the two **match**; the match is re-validated on **both** calls. + +On mismatch the provider does not serve the precomputed values: it emits a `PROVIDER_ERROR` +event and enters the error provider state, and flag evaluations return the default with an +`INVALID_CONTEXT` error code (the active context does not match the loaded precomputed +configuration). `PROVIDER_ERROR` is an OpenFeature provider event/status, not an evaluation +error code. `INVALID_CONTEXT` is an existing OpenFeature `ErrorCode` — no new code is needed — +but RN's local `FlagErrorCode` union +(`PROVIDER_NOT_READY | FLAG_NOT_FOUND | PARSE_ERROR | TYPE_MISMATCH`) has to be extended to +include it. The provider's `toFlagResolution` already maps any `ErrorCode`, so it needs no +change. This is a port of the reference `configMatchesContext` (deep-equality on `targetingKey` + attributes), including its nuance: **a config with no embedded `context` is context-agnostic @@ -188,7 +214,8 @@ overwriting the config-populated cache. This is a JS-only change and lives in FF - Check with the flags team whether the `PrecomputedFlag`-style shape seen in the sample (`example.json`) is stable across environments/versions, vs the OpenFeature-aligned proposal page (FFL-2687). -- Precomputed context-mismatch behavior: default value vs `PROVIDER_NOT_READY` vs error - (RFC open question). +- Precomputed context mismatch surfaces as a `PROVIDER_ERROR` event/state plus an + `INVALID_CONTEXT` evaluation error code (extending RN's `FlagErrorCode` union with this + existing OpenFeature code) — current direction for RFC open Q2. - `setConfiguration` sync vs `Promise`-returning (JS-only work is synchronous, but a Promise keeps parity with `setEvaluationContext` and forward-compat for rules). From 66a815d4c12ba3a4eed236eab66c9d1eb250602d Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 09:18:44 -0400 Subject: [PATCH 10/64] docs(plan): add fetchPolicy to opt out of fetch on setEvaluationContext setEvaluationContext always fetches from the CDN today with no toggle. Add a fetchPolicy (ALWAYS default / NEVER / ON_MISMATCH) set at enable() with a per-getClient() override; build ALWAYS + NEVER now and defer ON_MISMATCH. Tracked as FFL-2718. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 44 +++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index be6483aac..ba98d1220 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -59,6 +59,9 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. - **Expose the API on both** the core `FlagsClient` and the `DatadogOpenFeatureProvider`. - The customer still calls `setEvaluationContext` themselves; `setConfiguration` must **check that the precomputed config matches the active context** (see below). +- Add a **`fetchPolicy`** (`ALWAYS` default / `NEVER` / `ON_MISMATCH`) set at `enable()` with a + per-`getClient()` override, so customers can turn off the fetch-on-`setEvaluationContext`. + Offline init needs `NEVER`; see [Fetch policy](#fetch-policy). - **Port, don't depend.** `openfeature-js-client` is not a dependency here (only upstream `@openfeature/core` + `@openfeature/web-sdk` are). Port its small pure helpers — `wire.ts` (`configurationFromString`/`configurationToString`) and `configMatchesContext` — @@ -194,9 +197,37 @@ this.flagsCache = result; // overwrites l ``` In the offline flow the customer still calls `setEvaluationContext` (needed for context matching -and to hand a context to `trackEvaluation`), so this path has to branch when an offline -configuration is present: record the context without invoking the native fetch and without -overwriting the config-populated cache. This is a JS-only change and lives in FFL-2688. +and to hand a context to `trackEvaluation`), so under `fetchPolicy: NEVER` this path records the +context without invoking the native fetch and without overwriting the config-populated cache. +This is a JS-only change. See [Fetch policy](#fetch-policy) below. + +## Fetch policy + +Because `setEvaluationContext` fetches from the CDN today (see above), offline customers need an +explicit way to turn that off — and a hard "no network" guarantee, not just "usually won't." We +add a `fetchPolicy` set at `enable()` (global default) with an optional per-`getClient()` +override: + +```ts +enum FetchPolicy { + ALWAYS, // fetch on setEvaluationContext (today's behavior; default) + NEVER, // never fetch; serve only configurations supplied via setConfiguration + ON_MISMATCH, // use a matching supplied config; fetch only when it doesn't match the context +} +``` + +- **`ALWAYS`** — default; preserves current behavior. A `setConfiguration` bootstrap is + overwritten by the fetch, so this mode is mostly for online apps. +- **`NEVER`** — MVP target for offline. `setEvaluationContext` records the context but does not + call the native fetch or overwrite the cache; the client serves only what `setConfiguration` + loaded. A context set with no matching config → provider not-ready / `PROVIDER_ERROR`. +- **`ON_MISMATCH`** — *fast-follow, not in this MVP.* Bootstrap-then-refresh: serve a supplied + config when it matches the active context, otherwise fetch. Adds async fetch orchestration, + `Reconciling`/`Stale`/`Ready` sequencing, and a fetch-failure fallback, so it is deferred. + +Only `ALWAYS` (default) and `NEVER` are built now. `ON_MISMATCH` is declared for forward-compat +and implemented later. A mutable runtime setter is intentionally left out of v1 to avoid races +with in-flight fetches and already-loaded config. ## Work breakdown @@ -204,7 +235,8 @@ overwriting the config-populated cache. This is a JS-only change and lives in FF | :------ | :------ | | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — ~1:1, plain JSON | -| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`); branch `setEvaluationContext` so it does not trigger the native CDN fetch / overwrite the loaded cache when an offline config is present | +| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`); mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT` | +| [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring: `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite). `ON_MISMATCH` declared, implemented later | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | | [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Tests (parse/round-trip, decode, context match/mismatch, events) | @@ -219,3 +251,7 @@ overwriting the config-populated cache. This is a JS-only change and lives in FF existing OpenFeature code) — current direction for RFC open Q2. - `setConfiguration` sync vs `Promise`-returning (JS-only work is synchronous, but a Promise keeps parity with `setEvaluationContext` and forward-compat for rules). +- For the future `ON_MISMATCH` policy: fetch-failure fallback (keep serving the previous usable + config and report `Stale`, but never serve a config that doesn't match the context) and a + staleness axis (`fetchedAt`/`etag`/`expiresAt`-driven refresh) are separate from context + matching and out of this MVP. From 6bbdb1633d0b073b2444178dd6c7b30aeaf24a6b Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 09:51:42 -0400 Subject: [PATCH 11/64] docs(plan): add 4-PR delivery plan and repurpose FFL-2691 to RUM FIT e2e Group the seven sub-tasks into four stacked, self-contained PRs (unit tests co-located, no trailing test-only PR) and document the ordering rationale. FFL-2691 is repurposed to RUM FIT integration/e2e coverage (offline-loaded flag -> RUM parity). Jira encodes the order via Blocks links. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index ba98d1220..c374e1e5c 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -239,7 +239,29 @@ with in-flight fetches and already-loaded config. | [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring: `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite). `ON_MISMATCH` declared, implemented later | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | -| [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Tests (parse/round-trip, decode, context match/mismatch, events) | +| [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Integration / e2e tests (RUM FIT) — offline-loaded flag → RUM parity; unit tests ship inside each PR above | + +## Delivery plan (PRs) + +To keep each changeset easy to review, the seven sub-tasks ship as **four stacked PRs**, each +self-contained with its own unit tests (no trailing test-only PR). RUM FIT integration/e2e +coverage (FFL-2691) lands after the feature is complete. + +``` +PR1 ─▶ PR2 ─▶ PR3 ─▶ PR4 (each branch based on the previous) +``` + +| PR | Sub-tasks | Scope | +| :- | :-------- | :---- | +| PR1 | FFL-2686 + FFL-2687 | Pure JS: wire parse + precomputed → `FlagCacheEntry`, with fixtures. Kept internal (un-exported) until PR4. | +| PR2 | FFL-2688 | `FlagsClient.setConfiguration` + context matching; mismatch → `PROVIDER_ERROR` / `INVALID_CONTEXT`. | +| PR3 | FFL-2718 | `fetchPolicy` `ALWAYS`/`NEVER`. Code-independent of PR1/PR2 — can be authored in parallel and rebased into the stack. | +| PR4 | FFL-2689 + FFL-2690 | OpenFeature provider `setConfiguration` + events, public exports, docs, example. | +| after | FFL-2691 | RUM FIT integration/e2e: an offline-loaded flag evaluation reports to RUM with parity to the fetch path. | + +Ordering rationale: leaf-first (pure, tested transforms), then client behavior, then fetch +control, then the public surface — one reviewable idea per PR, tests co-located. Jira encodes +this order with `Blocks` links: 2686 → 2687 → 2688 → 2718 → 2689 → 2690 → 2691. ## Open items (non-blocking) From 96838948fcde8d6f3cb1025d786031ea8d21244c Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 10:35:53 -0400 Subject: [PATCH 12/64] docs(plan): avoid FlagsConfiguration name collision (H1) The parsed-wire config type must not reuse FlagsConfiguration, which already names the enable() options type. Use ParsedFlagsConfiguration for the configurationFromString/setConfiguration surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index c374e1e5c..232dad67e 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -26,7 +26,8 @@ later: - **`fetchPrecomputedConfiguration(...)`** — a convenience helper that fetches precomputed assignments over HTTP (from the Datadog/Fastly edge CDN, or from the customer's own service/proxy) and returns a `FlagsConfiguration`. Customers in this work fetch the - configuration themselves; a JS-level fetch helper (auth, endpoint, ETag/`304`) is a separate + configuration themselves; a JS-level fetch helper (auth, endpoint, ETag/`304`) returns a + `ParsedFlagsConfiguration` and is a separate convenience feature for customers who would rather not handle the HTTP fetch, and is later work. - **`precomputeConfiguration(...)`** — server-side conversion of rules into a client precomputed @@ -57,6 +58,10 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. - **No Swift/Kotlin changes** — evaluation and per-flag exposure tracking already run off JS-provided data. - **Expose the API on both** the core `FlagsClient` and the `DatadogOpenFeatureProvider`. +- **Avoid the `FlagsConfiguration` name collision.** That identifier is already the `enable()` + options type (`packages/core/src/flags/types.ts`). Name the parsed-wire config type distinctly + — this plan uses **`ParsedFlagsConfiguration`** — so `configurationFromString` / + `setConfiguration` don't overload the existing type. - The customer still calls `setEvaluationContext` themselves; `setConfiguration` must **check that the precomputed config matches the active context** (see below). - Add a **`fetchPolicy`** (`ALWAYS` default / `NEVER` / `ON_MISMATCH`) set at `enable()` with a @@ -233,7 +238,7 @@ with in-flight fetches and already-loaded config. | Subtask | Summary | | :------ | :------ | -| [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | +| [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `ParsedFlagsConfiguration` type (distinct from the existing `enable()` `FlagsConfiguration`; parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — ~1:1, plain JSON | | [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`); mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT` | | [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring: `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite). `ON_MISMATCH` declared, implemented later | From 03d432a0a13639aefa8b2ccacd099de48c30d1c9 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 10:36:37 -0400 Subject: [PATCH 13/64] docs(plan): separate config-kind axis from fetchPolicy (H2, H3) Add a configuration-kind/evaluation-mode concept chosen by which wire branch is populated (precomputed vs future server/rules), keep fetchPolicy as the network axis only, and gate the context match/mismatch-error on the precomputed kind so a future rules config (context-agnostic) is not rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 232dad67e..0ba66881a 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -153,12 +153,30 @@ Key facts that de-risk the JS approach: fields present directly. The one transform is the typed `variationValue` → RN's typed `value` plus a stringified `variationValue`. +## Configuration kind (evaluation mode) + +Keep two axes separate so the future rules mode is additive, not a reshape: + +- **Configuration kind / evaluation mode** — *how* a loaded config is evaluated, chosen by which + wire branch is populated (the way the reference providers do it): `precomputed` → look up an + assignment that **must match** the active context; `server` (future rules/UFC) → evaluate the + active context **locally** and serve *any* context. Only `precomputed` is implemented now. +- **`fetchPolicy`** — *whether* the SDK may hit the network on `setEvaluationContext` + ([Fetch policy](#fetch-policy)). Network posture only; it does not select the evaluation mode. + +The context match/mismatch rules below apply to the **precomputed** kind; a rules config is +context-agnostic (below). + ## Context matching (order-independent) Customers may call `setConfiguration` and `setEvaluationContext` in either order. The `FlagsClient` holds the **loaded configuration** (carrying its embedded `context`) and the -**active evaluation context** independently. The servable `flagsCache` is only populated -when the two **match**; the match is re-validated on **both** calls. +**active evaluation context** independently. + +**For a precomputed configuration** the servable `flagsCache` is only populated when the two +**match**; the match is re-validated on **both** calls. (A future rules/`server` configuration +is context-agnostic — it evaluates any active context locally and is never rejected for a +context mismatch; the match check is gated on config kind = precomputed.) On mismatch the provider does not serve the precomputed values: it emits a `PROVIDER_ERROR` event and enters the error provider state, and flag evaluations return the default with an @@ -172,7 +190,8 @@ change. This is a port of the reference `configMatchesContext` (deep-equality on `targetingKey` + attributes), including its nuance: **a config with no embedded `context` is context-agnostic -and matches any evaluation context; a stored context must match exactly.** +and matches any evaluation context; a stored context must match exactly.** The RN port must +also treat a **rules-kind** config (not just a missing `context`) as context-agnostic. ## Native tracking path and `setEvaluationContext` @@ -234,13 +253,16 @@ Only `ALWAYS` (default) and `NEVER` are built now. `ON_MISMATCH` is declared for and implemented later. A mutable runtime setter is intentionally left out of v1 to avoid races with in-flight fetches and already-loaded config. +`fetchPolicy` is purely the **network axis**; *how* a config is evaluated is set by its +[configuration kind](#configuration-kind-evaluation-mode), not by `fetchPolicy`. + ## Work breakdown | Subtask | Summary | | :------ | :------ | | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `ParsedFlagsConfiguration` type (distinct from the existing `enable()` `FlagsConfiguration`; parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — ~1:1, plain JSON | -| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`); mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT` | +| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`, gated on config kind = precomputed); mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT` | | [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring: `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite). `ON_MISMATCH` declared, implemented later | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | From 3c8596be532c05ab46358e1ec528e6e97b57dc7f Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 10:37:08 -0400 Subject: [PATCH 14/64] docs(plan): add obfuscation guard to the decoder (H6) The decoder must read attributes.obfuscated and fail predictably (unsupported -> PROVIDER_ERROR) instead of mis-mapping hashed keys as flag names if the CDN ever enables obfuscation. This is the seam for a future obfuscated format. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 0ba66881a..d7c9bf2fa 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -139,7 +139,10 @@ Key facts that de-risk the JS approach: - **Obfuscation is not supported** in the DD precomputed format (the sample response carries `obfuscated: false`) — parsing is plain JSON → object mapping (no key hashing, no - base64/salt decoding). + base64/salt decoding). The decoder must still **read `attributes.obfuscated` and fail + predictably** (unsupported → `PROVIDER_ERROR`) if it is ever `true`, rather than silently + mis-mapping hashed keys as flag names when the CDN flips it on. This is the seam for a future + obfuscated precomputed/rules format. - `context` and the active context are both plain (`targetingKey` + attributes) — matching is a normalized deep-equality. - **`configurationFromString` is lenient** — it returns an empty config (`{}`) on a parse @@ -261,7 +264,7 @@ with in-flight fetches and already-loaded config. | Subtask | Summary | | :------ | :------ | | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `ParsedFlagsConfiguration` type (distinct from the existing `enable()` `FlagsConfiguration`; parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | -| [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — ~1:1, plain JSON | +| [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — plain JSON; inject `key`; derive typed `value` + string `variationValue`; fail predictably if `attributes.obfuscated` | | [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`, gated on config kind = precomputed); mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT` | | [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring: `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite). `ON_MISMATCH` declared, implemented later | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | From 7f9442fd933b6c42c9e1e09477156c6ffb27b3b9 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 10:37:46 -0400 Subject: [PATCH 15/64] docs(plan): fetchPolicy options object + depend on shared rules evaluator (M6, M5) Pass fetchPolicy inside an options object so it can grow (ttl, staleWhileRevalidate) without an API break, and record that the future rules (UFC) evaluator should be depended on from a shared core rather than re-ported, to avoid silent assignment drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index d7c9bf2fa..1bd2a4c58 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -71,7 +71,11 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. `@openfeature/core` + `@openfeature/web-sdk` are). Port its small pure helpers — `wire.ts` (`configurationFromString`/`configurationToString`) and `configMatchesContext` — into RN core rather than depending on the package, whose provider/exposure-logging would - bypass RN's native RUM/exposure path. + bypass RN's native RUM/exposure path. **But for the future rules (UFC) evaluator and any + obfuscation logic, prefer *depending* on a shared platform-agnostic core (e.g. + `@datadog/flagging-core`) rather than re-porting** — hand-porting an evolving evaluator risks + silent assignment drift. Keep the ported helpers behind a thin internal module boundary so a + later port → dependency swap stays contained. ## Wire format @@ -256,6 +260,10 @@ Only `ALWAYS` (default) and `NEVER` are built now. `ON_MISMATCH` is declared for and implemented later. A mutable runtime setter is intentionally left out of v1 to avoid races with in-flight fetches and already-loaded config. +`fetchPolicy` is passed inside an **options object** at `enable()` / `getClient()` (not a bare +positional enum) so it can grow fields later — e.g. `ttl`, `staleWhileRevalidate` for the future +staleness axis — without an API break. + `fetchPolicy` is purely the **network axis**; *how* a config is evaluated is set by its [configuration kind](#configuration-kind-evaluation-mode), not by `fetchPolicy`. @@ -266,7 +274,7 @@ with in-flight fetches and already-loaded config. | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `ParsedFlagsConfiguration` type (distinct from the existing `enable()` `FlagsConfiguration`; parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — plain JSON; inject `key`; derive typed `value` + string `variationValue`; fail predictably if `attributes.obfuscated` | | [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`, gated on config kind = precomputed); mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT` | -| [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring: `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite). `ON_MISMATCH` declared, implemented later | +| [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring via an **options object** at `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite and sets context synchronously). `ON_MISMATCH` declared, implemented later | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | | [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Integration / e2e tests (RUM FIT) — offline-loaded flag → RUM parity; unit tests ship inside each PR above | From 1e39a24d7b72834e3a9c2516ae82c6e5cea82a42 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 10:38:23 -0400 Subject: [PATCH 16/64] docs(plan): add review-driven acceptance criteria (implementation must-fixes) Capture the plan-review silent-failure risks as a per-subtask acceptance checklist: variationValue stringification, wire-context normalization before matching, NEVER sets context synchronously, provider event ordering, and the iOS int/double parity note. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 1bd2a4c58..c007dc295 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -301,6 +301,35 @@ Ordering rationale: leaf-first (pure, tested transforms), then client behavior, control, then the public surface — one reviewable idea per PR, tests co-located. Jira encodes this order with `Blocks` links: 2686 → 2687 → 2688 → 2718 → 2689 → 2690 → 2691. +## Review-driven acceptance criteria (must-fix at implementation) + +From a two-agent plan review. These are silent-failure risks; mirrored as acceptance checklists +on each sub-task. + +- **FFL-2686 (parse):** accept a *known set* of `version`s (not a hard `1`); `setConfiguration` + must distinguish parse-failure from valid-but-no-`precomputed` (don't let a lenient `{}` + masquerade as success); align the type with the ported source (no phantom `etag`; `fetchedAt` + type). +- **FFL-2687 (decode):** inject `key` from the flag map key (`PrecomputedFlag` has none, + `FlagCacheEntry.key` is required); set `value` = typed `variationValue` **and** a string + `variationValue` (`JSON.stringify` for objects, lowercase `"true"/"false"` for booleans) so + Android exposure tracking round-trips; `serialId?: number | null`; validate `variationType` + ∈ the four values and reject unknowns; fail predictably if `obfuscated`. +- **FFL-2688 (setConfiguration + matching):** normalize the wire `context` through the *same* + `processEvaluationContext` before comparing — the raw wire context is flat + `{ targetingKey, …attrs }` while the active one is `{ targetingKey, attributes: {…} }`, so a + naive deep-equal always mismatches; gate the match on config kind = precomputed; emit + `PROVIDER_ERROR` via the provider event emitter (do **not** reject `initialize` / + `onContextChange`); distinguish empty/absent precomputed (`PROVIDER_ERROR`) from `FLAG_NOT_FOUND`. +- **FFL-2718 (fetchPolicy):** the `NEVER` branch must set `evaluationContext` **synchronously** + (no `await`, no throw) and re-run matching, or the provider is stuck `PROVIDER_NOT_READY`. +- **FFL-2689 (provider):** one owner of `{ loadedConfig, activeContext, cache }`; define ordering + of `setConfiguration` vs the `contextChangePromise` chain and reset it to resolved after + failures so later context changes aren't poisoned; keep any future rules evaluator in a + tree-shakeable entry point. +- **FFL-2691 (RUM FIT):** iOS derives numeric type from `value` at runtime (whole number → + integer, fractional → double) — watch int/double parity vs Android in the RUM assertions. + ## Open items (non-blocking) - Check with the flags team whether the `PrecomputedFlag`-style shape seen in the sample From 5a9fa8f172acdeabff01a983a3225e91e15ed985 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 11:37:04 -0400 Subject: [PATCH 17/64] docs(plan): add per-step meta-plan for future context-free agents Add an "Executing a step" section: what to read (this doc, the RFCs, the Jira ticket + acceptance checklist), where to ground in code/formats, the required shape of the per-step implementation plan (explicit files, signatures, return types, tests), the guardrails, and a reusable prompt. Lets a fresh-context agent execute any PR step consistently. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 63 +++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index c007dc295..f191d697a 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -301,6 +301,69 @@ Ordering rationale: leaf-first (pure, tested transforms), then client behavior, control, then the public surface — one reviewable idea per PR, tests co-located. Jira encodes this order with `Blocks` links: 2686 → 2687 → 2688 → 2718 → 2689 → 2690 → 2691. +## Executing a step (meta-plan for future agents) + +Each PR in the [Delivery plan](#delivery-plan-prs) is a "step." A future agent may start a step +with **no prior context**. To execute a step (one of FFL-2686 / 2687 / 2688 / 2718 / 2689 / 2690 / +2691), follow this procedure. + +**1. Orient — read, in this order:** +- This file (`provider_set_configuration.md`) end to end — it is the source of truth for scope, + design decisions, wire/format shapes, the config-kind vs `fetchPolicy` split, and the + [acceptance criteria](#review-driven-acceptance-criteria-must-fix-at-implementation). +- `Offline-Initialization-for-Feature-Flagging.md` (read fully — small) and + `Portable-Flag-Configuration-RFC.md` (repo root) for intent. ⚠️ The Portable RFC is ~200 KB + with an embedded base64 image — **do not read it whole; grep it for specific terms.** +- The step's Jira ticket(s) via the Atlassian MCP (`getJiraIssue`, cloudId + `datadoghq.atlassian.net`); parent is FFL-2666. **Read the "Acceptance checklist" in the ticket + description — every item is a requirement for the step.** The same items are mirrored in + [Review-driven acceptance criteria](#review-driven-acceptance-criteria-must-fix-at-implementation) + below in case the MCP is unavailable (if it is, ask the user to authenticate). + +**2. Ground in the code & formats — only what the step touches:** +- Current flags code: `packages/core/src/flags/{FlagsClient.ts,DdFlags.ts,types.ts,internal.ts}`, + `packages/core/src/specs/NativeDdFlags.ts`, `packages/react-native-openfeature/src/provider.ts`. +- Reference to **port from** (do NOT add it as a dependency): + `~/dd/openfeature-js-client/packages/core/src/configuration/{wire.ts,configuration.ts}`; for + provider behavior, `~/dd/openfeature-js-client/packages/{browser,node-server}/src/…provider*.ts`. +- Sample precomputed CDN payload for fixtures: `example.json` (repo root, untracked). +- Native tracking path (for parity — **not** to change): iOS + `example-new-architecture/ios/Pods/DatadogFlags/DatadogFlags/Sources/…`; Android in the + `dd-sdk-android-flags` `.aar` in the gradle cache. +- Format specs (Confluence, via Atlassian MCP `getConfluencePage`): ConfigurationWire + `5141725646`, PrecomputedConfiguration `5141791092`, RUM FIT `6896386323`. + +**3. Produce a short implementation plan _before_ writing code.** It must be explicit: +- **Files** to add/change (exact paths). +- **Function / type signatures** with parameter and **return types**, e.g. + `configurationFromString(wire: string): ParsedFlagsConfiguration`. +- **Behavior** for each acceptance-checklist item, and how each is tested (test file paths + cases). +- Anything touching **native** — expected to be **none** (see + [Native tracking path](#native-tracking-path-and-setevaluationcontext)); if a step seems to + need a native change, stop and flag it to the user. + +**4. Guardrails:** +- **JS-only, no Swift/Kotlin changes.** Evaluation and per-flag tracking already run off + JS-supplied data. +- Use the agreed names: `ParsedFlagsConfiguration` (never reuse `FlagsConfiguration`, the + `enable()` options type); config-kind chosen by which wire branch is populated; `fetchPolicy` + is network-only, passed inside an options object; mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT`. +- Keep new parse/decode helpers **internal (un-exported)** until the exports step (FFL-2690). +- Ship unit tests **in the same PR** (no trailing test-only PR). +- Delivery is **stacked PRs**: base each step on the previous step's branch (base of the stack is + `develop`), in the order the Jira `Blocks` links encode. +- Writing style for plans/PRs: state facts and assumptions plainly; don't overstate certainty + about things that are still assumptions. + +**5. Reusable prompt** — instantiate `{TICKET}` and `{STEP}`: + +> Read `Offline-Initialization-for-Feature-Flagging.md` and `Portable-Flag-Configuration-RFC.md` +> (grep the Portable RFC — do not read it whole). Read `provider_set_configuration.md` and the +> Jira ticket `{TICKET}` (Atlassian MCP, cloudId `datadoghq.atlassian.net`); note its acceptance +> checklist. Then produce a short plan to achieve `{STEP}` of `provider_set_configuration.md`, +> with **explicit changes** — filenames, function signatures, return types, and the test cases +> covering each acceptance item — before writing any code. + ## Review-driven acceptance criteria (must-fix at implementation) From a two-agent plan review. These are silent-failure risks; mirrored as acceptance checklists From 09a01cdc7709e6ed5a8b09069126aef9531bb3b8 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 12:18:14 -0400 Subject: [PATCH 18/64] docs(plan): add per-PR branch naming convention Name each step branch blake.thomas/FFL-2666-PR{N}, based on the previous step's branch. PR1 branches off this planning branch so every step inherits the plan. Add a Branch column to the delivery table. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index f191d697a..7b40b4fe5 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -289,18 +289,22 @@ coverage (FFL-2691) lands after the feature is complete. PR1 ─▶ PR2 ─▶ PR3 ─▶ PR4 (each branch based on the previous) ``` -| PR | Sub-tasks | Scope | -| :- | :-------- | :---- | -| PR1 | FFL-2686 + FFL-2687 | Pure JS: wire parse + precomputed → `FlagCacheEntry`, with fixtures. Kept internal (un-exported) until PR4. | -| PR2 | FFL-2688 | `FlagsClient.setConfiguration` + context matching; mismatch → `PROVIDER_ERROR` / `INVALID_CONTEXT`. | -| PR3 | FFL-2718 | `fetchPolicy` `ALWAYS`/`NEVER`. Code-independent of PR1/PR2 — can be authored in parallel and rebased into the stack. | -| PR4 | FFL-2689 + FFL-2690 | OpenFeature provider `setConfiguration` + events, public exports, docs, example. | -| after | FFL-2691 | RUM FIT integration/e2e: an offline-loaded flag evaluation reports to RUM with parity to the fetch path. | +| PR | Branch | Sub-tasks | Scope | +| :- | :----- | :-------- | :---- | +| PR1 | `blake.thomas/FFL-2666-PR1` | FFL-2686 + FFL-2687 | Pure JS: wire parse + precomputed → `FlagCacheEntry`, with fixtures. Kept internal (un-exported) until PR4. | +| PR2 | `blake.thomas/FFL-2666-PR2` | FFL-2688 | `FlagsClient.setConfiguration` + context matching; mismatch → `PROVIDER_ERROR` / `INVALID_CONTEXT`. | +| PR3 | `blake.thomas/FFL-2666-PR3` | FFL-2718 | `fetchPolicy` `ALWAYS`/`NEVER`. Code-independent of PR1/PR2 — can be authored in parallel and rebased into the stack. | +| PR4 | `blake.thomas/FFL-2666-PR4` | FFL-2689 + FFL-2690 | OpenFeature provider `setConfiguration` + events, public exports, docs, example. | +| after | (RUM FIT repos) | FFL-2691 | RUM FIT integration/e2e in the test-framework repos: an offline-loaded flag evaluation reports to RUM with parity to the fetch path. | Ordering rationale: leaf-first (pure, tested transforms), then client behavior, then fetch control, then the public surface — one reviewable idea per PR, tests co-located. Jira encodes this order with `Blocks` links: 2686 → 2687 → 2688 → 2718 → 2689 → 2690 → 2691. +**Branch naming & base:** each step branch is `blake.thomas/FFL-2666-PR{N}`, based on the +previous step's branch. PR1 is based on this planning branch (`blake.thomas/FFL-2666`) so every +step inherits this plan. (FFL-2691 lands in the RUM FIT repos, not this one.) + ## Executing a step (meta-plan for future agents) Each PR in the [Delivery plan](#delivery-plan-prs) is a "step." A future agent may start a step @@ -350,8 +354,9 @@ with **no prior context**. To execute a step (one of FFL-2686 / 2687 / 2688 / 27 is network-only, passed inside an options object; mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT`. - Keep new parse/decode helpers **internal (un-exported)** until the exports step (FFL-2690). - Ship unit tests **in the same PR** (no trailing test-only PR). -- Delivery is **stacked PRs**: base each step on the previous step's branch (base of the stack is - `develop`), in the order the Jira `Blocks` links encode. +- Delivery is **stacked PRs** named `blake.thomas/FFL-2666-PR{N}`: base each step on the previous + step's branch, in the order the Jira `Blocks` links encode. The base of the stack (PR1) is this + planning branch `blake.thomas/FFL-2666`, so every step inherits this plan. - Writing style for plans/PRs: state facts and assumptions plainly; don't overstate certainty about things that are still assumptions. From 09e0fddb929d6bef12b97b75a33e6594728b2439 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 12:31:13 -0400 Subject: [PATCH 19/64] Update provider_set_configuration.md Co-authored-by: Oleksii Shmalko --- provider_set_configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 7b40b4fe5..3f062c840 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -115,7 +115,7 @@ with RN's existing `FlagCacheEntry`: format: 'PRECOMPUTED' environment: { name: string } flags: Record Date: Tue, 7 Jul 2026 13:01:01 -0400 Subject: [PATCH 20/64] docs(plan): add open question on persisting loaded config to disk A config loaded via setConfiguration is in-memory only today. Capture as an open question whether to offer a persist option for offline last-known-values across cold starts (wire vs parsed, JS-side store vs native flagsDataStore, auto-load + fetchPolicy interaction, staleness). Likely a fast-follow. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 3f062c840..ece14f0c7 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -412,3 +412,13 @@ on each sub-task. config and report `Stale`, but never serve a config that doesn't match the context) and a staleness axis (`fetchedAt`/`etag`/`expiresAt`-driven refresh) are separate from context matching and out of this MVP. +- **Persist the loaded configuration to disk?** Today a config loaded via `setConfiguration` is + in-memory only — on the next cold start the customer must call `setConfiguration` again. An + option (e.g. `setConfiguration(config, { persist: true })`) could cache it for offline + last-known-values across restarts. Sub-questions: (a) persist the raw wire string or the parsed + config; (b) where — a JS-side store (AsyncStorage / filesystem / MMKV) or the native + `flagsDataStore` (native work, breaks the JS-only guarantee); (c) auto-load on next launch and + how that interacts with `fetchPolicy` (`NEVER` + persisted = offline startup with no network); + (d) staleness / eviction of the persisted copy. Note the native SDK already persists its own + fetched flags to disk, but RN's JS read path does not consume that store. Likely a fast-follow, + not MVP. From 4c864061633490fda52759c78aaf3e770a535479 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 13:06:06 -0400 Subject: [PATCH 21/64] docs(plan): reconcile variationType accepted set with integer/float The wire variationType union now includes integer and float, so widen the FFL-2687 validation to boolean|string|number|integer|float|object and note that integer/float map to a JS number for value while the original variationType string is preserved for Android's exposure round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index ece14f0c7..502a8745f 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -382,7 +382,9 @@ on each sub-task. `FlagCacheEntry.key` is required); set `value` = typed `variationValue` **and** a string `variationValue` (`JSON.stringify` for objects, lowercase `"true"/"false"` for booleans) so Android exposure tracking round-trips; `serialId?: number | null`; validate `variationType` - ∈ the four values and reject unknowns; fail predictably if `obfuscated`. + ∈ `boolean | string | number | integer | float | object` and reject unknowns; map both + `integer` and `float` to a JS `number` for `value` while preserving the original + `variationType` string for Android's round-trip; fail predictably if `obfuscated`. - **FFL-2688 (setConfiguration + matching):** normalize the wire `context` through the *same* `processEvaluationContext` before comparing — the raw wire context is flat `{ targetingKey, …attrs }` while the active one is `{ targetingKey, attributes: {…} }`, so a From 492e1a9b6f0a3d8f975df8991b076ec2f69600b7 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 18:15:22 -0400 Subject: [PATCH 22/64] docs(plan): replace fetchPolicy with an OfflineProvider plan Deliver never-fetch as a dedicated offline OpenFeature provider rather than a fetchPolicy flag. Its initialize/onContextChange use FlagsClient.setEvaluationContextWithoutFetching and never hit the CDN. Simpler core, no online/offline mixing, all-JS (no native changes), and the same provider extends to future offline dynamic rules. Collapse the delivery to three stacked PRs (PR2 = core offline API incl. the no-fetch method, PR3 = OfflineProvider + exports) and update the work breakdown and acceptance. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 133 +++++++++++++++++----------------- 1 file changed, 66 insertions(+), 67 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 502a8745f..d5947e80c 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -1,7 +1,7 @@ # `provider.setConfiguration` — Offline Init for React Native Feature Flags **Jira:** [FFL-2666](https://datadoghq.atlassian.net/browse/FFL-2666) -**Status:** Planning (no implementation yet) +**Status:** In progress — see [Delivery plan](#delivery-plan-prs) ## Goal @@ -57,16 +57,18 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. - **Input** is a `ConfigurationWire` string, consumed via `configurationFromString(wire)`. - **No Swift/Kotlin changes** — evaluation and per-flag exposure tracking already run off JS-provided data. -- **Expose the API on both** the core `FlagsClient` and the `DatadogOpenFeatureProvider`. +- **Expose `setConfiguration`** on the core `FlagsClient` and on a new **`OfflineProvider`** + (see [Offline provider](#offline-provider)). - **Avoid the `FlagsConfiguration` name collision.** That identifier is already the `enable()` options type (`packages/core/src/flags/types.ts`). Name the parsed-wire config type distinctly — this plan uses **`ParsedFlagsConfiguration`** — so `configurationFromString` / `setConfiguration` don't overload the existing type. - The customer still calls `setEvaluationContext` themselves; `setConfiguration` must **check that the precomputed config matches the active context** (see below). -- Add a **`fetchPolicy`** (`ALWAYS` default / `NEVER` / `ON_MISMATCH`) set at `enable()` with a - per-`getClient()` override, so customers can turn off the fetch-on-`setEvaluationContext`. - Offline init needs `NEVER`; see [Fetch policy](#fetch-policy). +- **Never-fetch via a dedicated `OfflineProvider`, not a `fetchPolicy` flag.** The offline + behavior is delivered by a second OpenFeature provider whose `initialize`/`onContextChange` + use `FlagsClient.setEvaluationContextWithoutFetching` and never hit the CDN. See + [Offline provider](#offline-provider). - **Port, don't depend.** `openfeature-js-client` is not a dependency here (only upstream `@openfeature/core` + `@openfeature/web-sdk` are). Port its small pure helpers — `wire.ts` (`configurationFromString`/`configurationToString`) and `configMatchesContext` — @@ -168,8 +170,10 @@ Keep two axes separate so the future rules mode is additive, not a reshape: wire branch is populated (the way the reference providers do it): `precomputed` → look up an assignment that **must match** the active context; `server` (future rules/UFC) → evaluate the active context **locally** and serve *any* context. Only `precomputed` is implemented now. -- **`fetchPolicy`** — *whether* the SDK may hit the network on `setEvaluationContext` - ([Fetch policy](#fetch-policy)). Network posture only; it does not select the evaluation mode. +- **Provider choice** — *whether* the SDK may hit the network on `setEvaluationContext` depends + on which provider you use: `DatadogOpenFeatureProvider` (online, fetches) vs + `OfflineProvider` (never fetches; see [Offline provider](#offline-provider)). Network posture + only; it does not select the evaluation mode. The context match/mismatch rules below apply to the **precomputed** kind; a rules config is context-agnostic (below). @@ -227,45 +231,40 @@ this.evaluationContext = processedContext; this.flagsCache = result; // overwrites loaded config ``` -In the offline flow the customer still calls `setEvaluationContext` (needed for context matching -and to hand a context to `trackEvaluation`), so under `fetchPolicy: NEVER` this path records the -context without invoking the native fetch and without overwriting the config-populated cache. -This is a JS-only change. See [Fetch policy](#fetch-policy) below. +For the offline flow the client exposes **`setEvaluationContextWithoutFetching(context)`**: it +records the context and reconciles the loaded config (context matching for precomputed) without +invoking the native fetch. The `OfflineProvider` uses this in `initialize`/`onContextChange`. +This is a JS-only change. See [Offline provider](#offline-provider) below. -## Fetch policy +## Offline provider -Because `setEvaluationContext` fetches from the CDN today (see above), offline customers need an -explicit way to turn that off — and a hard "no network" guarantee, not just "usually won't." We -add a `fetchPolicy` set at `enable()` (global default) with an optional per-`getClient()` -override: +Rather than a `fetchPolicy` flag on the core, the never-fetch behavior is a **provider choice**. +Alongside the existing `DatadogOpenFeatureProvider` (online — fetches on context changes), add an +**`OfflineProvider`** (working name; likely `DatadogOfflineOpenFeatureProvider`) in +`react-native-openfeature`. It is identical to `DatadogOpenFeatureProvider` **except**: -```ts -enum FetchPolicy { - ALWAYS, // fetch on setEvaluationContext (today's behavior; default) - NEVER, // never fetch; serve only configurations supplied via setConfiguration - ON_MISMATCH, // use a matching supplied config; fetch only when it doesn't match the context -} -``` +- `initialize` / `onContextChange` call `FlagsClient.setEvaluationContextWithoutFetching` instead + of the fetching `setEvaluationContext`, so it **never hits the CDN**. +- It exposes `setConfiguration` (delegating to the `FlagsClient`) and re-exports + `configurationFromString`. -- **`ALWAYS`** — default; preserves current behavior. A `setConfiguration` bootstrap is - overwritten by the fetch, so this mode is mostly for online apps. -- **`NEVER`** — MVP target for offline. `setEvaluationContext` records the context but does not - call the native fetch or overwrite the cache; the client serves only what `setConfiguration` - loaded. A context set with no matching config → provider not-ready / `PROVIDER_ERROR`. -- **`ON_MISMATCH`** — *fast-follow, not in this MVP.* Bootstrap-then-refresh: serve a supplied - config when it matches the active context, otherwise fetch. Adds async fetch orchestration, - `Reconciling`/`Stale`/`Ready` sequencing, and a fetch-failure fallback, so it is deferred. +Everything else — `resolveBoolean/String/Number/Object`, `toDdContext`, `toFlagResolution`, the +event emitter — is shared with `DatadogOpenFeatureProvider` (via a common base/helpers). -Only `ALWAYS` (default) and `NEVER` are built now. `ON_MISMATCH` is declared for forward-compat -and implemented later. A mutable runtime setter is intentionally left out of v1 to avoid races -with in-flight fetches and already-loaded config. +Why a provider instead of a flag: -`fetchPolicy` is passed inside an **options object** at `enable()` / `getClient()` (not a bare -positional enum) so it can grow fields later — e.g. `ttl`, `staleWhileRevalidate` for the future -staleness axis — without an API break. +- **Simpler core** — no `fetchPolicy` enum or options object on `enable()`/`getClient()`. +- **No online/offline mixing** — a given client is driven by one provider, so "always fetch" and + "never fetch" never contend on the same `FlagsClient` (this dissolves the precedence/overlay + edge cases from the PR2 review). +- **Extensible** — the same `OfflineProvider` serves future offline **dynamic rules**: + `onContextChange` re-evaluates the ruleset locally (still no fetch); only the core evaluator + gains a rules branch later. +- **No native changes** — the offline path uses existing native `enable` + `trackEvaluation` and + skips native `setEvaluationContext` entirely; evaluation is all JS. -`fetchPolicy` is purely the **network axis**; *how* a config is evaluated is set by its -[configuration kind](#configuration-kind-evaluation-mode), not by `fetchPolicy`. +`OfflineProvider` emits the OpenFeature events: first configuration → `PROVIDER_READY`, +subsequent → `PROVIDER_CONFIGURATION_CHANGED`, invalid/mismatch → `PROVIDER_ERROR`. ## Work breakdown @@ -274,32 +273,30 @@ staleness axis — without an API break. | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `ParsedFlagsConfiguration` type (distinct from the existing `enable()` `FlagsConfiguration`; parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — plain JSON; inject `key`; derive typed `value` + string `variationValue`; fail predictably if `attributes.obfuscated` | | [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`, gated on config kind = precomputed); mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT` | -| [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring via an **options object** at `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite and sets context synchronously). `ON_MISMATCH` declared, implemented later | -| [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | +| [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `FlagsClient.setEvaluationContextWithoutFetching` — set context + reconcile the loaded config with no native fetch (the primitive the `OfflineProvider` uses). Replaces the earlier `fetchPolicy` flag | +| [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | `OfflineProvider` (never-fetch OpenFeature provider) + `setConfiguration` surface + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | | [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Integration / e2e tests (RUM FIT) — offline-loaded flag → RUM parity; unit tests ship inside each PR above | ## Delivery plan (PRs) -To keep each changeset easy to review, the seven sub-tasks ship as **four stacked PRs**, each +To keep each changeset easy to review, the work ships as **three stacked PRs**, each self-contained with its own unit tests (no trailing test-only PR). RUM FIT integration/e2e -coverage (FFL-2691) lands after the feature is complete. +coverage (FFL-2691) lands after the feature is complete, in the test-framework repos. ``` -PR1 ─▶ PR2 ─▶ PR3 ─▶ PR4 (each branch based on the previous) +PR1 ─▶ PR2 ─▶ PR3 (each branch based on the previous) ``` | PR | Branch | Sub-tasks | Scope | | :- | :----- | :-------- | :---- | -| PR1 | `blake.thomas/FFL-2666-PR1` | FFL-2686 + FFL-2687 | Pure JS: wire parse + precomputed → `FlagCacheEntry`, with fixtures. Kept internal (un-exported) until PR4. | -| PR2 | `blake.thomas/FFL-2666-PR2` | FFL-2688 | `FlagsClient.setConfiguration` + context matching; mismatch → `PROVIDER_ERROR` / `INVALID_CONTEXT`. | -| PR3 | `blake.thomas/FFL-2666-PR3` | FFL-2718 | `fetchPolicy` `ALWAYS`/`NEVER`. Code-independent of PR1/PR2 — can be authored in parallel and rebased into the stack. | -| PR4 | `blake.thomas/FFL-2666-PR4` | FFL-2689 + FFL-2690 | OpenFeature provider `setConfiguration` + events, public exports, docs, example. | -| after | (RUM FIT repos) | FFL-2691 | RUM FIT integration/e2e in the test-framework repos: an offline-loaded flag evaluation reports to RUM with parity to the fetch path. | +| PR1 | `blake.thomas/FFL-2666-PR1` | FFL-2686 + FFL-2687 | Pure JS: wire parse + precomputed → `FlagCacheEntry`, with fixtures. Internal (un-exported). | +| PR2 | `blake.thomas/FFL-2666-PR2` | FFL-2688 + FFL-2718 | Core offline API on `FlagsClient`: `setConfiguration` + context matching + `setEvaluationContextWithoutFetching`. | +| PR3 | `blake.thomas/FFL-2666-PR3` | FFL-2689 + FFL-2690 | `OfflineProvider` (never-fetch) + `setConfiguration` surface + events + public exports/docs/example. | +| after | (RUM FIT repos) | FFL-2691 | RUM FIT integration/e2e: an offline-loaded flag evaluation reports to RUM with parity to the fetch path. | -Ordering rationale: leaf-first (pure, tested transforms), then client behavior, then fetch -control, then the public surface — one reviewable idea per PR, tests co-located. Jira encodes -this order with `Blocks` links: 2686 → 2687 → 2688 → 2718 → 2689 → 2690 → 2691. +Ordering rationale: leaf-first (pure, tested transforms) → core client API → the provider + +public surface. One reviewable idea per PR, tests co-located. **Branch naming & base:** each step branch is `blake.thomas/FFL-2666-PR{N}`, based on the previous step's branch. PR1 is based on this planning branch (`blake.thomas/FFL-2666`) so every @@ -313,7 +310,7 @@ with **no prior context**. To execute a step (one of FFL-2686 / 2687 / 2688 / 27 **1. Orient — read, in this order:** - This file (`provider_set_configuration.md`) end to end — it is the source of truth for scope, - design decisions, wire/format shapes, the config-kind vs `fetchPolicy` split, and the + design decisions, wire/format shapes, the config-kind vs provider-choice split, and the [acceptance criteria](#review-driven-acceptance-criteria-must-fix-at-implementation). - `Offline-Initialization-for-Feature-Flagging.md` (read fully — small) and `Portable-Flag-Configuration-RFC.md` (repo root) for intent. ⚠️ The Portable RFC is ~200 KB @@ -350,8 +347,8 @@ with **no prior context**. To execute a step (one of FFL-2686 / 2687 / 2688 / 27 - **JS-only, no Swift/Kotlin changes.** Evaluation and per-flag tracking already run off JS-supplied data. - Use the agreed names: `ParsedFlagsConfiguration` (never reuse `FlagsConfiguration`, the - `enable()` options type); config-kind chosen by which wire branch is populated; `fetchPolicy` - is network-only, passed inside an options object; mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT`. + `enable()` options type); config-kind chosen by which wire branch is populated; never-fetch is + a provider choice (`OfflineProvider`), not a flag; mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT`. - Keep new parse/decode helpers **internal (un-exported)** until the exports step (FFL-2690). - Ship unit tests **in the same PR** (no trailing test-only PR). - Delivery is **stacked PRs** named `blake.thomas/FFL-2666-PR{N}`: base each step on the previous @@ -391,11 +388,13 @@ on each sub-task. naive deep-equal always mismatches; gate the match on config kind = precomputed; emit `PROVIDER_ERROR` via the provider event emitter (do **not** reject `initialize` / `onContextChange`); distinguish empty/absent precomputed (`PROVIDER_ERROR`) from `FLAG_NOT_FOUND`. -- **FFL-2718 (fetchPolicy):** the `NEVER` branch must set `evaluationContext` **synchronously** - (no `await`, no throw) and re-run matching, or the provider is stuck `PROVIDER_NOT_READY`. -- **FFL-2689 (provider):** one owner of `{ loadedConfig, activeContext, cache }`; define ordering - of `setConfiguration` vs the `contextChangePromise` chain and reset it to resolved after - failures so later context changes aren't poisoned; keep any future rules evaluator in a +- **FFL-2718 (`setEvaluationContextWithoutFetching`):** sets the context and re-runs matching + with **no native call**; a context change re-validates the loaded config (match serves, + mismatch → `INVALID_CONTEXT`). Replaces the earlier `fetchPolicy` flag. +- **FFL-2689 (`OfflineProvider`):** `initialize`/`onContextChange` use + `setEvaluationContextWithoutFetching` (never fetch); shares code with + `DatadogOpenFeatureProvider`; one owner of `{ loadedConfig, activeContext, cache }`; a rejected + promise must not poison later `onContextChange`; keep any future rules evaluator in a tree-shakeable entry point. - **FFL-2691 (RUM FIT):** iOS derives numeric type from `value` at runtime (whole number → integer, fractional → double) — watch int/double parity vs Android in the RUM assertions. @@ -408,19 +407,19 @@ on each sub-task. - Precomputed context mismatch surfaces as a `PROVIDER_ERROR` event/state plus an `INVALID_CONTEXT` evaluation error code (extending RN's `FlagErrorCode` union with this existing OpenFeature code) — current direction for RFC open Q2. -- `setConfiguration` sync vs `Promise`-returning (JS-only work is synchronous, but a Promise - keeps parity with `setEvaluationContext` and forward-compat for rules). -- For the future `ON_MISMATCH` policy: fetch-failure fallback (keep serving the previous usable - config and report `Stale`, but never serve a config that doesn't match the context) and a - staleness axis (`fetchedAt`/`etag`/`expiresAt`-driven refresh) are separate from context - matching and out of this MVP. +- Resolved: `setConfiguration` and `setEvaluationContextWithoutFetching` are synchronous + (`void`) — JS-only work, no I/O. +- Future online refresh / bootstrap-then-refresh (an online provider that starts from a supplied + config, then refreshes via the CDN): fetch-failure fallback (keep serving the previous usable + config, report `Stale`, never serve a mismatched config) and a staleness axis + (`fetchedAt`/`etag`/`expiresAt`) are separate from context matching and out of this MVP. - **Persist the loaded configuration to disk?** Today a config loaded via `setConfiguration` is in-memory only — on the next cold start the customer must call `setConfiguration` again. An option (e.g. `setConfiguration(config, { persist: true })`) could cache it for offline last-known-values across restarts. Sub-questions: (a) persist the raw wire string or the parsed config; (b) where — a JS-side store (AsyncStorage / filesystem / MMKV) or the native `flagsDataStore` (native work, breaks the JS-only guarantee); (c) auto-load on next launch and - how that interacts with `fetchPolicy` (`NEVER` + persisted = offline startup with no network); + how it interacts with the `OfflineProvider` (persisted + offline = startup with no network); (d) staleness / eviction of the persisted copy. Note the native SDK already persists its own fetched flags to disk, but RN's JS read path does not consume that store. Likely a fast-follow, not MVP. From 9473b0fd29633425ae02abe16e03cc3ef5b495d6 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 13:17:00 -0400 Subject: [PATCH 23/64] feat(flags): add ConfigurationWire parse + precomputed decode (FFL-2686, FFL-2687) Internal, pure-JS building blocks for offline init (kept un-exported until the exports step): - configurationFromString / configurationToString for the ConfigurationWire v1 envelope, with a ParsedFlagsConfiguration type (distinct from the enable() FlagsConfiguration). Lenient parse: returns {} on malformed input or an unsupported version; accepts a known set of versions. - decodePrecomputedFlags: maps a precomputed CDN response to the existing FlagCacheEntry map. Injects key from the flag map key; value = typed variationValue (integer/float -> JS number, variationType string preserved); derives the string variationValue (JSON for objects, lowercase booleans) for Android exposure round-trip; validates variationType and omits unknowns; throws UnsupportedConfigurationError on obfuscated payloads. Unit tests cover parse/round-trip, unsupported version/invalid JSON, and decode across all variation types incl. obfuscation and mismatch handling. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/precomputed.test.ts | 180 ++++++++++++++++++ .../configuration/__tests__/wire.test.ts | 119 ++++++++++++ .../core/src/flags/configuration/index.ts | 23 +++ .../src/flags/configuration/precomputed.ts | 135 +++++++++++++ .../core/src/flags/configuration/types.ts | 119 ++++++++++++ packages/core/src/flags/configuration/wire.ts | 78 ++++++++ 6 files changed, 654 insertions(+) create mode 100644 packages/core/src/flags/configuration/__tests__/precomputed.test.ts create mode 100644 packages/core/src/flags/configuration/__tests__/wire.test.ts create mode 100644 packages/core/src/flags/configuration/index.ts create mode 100644 packages/core/src/flags/configuration/precomputed.ts create mode 100644 packages/core/src/flags/configuration/types.ts create mode 100644 packages/core/src/flags/configuration/wire.ts diff --git a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts new file mode 100644 index 000000000..cce1fa183 --- /dev/null +++ b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts @@ -0,0 +1,180 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { InternalLog } from '../../../InternalLog'; +import { + UnsupportedConfigurationError, + decodePrecomputedFlags +} from '../precomputed'; +import type { + PrecomputedConfigurationResponse, + PrecomputedFlag +} from '../types'; + +jest.mock('../../../InternalLog', () => { + return { + InternalLog: { log: jest.fn() }, + DATADOG_MESSAGE_PREFIX: 'DATADOG:' + }; +}); + +const flag = (overrides: Partial): PrecomputedFlag => ({ + variationType: 'boolean', + variationValue: true, + variationKey: 'true', + allocationKey: 'alloc-1', + reason: 'STATIC', + doLog: false, + extraLogging: {}, + ...overrides +}); + +const responseWith = ( + flags: Record, + obfuscated = false +): PrecomputedConfigurationResponse => ({ + data: { + id: '2', + type: 'precomputed-assignments', + attributes: { + obfuscated, + createdAt: '2026-07-06T23:01:56.822171460Z', + format: 'PRECOMPUTED', + environment: { name: 'Staging' }, + flags + } + } +}); + +describe('decodePrecomputedFlags', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('maps each variation type to a FlagCacheEntry with the correct value + string form', () => { + const cache = decodePrecomputedFlags( + responseWith({ + bool: flag({ + variationType: 'boolean', + variationValue: false, + variationKey: 'false' + }), + str: flag({ + variationType: 'string', + variationValue: 'hello', + variationKey: 'Hello' + }), + num: flag({ + variationType: 'number', + variationValue: 42, + variationKey: '42' + }), + int: flag({ + variationType: 'integer', + variationValue: 7, + variationKey: '7' + }), + flt: flag({ + variationType: 'float', + variationValue: 1.5, + variationKey: '1.5' + }), + obj: flag({ + variationType: 'object', + variationValue: { greeting: 'hi' }, + variationKey: 'Greeting' + }) + }) + ); + + expect(cache.bool).toEqual({ + key: 'bool', + value: false, + allocationKey: 'alloc-1', + variationKey: 'false', + variationType: 'boolean', + variationValue: 'false', + reason: 'STATIC', + doLog: false, + extraLogging: {} + }); + expect(cache.str.value).toBe('hello'); + expect(cache.str.variationValue).toBe('hello'); + expect(cache.num.value).toBe(42); + expect(cache.num.variationValue).toBe('42'); + // integer/float keep their wire variationType but decode to a JS number. + expect(cache.int.value).toBe(7); + expect(cache.int.variationType).toBe('integer'); + expect(cache.int.variationValue).toBe('7'); + expect(cache.flt.value).toBe(1.5); + expect(cache.flt.variationType).toBe('float'); + expect(cache.flt.variationValue).toBe('1.5'); + // objects are JSON-encoded for the string form; value stays an object. + expect(cache.obj.value).toEqual({ greeting: 'hi' }); + expect(cache.obj.variationValue).toBe('{"greeting":"hi"}'); + }); + + it('uses the flag map key as the entry key', () => { + const cache = decodePrecomputedFlags( + responseWith({ 'my-feature': flag({}) }) + ); + + expect(cache['my-feature'].key).toBe('my-feature'); + }); + + it('defaults missing extraLogging to an empty object', () => { + const cache = decodePrecomputedFlags( + responseWith({ f: flag({ extraLogging: undefined }) }) + ); + + expect(cache.f.extraLogging).toEqual({}); + }); + + it('tolerates a null serialId', () => { + const cache = decodePrecomputedFlags( + responseWith({ f: flag({ serialId: null }) }) + ); + + expect(cache.f.key).toBe('f'); + }); + + it('omits flags with an unsupported variation type and logs a warning', () => { + const cache = decodePrecomputedFlags( + responseWith({ + good: flag({}), + bad: flag({ variationType: 'timestamp' }) + }) + ); + + expect(cache.good).toBeDefined(); + expect(cache.bad).toBeUndefined(); + expect(InternalLog.log).toHaveBeenCalled(); + }); + + it('omits flags whose value does not match their variation type', () => { + const cache = decodePrecomputedFlags( + responseWith({ + mismatched: flag({ + variationType: 'number', + variationValue: 'not-a-number' + }) + }) + ); + + expect(cache.mismatched).toBeUndefined(); + expect(InternalLog.log).toHaveBeenCalled(); + }); + + it('throws UnsupportedConfigurationError for an obfuscated response', () => { + expect(() => + decodePrecomputedFlags(responseWith({ f: flag({}) }, true)) + ).toThrow(UnsupportedConfigurationError); + }); + + it('returns an empty map when there are no flags', () => { + expect(decodePrecomputedFlags(responseWith({}))).toEqual({}); + }); +}); diff --git a/packages/core/src/flags/configuration/__tests__/wire.test.ts b/packages/core/src/flags/configuration/__tests__/wire.test.ts new file mode 100644 index 000000000..199faad68 --- /dev/null +++ b/packages/core/src/flags/configuration/__tests__/wire.test.ts @@ -0,0 +1,119 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { ParsedFlagsConfiguration } from '../types'; +import { configurationFromString, configurationToString } from '../wire'; + +const buildResponse = () => ({ + data: { + id: '2', + type: 'precomputed-assignments', + attributes: { + obfuscated: false, + createdAt: '2026-07-06T23:01:56.822171460Z', + format: 'PRECOMPUTED', + environment: { name: 'Staging' }, + flags: { + 'a-flag': { + variationType: 'boolean', + variationValue: true, + variationKey: 'true', + allocationKey: 'alloc-1', + reason: 'STATIC', + doLog: false, + extraLogging: {} + } + } + } + } +}); + +const buildWire = (overrides: Record = {}) => + JSON.stringify({ + version: 1, + precomputed: { + response: JSON.stringify(buildResponse()), + context: { targetingKey: 'user-1', country: 'US' }, + fetchedAt: 1748449320785 + }, + ...overrides + }); + +describe('configurationFromString', () => { + it('parses a valid v1 wire with a precomputed branch', () => { + const config = configurationFromString(buildWire()); + + expect(config.precomputed).toBeDefined(); + expect(config.precomputed?.context).toEqual({ + targetingKey: 'user-1', + country: 'US' + }); + expect(config.precomputed?.fetchedAt).toBe(1748449320785); + // The inner `response` string is parsed into an object. + expect( + config.precomputed?.response.data.attributes.flags['a-flag'] + .variationValue + ).toBe(true); + }); + + it('returns an empty config for an unsupported version', () => { + const wire = JSON.stringify({ + version: 2, + precomputed: { response: JSON.stringify(buildResponse()) } + }); + + expect(configurationFromString(wire)).toEqual({}); + }); + + it('returns an empty config for invalid JSON', () => { + expect(configurationFromString('not json')).toEqual({}); + }); + + it('returns an empty config when the inner response is invalid JSON', () => { + const wire = JSON.stringify({ + version: 1, + precomputed: { response: '{ not json' } + }); + + expect(configurationFromString(wire)).toEqual({}); + }); + + it('returns a config with no precomputed branch when none is present', () => { + const wire = JSON.stringify({ version: 1 }); + + expect(configurationFromString(wire)).toEqual({}); + }); + + it('does not populate a precomputed branch from a server-only wire (MVP)', () => { + const wire = JSON.stringify({ + version: 1, + server: { response: '{}' } + }); + + const config = configurationFromString(wire); + expect(config.precomputed).toBeUndefined(); + }); +}); + +describe('configurationToString round-trip', () => { + it('round-trips a precomputed configuration', () => { + const original = configurationFromString(buildWire()); + + const restored = configurationFromString( + configurationToString(original) + ); + + expect(restored).toEqual(original); + }); + + it('serializes an empty configuration to a v1 wire', () => { + const empty: ParsedFlagsConfiguration = {}; + + expect(configurationToString(empty)).toBe( + JSON.stringify({ version: 1 }) + ); + }); +}); diff --git a/packages/core/src/flags/configuration/index.ts b/packages/core/src/flags/configuration/index.ts new file mode 100644 index 000000000..3890eefc3 --- /dev/null +++ b/packages/core/src/flags/configuration/index.ts @@ -0,0 +1,23 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +// Internal module boundary for portable-configuration handling. Intentionally NOT +// re-exported from the package's public entry point (`packages/core/src/index.tsx`) +// until the exports step (FFL-2690). Keeping the surface behind this boundary makes a +// future "port -> depend on a shared core" swap contained. + +export { configurationFromString, configurationToString } from './wire'; +export { + decodePrecomputedFlags, + UnsupportedConfigurationError +} from './precomputed'; +export type { + ParsedFlagsConfiguration, + ParsedPrecomputedConfiguration, + PrecomputedConfigurationResponse, + PrecomputedFlag, + WireEvaluationContext +} from './types'; diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts new file mode 100644 index 000000000..7aebb8110 --- /dev/null +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -0,0 +1,135 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { InternalLog } from '../../InternalLog'; +import { SdkVerbosity } from '../../config/types/SdkVerbosity'; +import type { FlagCacheEntry } from '../internal'; + +import type { + PrecomputedConfigurationResponse, + PrecomputedFlag +} from './types'; +import { SUPPORTED_VARIATION_TYPES } from './types'; + +/** + * Thrown when a configuration cannot be supported by this SDK (e.g. an obfuscated + * precomputed payload). Callers translate this into a provider error state rather + * than silently serving wrong data. + */ +export class UnsupportedConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = 'UnsupportedConfigurationError'; + } +} + +/** + * Decode a precomputed CDN response into the `FlagCacheEntry` map that `FlagsClient` + * caches and evaluates against — the same shape the native CDN fetch returns today. + * + * The mapping is ~1:1. Two transforms are applied per flag: + * - `value` is the typed `variationValue` as-is (`integer`/`float` are JS `number`s); + * - a string `variationValue` is derived (JSON for objects, `"true"/"false"` for + * booleans, `String(...)` otherwise) because native Android exposure tracking + * rebuilds the flag from the string form. + * + * @throws {UnsupportedConfigurationError} if the response is obfuscated. + */ +export const decodePrecomputedFlags = ( + response: PrecomputedConfigurationResponse +): Record => { + const attributes = response?.data?.attributes; + + if (attributes?.obfuscated) { + // Obfuscated payloads would need key de-hashing / value decoding that this SDK + // does not implement. Fail predictably instead of mis-mapping hashed keys. + throw new UnsupportedConfigurationError( + 'Obfuscated precomputed configurations are not supported.' + ); + } + + const flags = attributes?.flags ?? {}; + const cache: Record = {}; + + for (const [key, flag] of Object.entries(flags)) { + const entry = toFlagCacheEntry(key, flag); + if (entry) { + cache[key] = entry; + } + } + + return cache; +}; + +const toFlagCacheEntry = ( + key: string, + flag: PrecomputedFlag +): FlagCacheEntry | null => { + const { variationType, variationValue } = flag; + + if (!SUPPORTED_VARIATION_TYPES.has(variationType)) { + InternalLog.log( + `Flag "${key}" has unsupported variation type "${variationType}". Omitting it from the configuration.`, + SdkVerbosity.WARN + ); + return null; + } + + if (!valueMatchesVariationType(variationValue, variationType)) { + InternalLog.log( + `Flag "${key}" value does not match its variation type "${variationType}". Omitting it from the configuration.`, + SdkVerbosity.WARN + ); + return null; + } + + return { + key, + value: variationValue, + allocationKey: flag.allocationKey, + variationKey: flag.variationKey, + variationType, + variationValue: stringifyValue(variationValue), + reason: flag.reason, + doLog: flag.doLog, + extraLogging: flag.extraLogging ?? {} + }; +}; + +const valueMatchesVariationType = ( + value: unknown, + variationType: string +): boolean => { + switch (variationType) { + case 'boolean': + return typeof value === 'boolean'; + case 'string': + return typeof value === 'string'; + case 'number': + case 'integer': + case 'float': + return typeof value === 'number'; + case 'object': + return typeof value === 'object' && value !== null; + default: + return false; + } +}; + +/** + * Derive the string form of a flag value expected by native Android exposure tracking. + * Objects/arrays are JSON-encoded; everything else uses `String(...)`, which yields + * lowercase `"true"/"false"` for booleans. + */ +const stringifyValue = (value: unknown): string => { + if (value === null) { + return 'null'; + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); +}; diff --git a/packages/core/src/flags/configuration/types.ts b/packages/core/src/flags/configuration/types.ts new file mode 100644 index 000000000..898419072 --- /dev/null +++ b/packages/core/src/flags/configuration/types.ts @@ -0,0 +1,119 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** + * The set of `ConfigurationWire` versions this SDK can parse. A known set (rather + * than a single hardcoded value) leaves room for the future `server`/rules format + * to bump the version without forcing a parser change. + */ +export const SUPPORTED_WIRE_VERSIONS: ReadonlySet = new Set([1]); + +/** + * The flag `variationType`s emitted by the precomputed CDN response. + * + * `integer` and `float` are distinct on the wire but both map to a JavaScript + * `number` when decoded (JS has no int/float distinction). The original string is + * preserved on the {@link FlagCacheEntry} so native exposure tracking round-trips. + */ +export const SUPPORTED_VARIATION_TYPES: ReadonlySet = new Set([ + 'boolean', + 'string', + 'number', + 'integer', + 'float', + 'object' +]); + +/** + * The context an evaluation is performed against, as it appears **on the wire**. + * + * This is the OpenFeature-shaped context: a flat object with an optional + * `targetingKey` and arbitrary sibling attributes. It is intentionally different + * from the SDK's internal `EvaluationContext` (`{ targetingKey, attributes }`); + * callers must normalize before comparing the two. + */ +export type WireEvaluationContext = { + targetingKey?: string; +} & Record; + +/** + * A single precomputed flag assignment as it appears inside the CDN response. + * + * `variationValue` is the already-typed value (e.g. the boolean `false`, the number + * `1.5`, or a JSON object), not a string. + */ +export interface PrecomputedFlag { + variationType: string; + variationValue: unknown; + variationKey: string; + allocationKey: string; + reason: string; + doLog: boolean; + extraLogging?: Record; + serialId?: number | null; +} + +/** + * The precomputed assignments payload returned by the CDN (the JSON that is encoded + * as the `precomputed.response` string on the wire). + */ +export interface PrecomputedConfigurationResponse { + data: { + id?: string; + type?: string; + attributes: { + obfuscated?: boolean; + createdAt?: string; + format?: string; + environment?: { name?: string }; + flags: Record; + }; + }; +} + +/** + * In-memory precomputed configuration: the parsed CDN response plus the metadata + * that travelled alongside it on the wire. + */ +export interface ParsedPrecomputedConfiguration { + /** The parsed CDN response (decoded from the wire's `response` string). */ + response: PrecomputedConfigurationResponse; + /** The evaluation context the assignments were computed for, if any. */ + context?: WireEvaluationContext; + /** Milliseconds since the Unix epoch when the configuration was fetched. */ + fetchedAt?: number; +} + +/** + * The in-memory configuration the SDK operates on, parsed from a `ConfigurationWire` + * string via {@link configurationFromString}. + * + * Named distinctly from the `enable()` options type (`FlagsConfiguration`) to avoid a + * collision. Modelled with an optional `precomputed` branch and reserved space for a + * future `server` (rules/UFC) branch so the type is additive. + */ +export interface ParsedFlagsConfiguration { + precomputed?: ParsedPrecomputedConfiguration; + // server?: ParsedServerConfiguration; // future rules/UFC branch — not implemented +} + +/** + * The serialized `ConfigurationWire` envelope (version 1). Internal to this module; + * the only public entry/exit points are {@link configurationFromString} / + * {@link configurationToString}. + */ +export interface ConfigurationWire { + version: number; + precomputed?: { + response: string; + context?: WireEvaluationContext; + fetchedAt?: number; + }; + server?: { + response: string; + fetchedAt?: number; + }; +} diff --git a/packages/core/src/flags/configuration/wire.ts b/packages/core/src/flags/configuration/wire.ts new file mode 100644 index 000000000..8b41449b2 --- /dev/null +++ b/packages/core/src/flags/configuration/wire.ts @@ -0,0 +1,78 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { + ConfigurationWire, + ParsedFlagsConfiguration, + PrecomputedConfigurationResponse +} from './types'; +import { SUPPORTED_WIRE_VERSIONS } from './types'; + +/** + * Parse a portable `ConfigurationWire` string into an in-memory + * {@link ParsedFlagsConfiguration}. + * + * Parsing is **lenient**: an empty configuration (`{}`) is returned for malformed + * input or an unsupported wire version rather than throwing. Predictable failure is + * surfaced later, at the `setConfiguration`/provider layer, from an empty/absent + * configuration. + * + * @param wire A `ConfigurationWire` string (as produced by {@link configurationToString}). + */ +export const configurationFromString = ( + wire: string +): ParsedFlagsConfiguration => { + try { + const parsed: ConfigurationWire = JSON.parse(wire); + + if (!SUPPORTED_WIRE_VERSIONS.has(parsed?.version)) { + return {}; + } + + const configuration: ParsedFlagsConfiguration = {}; + + if (parsed.precomputed) { + const response: PrecomputedConfigurationResponse = JSON.parse( + parsed.precomputed.response + ); + + configuration.precomputed = { + response, + context: parsed.precomputed.context, + fetchedAt: parsed.precomputed.fetchedAt + }; + } + + // The `server` (rules/UFC) branch is intentionally not parsed here — it is + // reserved for future work. Leaving it unhandled keeps this MVP precomputed-only. + + return configuration; + } catch { + return {}; + } +}; + +/** + * Serialize an in-memory {@link ParsedFlagsConfiguration} back into a portable + * `ConfigurationWire` string that {@link configurationFromString} can read. + * + * The serialized format is unspecified/opaque and may change between versions. + */ +export const configurationToString = ( + configuration: ParsedFlagsConfiguration +): string => { + const wire: ConfigurationWire = { version: 1 }; + + if (configuration.precomputed) { + wire.precomputed = { + response: JSON.stringify(configuration.precomputed.response), + context: configuration.precomputed.context, + fetchedAt: configuration.precomputed.fetchedAt + }; + } + + return JSON.stringify(wire); +}; From dae07c37140222d949bd8cc0cc297f88cdadaedd Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 14:10:42 -0400 Subject: [PATCH 24/64] fix(flags): harden precomputed decode against __proto__ keys and array object values Review follow-ups on PR1: - H1: accumulate decoded flags in a Map and materialize with Object.fromEntries, so a flag keyed "__proto__" is stored as data instead of hitting the Object.prototype setter (which silently dropped it and reassigned the proto). - H2: reject array values for object-typed flags (object flags are a JSON object at the root). Arrays are omitted plus logged, like other type mismatches. Adds regression tests for both. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/precomputed.test.ts | 27 +++++++++++++++++++ .../src/flags/configuration/precomputed.ts | 17 +++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts index cce1fa183..5a0b2ac26 100644 --- a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts +++ b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts @@ -177,4 +177,31 @@ describe('decodePrecomputedFlags', () => { it('returns an empty map when there are no flags', () => { expect(decodePrecomputedFlags(responseWith({}))).toEqual({}); }); + + it('rejects an array value for an object flag', () => { + const cache = decodePrecomputedFlags( + responseWith({ + arr: flag({ + variationType: 'object', + variationValue: [1, 2, 3] + }) + }) + ); + + expect(cache.arr).toBeUndefined(); + expect(InternalLog.log).toHaveBeenCalled(); + }); + + it('stores a flag keyed "__proto__" as data without polluting the prototype', () => { + // Computed key mirrors how JSON.parse yields an own "__proto__" property. + const cache = decodePrecomputedFlags( + responseWith({ ['__proto__']: flag({ variationValue: true }) }) + ); + + // Stored as an own data property, not via the Object.prototype setter. + expect(Object.getPrototypeOf(cache)).toBe(Object.prototype); + expect(Object.keys(cache)).toContain('__proto__'); + // No global prototype pollution. + expect(({} as Record).variationType).toBeUndefined(); + }); }); diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index 7aebb8110..270c28d54 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -52,16 +52,20 @@ export const decodePrecomputedFlags = ( } const flags = attributes?.flags ?? {}; - const cache: Record = {}; + // Accumulate in a Map so a pathological flag keyed "__proto__" is stored as data + // instead of hitting the `Object.prototype` "__proto__" setter (which a plain + // `obj[key] = ...` assignment would). `Object.fromEntries` then materializes own + // properties without invoking inherited setters. + const cache = new Map(); for (const [key, flag] of Object.entries(flags)) { const entry = toFlagCacheEntry(key, flag); if (entry) { - cache[key] = entry; + cache.set(key, entry); } } - return cache; + return Object.fromEntries(cache); }; const toFlagCacheEntry = ( @@ -113,7 +117,12 @@ const valueMatchesVariationType = ( case 'float': return typeof value === 'number'; case 'object': - return typeof value === 'object' && value !== null; + // Object flags are a JSON object at the root; arrays are not valid values. + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ); default: return false; } From b020b1798487bd59517e4bee2e8fd7f26e8d87b4 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 14:36:53 -0400 Subject: [PATCH 25/64] refactor(flags): tighten numeric validation, document serialId/metadata (review M/L) Remaining PR1 review follow-ups: - M1: integer flags require a whole number; number/float require a finite value (reject NaN/Infinity) so native parsers round-trip. - M2: document that serialId is intentionally not propagated (no FlagCacheEntry slot; native snapshot omits it too). - L3: comment that only flags (and obfuscated) are load-bearing; the rest of the response attributes are optional/ignored on purpose. - L1/L5: broaden round-trip fixture (number + nested object flags) and add tests for fractional integer, non-finite number, and a structurally broken response. L2 (distinguishing parse-failure from valid-but-empty) is intentionally left to PR2 (FFL-2688). L4 (configurationToString vs the reference's bug) is correct as-is. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/precomputed.test.ts | 29 +++++++++++++++++++ .../configuration/__tests__/wire.test.ts | 18 ++++++++++++ .../src/flags/configuration/precomputed.ts | 11 +++++-- .../core/src/flags/configuration/types.ts | 3 ++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts index 5a0b2ac26..f6c3b0f28 100644 --- a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts +++ b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts @@ -178,6 +178,35 @@ describe('decodePrecomputedFlags', () => { expect(decodePrecomputedFlags(responseWith({}))).toEqual({}); }); + it('omits an integer flag with a fractional value', () => { + const cache = decodePrecomputedFlags( + responseWith({ + frac: flag({ variationType: 'integer', variationValue: 7.9 }) + }) + ); + + expect(cache.frac).toBeUndefined(); + expect(InternalLog.log).toHaveBeenCalled(); + }); + + it('omits a number flag whose value is not finite', () => { + const cache = decodePrecomputedFlags( + responseWith({ + inf: flag({ variationType: 'number', variationValue: Infinity }) + }) + ); + + expect(cache.inf).toBeUndefined(); + }); + + it('returns an empty map for a structurally broken response', () => { + expect( + decodePrecomputedFlags( + ({} as unknown) as Parameters[0] + ) + ).toEqual({}); + }); + it('rejects an array value for an object flag', () => { const cache = decodePrecomputedFlags( responseWith({ diff --git a/packages/core/src/flags/configuration/__tests__/wire.test.ts b/packages/core/src/flags/configuration/__tests__/wire.test.ts index 199faad68..1e6c9da8a 100644 --- a/packages/core/src/flags/configuration/__tests__/wire.test.ts +++ b/packages/core/src/flags/configuration/__tests__/wire.test.ts @@ -25,6 +25,24 @@ const buildResponse = () => ({ reason: 'STATIC', doLog: false, extraLogging: {} + }, + 'num-flag': { + variationType: 'number', + variationValue: 1.5, + variationKey: '1.5', + allocationKey: 'alloc-2', + reason: 'STATIC', + doLog: true, + extraLogging: {} + }, + 'obj-flag': { + variationType: 'object', + variationValue: { nested: { a: 1 }, list: [1, 2] }, + variationKey: 'obj', + allocationKey: 'alloc-3', + reason: 'TARGETING_MATCH', + doLog: false, + extraLogging: { extra: 'x' } } } } diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index 270c28d54..5ab10bb59 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -90,6 +90,9 @@ const toFlagCacheEntry = ( return null; } + // `serialId` is intentionally not propagated: `FlagCacheEntry` has no slot for it + // and the native CDN-fetched snapshot omits it too, so dropping it keeps + // offline/online parity. return { key, value: variationValue, @@ -113,9 +116,13 @@ const valueMatchesVariationType = ( case 'string': return typeof value === 'string'; case 'number': - case 'integer': case 'float': - return typeof value === 'number'; + // Reject NaN/Infinity: native parsers can't round-trip them. + return typeof value === 'number' && Number.isFinite(value); + case 'integer': + // A fractional value under an integer flag would be truncated/mis-parsed + // natively, so require a whole number. + return Number.isInteger(value); case 'object': // Object flags are a JSON object at the root; arrays are not valid values. return ( diff --git a/packages/core/src/flags/configuration/types.ts b/packages/core/src/flags/configuration/types.ts index 898419072..b099812b1 100644 --- a/packages/core/src/flags/configuration/types.ts +++ b/packages/core/src/flags/configuration/types.ts @@ -65,6 +65,9 @@ export interface PrecomputedConfigurationResponse { id?: string; type?: string; attributes: { + // Only `flags` is load-bearing (and `obfuscated`, which gates support). The + // remaining fields are metadata the decoder ignores; typed loosely/optional + // on purpose so payload variation across environments doesn't matter. obfuscated?: boolean; createdAt?: string; format?: string; From 894b1fcab61a2a0f0225e1cee4e8acf4aedeb321 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 8 Jul 2026 09:32:40 -0400 Subject: [PATCH 26/64] refactor(flags): pin ConfigurationWire version to literal 1 (PR1 review) Model the wire version as the literal type `version: 1` instead of `number`, and drop the SUPPORTED_WIRE_VERSIONS Set in favor of a direct `parsed.version !== 1` check. The literal type is now the single, type-checked source for the supported version (configurationToString's `{ version: 1 }` is validated against it), so no separate constant is needed. A future incompatible wire shape should be modeled as a versioned discriminated union rather than a widened number. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/configuration/types.ts | 9 +-------- packages/core/src/flags/configuration/wire.ts | 7 ++++--- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/core/src/flags/configuration/types.ts b/packages/core/src/flags/configuration/types.ts index b099812b1..6d13741ed 100644 --- a/packages/core/src/flags/configuration/types.ts +++ b/packages/core/src/flags/configuration/types.ts @@ -4,13 +4,6 @@ * Copyright 2016-Present Datadog, Inc. */ -/** - * The set of `ConfigurationWire` versions this SDK can parse. A known set (rather - * than a single hardcoded value) leaves room for the future `server`/rules format - * to bump the version without forcing a parser change. - */ -export const SUPPORTED_WIRE_VERSIONS: ReadonlySet = new Set([1]); - /** * The flag `variationType`s emitted by the precomputed CDN response. * @@ -109,7 +102,7 @@ export interface ParsedFlagsConfiguration { * {@link configurationToString}. */ export interface ConfigurationWire { - version: number; + version: 1; precomputed?: { response: string; context?: WireEvaluationContext; diff --git a/packages/core/src/flags/configuration/wire.ts b/packages/core/src/flags/configuration/wire.ts index 8b41449b2..0f91125e4 100644 --- a/packages/core/src/flags/configuration/wire.ts +++ b/packages/core/src/flags/configuration/wire.ts @@ -9,7 +9,6 @@ import type { ParsedFlagsConfiguration, PrecomputedConfigurationResponse } from './types'; -import { SUPPORTED_WIRE_VERSIONS } from './types'; /** * Parse a portable `ConfigurationWire` string into an in-memory @@ -26,9 +25,11 @@ export const configurationFromString = ( wire: string ): ParsedFlagsConfiguration => { try { - const parsed: ConfigurationWire = JSON.parse(wire); + const parsed: Partial = JSON.parse(wire); - if (!SUPPORTED_WIRE_VERSIONS.has(parsed?.version)) { + // Only version 1 is supported. Any other version (or none) is treated as + // an unusable empty configuration rather than throwing. + if (parsed?.version !== 1) { return {}; } From 3e9edd3d42c9a7cb4d2ec1129a414b63fa95a916 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 8 Jul 2026 09:51:04 -0400 Subject: [PATCH 27/64] refactor(flags): remove speculative server wire branch (PR1 review) Drop the unimplemented `server` (rules/UFC) branch from ConfigurationWire and ParsedFlagsConfiguration, along with its doc/parse comments and the server-only test. It was speculative future scope (YAGNI) with no reader. The remaining 'no precomputed branch yields empty config' behavior is still covered by the adjacent empty-wire test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/flags/configuration/__tests__/wire.test.ts | 10 ---------- packages/core/src/flags/configuration/types.ts | 8 +------- packages/core/src/flags/configuration/wire.ts | 3 --- 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/packages/core/src/flags/configuration/__tests__/wire.test.ts b/packages/core/src/flags/configuration/__tests__/wire.test.ts index 1e6c9da8a..23f1d359b 100644 --- a/packages/core/src/flags/configuration/__tests__/wire.test.ts +++ b/packages/core/src/flags/configuration/__tests__/wire.test.ts @@ -104,16 +104,6 @@ describe('configurationFromString', () => { expect(configurationFromString(wire)).toEqual({}); }); - - it('does not populate a precomputed branch from a server-only wire (MVP)', () => { - const wire = JSON.stringify({ - version: 1, - server: { response: '{}' } - }); - - const config = configurationFromString(wire); - expect(config.precomputed).toBeUndefined(); - }); }); describe('configurationToString round-trip', () => { diff --git a/packages/core/src/flags/configuration/types.ts b/packages/core/src/flags/configuration/types.ts index 6d13741ed..969abfcca 100644 --- a/packages/core/src/flags/configuration/types.ts +++ b/packages/core/src/flags/configuration/types.ts @@ -88,12 +88,10 @@ export interface ParsedPrecomputedConfiguration { * string via {@link configurationFromString}. * * Named distinctly from the `enable()` options type (`FlagsConfiguration`) to avoid a - * collision. Modelled with an optional `precomputed` branch and reserved space for a - * future `server` (rules/UFC) branch so the type is additive. + * collision. */ export interface ParsedFlagsConfiguration { precomputed?: ParsedPrecomputedConfiguration; - // server?: ParsedServerConfiguration; // future rules/UFC branch — not implemented } /** @@ -108,8 +106,4 @@ export interface ConfigurationWire { context?: WireEvaluationContext; fetchedAt?: number; }; - server?: { - response: string; - fetchedAt?: number; - }; } diff --git a/packages/core/src/flags/configuration/wire.ts b/packages/core/src/flags/configuration/wire.ts index 0f91125e4..128ae0136 100644 --- a/packages/core/src/flags/configuration/wire.ts +++ b/packages/core/src/flags/configuration/wire.ts @@ -47,9 +47,6 @@ export const configurationFromString = ( }; } - // The `server` (rules/UFC) branch is intentionally not parsed here — it is - // reserved for future work. Leaving it unhandled keeps this MVP precomputed-only. - return configuration; } catch { return {}; From 988e93e63029c4dc4b8845d37d35203bbb487d0f Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 8 Jul 2026 10:14:34 -0400 Subject: [PATCH 28/64] refactor(flags): harden precomputed decode and log wire parse failures (PR1 review) - Guard toFlagCacheEntry against non-object flag entries (e.g. a null value) so one malformed flag is skipped with a warning instead of throwing and aborting the whole decode. - Validate the forwarded metadata fields (allocationKey, variationKey, reason, doLog, extraLogging) and omit the flag when their types are wrong, so a corrupt payload cannot propagate bad data into evaluation/tracking. - Destructure the flag once and build the cache entry from the locals for a cleaner return object. - Log an InternalLog WARN when configurationFromString fails to parse, instead of swallowing the error silently. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/precomputed.test.ts | 26 ++++++++ .../configuration/__tests__/wire.test.ts | 15 ++++- .../src/flags/configuration/precomputed.ts | 59 ++++++++++++++++--- packages/core/src/flags/configuration/wire.ts | 11 +++- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts index f6c3b0f28..6a4afb7ed 100644 --- a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts +++ b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts @@ -168,6 +168,32 @@ describe('decodePrecomputedFlags', () => { expect(InternalLog.log).toHaveBeenCalled(); }); + it('omits a non-object flag entry and keeps the valid ones', () => { + const cache = decodePrecomputedFlags( + responseWith({ + good: flag({}), + bad: (null as unknown) as PrecomputedFlag + }) + ); + + expect(cache.good).toBeDefined(); + expect(cache.bad).toBeUndefined(); + expect(InternalLog.log).toHaveBeenCalled(); + }); + + it('omits a flag with malformed metadata field types', () => { + const cache = decodePrecomputedFlags( + responseWith({ + badReason: flag({ reason: (42 as unknown) as string }), + badDoLog: flag({ doLog: ('yes' as unknown) as boolean }) + }) + ); + + expect(cache.badReason).toBeUndefined(); + expect(cache.badDoLog).toBeUndefined(); + expect(InternalLog.log).toHaveBeenCalled(); + }); + it('throws UnsupportedConfigurationError for an obfuscated response', () => { expect(() => decodePrecomputedFlags(responseWith({ f: flag({}) }, true)) diff --git a/packages/core/src/flags/configuration/__tests__/wire.test.ts b/packages/core/src/flags/configuration/__tests__/wire.test.ts index 23f1d359b..0ae2f056d 100644 --- a/packages/core/src/flags/configuration/__tests__/wire.test.ts +++ b/packages/core/src/flags/configuration/__tests__/wire.test.ts @@ -4,9 +4,21 @@ * Copyright 2016-Present Datadog, Inc. */ +import { InternalLog } from '../../../InternalLog'; import type { ParsedFlagsConfiguration } from '../types'; import { configurationFromString, configurationToString } from '../wire'; +jest.mock('../../../InternalLog', () => { + return { + InternalLog: { log: jest.fn() }, + DATADOG_MESSAGE_PREFIX: 'DATADOG:' + }; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + const buildResponse = () => ({ data: { id: '2', @@ -86,8 +98,9 @@ describe('configurationFromString', () => { expect(configurationFromString(wire)).toEqual({}); }); - it('returns an empty config for invalid JSON', () => { + it('returns an empty config and logs a warning for invalid JSON', () => { expect(configurationFromString('not json')).toEqual({}); + expect(InternalLog.log).toHaveBeenCalled(); }); it('returns an empty config when the inner response is invalid JSON', () => { diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index 5ab10bb59..8951ed1aa 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -70,13 +70,37 @@ export const decodePrecomputedFlags = ( const toFlagCacheEntry = ( key: string, - flag: PrecomputedFlag + flag: unknown ): FlagCacheEntry | null => { - const { variationType, variationValue } = flag; + // A malformed payload can carry a non-object flag (e.g. `flags: { "k": null }`). + // Skip the bad entry with a warning instead of throwing and aborting decoding of + // the whole configuration. + if (typeof flag !== 'object' || flag === null) { + InternalLog.log( + `Flag "${key}" is not an object. Omitting it from the configuration.`, + SdkVerbosity.WARN + ); + return null; + } + + const { + variationType, + variationValue, + variationKey, + allocationKey, + reason, + doLog, + extraLogging + } = flag as Partial; - if (!SUPPORTED_VARIATION_TYPES.has(variationType)) { + if ( + typeof variationType !== 'string' || + !SUPPORTED_VARIATION_TYPES.has(variationType) + ) { InternalLog.log( - `Flag "${key}" has unsupported variation type "${variationType}". Omitting it from the configuration.`, + `Flag "${key}" has an unsupported variation type "${String( + variationType + )}". Omitting it from the configuration.`, SdkVerbosity.WARN ); return null; @@ -90,19 +114,36 @@ const toFlagCacheEntry = ( return null; } + // The remaining fields feed evaluation and native exposure tracking. A corrupt + // payload could carry wrong types here, so validate before forwarding them. + if ( + typeof allocationKey !== 'string' || + typeof variationKey !== 'string' || + typeof reason !== 'string' || + typeof doLog !== 'boolean' || + (extraLogging !== undefined && + (typeof extraLogging !== 'object' || extraLogging === null)) + ) { + InternalLog.log( + `Flag "${key}" has malformed metadata. Omitting it from the configuration.`, + SdkVerbosity.WARN + ); + return null; + } + // `serialId` is intentionally not propagated: `FlagCacheEntry` has no slot for it // and the native CDN-fetched snapshot omits it too, so dropping it keeps // offline/online parity. return { key, value: variationValue, - allocationKey: flag.allocationKey, - variationKey: flag.variationKey, + allocationKey, + variationKey, variationType, variationValue: stringifyValue(variationValue), - reason: flag.reason, - doLog: flag.doLog, - extraLogging: flag.extraLogging ?? {} + reason, + doLog, + extraLogging: extraLogging ?? {} }; }; diff --git a/packages/core/src/flags/configuration/wire.ts b/packages/core/src/flags/configuration/wire.ts index 128ae0136..d8691349b 100644 --- a/packages/core/src/flags/configuration/wire.ts +++ b/packages/core/src/flags/configuration/wire.ts @@ -4,6 +4,9 @@ * Copyright 2016-Present Datadog, Inc. */ +import { InternalLog } from '../../InternalLog'; +import { SdkVerbosity } from '../../config/types/SdkVerbosity'; + import type { ConfigurationWire, ParsedFlagsConfiguration, @@ -48,7 +51,13 @@ export const configurationFromString = ( } return configuration; - } catch { + } catch (error) { + InternalLog.log( + `Failed to parse the ConfigurationWire string: ${ + error instanceof Error ? error.message : String(error) + }`, + SdkVerbosity.WARN + ); return {}; } }; From 840ff38f020d5a06393036aafce2e6359c89d14b Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 8 Jul 2026 10:30:15 -0400 Subject: [PATCH 29/64] fix(flags): accept any JSON value for object-typed flags (PR1 review) The decoder previously required an object variation value to be a non-array, non-null object. ffe-service enforces a top-level object at the API layer, but that is not a storage constraint, so a precomputed payload could carry any JSON value. Accept it defensively. Traced the downstream paths: stringifyValue handles arrays/null/primitives, and getDetails validates the value type at evaluation time (typeof flag.value), so a non-object value under an object flag falls back to the default via TYPE_MISMATCH rather than mis-serving. Native tracking only runs post type-check. No path throws. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/precomputed.test.ts | 20 ++++++++++++++++--- .../src/flags/configuration/precomputed.ts | 12 +++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts index 6a4afb7ed..28b2e6928 100644 --- a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts +++ b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts @@ -233,18 +233,32 @@ describe('decodePrecomputedFlags', () => { ).toEqual({}); }); - it('rejects an array value for an object flag', () => { + it('accepts any JSON value for an object flag (array, null, primitive)', () => { + // ffe-service enforces a top-level object at the API layer, but that is not a + // storage constraint, so the decoder accepts whatever JSON arrives here. const cache = decodePrecomputedFlags( responseWith({ arr: flag({ variationType: 'object', variationValue: [1, 2, 3] + }), + nul: flag({ + variationType: 'object', + variationValue: null + }), + str: flag({ + variationType: 'object', + variationValue: 'hi' }) }) ); - expect(cache.arr).toBeUndefined(); - expect(InternalLog.log).toHaveBeenCalled(); + expect(cache.arr.value).toEqual([1, 2, 3]); + expect(cache.arr.variationValue).toBe('[1,2,3]'); + expect(cache.nul.value).toBeNull(); + expect(cache.nul.variationValue).toBe('null'); + expect(cache.str.value).toBe('hi'); + expect(cache.str.variationValue).toBe('hi'); }); it('stores a flag keyed "__proto__" as data without polluting the prototype', () => { diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index 8951ed1aa..c8d6bd14c 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -165,12 +165,12 @@ const valueMatchesVariationType = ( // natively, so require a whole number. return Number.isInteger(value); case 'object': - // Object flags are a JSON object at the root; arrays are not valid values. - return ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) - ); + // The `object` variation type carries arbitrary JSON — objects, arrays, + // numbers, strings, booleans, or null. ffe-service enforces a top-level + // object at the API layer, but that is not a storage constraint, so a + // payload could still carry any JSON value here. Accept it and rely on the + // value being defended downstream (string form + evaluation both tolerate it). + return true; default: return false; } From 3f7c10a09e93ef8908e79729cb34cb46eda4bc55 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 8 Jul 2026 13:28:34 -0400 Subject: [PATCH 30/64] refactor(flags): reuse @datadog/flagging-core wire types and parser (PR1 review) Depend on @datadog/flagging-core (and @openfeature/core, which its published types reference) and reuse its canonical wire types and configurationFromString instead of maintaining our own copies. Our local type names are kept as aliases so the rest of the SDK is insulated from upstream naming. configurationToString stays local for now, with a loud TODO to adopt flagging-core's once the next major (>= 2.0.0) ships the fix from openfeature-js-client PR #331 (its 1.2.x serializer is broken). Consequences of delegating to flagging-core's parser: the bespoke parse-failure InternalLog warning is dropped (its parser swallows errors), and obfuscated is read via a small cast since it is absent from flagging-core's response type. integer/float variation types are kept as defensive runtime handling even though the CDN and flagging-core model only boolean/string/number/object. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/package.json | 2 + .../configuration/__tests__/wire.test.ts | 15 +--- .../src/flags/configuration/precomputed.ts | 8 +- .../core/src/flags/configuration/types.ts | 90 +++++-------------- packages/core/src/flags/configuration/wire.ts | 71 ++++----------- yarn.lock | 18 ++++ 6 files changed, 62 insertions(+), 142 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 2658db3c0..5e53d53fe 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -69,6 +69,7 @@ "react-native": ">=0.63.4 <1.0" }, "devDependencies": { + "@openfeature/core": "^1.9.2", "@testing-library/react-native": "7.0.2", "react-native-builder-bob": "0.26.0" }, @@ -116,6 +117,7 @@ } }, "dependencies": { + "@datadog/flagging-core": "^1.2.1", "big-integer": "^1.6.52" } } diff --git a/packages/core/src/flags/configuration/__tests__/wire.test.ts b/packages/core/src/flags/configuration/__tests__/wire.test.ts index 0ae2f056d..23f1d359b 100644 --- a/packages/core/src/flags/configuration/__tests__/wire.test.ts +++ b/packages/core/src/flags/configuration/__tests__/wire.test.ts @@ -4,21 +4,9 @@ * Copyright 2016-Present Datadog, Inc. */ -import { InternalLog } from '../../../InternalLog'; import type { ParsedFlagsConfiguration } from '../types'; import { configurationFromString, configurationToString } from '../wire'; -jest.mock('../../../InternalLog', () => { - return { - InternalLog: { log: jest.fn() }, - DATADOG_MESSAGE_PREFIX: 'DATADOG:' - }; -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); - const buildResponse = () => ({ data: { id: '2', @@ -98,9 +86,8 @@ describe('configurationFromString', () => { expect(configurationFromString(wire)).toEqual({}); }); - it('returns an empty config and logs a warning for invalid JSON', () => { + it('returns an empty config for invalid JSON', () => { expect(configurationFromString('not json')).toEqual({}); - expect(InternalLog.log).toHaveBeenCalled(); }); it('returns an empty config when the inner response is invalid JSON', () => { diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index c8d6bd14c..d795c7c6e 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -43,9 +43,11 @@ export const decodePrecomputedFlags = ( ): Record => { const attributes = response?.data?.attributes; - if (attributes?.obfuscated) { - // Obfuscated payloads would need key de-hashing / value decoding that this SDK - // does not implement. Fail predictably instead of mis-mapping hashed keys. + // `obfuscated` is not part of flagging-core's response type, but the CDN payload + // carries it. Read it defensively so obfuscated payloads are still rejected: + // de-hashing keys / decoding values is not implemented here, so fail predictably + // instead of mis-mapping hashed keys. + if ((attributes as { obfuscated?: boolean } | undefined)?.obfuscated) { throw new UnsupportedConfigurationError( 'Obfuscated precomputed configurations are not supported.' ); diff --git a/packages/core/src/flags/configuration/types.ts b/packages/core/src/flags/configuration/types.ts index 969abfcca..0a21b8863 100644 --- a/packages/core/src/flags/configuration/types.ts +++ b/packages/core/src/flags/configuration/types.ts @@ -4,12 +4,18 @@ * Copyright 2016-Present Datadog, Inc. */ +import type { + FlagsConfiguration, + PrecomputedConfiguration, + PrecomputedConfigurationResponse as FlaggingCorePrecomputedConfigurationResponse, + PrecomputedFlag as FlaggingCorePrecomputedFlag +} from '@datadog/flagging-core'; + /** - * The flag `variationType`s emitted by the precomputed CDN response. - * - * `integer` and `float` are distinct on the wire but both map to a JavaScript - * `number` when decoded (JS has no int/float distinction). The original string is - * preserved on the {@link FlagCacheEntry} so native exposure tracking round-trips. + * The flag `variationType`s the decoder accepts. `@datadog/flagging-core` models the + * type as OpenFeature's `boolean | string | number | object`, and the CDN confirms only + * those are emitted. `integer`/`float` are kept here defensively — the decoder validates + * untrusted payloads and treats both as JavaScript `number`s. */ export const SUPPORTED_VARIATION_TYPES: ReadonlySet = new Set([ 'boolean', @@ -32,72 +38,18 @@ export type WireEvaluationContext = { targetingKey?: string; } & Record; -/** - * A single precomputed flag assignment as it appears inside the CDN response. - * - * `variationValue` is the already-typed value (e.g. the boolean `false`, the number - * `1.5`, or a JSON object), not a string. - */ -export interface PrecomputedFlag { - variationType: string; - variationValue: unknown; - variationKey: string; - allocationKey: string; - reason: string; - doLog: boolean; - extraLogging?: Record; - serialId?: number | null; -} - -/** - * The precomputed assignments payload returned by the CDN (the JSON that is encoded - * as the `precomputed.response` string on the wire). - */ -export interface PrecomputedConfigurationResponse { - data: { - id?: string; - type?: string; - attributes: { - // Only `flags` is load-bearing (and `obfuscated`, which gates support). The - // remaining fields are metadata the decoder ignores; typed loosely/optional - // on purpose so payload variation across environments doesn't matter. - obfuscated?: boolean; - createdAt?: string; - format?: string; - environment?: { name?: string }; - flags: Record; - }; - }; -} - -/** - * In-memory precomputed configuration: the parsed CDN response plus the metadata - * that travelled alongside it on the wire. - */ -export interface ParsedPrecomputedConfiguration { - /** The parsed CDN response (decoded from the wire's `response` string). */ - response: PrecomputedConfigurationResponse; - /** The evaluation context the assignments were computed for, if any. */ - context?: WireEvaluationContext; - /** Milliseconds since the Unix epoch when the configuration was fetched. */ - fetchedAt?: number; -} - -/** - * The in-memory configuration the SDK operates on, parsed from a `ConfigurationWire` - * string via {@link configurationFromString}. - * - * Named distinctly from the `enable()` options type (`FlagsConfiguration`) to avoid a - * collision. - */ -export interface ParsedFlagsConfiguration { - precomputed?: ParsedPrecomputedConfiguration; -} +// The wire/precomputed types are re-exported from `@datadog/flagging-core` so this SDK +// shares the canonical shapes instead of maintaining its own copies. Local names are +// kept so the rest of the SDK is insulated from the upstream naming. +export type PrecomputedFlag = FlaggingCorePrecomputedFlag; +export type PrecomputedConfigurationResponse = FlaggingCorePrecomputedConfigurationResponse; +export type ParsedPrecomputedConfiguration = PrecomputedConfiguration; +export type ParsedFlagsConfiguration = FlagsConfiguration; /** - * The serialized `ConfigurationWire` envelope (version 1). Internal to this module; - * the only public entry/exit points are {@link configurationFromString} / - * {@link configurationToString}. + * The serialized `ConfigurationWire` envelope (version 1). `@datadog/flagging-core` + * keeps its own wire envelope internal, so this mirrors the shape for our local + * {@link configurationToString} (see wire.ts). */ export interface ConfigurationWire { version: 1; diff --git a/packages/core/src/flags/configuration/wire.ts b/packages/core/src/flags/configuration/wire.ts index d8691349b..3608370cf 100644 --- a/packages/core/src/flags/configuration/wire.ts +++ b/packages/core/src/flags/configuration/wire.ts @@ -4,69 +4,28 @@ * Copyright 2016-Present Datadog, Inc. */ -import { InternalLog } from '../../InternalLog'; -import { SdkVerbosity } from '../../config/types/SdkVerbosity'; +import { configurationFromString } from '@datadog/flagging-core'; -import type { - ConfigurationWire, - ParsedFlagsConfiguration, - PrecomputedConfigurationResponse -} from './types'; +import type { ConfigurationWire, ParsedFlagsConfiguration } from './types'; -/** - * Parse a portable `ConfigurationWire` string into an in-memory - * {@link ParsedFlagsConfiguration}. - * - * Parsing is **lenient**: an empty configuration (`{}`) is returned for malformed - * input or an unsupported wire version rather than throwing. Predictable failure is - * surfaced later, at the `setConfiguration`/provider layer, from an empty/absent - * configuration. - * - * @param wire A `ConfigurationWire` string (as produced by {@link configurationToString}). - */ -export const configurationFromString = ( - wire: string -): ParsedFlagsConfiguration => { - try { - const parsed: Partial = JSON.parse(wire); - - // Only version 1 is supported. Any other version (or none) is treated as - // an unusable empty configuration rather than throwing. - if (parsed?.version !== 1) { - return {}; - } - - const configuration: ParsedFlagsConfiguration = {}; - - if (parsed.precomputed) { - const response: PrecomputedConfigurationResponse = JSON.parse( - parsed.precomputed.response - ); - - configuration.precomputed = { - response, - context: parsed.precomputed.context, - fetchedAt: parsed.precomputed.fetchedAt - }; - } - - return configuration; - } catch (error) { - InternalLog.log( - `Failed to parse the ConfigurationWire string: ${ - error instanceof Error ? error.message : String(error) - }`, - SdkVerbosity.WARN - ); - return {}; - } -}; +// Parsing is reused from `@datadog/flagging-core` (the canonical wire implementation) +// rather than reimplemented here. It is lenient: it returns an empty configuration +// (`{}`) for malformed input or an unsupported wire version rather than throwing. +export { configurationFromString }; /** * Serialize an in-memory {@link ParsedFlagsConfiguration} back into a portable - * `ConfigurationWire` string that {@link configurationFromString} can read. + * `ConfigurationWire` string that `configurationFromString` can read. * * The serialized format is unspecified/opaque and may change between versions. + * + * TODO: replace this with `@datadog/flagging-core`'s `configurationToString` once the + * next major version (>= 2.0.0) lands. flagging-core 1.2.x has a broken serializer + * (it stringifies the whole `precomputed` object into `precomputed.response` instead of + * just `.response`, which double-nests the response and drops every flag on a + * serialize→parse round-trip — https://github.com/DataDog/openfeature-js-client/pull/331). + * Until the fix ships, we keep this correct local copy and depend on flagging-core only + * for `configurationFromString`. */ export const configurationToString = ( configuration: ParsedFlagsConfiguration diff --git a/yarn.lock b/yarn.lock index 47f5dbc44..26788d988 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2722,6 +2722,15 @@ __metadata: languageName: node linkType: hard +"@datadog/flagging-core@npm:^1.2.1": + version: 1.2.1 + resolution: "@datadog/flagging-core@npm:1.2.1" + dependencies: + spark-md5: ^3.0.2 + checksum: 714e92b0fc43d9d14b79d9d235da0c97522332f3ba384ca8155b5c35817967286d012096282ee6975bf43d550358e750131ab0c3b00977dca2ffb0d5d9c3cd38 + languageName: node + linkType: hard + "@datadog/libdatadog@npm:^0.6.0": version: 0.6.0 resolution: "@datadog/libdatadog@npm:0.6.0" @@ -2863,6 +2872,8 @@ __metadata: version: 0.0.0-use.local resolution: "@datadog/mobile-react-native@workspace:packages/core" dependencies: + "@datadog/flagging-core": ^1.2.1 + "@openfeature/core": ^1.9.2 "@testing-library/react-native": 7.0.2 big-integer: ^1.6.52 react-native-builder-bob: 0.26.0 @@ -18126,6 +18137,13 @@ __metadata: languageName: node linkType: hard +"spark-md5@npm:^3.0.2": + version: 3.0.2 + resolution: "spark-md5@npm:3.0.2" + checksum: 5feebff0bfabcecf56ba03af3e38fdb068272ed41fbf0a94ff9ef65b9bb9cb1dd69be3684db6542e62497b1eac3ae324c07ac4dcb606465dc36ca048177077bf + languageName: node + linkType: hard + "spdx-correct@npm:^3.0.0": version: 3.2.0 resolution: "spdx-correct@npm:3.2.0" From 9b1487fe131a31267070b59c7f721908e93f53b3 Mon Sep 17 00:00:00 2001 From: Blake Date: Thu, 16 Jul 2026 14:33:39 -0400 Subject: [PATCH 31/64] refactor(flags): adopt flagging-core 2.0.0 and reuse configurationToString Bump @datadog/flagging-core ^1.2.1 to ^2.0.0 and re-export its configurationToString instead of keeping our local copy. The serializer bug our local copy worked around was fixed upstream (openfeature-js-client PR #331) and shipped in 2.0.0, so the local implementation and its now-unused ConfigurationWire type are removed. 2.0.0 changed PrecomputedConfiguration.fetchedAt from number to a branded TimeStamp (from @datadog/js-core, pulled in transitively). Nothing in this SDK reads fetchedAt, so that is a no-op for us. @openfeature/core remains a devDependency (flagging-core still only devDepends on it). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/package.json | 2 +- .../core/src/flags/configuration/types.ts | 14 ------ packages/core/src/flags/configuration/wire.ts | 47 ++++--------------- yarn.lock | 18 +++++-- 4 files changed, 23 insertions(+), 58 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 5e53d53fe..8d90b1e2b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -117,7 +117,7 @@ } }, "dependencies": { - "@datadog/flagging-core": "^1.2.1", + "@datadog/flagging-core": "^2.0.0", "big-integer": "^1.6.52" } } diff --git a/packages/core/src/flags/configuration/types.ts b/packages/core/src/flags/configuration/types.ts index 0a21b8863..f84e588d6 100644 --- a/packages/core/src/flags/configuration/types.ts +++ b/packages/core/src/flags/configuration/types.ts @@ -45,17 +45,3 @@ export type PrecomputedFlag = FlaggingCorePrecomputedFlag; export type PrecomputedConfigurationResponse = FlaggingCorePrecomputedConfigurationResponse; export type ParsedPrecomputedConfiguration = PrecomputedConfiguration; export type ParsedFlagsConfiguration = FlagsConfiguration; - -/** - * The serialized `ConfigurationWire` envelope (version 1). `@datadog/flagging-core` - * keeps its own wire envelope internal, so this mirrors the shape for our local - * {@link configurationToString} (see wire.ts). - */ -export interface ConfigurationWire { - version: 1; - precomputed?: { - response: string; - context?: WireEvaluationContext; - fetchedAt?: number; - }; -} diff --git a/packages/core/src/flags/configuration/wire.ts b/packages/core/src/flags/configuration/wire.ts index 3608370cf..952636fd0 100644 --- a/packages/core/src/flags/configuration/wire.ts +++ b/packages/core/src/flags/configuration/wire.ts @@ -4,41 +4,12 @@ * Copyright 2016-Present Datadog, Inc. */ -import { configurationFromString } from '@datadog/flagging-core'; - -import type { ConfigurationWire, ParsedFlagsConfiguration } from './types'; - -// Parsing is reused from `@datadog/flagging-core` (the canonical wire implementation) -// rather than reimplemented here. It is lenient: it returns an empty configuration -// (`{}`) for malformed input or an unsupported wire version rather than throwing. -export { configurationFromString }; - -/** - * Serialize an in-memory {@link ParsedFlagsConfiguration} back into a portable - * `ConfigurationWire` string that `configurationFromString` can read. - * - * The serialized format is unspecified/opaque and may change between versions. - * - * TODO: replace this with `@datadog/flagging-core`'s `configurationToString` once the - * next major version (>= 2.0.0) lands. flagging-core 1.2.x has a broken serializer - * (it stringifies the whole `precomputed` object into `precomputed.response` instead of - * just `.response`, which double-nests the response and drops every flag on a - * serialize→parse round-trip — https://github.com/DataDog/openfeature-js-client/pull/331). - * Until the fix ships, we keep this correct local copy and depend on flagging-core only - * for `configurationFromString`. - */ -export const configurationToString = ( - configuration: ParsedFlagsConfiguration -): string => { - const wire: ConfigurationWire = { version: 1 }; - - if (configuration.precomputed) { - wire.precomputed = { - response: JSON.stringify(configuration.precomputed.response), - context: configuration.precomputed.context, - fetchedAt: configuration.precomputed.fetchedAt - }; - } - - return JSON.stringify(wire); -}; +// Wire (de)serialization is reused from `@datadog/flagging-core` (the canonical +// implementation) rather than reimplemented here. `configurationFromString` is lenient: +// it returns an empty configuration (`{}`) for malformed input or an unsupported wire +// version rather than throwing. `configurationToString` is the inverse (its fix from +// https://github.com/DataDog/openfeature-js-client/pull/331 shipped in flagging-core 2.0.0). +export { + configurationFromString, + configurationToString +} from '@datadog/flagging-core'; diff --git a/yarn.lock b/yarn.lock index 26788d988..efedd86fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2722,12 +2722,20 @@ __metadata: languageName: node linkType: hard -"@datadog/flagging-core@npm:^1.2.1": - version: 1.2.1 - resolution: "@datadog/flagging-core@npm:1.2.1" +"@datadog/flagging-core@npm:^2.0.0": + version: 2.0.0 + resolution: "@datadog/flagging-core@npm:2.0.0" dependencies: + "@datadog/js-core": 0.0.3 spark-md5: ^3.0.2 - checksum: 714e92b0fc43d9d14b79d9d235da0c97522332f3ba384ca8155b5c35817967286d012096282ee6975bf43d550358e750131ab0c3b00977dca2ffb0d5d9c3cd38 + checksum: d7ced1b9568410269f6a88b4a95cac4caf0025fb771f56f644e290b4d376dc41d5abba465f14dfd99f209f5611050d88146e7fc3c492f1c67ebe5d31d190e8ba + languageName: node + linkType: hard + +"@datadog/js-core@npm:0.0.3": + version: 0.0.3 + resolution: "@datadog/js-core@npm:0.0.3" + checksum: c8d70cb21f0c5089fa9d3e8d54a9bc2fafde8927b9f9286721b5a12c82cf46b5ff2d39486ecd1c871e4d8a321d88ed5ad5d1cccc06659bce7b9c67c4eaac6695 languageName: node linkType: hard @@ -2872,7 +2880,7 @@ __metadata: version: 0.0.0-use.local resolution: "@datadog/mobile-react-native@workspace:packages/core" dependencies: - "@datadog/flagging-core": ^1.2.1 + "@datadog/flagging-core": ^2.0.0 "@openfeature/core": ^1.9.2 "@testing-library/react-native": 7.0.2 big-integer: ^1.6.52 From 267e864738a9ac3f97b226dc17895e231a561090 Mon Sep 17 00:00:00 2001 From: Blake Date: Fri, 17 Jul 2026 10:37:20 -0400 Subject: [PATCH 32/64] refactor(flags): decodePrecomputedFlags returns a Map (PR1 review) Return the internal Map directly instead of converting it to a plain object via Object.fromEntries. A Map is prototype-safe for both writes and reads: a flag keyed 'toString'/'constructor'/'__proto__' is stored/looked up as data and never resolves an inherited Object.prototype member. Consumers use .get()/.has(). Tests updated to the Map API. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/precomputed.test.ts | 74 +++++++++---------- .../src/flags/configuration/precomputed.ts | 11 ++- 2 files changed, 42 insertions(+), 43 deletions(-) diff --git a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts index 28b2e6928..77a3ce93b 100644 --- a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts +++ b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts @@ -90,7 +90,7 @@ describe('decodePrecomputedFlags', () => { }) ); - expect(cache.bool).toEqual({ + expect(cache.get('bool')).toEqual({ key: 'bool', value: false, allocationKey: 'alloc-1', @@ -101,20 +101,20 @@ describe('decodePrecomputedFlags', () => { doLog: false, extraLogging: {} }); - expect(cache.str.value).toBe('hello'); - expect(cache.str.variationValue).toBe('hello'); - expect(cache.num.value).toBe(42); - expect(cache.num.variationValue).toBe('42'); + expect(cache.get('str')?.value).toBe('hello'); + expect(cache.get('str')?.variationValue).toBe('hello'); + expect(cache.get('num')?.value).toBe(42); + expect(cache.get('num')?.variationValue).toBe('42'); // integer/float keep their wire variationType but decode to a JS number. - expect(cache.int.value).toBe(7); - expect(cache.int.variationType).toBe('integer'); - expect(cache.int.variationValue).toBe('7'); - expect(cache.flt.value).toBe(1.5); - expect(cache.flt.variationType).toBe('float'); - expect(cache.flt.variationValue).toBe('1.5'); + expect(cache.get('int')?.value).toBe(7); + expect(cache.get('int')?.variationType).toBe('integer'); + expect(cache.get('int')?.variationValue).toBe('7'); + expect(cache.get('flt')?.value).toBe(1.5); + expect(cache.get('flt')?.variationType).toBe('float'); + expect(cache.get('flt')?.variationValue).toBe('1.5'); // objects are JSON-encoded for the string form; value stays an object. - expect(cache.obj.value).toEqual({ greeting: 'hi' }); - expect(cache.obj.variationValue).toBe('{"greeting":"hi"}'); + expect(cache.get('obj')?.value).toEqual({ greeting: 'hi' }); + expect(cache.get('obj')?.variationValue).toBe('{"greeting":"hi"}'); }); it('uses the flag map key as the entry key', () => { @@ -122,7 +122,7 @@ describe('decodePrecomputedFlags', () => { responseWith({ 'my-feature': flag({}) }) ); - expect(cache['my-feature'].key).toBe('my-feature'); + expect(cache.get('my-feature')?.key).toBe('my-feature'); }); it('defaults missing extraLogging to an empty object', () => { @@ -130,7 +130,7 @@ describe('decodePrecomputedFlags', () => { responseWith({ f: flag({ extraLogging: undefined }) }) ); - expect(cache.f.extraLogging).toEqual({}); + expect(cache.get('f')?.extraLogging).toEqual({}); }); it('tolerates a null serialId', () => { @@ -138,7 +138,7 @@ describe('decodePrecomputedFlags', () => { responseWith({ f: flag({ serialId: null }) }) ); - expect(cache.f.key).toBe('f'); + expect(cache.get('f')?.key).toBe('f'); }); it('omits flags with an unsupported variation type and logs a warning', () => { @@ -149,8 +149,8 @@ describe('decodePrecomputedFlags', () => { }) ); - expect(cache.good).toBeDefined(); - expect(cache.bad).toBeUndefined(); + expect(cache.get('good')).toBeDefined(); + expect(cache.get('bad')).toBeUndefined(); expect(InternalLog.log).toHaveBeenCalled(); }); @@ -164,7 +164,7 @@ describe('decodePrecomputedFlags', () => { }) ); - expect(cache.mismatched).toBeUndefined(); + expect(cache.get('mismatched')).toBeUndefined(); expect(InternalLog.log).toHaveBeenCalled(); }); @@ -176,8 +176,8 @@ describe('decodePrecomputedFlags', () => { }) ); - expect(cache.good).toBeDefined(); - expect(cache.bad).toBeUndefined(); + expect(cache.get('good')).toBeDefined(); + expect(cache.get('bad')).toBeUndefined(); expect(InternalLog.log).toHaveBeenCalled(); }); @@ -189,8 +189,8 @@ describe('decodePrecomputedFlags', () => { }) ); - expect(cache.badReason).toBeUndefined(); - expect(cache.badDoLog).toBeUndefined(); + expect(cache.get('badReason')).toBeUndefined(); + expect(cache.get('badDoLog')).toBeUndefined(); expect(InternalLog.log).toHaveBeenCalled(); }); @@ -201,7 +201,7 @@ describe('decodePrecomputedFlags', () => { }); it('returns an empty map when there are no flags', () => { - expect(decodePrecomputedFlags(responseWith({}))).toEqual({}); + expect(decodePrecomputedFlags(responseWith({})).size).toBe(0); }); it('omits an integer flag with a fractional value', () => { @@ -211,7 +211,7 @@ describe('decodePrecomputedFlags', () => { }) ); - expect(cache.frac).toBeUndefined(); + expect(cache.get('frac')).toBeUndefined(); expect(InternalLog.log).toHaveBeenCalled(); }); @@ -222,15 +222,15 @@ describe('decodePrecomputedFlags', () => { }) ); - expect(cache.inf).toBeUndefined(); + expect(cache.get('inf')).toBeUndefined(); }); it('returns an empty map for a structurally broken response', () => { expect( decodePrecomputedFlags( ({} as unknown) as Parameters[0] - ) - ).toEqual({}); + ).size + ).toBe(0); }); it('accepts any JSON value for an object flag (array, null, primitive)', () => { @@ -253,12 +253,12 @@ describe('decodePrecomputedFlags', () => { }) ); - expect(cache.arr.value).toEqual([1, 2, 3]); - expect(cache.arr.variationValue).toBe('[1,2,3]'); - expect(cache.nul.value).toBeNull(); - expect(cache.nul.variationValue).toBe('null'); - expect(cache.str.value).toBe('hi'); - expect(cache.str.variationValue).toBe('hi'); + expect(cache.get('arr')?.value).toEqual([1, 2, 3]); + expect(cache.get('arr')?.variationValue).toBe('[1,2,3]'); + expect(cache.get('nul')?.value).toBeNull(); + expect(cache.get('nul')?.variationValue).toBe('null'); + expect(cache.get('str')?.value).toBe('hi'); + expect(cache.get('str')?.variationValue).toBe('hi'); }); it('stores a flag keyed "__proto__" as data without polluting the prototype', () => { @@ -267,9 +267,9 @@ describe('decodePrecomputedFlags', () => { responseWith({ ['__proto__']: flag({ variationValue: true }) }) ); - // Stored as an own data property, not via the Object.prototype setter. - expect(Object.getPrototypeOf(cache)).toBe(Object.prototype); - expect(Object.keys(cache)).toContain('__proto__'); + // A Map stores "__proto__" as an ordinary key, retrievable via .get(), and never + // touches Object.prototype. + expect(cache.get('__proto__')?.value).toBe(true); // No global prototype pollution. expect(({} as Record).variationType).toBeUndefined(); }); diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index d795c7c6e..49bba1d58 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -40,7 +40,7 @@ export class UnsupportedConfigurationError extends Error { */ export const decodePrecomputedFlags = ( response: PrecomputedConfigurationResponse -): Record => { +): Map => { const attributes = response?.data?.attributes; // `obfuscated` is not part of flagging-core's response type, but the CDN payload @@ -54,10 +54,9 @@ export const decodePrecomputedFlags = ( } const flags = attributes?.flags ?? {}; - // Accumulate in a Map so a pathological flag keyed "__proto__" is stored as data - // instead of hitting the `Object.prototype` "__proto__" setter (which a plain - // `obj[key] = ...` assignment would). `Object.fromEntries` then materializes own - // properties without invoking inherited setters. + // A Map (returned as-is) so a pathological flag keyed "__proto__" is stored as data + // and later looked up via `.get()` — never hitting the `Object.prototype` "__proto__" + // setter (on write) or the prototype chain (on read) the way a plain object would. const cache = new Map(); for (const [key, flag] of Object.entries(flags)) { @@ -67,7 +66,7 @@ export const decodePrecomputedFlags = ( } } - return Object.fromEntries(cache); + return cache; }; const toFlagCacheEntry = ( From 5216cacc499e06fa771ef26c8938aab1fbbb1e84 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Jul 2026 16:37:21 -0400 Subject: [PATCH 33/64] chore(flags): pin @datadog/flagging-core to exact 2.0.0 (PR1 review) @datadog/mobile-react-native is a published package, so a caret range would let downstream installs resolve any 2.x of this tightly-coupled dependency. Pin the exact version to avoid unexpected upgrades. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/package.json | 2 +- yarn.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 8d90b1e2b..9c3620e3a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -117,7 +117,7 @@ } }, "dependencies": { - "@datadog/flagging-core": "^2.0.0", + "@datadog/flagging-core": "2.0.0", "big-integer": "^1.6.52" } } diff --git a/yarn.lock b/yarn.lock index efedd86fc..ae0eb0e08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2722,7 +2722,7 @@ __metadata: languageName: node linkType: hard -"@datadog/flagging-core@npm:^2.0.0": +"@datadog/flagging-core@npm:2.0.0": version: 2.0.0 resolution: "@datadog/flagging-core@npm:2.0.0" dependencies: @@ -2880,7 +2880,7 @@ __metadata: version: 0.0.0-use.local resolution: "@datadog/mobile-react-native@workspace:packages/core" dependencies: - "@datadog/flagging-core": ^2.0.0 + "@datadog/flagging-core": 2.0.0 "@openfeature/core": ^1.9.2 "@testing-library/react-native": 7.0.2 big-integer: ^1.6.52 From cc7144088441d932557fca854550d3d97abe7a41 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 22 Jul 2026 10:30:53 -0400 Subject: [PATCH 34/64] chore(flags): allow patch updates for @datadog/flagging-core (~2.0.1) Loosen the exact 2.0.0 pin to ~2.0.1 so critical evaluator patch fixes (e.g. 2.0.1) are delivered without a manual bump, while still holding the minor version. Lockfile resolves to 2.0.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 9c3620e3a..f8c6dd0db 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -117,7 +117,7 @@ } }, "dependencies": { - "@datadog/flagging-core": "2.0.0", + "@datadog/flagging-core": "~2.0.1", "big-integer": "^1.6.52" } } diff --git a/yarn.lock b/yarn.lock index ae0eb0e08..b3cd7d5ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2722,13 +2722,13 @@ __metadata: languageName: node linkType: hard -"@datadog/flagging-core@npm:2.0.0": - version: 2.0.0 - resolution: "@datadog/flagging-core@npm:2.0.0" +"@datadog/flagging-core@npm:~2.0.1": + version: 2.0.1 + resolution: "@datadog/flagging-core@npm:2.0.1" dependencies: "@datadog/js-core": 0.0.3 spark-md5: ^3.0.2 - checksum: d7ced1b9568410269f6a88b4a95cac4caf0025fb771f56f644e290b4d376dc41d5abba465f14dfd99f209f5611050d88146e7fc3c492f1c67ebe5d31d190e8ba + checksum: d4864a76be535acda9bbcdaa95718484f1b16842fba8591ba13308b988e043f7aafd002e240b2393349af616091b5cdb9a75325e24e82aeede8b34dc2bc7560b languageName: node linkType: hard @@ -2880,7 +2880,7 @@ __metadata: version: 0.0.0-use.local resolution: "@datadog/mobile-react-native@workspace:packages/core" dependencies: - "@datadog/flagging-core": 2.0.0 + "@datadog/flagging-core": ~2.0.1 "@openfeature/core": ^1.9.2 "@testing-library/react-native": 7.0.2 big-integer: ^1.6.52 From 21524fb6b8e233d695ba4fe4cb6608875a54ba08 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 16:10:32 -0400 Subject: [PATCH 35/64] feat(flags): FlagsClient.setConfiguration with context matching (FFL-2688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add offline configuration loading to the core FlagsClient: - setConfiguration(config): decodes a precomputed configuration into the flag cache. When no context is set yet, it adopts the configuration's embedded context (implicit set) so offline evaluation works with no native fetch. When a context is already set, the configuration's context must match it. - Context matching: new configuration/context.ts normalizes the flat wire context through the same processEvaluationContext as the active context, then deep-compares — a config with no embedded context is context-agnostic. - Mismatch -> serve no values and report INVALID_CONTEXT (added to FlagErrorCode); empty/unsupported/invalid config -> PROVIDER_NOT_READY (not FLAG_NOT_FOUND). - A subsequent native setEvaluationContext fetch supersedes the offline config. Tests cover context normalization/matching and the setConfiguration flows (implicit-set no-fetch, match/mismatch against an explicit context, invalid config, and fetch supersession). fetchPolicy (PR3) and the OpenFeature provider lifecycle (PR4) are unchanged here. The parse-failure vs valid-but-empty distinction (L2) remains for a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 143 ++++++++++++++++++ .../src/flags/__tests__/FlagsClient.test.ts | 129 ++++++++++++++++ .../configuration/__tests__/context.test.ts | 105 +++++++++++++ .../core/src/flags/configuration/context.ts | 75 +++++++++ .../core/src/flags/configuration/index.ts | 1 + packages/core/src/flags/types.ts | 3 +- 6 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/flags/configuration/__tests__/context.test.ts create mode 100644 packages/core/src/flags/configuration/context.ts diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 62284cc40..3d69d950a 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -8,10 +8,23 @@ import { InternalLog } from '../InternalLog'; import { SdkVerbosity } from '../config/types/SdkVerbosity'; import type { DdNativeFlagsType } from '../nativeModulesTypes'; +import { + contextMatchesConfiguration, + decodePrecomputedFlags, + normalizeWireContext +} from './configuration'; +import type { ParsedFlagsConfiguration } from './configuration'; import { processEvaluationContext } from './internal'; import type { FlagCacheEntry } from './internal'; import type { JsonValue, EvaluationContext, FlagDetails } from './types'; +/** + * Tracks how a configuration supplied via {@link FlagsClient.setConfiguration} relates + * to the active evaluation context. `'none'` means no offline configuration is engaged + * (the online/fetch path is in effect). + */ +type ConfigurationStatus = 'none' | 'ready' | 'mismatch' | 'invalid'; + export class FlagsClient { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires private nativeFlags: DdNativeFlagsType = require('../specs/NativeDdFlags') @@ -23,6 +36,12 @@ export class FlagsClient { private flagsCache: Record = {}; + private loadedConfiguration: + | ParsedFlagsConfiguration + | undefined = undefined; + + private configurationStatus: ConfigurationStatus = 'none'; + constructor(clientName: string = 'default') { this.clientName = clientName; } @@ -65,6 +84,11 @@ export class FlagsClient { this.evaluationContext = processedContext; this.flagsCache = result; + + // An explicit online fetch supersedes any previously loaded offline + // configuration, so drop the offline overlay to keep state coherent. + this.loadedConfiguration = undefined; + this.configurationStatus = 'none'; } catch (error) { if (error instanceof Error) { InternalLog.log( @@ -77,6 +101,105 @@ export class FlagsClient { } }; + /** + * Load a configuration (parsed from a `ConfigurationWire` string via + * `configurationFromString`) into the client for offline evaluation. + * + * For a precomputed configuration this populates the flag cache and, when no context + * has been set yet, adopts the configuration's embedded evaluation context — **no + * network request is made**. If a context has already been set, the configuration's + * context must match it; otherwise the client serves no values and reports + * `INVALID_CONTEXT`. + * + * @param configuration The configuration to load. + * + * @example + * ```ts + * const configuration = configurationFromString(wire); + * flagsClient.setConfiguration(configuration); + * + * const value = flagsClient.getBooleanValue('new-feature', false); + * ``` + */ + setConfiguration = (configuration: ParsedFlagsConfiguration): void => { + this.loadedConfiguration = configuration; + this.applyConfiguration(); + }; + + /** + * Reconcile the loaded configuration against the active evaluation context and + * (re)compute the servable flag cache and configuration status. + */ + private applyConfiguration = (): void => { + const precomputed = this.loadedConfiguration?.precomputed; + + // Only precomputed configurations are supported for now. An empty configuration + // (an invalid/failed wire parse, or a server-only wire) is not usable. + if (!precomputed) { + this.flagsCache = {}; + this.configurationStatus = 'invalid'; + InternalLog.log( + `No usable precomputed configuration was provided for '${this.clientName}'.`, + SdkVerbosity.WARN + ); + return; + } + + let decoded: Record; + try { + decoded = decodePrecomputedFlags(precomputed.response); + } catch (error) { + this.flagsCache = {}; + this.configurationStatus = 'invalid'; + if (error instanceof Error) { + InternalLog.log( + `Unsupported flags configuration for '${this.clientName}': ${error.message}`, + SdkVerbosity.WARN + ); + } + return; + } + + // If no context has been set yet, adopt the configuration's embedded context + // (implicit set — no native fetch). A context-agnostic configuration falls back + // to an empty context so evaluation can proceed. + if (!this.evaluationContext) { + if (precomputed.context) { + this.evaluationContext = normalizeWireContext( + precomputed.context + ); + } else { + InternalLog.log( + `The provided configuration for '${this.clientName}' has no embedded context; treating it as context-agnostic.`, + SdkVerbosity.WARN + ); + this.evaluationContext = { targetingKey: '', attributes: {} }; + } + + this.flagsCache = decoded; + this.configurationStatus = 'ready'; + return; + } + + // A context is already set — the configuration must match it. + if ( + contextMatchesConfiguration( + precomputed.context, + this.evaluationContext + ) + ) { + this.flagsCache = decoded; + this.configurationStatus = 'ready'; + } else { + this.flagsCache = {}; + this.configurationStatus = 'mismatch'; + InternalLog.log( + `The provided configuration for '${this.clientName}' does not match the active evaluation context.`, + SdkVerbosity.WARN + ); + } + }; + private track = (flag: FlagCacheEntry, context: EvaluationContext) => { // A non-blocking call; don't await this. this.nativeFlags @@ -102,6 +225,26 @@ export class FlagsClient { defaultValue: T, type: 'boolean' | 'string' | 'number' | 'object' ): FlagDetails => { + if (this.configurationStatus === 'mismatch') { + return { + key, + value: defaultValue, + reason: 'ERROR', + errorCode: 'INVALID_CONTEXT', + errorMessage: `The loaded configuration for '${this.clientName}' does not match the active evaluation context.` + }; + } + + if (this.configurationStatus === 'invalid') { + return { + key, + value: defaultValue, + reason: 'ERROR', + errorCode: 'PROVIDER_NOT_READY', + errorMessage: `The loaded configuration for '${this.clientName}' is not usable. Provide a valid precomputed configuration.` + }; + } + if (!this.evaluationContext) { return { key, diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 06903099c..fb9b082ba 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -9,6 +9,7 @@ import { NativeModules } from 'react-native'; import { InternalLog } from '../../InternalLog'; import { SdkVerbosity } from '../../config/types/SdkVerbosity'; import { DdFlags } from '../DdFlags'; +import { configurationFromString } from '../configuration'; jest.spyOn(NativeModules.DdFlags, 'setEvaluationContext').mockResolvedValue({ 'test-boolean-flag': { @@ -317,4 +318,132 @@ describe('FlagsClient', () => { expect(numberFlagAsString).toBe('default'); }); }); + + describe('setConfiguration', () => { + const offlineFlags = { + 'offline-bool': { + variationType: 'boolean', + variationValue: true, + variationKey: 'true', + allocationKey: 'alloc-1', + reason: 'STATIC', + doLog: false, + extraLogging: {} + } + }; + + const buildConfig = ( + flags: Record, + context?: Record + ) => + configurationFromString( + JSON.stringify({ + version: 1, + precomputed: { + response: JSON.stringify({ + data: { attributes: { obfuscated: false, flags } } + }), + context + } + }) + ); + + it('serves flags from the configuration without a native fetch', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration( + buildConfig(offlineFlags, { + targetingKey: 'user-1', + country: 'US' + }) + ); + + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + expect( + NativeModules.DdFlags.setEvaluationContext + ).not.toHaveBeenCalled(); + }); + + it('serves flags when an explicit matching context was set first', async () => { + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + + flagsClient.setConfiguration( + buildConfig(offlineFlags, { + targetingKey: 'user-1', + country: 'US' + }) + ); + + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + }); + + it('returns INVALID_CONTEXT when the config does not match an explicit context', async () => { + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + + flagsClient.setConfiguration( + buildConfig(offlineFlags, { + targetingKey: 'user-2', + country: 'US' + }) + ); + + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'INVALID_CONTEXT' + }); + }); + + it('returns PROVIDER_NOT_READY for an empty/invalid configuration', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration(configurationFromString('garbage')); + + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'PROVIDER_NOT_READY' + }); + }); + + it('is superseded by a later native fetch', async () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig(offlineFlags, { + targetingKey: 'user-1', + country: 'US' + }) + ); + + // A subsequent explicit context fetch replaces the offline configuration + // with the native snapshot (mocked in __mocks__/react-native.ts + above). + await flagsClient.setEvaluationContext({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ errorCode: 'FLAG_NOT_FOUND' }); + expect( + flagsClient.getBooleanValue('test-boolean-flag', false) + ).toBe(true); + }); + }); }); diff --git a/packages/core/src/flags/configuration/__tests__/context.test.ts b/packages/core/src/flags/configuration/__tests__/context.test.ts new file mode 100644 index 000000000..de03670f3 --- /dev/null +++ b/packages/core/src/flags/configuration/__tests__/context.test.ts @@ -0,0 +1,105 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { EvaluationContext } from '../../types'; +import { contextMatchesConfiguration, normalizeWireContext } from '../context'; + +jest.mock('../../../InternalLog', () => { + return { + InternalLog: { log: jest.fn() }, + DATADOG_MESSAGE_PREFIX: 'DATADOG:' + }; +}); + +describe('normalizeWireContext', () => { + it('maps a flat wire context to { targetingKey, attributes }', () => { + expect( + normalizeWireContext({ + targetingKey: 'user-1', + country: 'US', + age: 25 + }) + ).toEqual({ + targetingKey: 'user-1', + attributes: { country: 'US', age: 25 } + }); + }); + + it('defaults a missing targeting key to an empty string', () => { + expect(normalizeWireContext({ country: 'US' })).toEqual({ + targetingKey: '', + attributes: { country: 'US' } + }); + }); + + it('drops non-primitive attributes', () => { + expect( + normalizeWireContext({ + targetingKey: 'user-1', + country: 'US', + nested: { a: 1 } + }) + ).toEqual({ targetingKey: 'user-1', attributes: { country: 'US' } }); + }); +}); + +describe('contextMatchesConfiguration', () => { + const active: EvaluationContext = { + targetingKey: 'user-1', + attributes: { country: 'US' } + }; + + it('matches any context when the config has no embedded context', () => { + expect(contextMatchesConfiguration(undefined, active)).toBe(true); + }); + + it('matches an equal context', () => { + expect( + contextMatchesConfiguration( + { targetingKey: 'user-1', country: 'US' }, + active + ) + ).toBe(true); + }); + + it('does not match a different targeting key', () => { + expect( + contextMatchesConfiguration( + { targetingKey: 'user-2', country: 'US' }, + active + ) + ).toBe(false); + }); + + it('does not match a different attribute value', () => { + expect( + contextMatchesConfiguration( + { targetingKey: 'user-1', country: 'CA' }, + active + ) + ).toBe(false); + }); + + it('does not match when the wire has an extra primitive attribute', () => { + expect( + contextMatchesConfiguration( + { targetingKey: 'user-1', country: 'US', plan: 'pro' }, + active + ) + ).toBe(false); + }); + + it('ignores dropped non-primitive attributes on both sides', () => { + // The nested attribute is dropped by the same normalization applied to the + // active context, so a wire context that only differs by it still matches. + expect( + contextMatchesConfiguration( + { targetingKey: 'user-1', country: 'US', nested: { a: 1 } }, + active + ) + ).toBe(true); + }); +}); diff --git a/packages/core/src/flags/configuration/context.ts b/packages/core/src/flags/configuration/context.ts new file mode 100644 index 000000000..06044db02 --- /dev/null +++ b/packages/core/src/flags/configuration/context.ts @@ -0,0 +1,75 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { processEvaluationContext } from '../internal'; +import type { EvaluationContext, PrimitiveValue } from '../types'; + +import type { WireEvaluationContext } from './types'; + +/** + * Normalize a wire (OpenFeature-flat) evaluation context into the SDK's internal + * `{ targetingKey, attributes }` shape, applying the **same** processing the active + * context went through (`processEvaluationContext`) so the two are comparable — the + * flat wire shape and the internal shape are otherwise never equal. + */ +export const normalizeWireContext = ( + wireContext: WireEvaluationContext +): EvaluationContext => { + const { targetingKey, ...attributes } = wireContext; + + return processEvaluationContext({ + targetingKey: targetingKey ?? '', + // `processEvaluationContext` drops non-primitive attributes; casting here mirrors + // how the active context's attributes are typed before that same processing. + attributes: attributes as Record + }); +}; + +/** + * Whether a precomputed configuration's embedded context matches the active evaluation + * context. + * + * - A configuration with **no** embedded context is context-agnostic and matches any + * active context. + * - Otherwise the embedded context must match the active context exactly (after + * normalizing both through the same processing). + */ +export const contextMatchesConfiguration = ( + wireContext: WireEvaluationContext | undefined, + activeContext: EvaluationContext +): boolean => { + if (!wireContext) { + return true; + } + + return contextsEqual(normalizeWireContext(wireContext), activeContext); +}; + +const contextsEqual = (a: EvaluationContext, b: EvaluationContext): boolean => { + if (a.targetingKey !== b.targetingKey) { + return false; + } + + return attributesEqual(a.attributes ?? {}, b.attributes ?? {}); +}; + +/** + * Compare two attribute maps. After `processEvaluationContext`, attribute values are + * primitives, so a key-set + strict-value comparison is sufficient. + */ +const attributesEqual = ( + a: Record, + b: Record +): boolean => { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + if (aKeys.length !== bKeys.length) { + return false; + } + + return aKeys.every(key => a[key] === b[key]); +}; diff --git a/packages/core/src/flags/configuration/index.ts b/packages/core/src/flags/configuration/index.ts index 3890eefc3..0ce6673c6 100644 --- a/packages/core/src/flags/configuration/index.ts +++ b/packages/core/src/flags/configuration/index.ts @@ -14,6 +14,7 @@ export { decodePrecomputedFlags, UnsupportedConfigurationError } from './precomputed'; +export { contextMatchesConfiguration, normalizeWireContext } from './context'; export type { ParsedFlagsConfiguration, ParsedPrecomputedConfiguration, diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts index 12bfb18f6..e451f4e72 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -169,7 +169,8 @@ type FlagErrorCode = | 'PROVIDER_NOT_READY' | 'FLAG_NOT_FOUND' | 'PARSE_ERROR' - | 'TYPE_MISMATCH'; + | 'TYPE_MISMATCH' + | 'INVALID_CONTEXT'; /** * Detailed information about a feature flag evaluation. From b2cfdc2beef9bfbcb3881cfca6e7cee15c23c5a4 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 17:41:20 -0400 Subject: [PATCH 36/64] refactor(flags): address PR2 review (rules seam, proto-safe context, tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on PR2: - #7: build the processed evaluation context with a Map + Object.fromEntries so reserved keys like "__proto__" (notably a "__proto__": null attribute, which a plain assignment would have used to null the object's prototype) are handled as data without prototype manipulation. - #1: mark the forward-compat seam in applyConfiguration so a future server/rules configuration is not rejected as "invalid" (rules configs are context-agnostic). - #3/#4/#5: document the deferred seams — fetch-failure/staleness fallback and the NEVER re-match belong to the fetch-policy step; the PROVIDER_ERROR provider event belongs to the OpenFeature provider step. - #6: add tests for a context-agnostic configuration, configuration replacement, and the __proto__ context-attribute / proto-null safety cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 19 ++++++++ .../src/flags/__tests__/FlagsClient.test.ts | 37 +++++++++++++++ .../core/src/flags/__tests__/internal.test.ts | 45 +++++++++++++++++++ .../configuration/__tests__/context.test.ts | 13 ++++++ packages/core/src/flags/internal.ts | 9 ++-- 5 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/flags/__tests__/internal.test.ts diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 3d69d950a..c0d1c317e 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -87,9 +87,18 @@ export class FlagsClient { // An explicit online fetch supersedes any previously loaded offline // configuration, so drop the offline overlay to keep state coherent. + // + // PROVISIONAL: this reflects the current fetch-always default. When the + // `NEVER` fetch policy lands, that path must skip the fetch and re-run + // `applyConfiguration()` against the new context instead of dropping the + // loaded configuration. this.loadedConfiguration = undefined; this.configurationStatus = 'none'; } catch (error) { + // NOTE: a failed fetch leaves any previously loaded offline configuration in + // place, so the client may keep serving it (and attribute exposures to its + // context). Fetch-failure/staleness fallback is deferred to the fetch-policy + // step. if (error instanceof Error) { InternalLog.log( `Error setting flag evaluation context: ${error.message}`, @@ -135,6 +144,10 @@ export class FlagsClient { // Only precomputed configurations are supported for now. An empty configuration // (an invalid/failed wire parse, or a server-only wire) is not usable. + // + // FORWARD-COMPAT SEAM: when the `server`/rules branch is parsed (see + // `ParsedFlagsConfiguration`), it must be handled BEFORE this guard — a rules + // configuration is context-agnostic and must NOT be rejected here as `invalid`. if (!precomputed) { this.flagsCache = {}; this.configurationStatus = 'invalid'; @@ -191,6 +204,9 @@ export class FlagsClient { this.flagsCache = decoded; this.configurationStatus = 'ready'; } else { + // Per spec, a context mismatch must not serve values. This also blocks a + // previously-fetched online cache until a matching config or a new fetch; + // the fetch policy (a later step) formalizes online/offline precedence. this.flagsCache = {}; this.configurationStatus = 'mismatch'; InternalLog.log( @@ -235,6 +251,9 @@ export class FlagsClient { }; } + // A loaded-but-unusable configuration surfaces as PROVIDER_NOT_READY at the + // evaluation layer (distinct from FLAG_NOT_FOUND). The dedicated PROVIDER_ERROR + // provider event is wired by the OpenFeature provider in a later step. if (this.configurationStatus === 'invalid') { return { key, diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index fb9b082ba..efcca17ed 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -422,6 +422,43 @@ describe('FlagsClient', () => { }); }); + it('serves a context-agnostic configuration (no embedded context)', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration(buildConfig(offlineFlags)); + + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + expect( + NativeModules.DdFlags.setEvaluationContext + ).not.toHaveBeenCalled(); + }); + + it('replaces a previously loaded configuration', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration( + buildConfig( + { 'flag-a': offlineFlags['offline-bool'] }, + { targetingKey: 'user-1' } + ) + ); + expect(flagsClient.getBooleanValue('flag-a', false)).toBe(true); + + flagsClient.setConfiguration( + buildConfig( + { 'flag-b': offlineFlags['offline-bool'] }, + { targetingKey: 'user-1' } + ) + ); + + expect( + flagsClient.getBooleanDetails('flag-a', false) + ).toMatchObject({ errorCode: 'FLAG_NOT_FOUND' }); + expect(flagsClient.getBooleanValue('flag-b', false)).toBe(true); + }); + it('is superseded by a later native fetch', async () => { const flagsClient = DdFlags.getClient(); flagsClient.setConfiguration( diff --git a/packages/core/src/flags/__tests__/internal.test.ts b/packages/core/src/flags/__tests__/internal.test.ts new file mode 100644 index 000000000..eedccacbf --- /dev/null +++ b/packages/core/src/flags/__tests__/internal.test.ts @@ -0,0 +1,45 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { processEvaluationContext } from '../internal'; + +jest.mock('../../InternalLog', () => { + return { + InternalLog: { log: jest.fn() }, + DATADOG_MESSAGE_PREFIX: 'DATADOG:' + }; +}); + +describe('processEvaluationContext', () => { + it('keeps primitive attributes and drops non-primitive ones', () => { + expect( + processEvaluationContext({ + targetingKey: 'user-1', + attributes: { + country: 'US', + age: 25, + beta: true, + // Dropped: non-primitive. + nested: { a: 1 } as never + } + }) + ).toEqual({ + targetingKey: 'user-1', + attributes: { country: 'US', age: 25, beta: true } + }); + }); + + it('does not null the prototype for a "__proto__": null attribute', () => { + const result = processEvaluationContext({ + targetingKey: 'user-1', + attributes: { ['__proto__']: null } + }); + + // A plain `attributes[key] = value` would have set the object's prototype to + // null here; the Map + Object.fromEntries build keeps it a normal object. + expect(Object.getPrototypeOf(result.attributes)).toBe(Object.prototype); + }); +}); diff --git a/packages/core/src/flags/configuration/__tests__/context.test.ts b/packages/core/src/flags/configuration/__tests__/context.test.ts index de03670f3..f75891993 100644 --- a/packages/core/src/flags/configuration/__tests__/context.test.ts +++ b/packages/core/src/flags/configuration/__tests__/context.test.ts @@ -44,6 +44,19 @@ describe('normalizeWireContext', () => { }) ).toEqual({ targetingKey: 'user-1', attributes: { country: 'US' } }); }); + + it('handles a "__proto__" attribute without polluting the prototype', () => { + const normalized = normalizeWireContext({ + targetingKey: 'user-1', + ['__proto__']: 'x' + }); + + // The reserved key is safely discarded; the prototype is untouched. + expect(Object.getPrototypeOf(normalized.attributes)).toBe( + Object.prototype + ); + expect(({} as Record).x).toBeUndefined(); + }); }); describe('contextMatchesConfiguration', () => { diff --git a/packages/core/src/flags/internal.ts b/packages/core/src/flags/internal.ts index 6d504d8cc..fc47b2109 100644 --- a/packages/core/src/flags/internal.ts +++ b/packages/core/src/flags/internal.ts @@ -30,7 +30,10 @@ export const processEvaluationContext = ( const providedAttributes: Record = context.attributes ?? {}; - const attributes: Record = {}; + // Accumulate in a Map so reserved keys such as "__proto__" are handled as data + // instead of hitting the Object.prototype setter (which would silently drop them or + // pollute the prototype). Object.fromEntries then materializes own properties safely. + const attributes = new Map(); for (const [key, value] of Object.entries(providedAttributes)) { const isPrimitiveValue = @@ -53,11 +56,11 @@ export const processEvaluationContext = ( continue; } - attributes[key] = value; + attributes.set(key, value as PrimitiveValue); } return { targetingKey, - attributes + attributes: Object.fromEntries(attributes) }; }; From db42f71683027ec6b3f583f5b156928d1d842185 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 18:05:25 -0400 Subject: [PATCH 37/64] feat(flags): add FlagsClient.setEvaluationContextWithoutFetching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offline counterpart to setEvaluationContext: records the active context and reconciles a configuration loaded via setConfiguration against it (context matching for a precomputed config) with no native request. This is the core seam an offline OpenFeature provider uses to update context on initialize/onContextChange without ever fetching from the CDN — replacing the previously-planned fetchPolicy flag with a provider-level choice. Tests cover the no-fetch reconcile (match serves, context-change mismatch -> INVALID_CONTEXT) and assert the native fetch is never called. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 24 ++++++ .../src/flags/__tests__/FlagsClient.test.ts | 74 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index c0d1c317e..392c87c6e 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -110,6 +110,30 @@ export class FlagsClient { } }; + /** + * Set the evaluation context **without** fetching a configuration from the network, + * then reconcile any configuration loaded via {@link setConfiguration} against it. + * + * This is the offline counterpart to {@link setEvaluationContext}: it records the + * active context and re-evaluates the loaded configuration (context matching, for a + * precomputed configuration) with no native request. It is intended for offline + * providers that own their configuration via `setConfiguration` and must not fetch on + * a context change. With no configuration loaded yet, the context is simply recorded. + * + * @param context The evaluation context to associate with the current client. + */ + setEvaluationContextWithoutFetching = ( + context: EvaluationContext + ): void => { + this.evaluationContext = processEvaluationContext(context); + + // Re-evaluate a loaded offline configuration against the new context. Readiness + // when no configuration is loaded yet is the provider's concern. + if (this.loadedConfiguration) { + this.applyConfiguration(); + } + }; + /** * Load a configuration (parsed from a `ConfigurationWire` string via * `configurationFromString`) into the client for offline evaluation. diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index efcca17ed..f49fea1d9 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -483,4 +483,78 @@ describe('FlagsClient', () => { ).toBe(true); }); }); + + describe('setEvaluationContextWithoutFetching', () => { + const offlineFlags = { + 'offline-bool': { + variationType: 'boolean', + variationValue: true, + variationKey: 'true', + allocationKey: 'alloc-1', + reason: 'STATIC', + doLog: false, + extraLogging: {} + } + }; + + const buildConfig = (context: Record) => + configurationFromString( + JSON.stringify({ + version: 1, + precomputed: { + response: JSON.stringify({ + data: { + attributes: { + obfuscated: false, + flags: offlineFlags + } + } + }), + context + } + }) + ); + + it('reconciles a loaded config against the context without fetching', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig({ targetingKey: 'user-1', country: 'US' }) + ); + + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + expect( + NativeModules.DdFlags.setEvaluationContext + ).not.toHaveBeenCalled(); + }); + + it('re-validates on a context change (mismatch -> INVALID_CONTEXT), still no fetch', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig({ targetingKey: 'user-1', country: 'US' }) + ); + + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: { country: 'US' } + }); + + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'INVALID_CONTEXT' + }); + expect( + NativeModules.DdFlags.setEvaluationContext + ).not.toHaveBeenCalled(); + }); + }); }); From f746fc96abff31fe8cd8e46d1efdf6cf16202752 Mon Sep 17 00:00:00 2001 From: Blake Date: Thu, 16 Jul 2026 21:45:20 -0400 Subject: [PATCH 38/64] refactor(flags): ignore a mismatched runtime context for offline precomputed (PR2 review) An offline precomputed configuration is a single-subject snapshot and offline never fetches, so a runtime evaluation context that differs from the one the snapshot was computed for can no longer be honored. Instead of the previous mismatch -> PROVIDER_ERROR/INVALID_CONTEXT behavior, we now ignore the differing context (keep serving the snapshot against its embedded context) and log a warning once, on the context change. Per-context evaluation is the job of the future rules-based configuration. Drops the 'mismatch' ConfigurationStatus and the getDetails INVALID_CONTEXT branch. Fixes the stale-cache case (Copilot review): setEvaluationContextWithoutFetching now clears the cache when no configuration is loaded. Un-exports contextMatchesConfiguration from the configuration module index (now an internal helper imported directly by FlagsClient). Corrects the __proto__ context test comment to only assert what it verifies (no prototype pollution). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 65 +++++++++---------- .../src/flags/__tests__/FlagsClient.test.ts | 36 +++++----- .../configuration/__tests__/context.test.ts | 4 +- .../core/src/flags/configuration/index.ts | 4 +- 4 files changed, 57 insertions(+), 52 deletions(-) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 392c87c6e..5ea813c10 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -8,11 +8,11 @@ import { InternalLog } from '../InternalLog'; import { SdkVerbosity } from '../config/types/SdkVerbosity'; import type { DdNativeFlagsType } from '../nativeModulesTypes'; -import { - contextMatchesConfiguration, - decodePrecomputedFlags, - normalizeWireContext -} from './configuration'; +// Imported directly (not via the module index): context matching is an internal helper, +// used here only to detect and warn about a runtime context that an offline precomputed +// configuration cannot honor. +import { contextMatchesConfiguration } from './configuration/context'; +import { decodePrecomputedFlags, normalizeWireContext } from './configuration'; import type { ParsedFlagsConfiguration } from './configuration'; import { processEvaluationContext } from './internal'; import type { FlagCacheEntry } from './internal'; @@ -23,7 +23,7 @@ import type { JsonValue, EvaluationContext, FlagDetails } from './types'; * to the active evaluation context. `'none'` means no offline configuration is engaged * (the online/fetch path is in effect). */ -type ConfigurationStatus = 'none' | 'ready' | 'mismatch' | 'invalid'; +type ConfigurationStatus = 'none' | 'ready' | 'invalid'; export class FlagsClient { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires @@ -127,10 +127,14 @@ export class FlagsClient { ): void => { this.evaluationContext = processEvaluationContext(context); - // Re-evaluate a loaded offline configuration against the new context. Readiness - // when no configuration is loaded yet is the provider's concern. + // Re-evaluate a loaded offline configuration against the new context. if (this.loadedConfiguration) { this.applyConfiguration(); + } else { + // No offline configuration is engaged: do not keep serving any previously + // cached flags (e.g. from a prior online fetch) against the new context. + this.flagsCache = {}; + this.configurationStatus = 'none'; } }; @@ -138,11 +142,11 @@ export class FlagsClient { * Load a configuration (parsed from a `ConfigurationWire` string via * `configurationFromString`) into the client for offline evaluation. * - * For a precomputed configuration this populates the flag cache and, when no context - * has been set yet, adopts the configuration's embedded evaluation context — **no - * network request is made**. If a context has already been set, the configuration's - * context must match it; otherwise the client serves no values and reports - * `INVALID_CONTEXT`. + * For a precomputed configuration this populates the flag cache and adopts the + * configuration's embedded evaluation context — **no network request is made**. A + * precomputed snapshot is single-subject: if a different runtime context has been set, + * it is ignored (the snapshot is served for its embedded context) and a warning is + * logged. Use a rules-based configuration for per-context evaluation. * * @param configuration The configuration to load. * @@ -218,26 +222,29 @@ export class FlagsClient { return; } - // A context is already set — the configuration must match it. + // A context is already set. An offline precomputed configuration is a snapshot + // bound to the context it was computed for, and offline never fetches, so it cannot + // be recomputed for a different subject. A runtime context that does not match is + // therefore IGNORED: revert to the embedded context, keep serving the snapshot, and + // warn once. (Precomputed is single-subject by design — a rules-based configuration + // is the path for per-context evaluation.) if ( - contextMatchesConfiguration( + !contextMatchesConfiguration( precomputed.context, this.evaluationContext ) ) { - this.flagsCache = decoded; - this.configurationStatus = 'ready'; - } else { - // Per spec, a context mismatch must not serve values. This also blocks a - // previously-fetched online cache until a matching config or a new fetch; - // the fetch policy (a later step) formalizes online/offline precedence. - this.flagsCache = {}; - this.configurationStatus = 'mismatch'; InternalLog.log( - `The provided configuration for '${this.clientName}' does not match the active evaluation context.`, + `Ignoring the evaluation context set for '${this.clientName}': an offline precomputed configuration is served against the context it was computed for. Set a matching context, or use a rules-based configuration for per-context evaluation.`, SdkVerbosity.WARN ); + this.evaluationContext = precomputed.context + ? normalizeWireContext(precomputed.context) + : { targetingKey: '', attributes: {} }; } + + this.flagsCache = decoded; + this.configurationStatus = 'ready'; }; private track = (flag: FlagCacheEntry, context: EvaluationContext) => { @@ -265,16 +272,6 @@ export class FlagsClient { defaultValue: T, type: 'boolean' | 'string' | 'number' | 'object' ): FlagDetails => { - if (this.configurationStatus === 'mismatch') { - return { - key, - value: defaultValue, - reason: 'ERROR', - errorCode: 'INVALID_CONTEXT', - errorMessage: `The loaded configuration for '${this.clientName}' does not match the active evaluation context.` - }; - } - // A loaded-but-unusable configuration surfaces as PROVIDER_NOT_READY at the // evaluation layer (distinct from FLAG_NOT_FOUND). The dedicated PROVIDER_ERROR // provider event is wired by the OpenFeature provider in a later step. diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index f49fea1d9..af1245bbe 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -385,7 +385,7 @@ describe('FlagsClient', () => { ); }); - it('returns INVALID_CONTEXT when the config does not match an explicit context', async () => { + it('ignores a differing explicit context, serving the snapshot and warning', async () => { const flagsClient = DdFlags.getClient(); await flagsClient.setEvaluationContext({ targetingKey: 'user-1', @@ -399,13 +399,15 @@ describe('FlagsClient', () => { }) ); - expect( - flagsClient.getBooleanDetails('offline-bool', false) - ).toMatchObject({ - value: false, - reason: 'ERROR', - errorCode: 'INVALID_CONTEXT' - }); + // Offline precomputed is single-subject: the differing context is ignored and + // the snapshot is served for its embedded context, with a warning. + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + expect(InternalLog.log).toHaveBeenCalledWith( + expect.stringContaining('Ignoring the evaluation context'), + expect.anything() + ); }); it('returns PROVIDER_NOT_READY for an empty/invalid configuration', () => { @@ -534,7 +536,7 @@ describe('FlagsClient', () => { ).not.toHaveBeenCalled(); }); - it('re-validates on a context change (mismatch -> INVALID_CONTEXT), still no fetch', () => { + it('ignores a differing context change, still serving without fetching', () => { const flagsClient = DdFlags.getClient(); flagsClient.setConfiguration( buildConfig({ targetingKey: 'user-1', country: 'US' }) @@ -545,13 +547,15 @@ describe('FlagsClient', () => { attributes: { country: 'US' } }); - expect( - flagsClient.getBooleanDetails('offline-bool', false) - ).toMatchObject({ - value: false, - reason: 'ERROR', - errorCode: 'INVALID_CONTEXT' - }); + // The differing context is ignored (offline never fetches) and the snapshot is + // still served for its embedded context, with a warning. + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + expect(InternalLog.log).toHaveBeenCalledWith( + expect.stringContaining('Ignoring the evaluation context'), + expect.anything() + ); expect( NativeModules.DdFlags.setEvaluationContext ).not.toHaveBeenCalled(); diff --git a/packages/core/src/flags/configuration/__tests__/context.test.ts b/packages/core/src/flags/configuration/__tests__/context.test.ts index f75891993..ec0155bad 100644 --- a/packages/core/src/flags/configuration/__tests__/context.test.ts +++ b/packages/core/src/flags/configuration/__tests__/context.test.ts @@ -51,7 +51,9 @@ describe('normalizeWireContext', () => { ['__proto__']: 'x' }); - // The reserved key is safely discarded; the prototype is untouched. + // A reserved "__proto__" attribute does not pollute the prototype: the normalized + // attributes keep `Object.prototype` and nothing leaks onto the global prototype. + // (Whether the key is retained or dropped is an implementation detail we don't assert.) expect(Object.getPrototypeOf(normalized.attributes)).toBe( Object.prototype ); diff --git a/packages/core/src/flags/configuration/index.ts b/packages/core/src/flags/configuration/index.ts index 0ce6673c6..6a943c9b2 100644 --- a/packages/core/src/flags/configuration/index.ts +++ b/packages/core/src/flags/configuration/index.ts @@ -14,7 +14,9 @@ export { decodePrecomputedFlags, UnsupportedConfigurationError } from './precomputed'; -export { contextMatchesConfiguration, normalizeWireContext } from './context'; +// `contextMatchesConfiguration` is intentionally NOT re-exported — it is an internal +// helper consumed directly by `FlagsClient` (see its import from `./configuration/context`). +export { normalizeWireContext } from './context'; export type { ParsedFlagsConfiguration, ParsedPrecomputedConfiguration, From 8a5d4c49f32b8b3d8bfd372aa551cc90e32cf49e Mon Sep 17 00:00:00 2001 From: Blake Date: Fri, 17 Jul 2026 08:58:07 -0400 Subject: [PATCH 39/64] docs(flags): drop stale context/server comments in FlagsClient (PR2) Follow-up to the ignore-a-mismatched-context change: the setEvaluationContextWithoutFetching doc no longer describes 'context matching' (it re-applies and ignores a differing precomputed context), and the applyConfiguration forward-compat note drops the reference to the removed 'server' wire branch / ParsedFlagsConfiguration while keeping the (still-valid) rules seam. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 5ea813c10..e23965ade 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -112,13 +112,15 @@ export class FlagsClient { /** * Set the evaluation context **without** fetching a configuration from the network, - * then reconcile any configuration loaded via {@link setConfiguration} against it. + * then re-apply any configuration loaded via {@link setConfiguration} against it. * * This is the offline counterpart to {@link setEvaluationContext}: it records the - * active context and re-evaluates the loaded configuration (context matching, for a - * precomputed configuration) with no native request. It is intended for offline - * providers that own their configuration via `setConfiguration` and must not fetch on - * a context change. With no configuration loaded yet, the context is simply recorded. + * active context and re-applies the loaded configuration with no native request. A + * precomputed configuration is single-subject, so a context that differs from the one + * it was computed for is ignored (the snapshot keeps serving) with a warning. Intended + * for offline providers that own their configuration via `setConfiguration` and must + * not fetch on a context change. With no configuration loaded, previously served flags + * are cleared. * * @param context The evaluation context to associate with the current client. */ @@ -171,11 +173,11 @@ export class FlagsClient { const precomputed = this.loadedConfiguration?.precomputed; // Only precomputed configurations are supported for now. An empty configuration - // (an invalid/failed wire parse, or a server-only wire) is not usable. + // (an invalid/failed wire parse, or a wire with no precomputed branch) is not usable. // - // FORWARD-COMPAT SEAM: when the `server`/rules branch is parsed (see - // `ParsedFlagsConfiguration`), it must be handled BEFORE this guard — a rules - // configuration is context-agnostic and must NOT be rejected here as `invalid`. + // FORWARD-COMPAT SEAM: when a rules-based configuration is supported, it must be + // handled BEFORE this guard — rules are context-agnostic and must NOT be rejected + // here as `invalid`. if (!precomputed) { this.flagsCache = {}; this.configurationStatus = 'invalid'; From 190a040231c15f7a630e5c06554dd9f8942426b8 Mon Sep 17 00:00:00 2001 From: Blake Date: Fri, 17 Jul 2026 10:15:58 -0400 Subject: [PATCH 40/64] refactor(flags): address PR2 review findings (dead code, wire safety, tests) - Remove the unused 'INVALID_CONTEXT' error code from FlagErrorCode: it was added for the old context-mismatch path, which the ignore-and-warn change removed, so the SDK never emits it. - Guard a non-string wire targetingKey in normalizeWireContext (the wire is untrusted): a non-string value is treated as absent instead of landing in a string-typed field. - Correct the 'warn once' comment (it warns on each differing context change, which is the intended behavior). - Add a test asserting exposures are attributed to the snapshot's embedded context, not an ignored runtime context. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 4 ++-- .../src/flags/__tests__/FlagsClient.test.ts | 24 +++++++++++++++++++ .../core/src/flags/configuration/context.ts | 3 ++- packages/core/src/flags/types.ts | 3 +-- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index e23965ade..974ba881a 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -228,8 +228,8 @@ export class FlagsClient { // bound to the context it was computed for, and offline never fetches, so it cannot // be recomputed for a different subject. A runtime context that does not match is // therefore IGNORED: revert to the embedded context, keep serving the snapshot, and - // warn once. (Precomputed is single-subject by design — a rules-based configuration - // is the path for per-context evaluation.) + // warn on the change. (Precomputed is single-subject by design — a rules-based + // configuration is the path for per-context evaluation.) if ( !contextMatchesConfiguration( precomputed.context, diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index af1245bbe..bbe3f19e1 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -560,5 +560,29 @@ describe('FlagsClient', () => { NativeModules.DdFlags.setEvaluationContext ).not.toHaveBeenCalled(); }); + + it('attributes exposures to the embedded context when a differing context is ignored', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig({ targetingKey: 'user-1', country: 'US' }) + ); + + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: { country: 'US' } + }); + + flagsClient.getBooleanValue('offline-bool', false); + + // The exposure is attributed to the snapshot's embedded context (user-1), not + // the ignored runtime context (user-2). + expect(NativeModules.DdFlags.trackEvaluation).toHaveBeenCalledWith( + expect.any(String), + 'offline-bool', + expect.any(Object), + 'user-1', + expect.objectContaining({ country: 'US' }) + ); + }); }); }); diff --git a/packages/core/src/flags/configuration/context.ts b/packages/core/src/flags/configuration/context.ts index 06044db02..436ad96b5 100644 --- a/packages/core/src/flags/configuration/context.ts +++ b/packages/core/src/flags/configuration/context.ts @@ -21,7 +21,8 @@ export const normalizeWireContext = ( const { targetingKey, ...attributes } = wireContext; return processEvaluationContext({ - targetingKey: targetingKey ?? '', + // The wire is untrusted, so a non-string targetingKey is treated as absent. + targetingKey: typeof targetingKey === 'string' ? targetingKey : '', // `processEvaluationContext` drops non-primitive attributes; casting here mirrors // how the active context's attributes are typed before that same processing. attributes: attributes as Record diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts index e451f4e72..12bfb18f6 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -169,8 +169,7 @@ type FlagErrorCode = | 'PROVIDER_NOT_READY' | 'FLAG_NOT_FOUND' | 'PARSE_ERROR' - | 'TYPE_MISMATCH' - | 'INVALID_CONTEXT'; + | 'TYPE_MISMATCH'; /** * Detailed information about a feature flag evaluation. From 084a09a40b4a4960664987b9c00359f80daeade3 Mon Sep 17 00:00:00 2001 From: Blake Date: Fri, 17 Jul 2026 10:40:02 -0400 Subject: [PATCH 41/64] refactor(flags): store flagsCache as a Map to avoid prototype-chain lookups (PR2 review) flagsCache was a plain object indexed by flag key, so a key equal to an inherited property name ('toString', 'constructor', '__proto__') resolved an Object.prototype member instead of undefined, bypassing the not-found guard and returning TYPE_MISMATCH rather than FLAG_NOT_FOUND. Use a Map with .get(): the online native result is wrapped via new Map(Object.entries(result)), the offline path consumes the Map from decodePrecomputedFlags directly, and lookups never touch the prototype chain. Fixes it for both online and offline paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 974ba881a..4881cc552 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -34,7 +34,11 @@ export class FlagsClient { private evaluationContext: EvaluationContext | undefined = undefined; - private flagsCache: Record = {}; + // A Map (not a plain object) so a flag keyed like an inherited property + // ("toString", "constructor", "__proto__") is looked up as data via `.get()` and never + // resolves an `Object.prototype` member. This matters now that the cache can be fed + // from untrusted wire data via `setConfiguration`. + private flagsCache: Map = new Map(); private loadedConfiguration: | ParsedFlagsConfiguration @@ -83,7 +87,7 @@ export class FlagsClient { ); this.evaluationContext = processedContext; - this.flagsCache = result; + this.flagsCache = new Map(Object.entries(result)); // An explicit online fetch supersedes any previously loaded offline // configuration, so drop the offline overlay to keep state coherent. @@ -135,7 +139,7 @@ export class FlagsClient { } else { // No offline configuration is engaged: do not keep serving any previously // cached flags (e.g. from a prior online fetch) against the new context. - this.flagsCache = {}; + this.flagsCache = new Map(); this.configurationStatus = 'none'; } }; @@ -179,7 +183,7 @@ export class FlagsClient { // handled BEFORE this guard — rules are context-agnostic and must NOT be rejected // here as `invalid`. if (!precomputed) { - this.flagsCache = {}; + this.flagsCache = new Map(); this.configurationStatus = 'invalid'; InternalLog.log( `No usable precomputed configuration was provided for '${this.clientName}'.`, @@ -188,11 +192,11 @@ export class FlagsClient { return; } - let decoded: Record; + let decoded: Map; try { decoded = decodePrecomputedFlags(precomputed.response); } catch (error) { - this.flagsCache = {}; + this.flagsCache = new Map(); this.configurationStatus = 'invalid'; if (error instanceof Error) { InternalLog.log( @@ -298,7 +302,7 @@ export class FlagsClient { } // Retrieve the flag from the cache. - const flag = this.flagsCache[key]; + const flag = this.flagsCache.get(key); if (!flag) { return { From 41ab2ac5dd4d39a4f33f43894c72340fbeed9a46 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Jul 2026 17:28:07 -0400 Subject: [PATCH 42/64] refactor(flags): model ConfigurationStatus as an as-const object (PR2 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bare string union with an `as const` object + derived union so assignment/comparison sites read ConfigurationStatus.None/Ready/Invalid — self-documenting and typo-safe — while the type stays an erasable string union (no TS enum runtime code, friendlier to Babel and tree-shaking). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 33 +++++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 4881cc552..f691e409a 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -22,8 +22,23 @@ import type { JsonValue, EvaluationContext, FlagDetails } from './types'; * Tracks how a configuration supplied via {@link FlagsClient.setConfiguration} relates * to the active evaluation context. `'none'` means no offline configuration is engaged * (the online/fetch path is in effect). + * + * Modelled as an `as const` object rather than a TS `enum` so call sites read + * `ConfigurationStatus.Ready` — self-documenting and typo-safe — while the type stays a + * plain string union that erases at build time (no runtime enum code, friendlier to Babel + * and tree-shaking). */ -type ConfigurationStatus = 'none' | 'ready' | 'invalid'; +const ConfigurationStatus = { + None: 'none', + Ready: 'ready', + Invalid: 'invalid' +} as const; + +// Intentional value + type merging: the const above provides the `.Ready`/`.None`/`.Invalid` +// accessors, this alias provides the string-union type. Same name is the idiomatic pattern; +// no-redeclare doesn't model TS's separate value/type namespaces. +// eslint-disable-next-line no-redeclare, @typescript-eslint/no-redeclare +type ConfigurationStatus = typeof ConfigurationStatus[keyof typeof ConfigurationStatus]; export class FlagsClient { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires @@ -44,7 +59,7 @@ export class FlagsClient { | ParsedFlagsConfiguration | undefined = undefined; - private configurationStatus: ConfigurationStatus = 'none'; + private configurationStatus: ConfigurationStatus = ConfigurationStatus.None; constructor(clientName: string = 'default') { this.clientName = clientName; @@ -97,7 +112,7 @@ export class FlagsClient { // `applyConfiguration()` against the new context instead of dropping the // loaded configuration. this.loadedConfiguration = undefined; - this.configurationStatus = 'none'; + this.configurationStatus = ConfigurationStatus.None; } catch (error) { // NOTE: a failed fetch leaves any previously loaded offline configuration in // place, so the client may keep serving it (and attribute exposures to its @@ -140,7 +155,7 @@ export class FlagsClient { // No offline configuration is engaged: do not keep serving any previously // cached flags (e.g. from a prior online fetch) against the new context. this.flagsCache = new Map(); - this.configurationStatus = 'none'; + this.configurationStatus = ConfigurationStatus.None; } }; @@ -184,7 +199,7 @@ export class FlagsClient { // here as `invalid`. if (!precomputed) { this.flagsCache = new Map(); - this.configurationStatus = 'invalid'; + this.configurationStatus = ConfigurationStatus.Invalid; InternalLog.log( `No usable precomputed configuration was provided for '${this.clientName}'.`, SdkVerbosity.WARN @@ -197,7 +212,7 @@ export class FlagsClient { decoded = decodePrecomputedFlags(precomputed.response); } catch (error) { this.flagsCache = new Map(); - this.configurationStatus = 'invalid'; + this.configurationStatus = ConfigurationStatus.Invalid; if (error instanceof Error) { InternalLog.log( `Unsupported flags configuration for '${this.clientName}': ${error.message}`, @@ -224,7 +239,7 @@ export class FlagsClient { } this.flagsCache = decoded; - this.configurationStatus = 'ready'; + this.configurationStatus = ConfigurationStatus.Ready; return; } @@ -250,7 +265,7 @@ export class FlagsClient { } this.flagsCache = decoded; - this.configurationStatus = 'ready'; + this.configurationStatus = ConfigurationStatus.Ready; }; private track = (flag: FlagCacheEntry, context: EvaluationContext) => { @@ -281,7 +296,7 @@ export class FlagsClient { // A loaded-but-unusable configuration surfaces as PROVIDER_NOT_READY at the // evaluation layer (distinct from FLAG_NOT_FOUND). The dedicated PROVIDER_ERROR // provider event is wired by the OpenFeature provider in a later step. - if (this.configurationStatus === 'invalid') { + if (this.configurationStatus === ConfigurationStatus.Invalid) { return { key, value: defaultValue, From af0cb9d20285dcd36c13b4081146bc53f1cea61b Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 21 Jul 2026 15:52:15 -0400 Subject: [PATCH 43/64] refactor(flags): error on context mismatch for offline precomputed (PR2) Switch the offline precomputed flow from "ignore a mismatched runtime context and keep serving the snapshot" to treating it as an error that serves coded defaults, so the OpenFeature provider can surface the correct error state. - Widen the offline APIs to return a discriminated ConfigurationResult (ready, or error with a precise errorCode) so the reason propagates: INVALID_CONTEXT (mismatch), PROVIDER_NOT_READY (no config loaded), GENERAL (unusable/undecodable/unsupported config). - Store load validity structurally (LoadedConfigurationState) and decode once at load, so a later context change can never promote an invalid load to ready and never re-decodes. - Add resetEvaluationContextWithoutFetching (clear the external override and reconcile) so clearing/omitting context re-adopts the embedded context. - getDetails serves coded defaults with the precise errorCode on error; no exposure tracking while serving defaults. - Add INVALID_CONTEXT and GENERAL to FlagErrorCode; drop the stale fetch-policy comments in setEvaluationContext. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 308 ++++++++++------ .../src/flags/__tests__/FlagsClient.test.ts | 337 +++++++++++++----- packages/core/src/flags/types.ts | 4 +- 3 files changed, 431 insertions(+), 218 deletions(-) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index f691e409a..0f1a0725e 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -9,36 +9,59 @@ import { SdkVerbosity } from '../config/types/SdkVerbosity'; import type { DdNativeFlagsType } from '../nativeModulesTypes'; // Imported directly (not via the module index): context matching is an internal helper, -// used here only to detect and warn about a runtime context that an offline precomputed -// configuration cannot honor. +// used here only to detect a runtime context an offline precomputed configuration cannot honor. import { contextMatchesConfiguration } from './configuration/context'; import { decodePrecomputedFlags, normalizeWireContext } from './configuration'; -import type { ParsedFlagsConfiguration } from './configuration'; +import type { + ParsedFlagsConfiguration, + ParsedPrecomputedConfiguration +} from './configuration'; import { processEvaluationContext } from './internal'; import type { FlagCacheEntry } from './internal'; import type { JsonValue, EvaluationContext, FlagDetails } from './types'; /** - * Tracks how a configuration supplied via {@link FlagsClient.setConfiguration} relates - * to the active evaluation context. `'none'` means no offline configuration is engaged - * (the online/fetch path is in effect). + * Error codes an offline configuration result can carry: + * - `INVALID_CONTEXT`: the active context does not match the precomputed snapshot. + * - `PROVIDER_NOT_READY`: an offline operation ran with no configuration loaded. + * - `GENERAL`: the loaded configuration is unusable (malformed/unsupported/undecodable). + */ +export type ConfigurationErrorCode = + | 'INVALID_CONTEXT' + | 'PROVIDER_NOT_READY' + | 'GENERAL'; + +/** + * Outcome of loading a configuration or reconciling it against the active evaluation context, + * returned by the offline APIs ({@link FlagsClient.setConfiguration}, + * {@link FlagsClient.setEvaluationContextWithoutFetching}, + * {@link FlagsClient.resetEvaluationContextWithoutFetching}). + * + * `'ready'` means the configuration can be served against the active context; `'error'` carries + * the precise reason so the OpenFeature provider surfaces the correct code and evaluation returns + * that code with the coded default. + * + * @internal Consumers observe OpenFeature provider events/evaluation details, not this result. + */ +export type ConfigurationResult = + | { status: 'ready' } + | { status: 'error'; errorCode: ConfigurationErrorCode }; + +/** + * The decoded outcome of the last loaded offline configuration. Load validity (`kind`) is stored + * separately from context compatibility so that later context changes reconcile against a decoded + * snapshot **without re-decoding** and can never promote an invalid load to a servable state. * - * Modelled as an `as const` object rather than a TS `enum` so call sites read - * `ConfigurationStatus.Ready` — self-documenting and typo-safe — while the type stays a - * plain string union that erases at build time (no runtime enum code, friendlier to Babel - * and tree-shaking). + * `'none'` = no offline configuration engaged (the online/fetch path, or nothing loaded yet). */ -const ConfigurationStatus = { - None: 'none', - Ready: 'ready', - Invalid: 'invalid' -} as const; - -// Intentional value + type merging: the const above provides the `.Ready`/`.None`/`.Invalid` -// accessors, this alias provides the string-union type. Same name is the idiomatic pattern; -// no-redeclare doesn't model TS's separate value/type namespaces. -// eslint-disable-next-line no-redeclare, @typescript-eslint/no-redeclare -type ConfigurationStatus = typeof ConfigurationStatus[keyof typeof ConfigurationStatus]; +type LoadedConfigurationState = + | { kind: 'none' } + | { kind: 'invalid'; errorCode: ConfigurationErrorCode } + | { + kind: 'precomputed'; + configuration: ParsedPrecomputedConfiguration; + flags: Map; + }; export class FlagsClient { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires @@ -49,17 +72,23 @@ export class FlagsClient { private evaluationContext: EvaluationContext | undefined = undefined; - // A Map (not a plain object) so a flag keyed like an inherited property - // ("toString", "constructor", "__proto__") is looked up as data via `.get()` and never - // resolves an `Object.prototype` member. This matters now that the cache can be fed - // from untrusted wire data via `setConfiguration`. + // The servable flag cache. On the online path it holds the native-fetched flags; on the + // offline path it mirrors the decoded snapshot when the configuration is servable. A Map + // (not a plain object) so a flag keyed like an inherited property ("toString", + // "__proto__") is looked up as data via `.get()` and never resolves an `Object.prototype` + // member — important now the cache can be fed from untrusted wire data. private flagsCache: Map = new Map(); - private loadedConfiguration: - | ParsedFlagsConfiguration - | undefined = undefined; + // The decoded outcome of the last loaded offline configuration (see the type). + private loadedConfiguration: LoadedConfigurationState = { kind: 'none' }; + + // Internal serving status. `'none'` = online path (serve `flagsCache`); `'ready'` = serve + // the offline snapshot; `'error'` = serve coded defaults with `configurationError`. + private configurationStatus: 'none' | 'ready' | 'error' = 'none'; - private configurationStatus: ConfigurationStatus = ConfigurationStatus.None; + private configurationError: + | { errorCode: ConfigurationErrorCode; errorMessage: string } + | undefined = undefined; constructor(clientName: string = 'default') { this.clientName = clientName; @@ -104,20 +133,16 @@ export class FlagsClient { this.evaluationContext = processedContext; this.flagsCache = new Map(Object.entries(result)); - // An explicit online fetch supersedes any previously loaded offline - // configuration, so drop the offline overlay to keep state coherent. - // - // PROVISIONAL: this reflects the current fetch-always default. When the - // `NEVER` fetch policy lands, that path must skip the fetch and re-run - // `applyConfiguration()` against the new context instead of dropping the - // loaded configuration. - this.loadedConfiguration = undefined; - this.configurationStatus = ConfigurationStatus.None; + // An explicit online fetch supersedes any previously loaded offline configuration. + // Online vs. offline is a property of the provider — the offline provider adopts + // context via `setEvaluationContextWithoutFetching` and never calls this method — so + // drop the offline overlay here to keep state coherent. + this.loadedConfiguration = { kind: 'none' }; + this.configurationStatus = 'none'; + this.configurationError = undefined; } catch (error) { // NOTE: a failed fetch leaves any previously loaded offline configuration in - // place, so the client may keep serving it (and attribute exposures to its - // context). Fetch-failure/staleness fallback is deferred to the fetch-policy - // step. + // place, so the client may keep serving it (and attribute exposures to its context). if (error instanceof Error) { InternalLog.log( `Error setting flag evaluation context: ${error.message}`, @@ -130,44 +155,49 @@ export class FlagsClient { }; /** - * Set the evaluation context **without** fetching a configuration from the network, - * then re-apply any configuration loaded via {@link setConfiguration} against it. + * Set the evaluation context **without** fetching a configuration from the network, then + * reconcile the loaded configuration against it. * - * This is the offline counterpart to {@link setEvaluationContext}: it records the - * active context and re-applies the loaded configuration with no native request. A - * precomputed configuration is single-subject, so a context that differs from the one - * it was computed for is ignored (the snapshot keeps serving) with a warning. Intended - * for offline providers that own their configuration via `setConfiguration` and must - * not fetch on a context change. With no configuration loaded, previously served flags - * are cleared. + * The offline counterpart to {@link setEvaluationContext}: records the active context and + * reconciles with no native request. A precomputed configuration is single-subject, so a + * context that does not match the one it was computed for reconciles to an error + * (`INVALID_CONTEXT`) and evaluation serves defaults. With no configuration loaded the + * result is `PROVIDER_NOT_READY`. Intended for offline providers that own their + * configuration via `setConfiguration` and must not fetch on a context change. * * @param context The evaluation context to associate with the current client. */ setEvaluationContextWithoutFetching = ( context: EvaluationContext - ): void => { + ): ConfigurationResult => { this.evaluationContext = processEvaluationContext(context); - // Re-evaluate a loaded offline configuration against the new context. - if (this.loadedConfiguration) { - this.applyConfiguration(); - } else { - // No offline configuration is engaged: do not keep serving any previously - // cached flags (e.g. from a prior online fetch) against the new context. - this.flagsCache = new Map(); - this.configurationStatus = ConfigurationStatus.None; - } + return this.reconcile(); + }; + + /** + * Clear any externally-set evaluation context and reconcile. + * + * This is the offline counterpart to clearing/omitting an OpenFeature context: it drops the + * external override so a loaded precomputed configuration is served against **its embedded + * context** again. Clearing the override (rather than skipping) matters so that a + * configuration loaded *after* a clear is not judged against a stale override. With no + * configuration loaded the result is `PROVIDER_NOT_READY`. + */ + resetEvaluationContextWithoutFetching = (): ConfigurationResult => { + this.evaluationContext = undefined; + + return this.reconcile(); }; /** * Load a configuration (parsed from a `ConfigurationWire` string via - * `configurationFromString`) into the client for offline evaluation. + * `configurationFromString`) into the client for offline evaluation, then reconcile it + * against the active context. * - * For a precomputed configuration this populates the flag cache and adopts the - * configuration's embedded evaluation context — **no network request is made**. A - * precomputed snapshot is single-subject: if a different runtime context has been set, - * it is ignored (the snapshot is served for its embedded context) and a warning is - * logged. Use a rules-based configuration for per-context evaluation. + * For a precomputed configuration this decodes the snapshot once and adopts its embedded + * evaluation context when none is set — **no network request is made**. An unusable + * configuration reconciles to an error (`GENERAL`); a context mismatch to `INVALID_CONTEXT`. * * @param configuration The configuration to load. * @@ -179,56 +209,90 @@ export class FlagsClient { * const value = flagsClient.getBooleanValue('new-feature', false); * ``` */ - setConfiguration = (configuration: ParsedFlagsConfiguration): void => { - this.loadedConfiguration = configuration; - this.applyConfiguration(); + setConfiguration = ( + configuration: ParsedFlagsConfiguration + ): ConfigurationResult => { + this.loadedConfiguration = this.loadConfiguration(configuration); + + return this.reconcile(); }; /** - * Reconcile the loaded configuration against the active evaluation context and - * (re)compute the servable flag cache and configuration status. + * Decode a supplied configuration into a {@link LoadedConfigurationState} **once**, capturing + * whether the load itself is valid (independent of the active context). Later context changes + * reconcile against this stored state without re-decoding, and can never turn an invalid load + * into a servable one. + * + * FORWARD-COMPAT SEAM: when a rules-based configuration is supported, it must be handled here + * BEFORE the precomputed guard — rules are context-agnostic and must NOT be classified invalid. */ - private applyConfiguration = (): void => { - const precomputed = this.loadedConfiguration?.precomputed; - - // Only precomputed configurations are supported for now. An empty configuration - // (an invalid/failed wire parse, or a wire with no precomputed branch) is not usable. - // - // FORWARD-COMPAT SEAM: when a rules-based configuration is supported, it must be - // handled BEFORE this guard — rules are context-agnostic and must NOT be rejected - // here as `invalid`. + private loadConfiguration = ( + configuration: ParsedFlagsConfiguration + ): LoadedConfigurationState => { + const precomputed = configuration?.precomputed; + + // An empty configuration (a failed/lenient wire parse, or a wire with no precomputed + // branch) is unusable. `configurationFromString` collapses malformed input and + // unsupported versions to the same empty shape, so this is classified as `GENERAL`. if (!precomputed) { - this.flagsCache = new Map(); - this.configurationStatus = ConfigurationStatus.Invalid; InternalLog.log( `No usable precomputed configuration was provided for '${this.clientName}'.`, SdkVerbosity.WARN ); - return; + return { kind: 'invalid', errorCode: 'GENERAL' }; } - let decoded: Map; try { - decoded = decodePrecomputedFlags(precomputed.response); + const flags = decodePrecomputedFlags(precomputed.response); + + return { kind: 'precomputed', configuration: precomputed, flags }; } catch (error) { - this.flagsCache = new Map(); - this.configurationStatus = ConfigurationStatus.Invalid; + // Decoding rejects unsupported payloads (e.g. obfuscated) — an unsupported kind, + // classified as `GENERAL`. if (error instanceof Error) { InternalLog.log( `Unsupported flags configuration for '${this.clientName}': ${error.message}`, SdkVerbosity.WARN ); } - return; + return { kind: 'invalid', errorCode: 'GENERAL' }; + } + }; + + /** + * Reconcile the stored {@link LoadedConfigurationState} against the active evaluation context + * and (re)compute the servable flag cache and configuration status/result. + * + * The stored load validity is consulted **before** context compatibility, so a context change + * can never promote an invalid (or absent) load to `ready`. + */ + private reconcile = (): ConfigurationResult => { + const loaded = this.loadedConfiguration; + + // No offline configuration engaged: an offline operation with nothing loaded is not ready. + if (loaded.kind === 'none') { + return this.enterError( + 'PROVIDER_NOT_READY', + `The evaluation context is not usable for '${this.clientName}': no configuration is loaded. Provide a configuration via setConfiguration.` + ); } - // If no context has been set yet, adopt the configuration's embedded context - // (implicit set — no native fetch). A context-agnostic configuration falls back - // to an empty context so evaluation can proceed. + // The load itself failed (malformed/unsupported/undecodable) — independent of context. + if (loaded.kind === 'invalid') { + return this.enterError( + loaded.errorCode, + `The loaded configuration for '${this.clientName}' is not usable. Provide a valid precomputed configuration.` + ); + } + + const { configuration, flags } = loaded; + + // Adopt the configuration's embedded context when no external context is set (implicit + // set — no native fetch). A context-agnostic configuration falls back to an empty context. if (!this.evaluationContext) { - if (precomputed.context) { + if (configuration.context) { this.evaluationContext = normalizeWireContext( - precomputed.context + configuration.context ); } else { InternalLog.log( @@ -237,35 +301,43 @@ export class FlagsClient { ); this.evaluationContext = { targetingKey: '', attributes: {} }; } - - this.flagsCache = decoded; - this.configurationStatus = ConfigurationStatus.Ready; - return; } - // A context is already set. An offline precomputed configuration is a snapshot - // bound to the context it was computed for, and offline never fetches, so it cannot - // be recomputed for a different subject. A runtime context that does not match is - // therefore IGNORED: revert to the embedded context, keep serving the snapshot, and - // warn on the change. (Precomputed is single-subject by design — a rules-based - // configuration is the path for per-context evaluation.) + // A precomputed snapshot is bound to the subject it was computed for. A non-matching + // context cannot be served (offline never fetches), so it is an error and evaluation + // serves coded defaults. The decoded snapshot is retained for a later matching context. if ( !contextMatchesConfiguration( - precomputed.context, + configuration.context, this.evaluationContext ) ) { - InternalLog.log( - `Ignoring the evaluation context set for '${this.clientName}': an offline precomputed configuration is served against the context it was computed for. Set a matching context, or use a rules-based configuration for per-context evaluation.`, - SdkVerbosity.WARN + // The decoded snapshot stays in `loadedConfiguration.flags`; a later matching context + // reconciles back to `ready` and repopulates `flagsCache` — no re-decode needed. + return this.enterError( + 'INVALID_CONTEXT', + `The evaluation context does not match the precomputed configuration for '${this.clientName}'. Serving default values. Set a matching context, or use a rules-based configuration for per-context evaluation.` ); - this.evaluationContext = precomputed.context - ? normalizeWireContext(precomputed.context) - : { targetingKey: '', attributes: {} }; } - this.flagsCache = decoded; - this.configurationStatus = ConfigurationStatus.Ready; + this.flagsCache = flags; + this.configurationStatus = 'ready'; + this.configurationError = undefined; + + return { status: 'ready' }; + }; + + /** Record an error status + message, clear the servable cache, and return the result. */ + private enterError = ( + errorCode: ConfigurationErrorCode, + errorMessage: string + ): ConfigurationResult => { + this.flagsCache = new Map(); + this.configurationStatus = 'error'; + this.configurationError = { errorCode, errorMessage }; + InternalLog.log(errorMessage, SdkVerbosity.WARN); + + return { status: 'error', errorCode }; }; private track = (flag: FlagCacheEntry, context: EvaluationContext) => { @@ -293,16 +365,16 @@ export class FlagsClient { defaultValue: T, type: 'boolean' | 'string' | 'number' | 'object' ): FlagDetails => { - // A loaded-but-unusable configuration surfaces as PROVIDER_NOT_READY at the - // evaluation layer (distinct from FLAG_NOT_FOUND). The dedicated PROVIDER_ERROR - // provider event is wired by the OpenFeature provider in a later step. - if (this.configurationStatus === ConfigurationStatus.Invalid) { + // An offline configuration that cannot be served against the active context surfaces the + // precise error code (INVALID_CONTEXT / GENERAL / PROVIDER_NOT_READY) with the coded + // default. The OpenFeature provider maps this to a PROVIDER_ERROR / ERROR state. + if (this.configurationStatus === 'error' && this.configurationError) { return { key, value: defaultValue, reason: 'ERROR', - errorCode: 'PROVIDER_NOT_READY', - errorMessage: `The loaded configuration for '${this.clientName}' is not usable. Provide a valid precomputed configuration.` + errorCode: this.configurationError.errorCode, + errorMessage: this.configurationError.errorMessage }; } diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index bbe3f19e1..7668610d6 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -319,45 +319,47 @@ describe('FlagsClient', () => { }); }); - describe('setConfiguration', () => { - const offlineFlags = { - 'offline-bool': { - variationType: 'boolean', - variationValue: true, - variationKey: 'true', - allocationKey: 'alloc-1', - reason: 'STATIC', - doLog: false, - extraLogging: {} - } - }; - - const buildConfig = ( - flags: Record, - context?: Record - ) => - configurationFromString( - JSON.stringify({ - version: 1, - precomputed: { - response: JSON.stringify({ - data: { attributes: { obfuscated: false, flags } } - }), - context - } - }) - ); + const offlineFlags = { + 'offline-bool': { + variationType: 'boolean', + variationValue: true, + variationKey: 'true', + allocationKey: 'alloc-1', + reason: 'STATIC', + doLog: false, + extraLogging: {} + } + }; + + const buildConfig = ( + flags: Record, + context?: Record, + obfuscated = false + ) => + configurationFromString( + JSON.stringify({ + version: 1, + precomputed: { + response: JSON.stringify({ + data: { attributes: { obfuscated, flags } } + }), + context + } + }) + ); + describe('setConfiguration', () => { it('serves flags from the configuration without a native fetch', () => { const flagsClient = DdFlags.getClient(); - flagsClient.setConfiguration( + const result = flagsClient.setConfiguration( buildConfig(offlineFlags, { targetingKey: 'user-1', country: 'US' }) ); + expect(result).toEqual({ status: 'ready' }); expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( true ); @@ -385,45 +387,69 @@ describe('FlagsClient', () => { ); }); - it('ignores a differing explicit context, serving the snapshot and warning', async () => { + it('errors with INVALID_CONTEXT and serves defaults when an explicit context differs', async () => { const flagsClient = DdFlags.getClient(); await flagsClient.setEvaluationContext({ targetingKey: 'user-1', attributes: { country: 'US' } }); - flagsClient.setConfiguration( + const result = flagsClient.setConfiguration( buildConfig(offlineFlags, { targetingKey: 'user-2', country: 'US' }) ); - // Offline precomputed is single-subject: the differing context is ignored and - // the snapshot is served for its embedded context, with a warning. - expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( - true - ); - expect(InternalLog.log).toHaveBeenCalledWith( - expect.stringContaining('Ignoring the evaluation context'), - expect.anything() - ); + // Offline precomputed is single-subject: a differing context cannot be served, so + // it is an error and evaluation returns the coded default with INVALID_CONTEXT. + expect(result).toEqual({ + status: 'error', + errorCode: 'INVALID_CONTEXT' + }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'INVALID_CONTEXT' + }); + // No exposure is tracked while serving defaults. + expect( + NativeModules.DdFlags.trackEvaluation + ).not.toHaveBeenCalled(); }); - it('returns PROVIDER_NOT_READY for an empty/invalid configuration', () => { + it('errors with GENERAL for an empty/unparseable configuration', () => { const flagsClient = DdFlags.getClient(); - flagsClient.setConfiguration(configurationFromString('garbage')); + const result = flagsClient.setConfiguration( + configurationFromString('garbage') + ); + expect(result).toEqual({ status: 'error', errorCode: 'GENERAL' }); expect( flagsClient.getBooleanDetails('offline-bool', false) ).toMatchObject({ value: false, reason: 'ERROR', - errorCode: 'PROVIDER_NOT_READY' + errorCode: 'GENERAL' }); }); + it('errors with GENERAL for an unsupported (obfuscated) configuration', () => { + const flagsClient = DdFlags.getClient(); + + const result = flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }, true) + ); + + expect(result).toEqual({ status: 'error', errorCode: 'GENERAL' }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ errorCode: 'GENERAL' }); + }); + it('serves a context-agnostic configuration (no embedded context)', () => { const flagsClient = DdFlags.getClient(); @@ -461,6 +487,45 @@ describe('FlagsClient', () => { expect(flagsClient.getBooleanValue('flag-b', false)).toBe(true); }); + it('does not resurrect a prior valid snapshot after an invalid replacement', () => { + const flagsClient = DdFlags.getClient(); + + // Valid A. + flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + + // Invalid replacement. + expect( + flagsClient.setConfiguration(configurationFromString('garbage')) + ).toEqual({ status: 'error', errorCode: 'GENERAL' }); + + // A later context change must NOT promote the invalid load back to ready. + const afterContextChange = flagsClient.setEvaluationContextWithoutFetching( + { targetingKey: 'user-1', attributes: {} } + ); + expect(afterContextChange).toEqual({ + status: 'error', + errorCode: 'GENERAL' + }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ value: false, errorCode: 'GENERAL' }); + + // A valid replacement recovers. + expect( + flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ) + ).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + }); + it('is superseded by a later native fetch', async () => { const flagsClient = DdFlags.getClient(); flagsClient.setConfiguration( @@ -487,47 +552,21 @@ describe('FlagsClient', () => { }); describe('setEvaluationContextWithoutFetching', () => { - const offlineFlags = { - 'offline-bool': { - variationType: 'boolean', - variationValue: true, - variationKey: 'true', - allocationKey: 'alloc-1', - reason: 'STATIC', - doLog: false, - extraLogging: {} - } - }; - - const buildConfig = (context: Record) => - configurationFromString( - JSON.stringify({ - version: 1, - precomputed: { - response: JSON.stringify({ - data: { - attributes: { - obfuscated: false, - flags: offlineFlags - } - } - }), - context - } - }) - ); - - it('reconciles a loaded config against the context without fetching', () => { + it('reconciles a loaded config against a matching context without fetching', () => { const flagsClient = DdFlags.getClient(); flagsClient.setConfiguration( - buildConfig({ targetingKey: 'user-1', country: 'US' }) + buildConfig(offlineFlags, { + targetingKey: 'user-1', + country: 'US' + }) ); - flagsClient.setEvaluationContextWithoutFetching({ + const result = flagsClient.setEvaluationContextWithoutFetching({ targetingKey: 'user-1', attributes: { country: 'US' } }); + expect(result).toEqual({ status: 'ready' }); expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( true ); @@ -536,53 +575,153 @@ describe('FlagsClient', () => { ).not.toHaveBeenCalled(); }); - it('ignores a differing context change, still serving without fetching', () => { + it('errors with INVALID_CONTEXT on a differing context change, serving defaults', () => { const flagsClient = DdFlags.getClient(); flagsClient.setConfiguration( - buildConfig({ targetingKey: 'user-1', country: 'US' }) + buildConfig(offlineFlags, { + targetingKey: 'user-1', + country: 'US' + }) ); - flagsClient.setEvaluationContextWithoutFetching({ + const result = flagsClient.setEvaluationContextWithoutFetching({ targetingKey: 'user-2', attributes: { country: 'US' } }); - // The differing context is ignored (offline never fetches) and the snapshot is - // still served for its embedded context, with a warning. + expect(result).toEqual({ + status: 'error', + errorCode: 'INVALID_CONTEXT' + }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ value: false, errorCode: 'INVALID_CONTEXT' }); + expect( + NativeModules.DdFlags.setEvaluationContext + ).not.toHaveBeenCalled(); + // Defaults are served, so no exposure is tracked. + expect( + NativeModules.DdFlags.trackEvaluation + ).not.toHaveBeenCalled(); + }); + + it('recovers to ready when a matching context is set after a mismatch', () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ); + + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: {} + }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ errorCode: 'INVALID_CONTEXT' }); + + const recovered = flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: {} + }); + + expect(recovered).toEqual({ status: 'ready' }); expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( true ); - expect(InternalLog.log).toHaveBeenCalledWith( - expect.stringContaining('Ignoring the evaluation context'), - expect.anything() - ); + }); + + it('returns PROVIDER_NOT_READY (not FLAG_NOT_FOUND) with no configuration loaded', () => { + const flagsClient = DdFlags.getClient(); + + const result = flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: {} + }); + + expect(result).toEqual({ + status: 'error', + errorCode: 'PROVIDER_NOT_READY' + }); expect( - NativeModules.DdFlags.setEvaluationContext - ).not.toHaveBeenCalled(); + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'PROVIDER_NOT_READY' + }); }); + }); - it('attributes exposures to the embedded context when a differing context is ignored', () => { + describe('resetEvaluationContextWithoutFetching', () => { + it('re-adopts the embedded context, recovering from a mismatch', () => { const flagsClient = DdFlags.getClient(); flagsClient.setConfiguration( - buildConfig({ targetingKey: 'user-1', country: 'US' }) + buildConfig(offlineFlags, { targetingKey: 'user-1' }) ); flagsClient.setEvaluationContextWithoutFetching({ targetingKey: 'user-2', - attributes: { country: 'US' } + attributes: {} }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ errorCode: 'INVALID_CONTEXT' }); - flagsClient.getBooleanValue('offline-bool', false); + const result = flagsClient.resetEvaluationContextWithoutFetching(); - // The exposure is attributed to the snapshot's embedded context (user-1), not - // the ignored runtime context (user-2). - expect(NativeModules.DdFlags.trackEvaluation).toHaveBeenCalledWith( - expect.any(String), - 'offline-bool', - expect.any(Object), - 'user-1', - expect.objectContaining({ country: 'US' }) + expect(result).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true ); }); + + it('clears the override so a config loaded after reset adopts its embedded context', () => { + const flagsClient = DdFlags.getClient(); + + // No config yet, an external context is set, then cleared. + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: {} + }); + flagsClient.resetEvaluationContextWithoutFetching(); + + // Loading A now adopts A's embedded context (not the stale user-2 override). + const result = flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ); + + expect(result).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + }); + + it('clears the override after an invalid load so a later valid load is ready', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration(configurationFromString('garbage')); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-2', + attributes: {} + }); + flagsClient.resetEvaluationContextWithoutFetching(); + + const result = flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ); + + expect(result).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + }); + + it('returns PROVIDER_NOT_READY with no configuration loaded', () => { + const flagsClient = DdFlags.getClient(); + + expect( + flagsClient.resetEvaluationContextWithoutFetching() + ).toEqual({ status: 'error', errorCode: 'PROVIDER_NOT_READY' }); + }); }); }); diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts index 12bfb18f6..ccd0fe04b 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -169,7 +169,9 @@ type FlagErrorCode = | 'PROVIDER_NOT_READY' | 'FLAG_NOT_FOUND' | 'PARSE_ERROR' - | 'TYPE_MISMATCH'; + | 'TYPE_MISMATCH' + | 'INVALID_CONTEXT' + | 'GENERAL'; /** * Detailed information about a feature flag evaluation. From 0c77f19a64442ec02975f398dae35c45b3c12680 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 21 Jul 2026 17:30:05 -0400 Subject: [PATCH 44/64] fix(flags): don't error when replacing an offline snapshot for a new subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track the app-set external context separately from the effective evaluation context. Previously, adopting a configuration's embedded context stored it in `evaluationContext`, so loading a replacement snapshot for a different subject was mistaken for a mismatch against an "already set" context and returned INVALID_CONTEXT — even though the app never set a context. reconcile() now checks only an explicit `externalContext` for a mismatch; the effective context is `externalContext ?? embedded`. Replacing snapshot A with snapshot B (or loading a subject-bound config after a context-agnostic one) with no external override stays `ready`. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 57 +++++++++++-------- .../src/flags/__tests__/FlagsClient.test.ts | 49 ++++++++++++++++ 2 files changed, 82 insertions(+), 24 deletions(-) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 0f1a0725e..e9f83fcc5 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -70,6 +70,15 @@ export class FlagsClient { private clientName: string; + // The context the app explicitly set — the online fetch context, or the offline override via + // {@link setEvaluationContextWithoutFetching}. `undefined` means "no external override": an + // offline precomputed configuration is then served against its own embedded context. Tracked + // separately from {@link evaluationContext} so that adopting a configuration's embedded context + // is never mistaken for an app-set override (which would spuriously fail a later replacement). + private externalContext: EvaluationContext | undefined = undefined; + + // The effective context evaluation and exposure tracking run against: the external override + // when set, otherwise the loaded configuration's embedded context. private evaluationContext: EvaluationContext | undefined = undefined; // The servable flag cache. On the online path it holds the native-fetched flags; on the @@ -130,6 +139,7 @@ export class FlagsClient { processedContext.attributes ?? {} ); + this.externalContext = processedContext; this.evaluationContext = processedContext; this.flagsCache = new Map(Object.entries(result)); @@ -170,7 +180,7 @@ export class FlagsClient { setEvaluationContextWithoutFetching = ( context: EvaluationContext ): ConfigurationResult => { - this.evaluationContext = processEvaluationContext(context); + this.externalContext = processEvaluationContext(context); return this.reconcile(); }; @@ -185,7 +195,7 @@ export class FlagsClient { * configuration loaded the result is `PROVIDER_NOT_READY`. */ resetEvaluationContextWithoutFetching = (): ConfigurationResult => { - this.evaluationContext = undefined; + this.externalContext = undefined; return this.reconcile(); }; @@ -287,39 +297,29 @@ export class FlagsClient { const { configuration, flags } = loaded; - // Adopt the configuration's embedded context when no external context is set (implicit - // set — no native fetch). A context-agnostic configuration falls back to an empty context. - if (!this.evaluationContext) { - if (configuration.context) { - this.evaluationContext = normalizeWireContext( - configuration.context - ); - } else { - InternalLog.log( - `The provided configuration for '${this.clientName}' has no embedded context; treating it as context-agnostic.`, - SdkVerbosity.WARN - ); - this.evaluationContext = { targetingKey: '', attributes: {} }; - } - } - - // A precomputed snapshot is bound to the subject it was computed for. A non-matching - // context cannot be served (offline never fetches), so it is an error and evaluation - // serves coded defaults. The decoded snapshot is retained for a later matching context. + // A precomputed snapshot is bound to the subject it was computed for. If the app set an + // *external* context that does not match, it cannot be served (offline never fetches), so + // it is an error and evaluation serves coded defaults. Only an external override is checked + // here — the configuration's own embedded context (adopted below when no override is set) + // matches by construction, so replacing one snapshot with another for a different subject + // stays `ready`. The decoded snapshot is retained for a later matching context. if ( + this.externalContext && !contextMatchesConfiguration( configuration.context, - this.evaluationContext + this.externalContext ) ) { - // The decoded snapshot stays in `loadedConfiguration.flags`; a later matching context - // reconciles back to `ready` and repopulates `flagsCache` — no re-decode needed. return this.enterError( 'INVALID_CONTEXT', `The evaluation context does not match the precomputed configuration for '${this.clientName}'. Serving default values. Set a matching context, or use a rules-based configuration for per-context evaluation.` ); } + // Serve against the external override when set, otherwise the configuration's embedded + // context (a context-agnostic configuration falls back to an empty context). + this.evaluationContext = + this.externalContext ?? this.embeddedContext(configuration); this.flagsCache = flags; this.configurationStatus = 'ready'; this.configurationError = undefined; @@ -327,6 +327,15 @@ export class FlagsClient { return { status: 'ready' }; }; + /** The evaluation context a precomputed configuration was computed for (empty if agnostic). */ + private embeddedContext = ( + configuration: ParsedPrecomputedConfiguration + ): EvaluationContext => { + return configuration.context + ? normalizeWireContext(configuration.context) + : { targetingKey: '', attributes: {} }; + }; + /** Record an error status + message, clear the servable cache, and return the result. */ private enterError = ( errorCode: ConfigurationErrorCode, diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 7668610d6..be236ed69 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -487,6 +487,55 @@ describe('FlagsClient', () => { expect(flagsClient.getBooleanValue('flag-b', false)).toBe(true); }); + it('replaces a snapshot for one subject with a snapshot for another (no external context)', () => { + const flagsClient = DdFlags.getClient(); + + // Load A for user-1 — no external context is set, so A's embedded context is adopted. + expect( + flagsClient.setConfiguration( + buildConfig( + { 'flag-a': offlineFlags['offline-bool'] }, + { targetingKey: 'user-1' } + ) + ) + ).toEqual({ status: 'ready' }); + + // Replace with B for a DIFFERENT subject. Because the app never set an external + // context, this must adopt B's embedded context and stay ready — not error as a + // mismatch against A's adopted context. + expect( + flagsClient.setConfiguration( + buildConfig( + { 'flag-b': offlineFlags['offline-bool'] }, + { targetingKey: 'user-2' } + ) + ) + ).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('flag-b', false)).toBe(true); + expect( + flagsClient.getBooleanDetails('flag-a', false) + ).toMatchObject({ errorCode: 'FLAG_NOT_FOUND' }); + }); + + it('adopts a subject-bound snapshot loaded after a context-agnostic one (no external context)', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration(buildConfig(offlineFlags)); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + + expect( + flagsClient.setConfiguration( + buildConfig( + { 'flag-b': offlineFlags['offline-bool'] }, + { targetingKey: 'user-2' } + ) + ) + ).toEqual({ status: 'ready' }); + expect(flagsClient.getBooleanValue('flag-b', false)).toBe(true); + }); + it('does not resurrect a prior valid snapshot after an invalid replacement', () => { const flagsClient = DdFlags.getClient(); From 68a929b0e96b41b621539aca4f1a2ef861606214 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 21 Jul 2026 17:32:32 -0400 Subject: [PATCH 45/64] fix(flags): drop the offline overlay when entering online mode Using one FlagsClient for both the offline and online providers is unsupported, but it should degrade safely rather than serve stale data. Previously the offline overlay was cleared only after a *successful* native fetch, so a failed online fetch left the offline snapshot in place and kept serving (and tracking) it. setEvaluationContext now discards any offline overlay up front (with a warning) before fetching, so a failed fetch falls back to coded defaults instead of the stale offline snapshot. Online-only clients have no overlay, so their keep-last-known-flags-on-failure behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 30 ++++++++++++------ .../src/flags/__tests__/FlagsClient.test.ts | 31 +++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index e9f83fcc5..fdaa1391c 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -132,6 +132,24 @@ export class FlagsClient { // Make sure to process the incoming context because we don't support nested object values in context. const processedContext = processEvaluationContext(context); + // Entering online mode drops any offline overlay *before* fetching, so a failed fetch falls + // back to coded defaults rather than continuing to serve a stale offline snapshot. Using one + // client for both online and offline is unsupported (give the offline provider its own + // `clientName`), so warn when an offline configuration is discarded here. Online-only clients + // have no overlay, so this does not touch their keep-last-known-flags-on-failure behavior. + if (this.loadedConfiguration.kind !== 'none') { + InternalLog.log( + `An offline configuration was loaded for '${this.clientName}' but an online fetch was requested; discarding it and serving default values on failure. Use a separate client for the offline provider.`, + SdkVerbosity.WARN + ); + this.loadedConfiguration = { kind: 'none' }; + this.configurationStatus = 'none'; + this.configurationError = undefined; + this.flagsCache = new Map(); + this.evaluationContext = undefined; + this.externalContext = undefined; + } + try { const result = await this.nativeFlags.setEvaluationContext( this.clientName, @@ -142,17 +160,9 @@ export class FlagsClient { this.externalContext = processedContext; this.evaluationContext = processedContext; this.flagsCache = new Map(Object.entries(result)); - - // An explicit online fetch supersedes any previously loaded offline configuration. - // Online vs. offline is a property of the provider — the offline provider adopts - // context via `setEvaluationContextWithoutFetching` and never calls this method — so - // drop the offline overlay here to keep state coherent. - this.loadedConfiguration = { kind: 'none' }; - this.configurationStatus = 'none'; - this.configurationError = undefined; } catch (error) { - // NOTE: a failed fetch leaves any previously loaded offline configuration in - // place, so the client may keep serving it (and attribute exposures to its context). + // A failed fetch leaves the previous online cache in place (keep-last-known); any offline + // overlay was already dropped above, so no stale offline snapshot is served. if (error instanceof Error) { InternalLog.log( `Error setting flag evaluation context: ${error.message}`, diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index be236ed69..8816d57ab 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -598,6 +598,37 @@ describe('FlagsClient', () => { flagsClient.getBooleanValue('test-boolean-flag', false) ).toBe(true); }); + + it('drops the offline overlay when entering online mode, serving defaults if the fetch fails', async () => { + const flagsClient = DdFlags.getClient(); + flagsClient.setConfiguration( + buildConfig(offlineFlags, { targetingKey: 'user-1' }) + ); + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + true + ); + + NativeModules.DdFlags.setEvaluationContext.mockRejectedValueOnce( + new Error('network down') + ); + await expect( + flagsClient.setEvaluationContext({ + targetingKey: 'user-1', + attributes: {} + }) + ).rejects.toThrow('network down'); + + // Using one client for both modes is unsupported: the online fetch discards the offline + // overlay up front and warns, so a failed fetch serves coded defaults rather than the + // stale offline snapshot. + expect(flagsClient.getBooleanValue('offline-bool', false)).toBe( + false + ); + expect(InternalLog.log).toHaveBeenCalledWith( + expect.stringContaining('online fetch was requested'), + expect.anything() + ); + }); }); describe('setEvaluationContextWithoutFetching', () => { From 67aa667a2992411f8d66cb7e52f406b14a49be68 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 21 Jul 2026 17:35:17 -0400 Subject: [PATCH 46/64] fix(flags): reject structurally malformed precomputed responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decodePrecomputedFlags treated a null/non-object envelope or a missing `data.attributes.flags` as an empty flag map, so a JSON-valid but structurally malformed configuration decoded to an empty — but "ready" — snapshot and evaluations returned FLAG_NOT_FOUND instead of an error. Validate that `flags` is a plain object (rejecting null, arrays, and missing envelopes) and throw UnsupportedConfigurationError otherwise, so setConfiguration classifies it as GENERAL. A genuinely empty `flags: {}` is still accepted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/precomputed.test.ts | 20 ++++++++++++++----- .../src/flags/configuration/precomputed.ts | 20 +++++++++++++++++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts index 77a3ce93b..db8497286 100644 --- a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts +++ b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts @@ -225,12 +225,22 @@ describe('decodePrecomputedFlags', () => { expect(cache.get('inf')).toBeUndefined(); }); - it('returns an empty map for a structurally broken response', () => { - expect( + it.each([ + ['a null response', null], + ['a non-object response', 'nonsense'], + ['a missing data envelope', {}], + ['a missing attributes envelope', { data: {} }], + ['a missing flags map', { data: { attributes: {} } }], + ['a null flags map', { data: { attributes: { flags: null } } }], + ['an array flags map', { data: { attributes: { flags: [] } } }] + ])('throws for a structurally malformed response (%s)', (_label, input) => { + expect(() => decodePrecomputedFlags( - ({} as unknown) as Parameters[0] - ).size - ).toBe(0); + (input as unknown) as Parameters< + typeof decodePrecomputedFlags + >[0] + ) + ).toThrow(UnsupportedConfigurationError); }); it('accepts any JSON value for an object flag (array, null, primitive)', () => { diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index 49bba1d58..e9a453006 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -36,7 +36,8 @@ export class UnsupportedConfigurationError extends Error { * booleans, `String(...)` otherwise) because native Android exposure tracking * rebuilds the flag from the string form. * - * @throws {UnsupportedConfigurationError} if the response is obfuscated. + * @throws {UnsupportedConfigurationError} if the response is obfuscated, or its envelope is + * structurally malformed (missing/non-object `data.attributes.flags`). */ export const decodePrecomputedFlags = ( response: PrecomputedConfigurationResponse @@ -53,7 +54,17 @@ export const decodePrecomputedFlags = ( ); } - const flags = attributes?.flags ?? {}; + // Validate the response envelope before trusting it. An untrusted wire can be JSON-valid yet + // structurally malformed (a null/non-object envelope, or a `flags` that is null or an array). + // Fail predictably so the caller classifies it as an error instead of silently decoding it to + // an empty — but "ready" — configuration. A genuinely empty `flags: {}` is still accepted. + const flags = attributes?.flags; + if (!isFlagsMap(flags)) { + throw new UnsupportedConfigurationError( + "Malformed precomputed configuration: 'data.attributes.flags' must be an object." + ); + } + // A Map (returned as-is) so a pathological flag keyed "__proto__" is stored as data // and later looked up via `.get()` — never hitting the `Object.prototype` "__proto__" // setter (on write) or the prototype chain (on read) the way a plain object would. @@ -69,6 +80,11 @@ export const decodePrecomputedFlags = ( return cache; }; +// A well-formed flags container is a plain object (a possibly-empty map of flag key -> flag). +// `null`, arrays, and primitives are malformed envelopes. +const isFlagsMap = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + const toFlagCacheEntry = ( key: string, flag: unknown From f7e019ff9008468b8cdc12820f91a5aa6f0c58a7 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 22 Jul 2026 10:14:36 -0400 Subject: [PATCH 47/64] test(flags): assert exposure attribution on replacement + malformed envelope - After replacing snapshot A with B (different subject, no external context), assert trackEvaluation is called with B's embedded targetingKey, so a regression that updated the flags but kept A's context would be caught. - Add a public-boundary test: a wire whose precomputed response omits `data.attributes.flags` reconciles to GENERAL and serves coded defaults. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/flags/__tests__/FlagsClient.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 8816d57ab..87e2bb575 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -450,6 +450,28 @@ describe('FlagsClient', () => { ).toMatchObject({ errorCode: 'GENERAL' }); }); + it('errors with GENERAL for a structurally malformed response envelope', () => { + const flagsClient = DdFlags.getClient(); + + // A wire whose precomputed response omits `data.attributes.flags` entirely. + const wire = JSON.stringify({ + version: 1, + precomputed: { + response: JSON.stringify({ data: { attributes: {} } }), + context: { targetingKey: 'user-1' } + } + }); + + const result = flagsClient.setConfiguration( + configurationFromString(wire) + ); + + expect(result).toEqual({ status: 'error', errorCode: 'GENERAL' }); + expect( + flagsClient.getBooleanDetails('offline-bool', false) + ).toMatchObject({ value: false, errorCode: 'GENERAL' }); + }); + it('serves a context-agnostic configuration (no embedded context)', () => { const flagsClient = DdFlags.getClient(); @@ -515,6 +537,16 @@ describe('FlagsClient', () => { expect( flagsClient.getBooleanDetails('flag-a', false) ).toMatchObject({ errorCode: 'FLAG_NOT_FOUND' }); + + // The exposure must be attributed to B's embedded context (user-2), not A's — a + // regression that updated the flags but kept A's context would misattribute here. + expect(NativeModules.DdFlags.trackEvaluation).toHaveBeenCalledWith( + 'default', + 'flag-b', + expect.any(Object), + 'user-2', + {} + ); }); it('adopts a subject-bound snapshot loaded after a context-agnostic one (no external context)', () => { From 2b8a643a77a13a70eba6989e2f5b2d6d9bdc300f Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 18:48:22 -0400 Subject: [PATCH 48/64] feat(openfeature): add DatadogOfflineOpenFeatureProvider (FFL-2689, FFL-2690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A never-fetch OpenFeature provider for offline initialization. It behaves like DatadogOpenFeatureProvider — same evaluation and exposure/RUM tracking — except initialize/onContextChange use FlagsClient.setEvaluationContextWithoutFetching instead of the fetching setEvaluationContext, so it never hits the CDN. It exposes setConfiguration (delegating to the FlagsClient) and emits provider events from the configuration outcome: first config -> READY, subsequent -> CONFIGURATION_CHANGED, mismatch/invalid -> ERROR. - Core: setConfiguration and setEvaluationContextWithoutFetching now return a ConfigurationStatus so the provider can map outcome -> event; export configurationFromString/configurationToString, ParsedFlagsConfiguration, and ConfigurationStatus. - react-native-openfeature: make flagsClient protected and export toDdContext so the offline provider can reuse the base; export DatadogOfflineOpenFeatureProvider and re-export configurationFromString; README offline-init section. No native changes — the offline path uses existing native enable + trackEvaluation and skips native setEvaluationContext; evaluation is all JS. Tests: provider never calls the fetching path, delegates setConfiguration, emits the right events, and resolves through the client. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/index.tsx | 11 +- packages/react-native-openfeature/README.md | 33 +++++ .../src/__tests__/offlineProvider.test.ts | 114 ++++++++++++++++++ .../react-native-openfeature/src/index.ts | 9 +- .../src/offlineProvider.ts | 96 +++++++++++++++ .../react-native-openfeature/src/provider.ts | 6 +- 6 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts create mode 100644 packages/react-native-openfeature/src/offlineProvider.ts diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index e85b3eb06..6d8084123 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -31,7 +31,12 @@ import { VitalsUpdateFrequency } from './config/types'; import { DdFlags } from './flags/DdFlags'; -import type { FlagsClient } from './flags/FlagsClient'; +import type { ConfigurationStatus, FlagsClient } from './flags/FlagsClient'; +import { + configurationFromString, + configurationToString +} from './flags/configuration'; +import type { ParsedFlagsConfiguration } from './flags/configuration'; import type { FlagsConfiguration, FlagDetails, @@ -72,6 +77,8 @@ export { InitializationMode, DdLogs, DdFlags, + configurationFromString, + configurationToString, DdTrace, DdRum, RumActionType, @@ -118,6 +125,8 @@ export type { TraceConfigurationOptions, FlagsConfiguration, FlagsClient, + ParsedFlagsConfiguration, + ConfigurationStatus, EvaluationContext, PrimitiveValue, FlagDetails diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index b1dd0b072..dee9a2e03 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -112,6 +112,39 @@ function App() { export default AppWithProviders; ``` +### Offline initialization + +If you fetch a flag configuration yourself (for example a precomputed-assignments payload +cached on disk, delivered via your own service, or bundled with the app), use +`DatadogOfflineOpenFeatureProvider` instead of `DatadogOpenFeatureProvider`. It evaluates flags +and reports exposures exactly like the online provider, but **never fetches configuration from +the network** — you supply it with `setConfiguration`. + +```tsx +import { DdFlags } from '@datadog/mobile-react-native'; +import { + DatadogOfflineOpenFeatureProvider, + configurationFromString +} from '@datadog/mobile-react-native-openfeature'; +import { OpenFeature } from '@openfeature/react-sdk'; + +await DdFlags.enable(); + +const provider = new DatadogOfflineOpenFeatureProvider(); +await OpenFeature.setProviderAndWait(provider); + +// `wire` is a ConfigurationWire string you fetched yourself. +provider.setConfiguration(configurationFromString(wire)); + +// Evaluate flags — no network request is made. +const client = OpenFeature.getClient(); +const isNewFeatureEnabled = client.getBooleanValue('new-feature-enabled', false); +``` + +The configuration carries the evaluation context it was computed for, so you do not need to call +`OpenFeature.setContext` for the offline precomputed flow. If you do set a context, it must match +the configuration's context; otherwise the provider reports an error and serves default values. + [1]: https://openfeature.dev/docs/reference/sdks/client/web/react/ [2]: https://docs.datadoghq.com/getting_started/feature_flags/ [3]: https://github.com/DataDog/dd-sdk-reactnative/tree/develop/packages/core diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts new file mode 100644 index 000000000..300abde74 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -0,0 +1,114 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { ProviderEvents } from '@openfeature/web-sdk'; + +import { DatadogOfflineOpenFeatureProvider } from '../offlineProvider'; + +const mockFlagsClient = { + setConfiguration: jest.fn(() => 'ready'), + setEvaluationContextWithoutFetching: jest.fn(() => 'ready'), + setEvaluationContext: jest.fn(() => Promise.resolve()), + getBooleanDetails: jest.fn(() => ({ + key: 'flag', + value: true, + reason: 'STATIC', + variant: 'true' + })) +}; + +jest.mock('@datadog/mobile-react-native', () => { + return { + DdFlags: { getClient: jest.fn(() => mockFlagsClient) }, + configurationFromString: jest.fn() + }; +}); + +describe('DatadogOfflineOpenFeatureProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFlagsClient.setConfiguration.mockReturnValue('ready'); + mockFlagsClient.setEvaluationContextWithoutFetching.mockReturnValue( + 'ready' + ); + }); + + it('advertises the offline provider name', () => { + expect(new DatadogOfflineOpenFeatureProvider().metadata.name).toBe( + 'datadog-react-native-offline' + ); + }); + + it('never fetches on initialize (uses the no-fetch context setter)', async () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + + await provider.initialize({ targetingKey: 'user-1' }); + + expect( + mockFlagsClient.setEvaluationContextWithoutFetching + ).toHaveBeenCalled(); + expect(mockFlagsClient.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('never fetches on a context change', async () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + + await provider.onContextChange({}, { targetingKey: 'user-2' }); + + expect( + mockFlagsClient.setEvaluationContextWithoutFetching + ).toHaveBeenCalledWith( + expect.objectContaining({ targetingKey: 'user-2' }) + ); + expect(mockFlagsClient.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('delegates setConfiguration to the client and emits READY then CONFIGURATION_CHANGED', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + const emitSpy = jest.spyOn(provider.events, 'emit'); + + provider.setConfiguration({} as never); + provider.setConfiguration({} as never); + + expect(mockFlagsClient.setConfiguration).toHaveBeenCalledTimes(2); + expect(emitSpy).toHaveBeenNthCalledWith(1, ProviderEvents.Ready); + expect(emitSpy).toHaveBeenNthCalledWith( + 2, + ProviderEvents.ConfigurationChanged + ); + }); + + it('emits PROVIDER_ERROR on a mismatched configuration', () => { + mockFlagsClient.setConfiguration.mockReturnValueOnce('mismatch'); + const provider = new DatadogOfflineOpenFeatureProvider(); + const emitSpy = jest.spyOn(provider.events, 'emit'); + + provider.setConfiguration({} as never); + + expect(emitSpy).toHaveBeenCalledWith( + ProviderEvents.Error, + expect.objectContaining({ message: expect.any(String) }) + ); + }); + + it('resolves boolean evaluation through the client', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + + const result = provider.resolveBooleanEvaluation( + 'flag', + false, + {}, + // eslint-disable-next-line no-console + console as never + ); + + expect(result.value).toBe(true); + expect(mockFlagsClient.getBooleanDetails).toHaveBeenCalledWith( + 'flag', + false + ); + }); +}); diff --git a/packages/react-native-openfeature/src/index.ts b/packages/react-native-openfeature/src/index.ts index 1c56eb753..55c571c9e 100644 --- a/packages/react-native-openfeature/src/index.ts +++ b/packages/react-native-openfeature/src/index.ts @@ -4,8 +4,15 @@ * Copyright 2016-Present Datadog, Inc. */ +import { configurationFromString } from '@datadog/mobile-react-native'; + +import { DatadogOfflineOpenFeatureProvider } from './offlineProvider'; import { DatadogOpenFeatureProvider } from './provider'; import type { DatadogOpenFeatureProviderOptions } from './provider'; -export { DatadogOpenFeatureProvider }; +export { + DatadogOpenFeatureProvider, + DatadogOfflineOpenFeatureProvider, + configurationFromString +}; export type { DatadogOpenFeatureProviderOptions }; diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts new file mode 100644 index 000000000..16c2953a1 --- /dev/null +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -0,0 +1,96 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { + ConfigurationStatus, + ParsedFlagsConfiguration +} from '@datadog/mobile-react-native'; +import { ProviderEvents } from '@openfeature/web-sdk'; +import type { + EvaluationContext as OFEvaluationContext, + ProviderMetadata +} from '@openfeature/web-sdk'; + +import { DatadogOpenFeatureProvider, toDdContext } from './provider'; + +/** + * An offline Datadog OpenFeature provider. + * + * It behaves exactly like {@link DatadogOpenFeatureProvider} — same flag evaluation and + * exposure/RUM tracking — **except it never fetches configuration from the network**. + * Instead of fetching on `initialize`/`onContextChange`, it records the context and + * reconciles a configuration supplied via {@link setConfiguration}. Use it for offline + * initialization: load a `ParsedFlagsConfiguration` (from `configurationFromString`) and + * evaluate flags with no network request. + * + * @example + * ```ts + * import { OpenFeature } from '@openfeature/web-sdk'; + * import { + * DatadogOfflineOpenFeatureProvider, + * configurationFromString + * } from '@datadog/mobile-react-native-openfeature'; + * + * const provider = new DatadogOfflineOpenFeatureProvider(); + * await OpenFeature.setProviderAndWait(provider); + * + * provider.setConfiguration(configurationFromString(wire)); + * const client = OpenFeature.getClient(); + * const enabled = client.getBooleanValue('new-feature', false); + * ``` + */ +export class DatadogOfflineOpenFeatureProvider extends DatadogOpenFeatureProvider { + readonly metadata: ProviderMetadata = { + name: 'datadog-react-native-offline' + }; + + private hasServedConfiguration = false; + + async initialize(context: OFEvaluationContext = {}): Promise { + // Record the context and reconcile any loaded configuration — no network fetch. + const status = this.flagsClient.setEvaluationContextWithoutFetching( + toDdContext(context) + ); + this.emitConfigurationOutcome(status); + } + + async onContextChange( + _oldContext: OFEvaluationContext, + newContext: OFEvaluationContext + ): Promise { + const status = this.flagsClient.setEvaluationContextWithoutFetching( + toDdContext(newContext) + ); + this.emitConfigurationOutcome(status); + } + + /** + * Load a configuration into the provider for offline evaluation. + * + * @param configuration A configuration parsed from a `ConfigurationWire` string via + * `configurationFromString`. + */ + setConfiguration(configuration: ParsedFlagsConfiguration): void { + const status = this.flagsClient.setConfiguration(configuration); + this.emitConfigurationOutcome(status); + } + + private emitConfigurationOutcome(status: ConfigurationStatus): void { + if (status === 'ready') { + if (this.hasServedConfiguration) { + this.events.emit(ProviderEvents.ConfigurationChanged); + } else { + this.hasServedConfiguration = true; + this.events.emit(ProviderEvents.Ready); + } + } else if (status === 'mismatch' || status === 'invalid') { + this.events.emit(ProviderEvents.Error, { + message: `The Datadog offline provider cannot serve the loaded configuration (${status}).` + }); + } + // 'none' — no configuration engaged yet; nothing to signal. + } +} diff --git a/packages/react-native-openfeature/src/provider.ts b/packages/react-native-openfeature/src/provider.ts index 6cbae29ca..84afb78c6 100644 --- a/packages/react-native-openfeature/src/provider.ts +++ b/packages/react-native-openfeature/src/provider.ts @@ -42,7 +42,7 @@ export class DatadogOpenFeatureProvider implements Provider { }; private options: DatadogOpenFeatureProviderOptions; - private flagsClient: FlagsClient; + protected flagsClient: FlagsClient; readonly events: ProviderEventEmitter = new OpenFeatureEventEmitter(); private contextChangePromise = Promise.resolve(); @@ -139,7 +139,9 @@ export class DatadogOpenFeatureProvider implements Provider { } } -const toDdContext = (context: OFEvaluationContext): DdEvaluationContext => { +export const toDdContext = ( + context: OFEvaluationContext +): DdEvaluationContext => { const { targetingKey, ...attributes } = context; // Important ⚠️ From 89849cf625ef13636396e9b2279d85f44204a4ae Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 20:20:21 -0400 Subject: [PATCH 49/64] refactor(openfeature): extract DatadogCoreOpenFeatureProvider base Both the online and offline providers now extend a shared, internal (un-exported) DatadogCoreOpenFeatureProvider that owns the FlagsClient, event emitter, and resolveX evaluation. DatadogOpenFeatureProvider keeps its fetch-on-context initialize/onContextChange, and DatadogOfflineOpenFeatureProvider extends the base directly instead of the online provider, so it no longer inherits the online fetch/promise-chain machinery. Public API and online behavior are unchanged and the base is not exported. Matches the Portable RFC's CoreProvider. Adds a provider test locking the online provider's fetch-on-initialize behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/provider.test.ts | 74 ++++++++ .../src/coreProvider.ts | 164 ++++++++++++++++++ .../src/offlineProvider.ts | 4 +- .../react-native-openfeature/src/provider.ts | 150 +--------------- 4 files changed, 249 insertions(+), 143 deletions(-) create mode 100644 packages/react-native-openfeature/src/__tests__/provider.test.ts create mode 100644 packages/react-native-openfeature/src/coreProvider.ts diff --git a/packages/react-native-openfeature/src/__tests__/provider.test.ts b/packages/react-native-openfeature/src/__tests__/provider.test.ts new file mode 100644 index 000000000..adf81c730 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/provider.test.ts @@ -0,0 +1,74 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { DatadogOpenFeatureProvider } from '../provider'; + +const mockFlagsClient = { + setEvaluationContext: jest.fn(() => Promise.resolve()), + setEvaluationContextWithoutFetching: jest.fn(() => 'ready'), + getBooleanDetails: jest.fn(() => ({ + key: 'flag', + value: true, + reason: 'STATIC', + variant: 'true' + })) +}; + +jest.mock('@datadog/mobile-react-native', () => { + return { + DdFlags: { getClient: jest.fn(() => mockFlagsClient) }, + configurationFromString: jest.fn() + }; +}); + +describe('DatadogOpenFeatureProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('advertises the online provider name', () => { + expect(new DatadogOpenFeatureProvider().metadata.name).toBe( + 'datadog-react-native' + ); + }); + + it('fetches on initialize (uses the fetching setEvaluationContext)', async () => { + const provider = new DatadogOpenFeatureProvider(); + + await provider.initialize({ targetingKey: 'user-1' }); + + expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith( + expect.objectContaining({ targetingKey: 'user-1' }) + ); + expect( + mockFlagsClient.setEvaluationContextWithoutFetching + ).not.toHaveBeenCalled(); + }); + + it('fetches on a context change', async () => { + const provider = new DatadogOpenFeatureProvider(); + + await provider.onContextChange({}, { targetingKey: 'user-2' }); + + expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith( + expect.objectContaining({ targetingKey: 'user-2' }) + ); + }); + + it('resolves boolean evaluation through the client', () => { + const provider = new DatadogOpenFeatureProvider(); + + const result = provider.resolveBooleanEvaluation( + 'flag', + false, + {}, + // eslint-disable-next-line no-console + console as never + ); + + expect(result.value).toBe(true); + }); +}); diff --git a/packages/react-native-openfeature/src/coreProvider.ts b/packages/react-native-openfeature/src/coreProvider.ts new file mode 100644 index 000000000..1e65e5bed --- /dev/null +++ b/packages/react-native-openfeature/src/coreProvider.ts @@ -0,0 +1,164 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { DdFlags } from '@datadog/mobile-react-native'; +import type { + FlagDetails, + FlagsClient, + EvaluationContext as DdEvaluationContext +} from '@datadog/mobile-react-native'; +import { OpenFeatureEventEmitter, ErrorCode } from '@openfeature/web-sdk'; +import type { + EvaluationContext as OFEvaluationContext, + JsonValue, + Logger, + Paradigm, + Provider, + ProviderMetadata, + ResolutionDetails, + PrimitiveValue, + ProviderEventEmitter, + ProviderEvents +} from '@openfeature/web-sdk'; + +export interface DatadogOpenFeatureProviderOptions { + /** + * The name of the Datadog Flags client to use. + * + * Provide this parameter in order to use different Datadog Flags clients for different OpenFeature domains. + * + * @default 'default' + */ + clientName?: string; +} + +/** + * Shared base for the Datadog OpenFeature providers. It owns the `FlagsClient`, the event + * emitter, and evaluation (`resolveX`) — everything the online and offline providers have in + * common. It does not decide how the evaluation context is sourced (fetch vs. offline load); + * concrete providers implement `initialize`/`onContextChange`. + * + * Internal: not exported from the package entry point. Customers use + * `DatadogOpenFeatureProvider` (online) or `DatadogOfflineOpenFeatureProvider` (offline). + */ +export abstract class DatadogCoreOpenFeatureProvider implements Provider { + readonly runsOn: Paradigm = 'client'; + abstract readonly metadata: ProviderMetadata; + + private options: DatadogOpenFeatureProviderOptions; + protected flagsClient: FlagsClient; + + readonly events: ProviderEventEmitter = new OpenFeatureEventEmitter(); + + constructor(options: DatadogOpenFeatureProviderOptions = {}) { + if (!options.clientName) { + options.clientName = 'default'; + } + + this.options = options; + + this.flagsClient = DdFlags.getClient(this.options.clientName); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + _context: OFEvaluationContext, + _logger: Logger + ): ResolutionDetails { + const details = this.flagsClient.getBooleanDetails( + flagKey, + defaultValue + ); + return toFlagResolution(details); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + _context: OFEvaluationContext, + _logger: Logger + ): ResolutionDetails { + const details = this.flagsClient.getStringDetails( + flagKey, + defaultValue + ); + return toFlagResolution(details); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + _context: OFEvaluationContext, + _logger: Logger + ): ResolutionDetails { + const details = this.flagsClient.getNumberDetails( + flagKey, + defaultValue + ); + return toFlagResolution(details); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + _context: OFEvaluationContext, + _logger: Logger + ): ResolutionDetails { + // The OpenFeature spec states that the return value can be any valid JSON value. + // However, the Datadog Flags feature only supports JSON objects for the JSON feature flag type. + // Thus, the user should always expect the returned value to be an object instead of any arbitrary JSON value. + // Also, the user is responsible for providing a proper `defaultValue` that's an object. + + const details = this.flagsClient.getObjectDetails( + flagKey, + defaultValue + ); + return toFlagResolution(details); + } +} + +export const toDdContext = ( + context: OFEvaluationContext +): DdEvaluationContext => { + const { targetingKey, ...attributes } = context; + + // Important ⚠️ + // The Flags SDK doesn't support nested non-primitive values in the evaluation context as per OF.3 FFE SDK requirement. + // However, we let the SDK handle this inside of FlagsClient since it does this processing anyways. + const ddContextAttributes = attributes as Record; + + return { + // Allow flag evaluations without a provided targeting key. + targetingKey: targetingKey ?? '', + attributes: ddContextAttributes + }; +}; + +const toFlagResolution = (details: FlagDetails): ResolutionDetails => { + const { + value, + reason, + variant, + allocationKey, + errorCode, + errorMessage + } = details; + + const parsedErrorCode = + errorCode && (ErrorCode[errorCode as ErrorCode] || ErrorCode.GENERAL); + + const result: ResolutionDetails = { + value, + reason, + variant, + flagMetadata: allocationKey ? { allocationKey } : undefined, + errorCode: parsedErrorCode, + errorMessage + }; + + return result; +}; diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index 16c2953a1..72dab00c0 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -14,7 +14,7 @@ import type { ProviderMetadata } from '@openfeature/web-sdk'; -import { DatadogOpenFeatureProvider, toDdContext } from './provider'; +import { DatadogCoreOpenFeatureProvider, toDdContext } from './coreProvider'; /** * An offline Datadog OpenFeature provider. @@ -42,7 +42,7 @@ import { DatadogOpenFeatureProvider, toDdContext } from './provider'; * const enabled = client.getBooleanValue('new-feature', false); * ``` */ -export class DatadogOfflineOpenFeatureProvider extends DatadogOpenFeatureProvider { +export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeatureProvider { readonly metadata: ProviderMetadata = { name: 'datadog-react-native-offline' }; diff --git a/packages/react-native-openfeature/src/provider.ts b/packages/react-native-openfeature/src/provider.ts index 84afb78c6..05a602106 100644 --- a/packages/react-native-openfeature/src/provider.ts +++ b/packages/react-native-openfeature/src/provider.ts @@ -4,59 +4,26 @@ * Copyright 2016-Present Datadog, Inc. */ -import { DdFlags } from '@datadog/mobile-react-native'; -import type { - FlagDetails, - FlagsClient, - EvaluationContext as DdEvaluationContext -} from '@datadog/mobile-react-native'; -import { OpenFeatureEventEmitter, ErrorCode } from '@openfeature/web-sdk'; import type { EvaluationContext as OFEvaluationContext, - JsonValue, - Logger, - Paradigm, - Provider, - ProviderMetadata, - ResolutionDetails, - PrimitiveValue, - ProviderEventEmitter, - ProviderEvents + ProviderMetadata } from '@openfeature/web-sdk'; -export interface DatadogOpenFeatureProviderOptions { - /** - * The name of the Datadog Flags client to use. - * - * Provide this parameter in order to use different Datadog Flags clients for different OpenFeature domains. - * - * @default 'default' - */ - clientName?: string; -} +import { DatadogCoreOpenFeatureProvider, toDdContext } from './coreProvider'; -export class DatadogOpenFeatureProvider implements Provider { - readonly runsOn: Paradigm = 'client'; +export type { DatadogOpenFeatureProviderOptions } from './coreProvider'; + +/** + * The online Datadog OpenFeature provider. Fetches precomputed flag assignments from Datadog + * whenever the evaluation context is set or changed. + */ +export class DatadogOpenFeatureProvider extends DatadogCoreOpenFeatureProvider { readonly metadata: ProviderMetadata = { name: 'datadog-react-native' }; - private options: DatadogOpenFeatureProviderOptions; - protected flagsClient: FlagsClient; - - readonly events: ProviderEventEmitter = new OpenFeatureEventEmitter(); private contextChangePromise = Promise.resolve(); - constructor(options: DatadogOpenFeatureProviderOptions = {}) { - if (!options.clientName) { - options.clientName = 'default'; - } - - this.options = options; - - this.flagsClient = DdFlags.getClient(this.options.clientName); - } - async initialize(context: OFEvaluationContext = {}): Promise { const ddContext = toDdContext(context); this.contextChangePromise = this.flagsClient.setEvaluationContext( @@ -80,103 +47,4 @@ export class DatadogOpenFeatureProvider implements Provider { // Wait for the current context change to complete. await this.contextChangePromise; } - - resolveBooleanEvaluation( - flagKey: string, - defaultValue: boolean, - _context: OFEvaluationContext, - _logger: Logger - ): ResolutionDetails { - const details = this.flagsClient.getBooleanDetails( - flagKey, - defaultValue - ); - return toFlagResolution(details); - } - - resolveStringEvaluation( - flagKey: string, - defaultValue: string, - _context: OFEvaluationContext, - _logger: Logger - ): ResolutionDetails { - const details = this.flagsClient.getStringDetails( - flagKey, - defaultValue - ); - return toFlagResolution(details); - } - - resolveNumberEvaluation( - flagKey: string, - defaultValue: number, - _context: OFEvaluationContext, - _logger: Logger - ): ResolutionDetails { - const details = this.flagsClient.getNumberDetails( - flagKey, - defaultValue - ); - return toFlagResolution(details); - } - - resolveObjectEvaluation( - flagKey: string, - defaultValue: T, - _context: OFEvaluationContext, - _logger: Logger - ): ResolutionDetails { - // The OpenFeature spec states that the return value can be any valid JSON value. - // However, the Datadog Flags feature only supports JSON objects for the JSON feature flag type. - // Thus, the user should always expect the returned value to be an object instead of any arbitrary JSON value. - // Also, the user is responsible for providing a proper `defaultValue` that's an object. - - const details = this.flagsClient.getObjectDetails( - flagKey, - defaultValue - ); - return toFlagResolution(details); - } } - -export const toDdContext = ( - context: OFEvaluationContext -): DdEvaluationContext => { - const { targetingKey, ...attributes } = context; - - // Important ⚠️ - // The Flags SDK doesn't support nested non-primitive values in the evaluation context as per OF.3 FFE SDK requirement. - // However, we let the SDK handle this inside of FlagsClient since it does this processing anyways. - const ddContextAttributes = attributes as Record; - - return { - // Allow flag evaluations without a provided targeting key. - targetingKey: targetingKey ?? '', - attributes: ddContextAttributes - }; -}; - -const toFlagResolution = (details: FlagDetails): ResolutionDetails => { - const { - value, - reason, - variant, - allocationKey, - errorCode, - errorMessage - } = details; - - const parsedErrorCode = - errorCode && (ErrorCode[errorCode as ErrorCode] || ErrorCode.GENERAL); - - const result: ResolutionDetails = { - value, - reason, - variant, - flagMetadata: allocationKey ? { allocationKey } : undefined, - errorCode: parsedErrorCode, - errorMessage - }; - - return result; -}; From 43ac1b571a03d9f190f5be660730facf20496513 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 20:42:14 -0400 Subject: [PATCH 50/64] fix(openfeature): address PR3 review (empty-context stamping, events, exports) Two-agent review follow-ups on the offline provider: - C1 (critical): the offline provider no longer stamps an empty OpenFeature context in initialize/onContextChange, so OpenFeature's initialize({}) on setProviderAndWait can't defeat the configuration's embedded context (which previously caused a spurious mismatch -> PROVIDER_ERROR for every real config). - H1/M1 (events): the provider now emits CONFIGURATION_CHANGED on a successful load (OpenFeature already emits READY when initialize resolves) and emits READY only to clear a prior error state, removing the premature/duplicate READY and fixing error recovery. - M2/L5: keep ConfigurationStatus internal (dropped from the public core export; the provider derives it via ReturnType) and document it may widen for rules. - L4: move toDdContext (and a new isEmptyContext) into mappers.ts so the base module no longer carries an exported free helper. - L3: fix the offline provider's stale TSDoc link. Tests: add a real-FlagsClient integration test that would have caught C1 (initialize({}) then setConfiguration serves; explicit mismatch errors), and update the unit tests for the empty-context and event behavior. Also lock the online provider's fetch-on-initialize behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/index.tsx | 3 +- packages/react-native-openfeature/README.md | 4 +- .../offlineProvider.integration.test.ts | 78 +++++++++++++++++++ .../src/__tests__/offlineProvider.test.ts | 40 +++++++--- .../src/coreProvider.ts | 24 +----- .../react-native-openfeature/src/mappers.ts | 40 ++++++++++ .../src/offlineProvider.ts | 76 +++++++++++------- .../react-native-openfeature/src/provider.ts | 3 +- 8 files changed, 201 insertions(+), 67 deletions(-) create mode 100644 packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts create mode 100644 packages/react-native-openfeature/src/mappers.ts diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index 6d8084123..33be79818 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -31,7 +31,7 @@ import { VitalsUpdateFrequency } from './config/types'; import { DdFlags } from './flags/DdFlags'; -import type { ConfigurationStatus, FlagsClient } from './flags/FlagsClient'; +import type { FlagsClient } from './flags/FlagsClient'; import { configurationFromString, configurationToString @@ -126,7 +126,6 @@ export type { FlagsConfiguration, FlagsClient, ParsedFlagsConfiguration, - ConfigurationStatus, EvaluationContext, PrimitiveValue, FlagDetails diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index dee9a2e03..e11f43692 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -131,11 +131,13 @@ import { OpenFeature } from '@openfeature/react-sdk'; await DdFlags.enable(); const provider = new DatadogOfflineOpenFeatureProvider(); -await OpenFeature.setProviderAndWait(provider); // `wire` is a ConfigurationWire string you fetched yourself. provider.setConfiguration(configurationFromString(wire)); +// Set the provider after loading the configuration so it is ready with real flag values. +await OpenFeature.setProviderAndWait(provider); + // Evaluate flags — no network request is made. const client = OpenFeature.getClient(); const isNewFeatureEnabled = client.getBooleanValue('new-feature-enabled', false); diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts new file mode 100644 index 000000000..45cfa90c8 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -0,0 +1,78 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +// Integration test: exercises the offline provider against the REAL core FlagsClient (no mock +// of @datadog/mobile-react-native), so the actual configuration parse + context reconcile run. +// It asserts via provider events only (no flag evaluation), so it needs no native module. + +import { configurationFromString } from '@datadog/mobile-react-native'; +import { ProviderEvents } from '@openfeature/web-sdk'; + +import { DatadogOfflineOpenFeatureProvider } from '../offlineProvider'; + +const wireFor = (targetingKey: string): string => + JSON.stringify({ + version: 1, + precomputed: { + response: JSON.stringify({ + data: { + attributes: { + obfuscated: false, + flags: { + 'new-feature': { + variationType: 'boolean', + variationValue: true, + variationKey: 'true', + allocationKey: 'alloc-1', + reason: 'STATIC', + doLog: false, + extraLogging: {} + } + } + } + } + }), + context: { targetingKey } + } + }); + +describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient)', () => { + it('adopts the config context after initialize({}) — regression for empty-context stamping', async () => { + const provider = new DatadogOfflineOpenFeatureProvider({ + clientName: 'offline-integration-adopt' + }); + const emitSpy = jest.spyOn(provider.events, 'emit'); + + // Empty OpenFeature context (as OpenFeature stamps on setProviderAndWait) must NOT + // defeat the configuration's embedded context. + await provider.initialize({}); + provider.setConfiguration(configurationFromString(wireFor('user-123'))); + + // Before the fix this mismatched → PROVIDER_ERROR; now it serves → configuration change. + expect(emitSpy).toHaveBeenCalledWith( + ProviderEvents.ConfigurationChanged + ); + expect(emitSpy).not.toHaveBeenCalledWith( + ProviderEvents.Error, + expect.anything() + ); + }); + + it('errors when an explicit context mismatches the config', async () => { + const provider = new DatadogOfflineOpenFeatureProvider({ + clientName: 'offline-integration-mismatch' + }); + const emitSpy = jest.spyOn(provider.events, 'emit'); + + await provider.initialize({ targetingKey: 'someone-else' }); + provider.setConfiguration(configurationFromString(wireFor('user-123'))); + + expect(emitSpy).toHaveBeenCalledWith( + ProviderEvents.Error, + expect.anything() + ); + }); +}); diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts index 300abde74..7f5a69ae6 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -42,18 +42,32 @@ describe('DatadogOfflineOpenFeatureProvider', () => { ); }); - it('never fetches on initialize (uses the no-fetch context setter)', async () => { + it('does not stamp an empty context on initialize', async () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + + await provider.initialize({}); + + // An empty OpenFeature context must not override the configuration's embedded context. + expect( + mockFlagsClient.setEvaluationContextWithoutFetching + ).not.toHaveBeenCalled(); + expect(mockFlagsClient.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('records a non-empty context without fetching', async () => { const provider = new DatadogOfflineOpenFeatureProvider(); await provider.initialize({ targetingKey: 'user-1' }); expect( mockFlagsClient.setEvaluationContextWithoutFetching - ).toHaveBeenCalled(); + ).toHaveBeenCalledWith( + expect.objectContaining({ targetingKey: 'user-1' }) + ); expect(mockFlagsClient.setEvaluationContext).not.toHaveBeenCalled(); }); - it('never fetches on a context change', async () => { + it('reconciles a non-empty context change without fetching', async () => { const provider = new DatadogOfflineOpenFeatureProvider(); await provider.onContextChange({}, { targetingKey: 'user-2' }); @@ -66,32 +80,34 @@ describe('DatadogOfflineOpenFeatureProvider', () => { expect(mockFlagsClient.setEvaluationContext).not.toHaveBeenCalled(); }); - it('delegates setConfiguration to the client and emits READY then CONFIGURATION_CHANGED', () => { + it('delegates setConfiguration to the client and emits CONFIGURATION_CHANGED', () => { const provider = new DatadogOfflineOpenFeatureProvider(); const emitSpy = jest.spyOn(provider.events, 'emit'); - provider.setConfiguration({} as never); provider.setConfiguration({} as never); - expect(mockFlagsClient.setConfiguration).toHaveBeenCalledTimes(2); - expect(emitSpy).toHaveBeenNthCalledWith(1, ProviderEvents.Ready); - expect(emitSpy).toHaveBeenNthCalledWith( - 2, + expect(mockFlagsClient.setConfiguration).toHaveBeenCalled(); + // The provider is already READY from initialize; a loaded config is a config change. + expect(emitSpy).toHaveBeenCalledWith( ProviderEvents.ConfigurationChanged ); + expect(emitSpy).not.toHaveBeenCalledWith(ProviderEvents.Ready); }); - it('emits PROVIDER_ERROR on a mismatched configuration', () => { - mockFlagsClient.setConfiguration.mockReturnValueOnce('mismatch'); + it('emits PROVIDER_ERROR on a mismatched configuration, then READY on recovery', () => { const provider = new DatadogOfflineOpenFeatureProvider(); const emitSpy = jest.spyOn(provider.events, 'emit'); + mockFlagsClient.setConfiguration.mockReturnValueOnce('mismatch'); provider.setConfiguration({} as never); - expect(emitSpy).toHaveBeenCalledWith( ProviderEvents.Error, expect.objectContaining({ message: expect.any(String) }) ); + + // A subsequent matching config recovers — emit READY to clear the error status. + provider.setConfiguration({} as never); + expect(emitSpy).toHaveBeenCalledWith(ProviderEvents.Ready); }); it('resolves boolean evaluation through the client', () => { diff --git a/packages/react-native-openfeature/src/coreProvider.ts b/packages/react-native-openfeature/src/coreProvider.ts index 1e65e5bed..e17f3aa80 100644 --- a/packages/react-native-openfeature/src/coreProvider.ts +++ b/packages/react-native-openfeature/src/coreProvider.ts @@ -5,11 +5,7 @@ */ import { DdFlags } from '@datadog/mobile-react-native'; -import type { - FlagDetails, - FlagsClient, - EvaluationContext as DdEvaluationContext -} from '@datadog/mobile-react-native'; +import type { FlagDetails, FlagsClient } from '@datadog/mobile-react-native'; import { OpenFeatureEventEmitter, ErrorCode } from '@openfeature/web-sdk'; import type { EvaluationContext as OFEvaluationContext, @@ -19,7 +15,6 @@ import type { Provider, ProviderMetadata, ResolutionDetails, - PrimitiveValue, ProviderEventEmitter, ProviderEvents } from '@openfeature/web-sdk'; @@ -121,23 +116,6 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { } } -export const toDdContext = ( - context: OFEvaluationContext -): DdEvaluationContext => { - const { targetingKey, ...attributes } = context; - - // Important ⚠️ - // The Flags SDK doesn't support nested non-primitive values in the evaluation context as per OF.3 FFE SDK requirement. - // However, we let the SDK handle this inside of FlagsClient since it does this processing anyways. - const ddContextAttributes = attributes as Record; - - return { - // Allow flag evaluations without a provided targeting key. - targetingKey: targetingKey ?? '', - attributes: ddContextAttributes - }; -}; - const toFlagResolution = (details: FlagDetails): ResolutionDetails => { const { value, diff --git a/packages/react-native-openfeature/src/mappers.ts b/packages/react-native-openfeature/src/mappers.ts new file mode 100644 index 000000000..1ba578cab --- /dev/null +++ b/packages/react-native-openfeature/src/mappers.ts @@ -0,0 +1,40 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { + EvaluationContext as DdEvaluationContext, + PrimitiveValue +} from '@datadog/mobile-react-native'; +import type { EvaluationContext as OFEvaluationContext } from '@openfeature/web-sdk'; + +/** + * Convert an OpenFeature evaluation context into the Datadog Flags shape. + */ +export const toDdContext = ( + context: OFEvaluationContext +): DdEvaluationContext => { + const { targetingKey, ...attributes } = context; + + // Important ⚠️ + // The Flags SDK doesn't support nested non-primitive values in the evaluation context as per OF.3 FFE SDK requirement. + // However, we let the SDK handle this inside of FlagsClient since it does this processing anyways. + const ddContextAttributes = attributes as Record; + + return { + // Allow flag evaluations without a provided targeting key. + targetingKey: targetingKey ?? '', + attributes: ddContextAttributes + }; +}; + +/** + * Whether an OpenFeature evaluation context carries no information (no targeting key and no + * attributes). Used by the offline provider to avoid overwriting a configuration's embedded + * context with an empty context stamped by the OpenFeature lifecycle. + */ +export const isEmptyContext = (context: OFEvaluationContext): boolean => { + return Object.keys(context).length === 0; +}; diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index 72dab00c0..3af6f3887 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -5,7 +5,7 @@ */ import type { - ConfigurationStatus, + FlagsClient, ParsedFlagsConfiguration } from '@datadog/mobile-react-native'; import { ProviderEvents } from '@openfeature/web-sdk'; @@ -14,17 +14,25 @@ import type { ProviderMetadata } from '@openfeature/web-sdk'; -import { DatadogCoreOpenFeatureProvider, toDdContext } from './coreProvider'; +import { DatadogCoreOpenFeatureProvider } from './coreProvider'; +import { isEmptyContext, toDdContext } from './mappers'; + +// The outcome of a `FlagsClient` reconcile. Kept internal (not part of the package's public +// API) — derived from the client so the provider maps it to OpenFeature events. +type ConfigurationOutcome = ReturnType; /** * An offline Datadog OpenFeature provider. * - * It behaves exactly like {@link DatadogOpenFeatureProvider} — same flag evaluation and + * It behaves like the online `DatadogOpenFeatureProvider` — same flag evaluation and * exposure/RUM tracking — **except it never fetches configuration from the network**. - * Instead of fetching on `initialize`/`onContextChange`, it records the context and - * reconciles a configuration supplied via {@link setConfiguration}. Use it for offline - * initialization: load a `ParsedFlagsConfiguration` (from `configurationFromString`) and - * evaluate flags with no network request. + * Instead of fetching on `initialize`/`onContextChange`, it evaluates against a configuration + * supplied via {@link DatadogOfflineOpenFeatureProvider.setConfiguration}. A precomputed + * configuration carries the evaluation context it was computed for, so you do not need to call + * `OpenFeature.setContext` for the offline precomputed flow. + * + * Load the configuration before setting the provider so the provider is ready with real flag + * values from the start: * * @example * ```ts @@ -35,9 +43,9 @@ import { DatadogCoreOpenFeatureProvider, toDdContext } from './coreProvider'; * } from '@datadog/mobile-react-native-openfeature'; * * const provider = new DatadogOfflineOpenFeatureProvider(); + * provider.setConfiguration(configurationFromString(wire)); // no network * await OpenFeature.setProviderAndWait(provider); * - * provider.setConfiguration(configurationFromString(wire)); * const client = OpenFeature.getClient(); * const enabled = client.getBooleanValue('new-feature', false); * ``` @@ -47,24 +55,17 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro name: 'datadog-react-native-offline' }; - private hasServedConfiguration = false; + private hasEmittedError = false; async initialize(context: OFEvaluationContext = {}): Promise { - // Record the context and reconcile any loaded configuration — no network fetch. - const status = this.flagsClient.setEvaluationContextWithoutFetching( - toDdContext(context) - ); - this.emitConfigurationOutcome(status); + this.applyContext(context); } async onContextChange( _oldContext: OFEvaluationContext, newContext: OFEvaluationContext ): Promise { - const status = this.flagsClient.setEvaluationContextWithoutFetching( - toDdContext(newContext) - ); - this.emitConfigurationOutcome(status); + this.applyContext(newContext); } /** @@ -74,21 +75,40 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro * `configurationFromString`. */ setConfiguration(configuration: ParsedFlagsConfiguration): void { - const status = this.flagsClient.setConfiguration(configuration); - this.emitConfigurationOutcome(status); + const outcome = this.flagsClient.setConfiguration(configuration); + this.emitConfigurationOutcome(outcome); } - private emitConfigurationOutcome(status: ConfigurationStatus): void { - if (status === 'ready') { - if (this.hasServedConfiguration) { - this.events.emit(ProviderEvents.ConfigurationChanged); - } else { - this.hasServedConfiguration = true; + private applyContext(context: OFEvaluationContext): void { + // Do not overwrite the configuration's embedded context with an empty context stamped + // by the OpenFeature lifecycle (e.g. `initialize({})` on `setProviderAndWait`). Only a + // context the app actually set should take effect. + if (isEmptyContext(context)) { + return; + } + + const outcome = this.flagsClient.setEvaluationContextWithoutFetching( + toDdContext(context) + ); + this.emitConfigurationOutcome(outcome); + } + + private emitConfigurationOutcome(outcome: ConfigurationOutcome): void { + if (outcome === 'ready') { + if (this.hasEmittedError) { + // Recover from a prior error state: emit READY to clear the provider status + // (a bare CONFIGURATION_CHANGED would not clear it). + this.hasEmittedError = false; this.events.emit(ProviderEvents.Ready); + } else { + // The provider is already READY from initialize; a (re)loaded configuration is + // signalled as a configuration change. + this.events.emit(ProviderEvents.ConfigurationChanged); } - } else if (status === 'mismatch' || status === 'invalid') { + } else if (outcome === 'mismatch' || outcome === 'invalid') { + this.hasEmittedError = true; this.events.emit(ProviderEvents.Error, { - message: `The Datadog offline provider cannot serve the loaded configuration (${status}).` + message: `The Datadog offline provider cannot serve the loaded configuration (${outcome}).` }); } // 'none' — no configuration engaged yet; nothing to signal. diff --git a/packages/react-native-openfeature/src/provider.ts b/packages/react-native-openfeature/src/provider.ts index 05a602106..2933931d2 100644 --- a/packages/react-native-openfeature/src/provider.ts +++ b/packages/react-native-openfeature/src/provider.ts @@ -9,7 +9,8 @@ import type { ProviderMetadata } from '@openfeature/web-sdk'; -import { DatadogCoreOpenFeatureProvider, toDdContext } from './coreProvider'; +import { DatadogCoreOpenFeatureProvider } from './coreProvider'; +import { toDdContext } from './mappers'; export type { DatadogOpenFeatureProviderOptions } from './coreProvider'; From 7c5daa8386d662c0aaeecdc2320359898a599b7e Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 8 Jul 2026 09:19:08 -0400 Subject: [PATCH 51/64] feat(openfeature): add offline provider example to example apps (FFL-2690) Demonstrate DatadogOfflineOpenFeatureProvider in both example apps alongside the online provider, with a runtime 'Flags source' toggle. Loads a bundled ConfigurationWire (no network) and evaluates rn-sdk-test-boolean-flag. - example/: flags helpers + FlagsSourceToggle, wired into App.tsx/MainScreen. - example-new-architecture/: same helpers. App.tsx renders the boolean flag via useBooleanFlagDetails (react-sdk 1.1.0 has no FeatureFlag component) and aligns setContext to the wire's embedded context. Co-Authored-By: Claude Opus 4.8 (1M context) --- example-new-architecture/App.tsx | 48 ++++++++++---- .../flags/FlagsSourceToggle.tsx | 63 +++++++++++++++++++ .../flags/flagsProvider.ts | 32 ++++++++++ .../flags/sampleOfflineConfiguration.ts | 54 ++++++++++++++++ example/src/App.tsx | 11 ++-- example/src/components/FlagsSourceToggle.tsx | 57 +++++++++++++++++ example/src/flags/flagsProvider.ts | 36 +++++++++++ .../src/flags/sampleOfflineConfiguration.ts | 50 +++++++++++++++ example/src/screens/MainScreen.tsx | 5 +- 9 files changed, 337 insertions(+), 19 deletions(-) create mode 100644 example-new-architecture/flags/FlagsSourceToggle.tsx create mode 100644 example-new-architecture/flags/flagsProvider.ts create mode 100644 example-new-architecture/flags/sampleOfflineConfiguration.ts create mode 100644 example/src/components/FlagsSourceToggle.tsx create mode 100644 example/src/flags/flagsProvider.ts create mode 100644 example/src/flags/sampleOfflineConfiguration.ts diff --git a/example-new-architecture/App.tsx b/example-new-architecture/App.tsx index 190667007..679d3a93a 100644 --- a/example-new-architecture/App.tsx +++ b/example-new-architecture/App.tsx @@ -12,12 +12,15 @@ import { DdFlags, PropagatorType, } from '@datadog/mobile-react-native'; -import {DatadogOpenFeatureProvider} from '@datadog/mobile-react-native-openfeature'; import { OpenFeature, OpenFeatureProvider, + useBooleanFlagDetails, useObjectFlagDetails, } from '@openfeature/react-sdk'; +import {setFlagsProvider} from './flags/flagsProvider'; +import {OFFLINE_CONTEXT} from './flags/sampleOfflineConfiguration'; +import {FlagsSourceToggle} from './flags/FlagsSourceToggle'; import React, {Suspense} from 'react'; import type {PropsWithChildren} from 'react'; import { @@ -75,9 +78,10 @@ import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; // Enable Datadog Flags feature. await DdFlags.enable(); - // Set the provider with OpenFeature. - const provider = new DatadogOpenFeatureProvider(); - OpenFeature.setProvider(provider); + // Set the flags provider. This app defaults to the offline provider (a bundled + // ConfigurationWire, no network); the "Flags source" switch flips to the online provider + // (CDN) at runtime. + await setFlagsProvider('offline'); // Datadog SDK usage examples. await DdRum.startView('main', 'Main'); @@ -94,15 +98,10 @@ import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; function AppWithProviders() { React.useEffect(() => { - const user = { - id: 'user-123', - favoriteFruit: 'apple', - }; - - OpenFeature.setContext({ - targetingKey: user.id, - favoriteFruit: user.favoriteFruit, - }); + // Set the same context the bundled offline configuration was precomputed for. Context + // matching is exact (targetingKey AND attributes), so this MUST equal OFFLINE_CONTEXT — + // otherwise the offline provider reports a mismatch and flags fall back to their defaults. + OpenFeature.setContext(OFFLINE_CONTEXT); }, []); return ( @@ -123,6 +122,8 @@ function App(): React.JSX.Element { const greetingFlag = useObjectFlagDetails('rn-sdk-test-json-flag', { greeting: 'Default greeting', }); + // The boolean flag carried by the bundled offline configuration. + const offlineFlag = useBooleanFlagDetails('rn-sdk-test-boolean-flag', false); const isDarkMode = useColorScheme() === 'dark'; const backgroundStyle = { @@ -138,6 +139,27 @@ function App(): React.JSX.Element {
+ + + Offline Feature Flag + + + {offlineFlag.value + ? 'Greetings from the offline Feature Flags!' + : 'Welcome!'} + + + `{offlineFlag.flagKey}` ={' '} + {String(offlineFlag.value)}, + reason {offlineFlag.reason}. + + + +
The title of this section is based on the{' '} diff --git a/example-new-architecture/flags/FlagsSourceToggle.tsx b/example-new-architecture/flags/FlagsSourceToggle.tsx new file mode 100644 index 000000000..5920c0da5 --- /dev/null +++ b/example-new-architecture/flags/FlagsSourceToggle.tsx @@ -0,0 +1,63 @@ +import React, {useState} from 'react'; +import { + View, + Text, + Switch, + ActivityIndicator, + StyleSheet, +} from 'react-native'; + +import {setFlagsProvider} from './flagsProvider'; +import type {FlagsSource} from './flagsProvider'; + +/** + * A runtime switch between the offline (bundled, no network) and online (CDN) flags + * providers. Toggling re-sets the OpenFeature provider, which re-renders any ``. + */ +export const FlagsSourceToggle = ({ + initialSource = 'offline', +}: { + initialSource?: FlagsSource; +}) => { + const [offline, setOffline] = useState(initialSource === 'offline'); + const [busy, setBusy] = useState(false); + + const onToggle = async (nextOffline: boolean) => { + setBusy(true); + try { + await setFlagsProvider(nextOffline ? 'offline' : 'online'); + setOffline(nextOffline); + } finally { + setBusy(false); + } + }; + + return ( + + + Flags source: {offline ? 'offline (bundled)' : 'online (CDN)'} + + + {busy ? : null} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 8, + }, + label: { + marginRight: 10, + }, + spinner: { + marginLeft: 10, + }, +}); diff --git a/example-new-architecture/flags/flagsProvider.ts b/example-new-architecture/flags/flagsProvider.ts new file mode 100644 index 000000000..a3c5b1c1a --- /dev/null +++ b/example-new-architecture/flags/flagsProvider.ts @@ -0,0 +1,32 @@ +import { + DatadogOpenFeatureProvider, + DatadogOfflineOpenFeatureProvider, + configurationFromString, +} from '@datadog/mobile-react-native-openfeature'; +import {OpenFeature} from '@openfeature/react-sdk'; + +import {buildSampleWire, OFFLINE_CONTEXT} from './sampleOfflineConfiguration'; + +export type FlagsSource = 'online' | 'offline'; + +/** + * Select which OpenFeature provider backs flag evaluations, and (re)set it at runtime. + * + * - `offline`: loads a bundled `ConfigurationWire` into `DatadogOfflineOpenFeatureProvider` + * **before** setting it, so flags resolve immediately with no network request. + * - `online`: the standard `DatadogOpenFeatureProvider`, which fetches assignments from the CDN. + * + * `DdFlags.enable()` must have been called once before this (it enables the native feature). + */ +export const setFlagsProvider = async (source: FlagsSource): Promise => { + if (source === 'offline') { + const provider = new DatadogOfflineOpenFeatureProvider(); + provider.setConfiguration( + configurationFromString(buildSampleWire(OFFLINE_CONTEXT)), + ); + await OpenFeature.setProviderAndWait(provider); + return; + } + + await OpenFeature.setProviderAndWait(new DatadogOpenFeatureProvider()); +}; diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts new file mode 100644 index 000000000..fd010e2b6 --- /dev/null +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -0,0 +1,54 @@ +// The boolean flag key demonstrated by the offline example. +export const OFFLINE_FLAG_KEY = 'rn-sdk-test-boolean-flag'; + +// Value type kept to JSON primitives so this is assignable to OpenFeature's +// `EvaluationContext` when passed to `OpenFeature.setContext`. +export type OfflineWireContext = {targetingKey?: string} & Record< + string, + string | number | boolean +>; + +// The evaluation context the bundled configuration is precomputed for. This app also calls +// `OpenFeature.setContext` (see App.tsx), so the two MUST be identical — context matching is +// exact (targetingKey AND attributes). A mismatch surfaces PROVIDER_ERROR and the flag falls +// back to its default. +export const OFFLINE_CONTEXT: OfflineWireContext = { + targetingKey: 'example-offline-user', + favoriteFruit: 'apple', +}; + +/** + * Build a bundled `ConfigurationWire` v1 string for the offline example. + * + * Mirrors the shape the Datadog Flags CDN returns, but is bundled with the app so the demo + * is fully offline — it never hits the network. Flip `variationValue` to `false` to confirm + * the flag's fallback renders. + */ +export const buildSampleWire = ( + context: OfflineWireContext = OFFLINE_CONTEXT, + variationValue = true, +): string => + JSON.stringify({ + version: 1, + precomputed: { + context, + response: JSON.stringify({ + data: { + attributes: { + obfuscated: false, + flags: { + [OFFLINE_FLAG_KEY]: { + variationType: 'boolean', + variationValue, + variationKey: String(variationValue), + allocationKey: 'offline-example-alloc', + reason: 'STATIC', + doLog: true, + extraLogging: {}, + }, + }, + }, + }, + }), + }, + }); diff --git a/example/src/App.tsx b/example/src/App.tsx index e6e4ac694..11926f907 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -9,11 +9,11 @@ import style from './screens/styles'; import { navigationRef } from './NavigationRoot'; import { DdRumReactNavigationTracking, NavigationTrackingOptions, ParamsTrackingPredicate, ViewNamePredicate, ViewTrackingPredicate } from '@datadog/mobile-react-navigation'; import { DatadogProvider, TrackingConsent, DdFlags } from '@datadog/mobile-react-native' -import { DatadogOpenFeatureProvider } from '@datadog/mobile-react-native-openfeature'; -import { OpenFeature, OpenFeatureProvider } from '@openfeature/react-sdk'; +import { OpenFeatureProvider } from '@openfeature/react-sdk'; import { Route } from "@react-navigation/native"; import { NestedNavigator } from './screens/NestedNavigator/NestedNavigator'; import { getDatadogConfig, onDatadogInitialization } from './ddUtils'; +import { setFlagsProvider } from './flags/flagsProvider'; const Tab = createBottomTabNavigator(); @@ -74,9 +74,10 @@ const handleDatadogInitialization = async () => { // Enable Datadog Flags feature. await DdFlags.enable(); - // Set the provider with OpenFeature. - const provider = new DatadogOpenFeatureProvider(); - OpenFeature.setProvider(provider); + // Set the flags provider. This example defaults to the offline provider (a bundled + // ConfigurationWire, no network); the "Flags source" switch on the Home screen flips to + // the online provider (CDN) at runtime. + await setFlagsProvider('offline'); } export default function App() { diff --git a/example/src/components/FlagsSourceToggle.tsx b/example/src/components/FlagsSourceToggle.tsx new file mode 100644 index 000000000..40f796b77 --- /dev/null +++ b/example/src/components/FlagsSourceToggle.tsx @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; +import { View, Text, Switch, ActivityIndicator, StyleSheet } from 'react-native'; + +import { setFlagsProvider } from '../flags/flagsProvider'; +import type { FlagsSource } from '../flags/flagsProvider'; + +/** + * A runtime switch between the offline (bundled, no network) and online (CDN) flags + * providers. Toggling re-sets the OpenFeature provider, which re-renders any ``. + */ +export const FlagsSourceToggle = ({ + initialSource = 'offline' +}: { + initialSource?: FlagsSource; +}) => { + const [offline, setOffline] = useState(initialSource === 'offline'); + const [busy, setBusy] = useState(false); + + const onToggle = async (nextOffline: boolean) => { + setBusy(true); + try { + await setFlagsProvider(nextOffline ? 'offline' : 'online'); + setOffline(nextOffline); + } finally { + setBusy(false); + } + }; + + return ( + + + Flags source: {offline ? 'offline (bundled)' : 'online (CDN)'} + + + {busy ? : null} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12 + }, + label: { + marginRight: 10 + }, + spinner: { + marginLeft: 10 + } +}); diff --git a/example/src/flags/flagsProvider.ts b/example/src/flags/flagsProvider.ts new file mode 100644 index 000000000..688ba3d80 --- /dev/null +++ b/example/src/flags/flagsProvider.ts @@ -0,0 +1,36 @@ +import { + DatadogOpenFeatureProvider, + DatadogOfflineOpenFeatureProvider, + configurationFromString +} from '@datadog/mobile-react-native-openfeature'; +import { OpenFeature } from '@openfeature/react-sdk'; + +import { buildSampleWire } from './sampleOfflineConfiguration'; +import type { OfflineWireContext } from './sampleOfflineConfiguration'; + +export type FlagsSource = 'online' | 'offline'; + +/** + * Select which OpenFeature provider backs flag evaluations, and (re)set it at runtime. + * + * - `offline`: loads a bundled `ConfigurationWire` into `DatadogOfflineOpenFeatureProvider` + * **before** setting it, so flags resolve immediately with no network request. + * - `online`: the standard `DatadogOpenFeatureProvider`, which fetches assignments from the CDN. + * + * `DdFlags.enable()` must have been called once before this (it enables the native feature). + */ +export const setFlagsProvider = async ( + source: FlagsSource, + offlineContext?: OfflineWireContext +): Promise => { + if (source === 'offline') { + const provider = new DatadogOfflineOpenFeatureProvider(); + provider.setConfiguration( + configurationFromString(buildSampleWire(offlineContext)) + ); + await OpenFeature.setProviderAndWait(provider); + return; + } + + await OpenFeature.setProviderAndWait(new DatadogOpenFeatureProvider()); +}; diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts new file mode 100644 index 000000000..e62b2b7ee --- /dev/null +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -0,0 +1,50 @@ +// The flag key shared with the online example, so the UI is comparable across providers. +export const OFFLINE_FLAG_KEY = 'rn-sdk-test-boolean-flag'; + +export type OfflineWireContext = { targetingKey?: string } & Record< + string, + string | number | boolean +>; + +// The evaluation context the bundled configuration is precomputed for. Because the wire +// carries its own context, the app does not need to call `OpenFeature.setContext` for the +// offline flow. +export const DEFAULT_OFFLINE_CONTEXT: OfflineWireContext = { + targetingKey: 'example-offline-user' +}; + +/** + * Build a bundled `ConfigurationWire` v1 string for the offline example. + * + * Mirrors the shape the Datadog Flags CDN returns, but is bundled with the app so the demo + * is fully offline — it never hits the network. Flip `variationValue` to `false` to confirm + * the flag's fallback renders. + */ +export const buildSampleWire = ( + context: OfflineWireContext = DEFAULT_OFFLINE_CONTEXT, + variationValue = true +): string => + JSON.stringify({ + version: 1, + precomputed: { + context, + response: JSON.stringify({ + data: { + attributes: { + obfuscated: false, + flags: { + [OFFLINE_FLAG_KEY]: { + variationType: 'boolean', + variationValue, + variationKey: String(variationValue), + allocationKey: 'offline-example-alloc', + reason: 'STATIC', + doLog: true, + extraLogging: {} + } + } + } + } + }) + } + }); diff --git a/example/src/screens/MainScreen.tsx b/example/src/screens/MainScreen.tsx index 1616e953d..37f14b8b0 100644 --- a/example/src/screens/MainScreen.tsx +++ b/example/src/screens/MainScreen.tsx @@ -12,6 +12,7 @@ import { import { DdLogs, DdSdkReactNative, TrackingConsent, DdFlags } from '@datadog/mobile-react-native'; import { FeatureFlag } from '@openfeature/react-sdk'; import styles from './styles'; +import { FlagsSourceToggle } from '../components/FlagsSourceToggle'; import { APPLICATION_KEY, API_KEY } from '../../src/ddCredentials'; import { getTrackingConsent, saveTrackingConsent } from '../utils'; import { ConsentModal } from '../components/consent'; @@ -114,11 +115,13 @@ export default class MainScreen extends Component { render() { return + + Welcome!}> Greetings from the Feature Flags! - The above greeting is being controlled by the{'\n'}`rn-sdk-test-boolean-flag` feature flag. + The above greeting is being controlled by the{'\n'}`rn-sdk-test-boolean-flag` feature flag,{'\n'}resolved from the source selected above.