feat(openfeature): DatadogOfflineOpenFeatureProvider [PR3] (FFL-2689, FFL-2690) - #1332
Conversation
5a7a06e to
8cce4b5
Compare
2ee5c00 to
38a7e7a
Compare
There was a problem hiding this comment.
Pull request overview
This PR adds an offline OpenFeature provider for the React Native Datadog SDK, enabling feature-flag evaluation from a precomputed configuration supplied by the app (no network fetch), while keeping evaluation + exposure/RUM tracking aligned with the existing online provider.
Changes:
- Introduces
DatadogOfflineOpenFeatureProviderand refactors shared logic intoDatadogCoreOpenFeatureProvider. - Adds offline configuration plumbing/events (context set without fetch; emit
ConfigurationChanged/Error/Readyas appropriate). - Publicly exports offline configuration helpers/types from core (
configurationFromString,configurationToString,ParsedFlagsConfiguration) and re-exportsconfigurationFromStringfrom the OpenFeature package; updates docs + adds tests.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/react-native-openfeature/src/provider.ts | Refactors online provider to extend shared core provider; keeps fetch-on-context behavior. |
| packages/react-native-openfeature/src/offlineProvider.ts | Adds offline provider that never fetches; supports setConfiguration + event mapping. |
| packages/react-native-openfeature/src/mappers.ts | Extracts OF → Datadog context conversion + empty-context detection helper. |
| packages/react-native-openfeature/src/index.ts | Exports offline provider and re-exports configurationFromString. |
| packages/react-native-openfeature/src/coreProvider.ts | Adds shared base provider owning FlagsClient, event emitter, and evaluations. |
| packages/react-native-openfeature/src/tests/provider.test.ts | Adds unit coverage for online provider behavior after refactor. |
| packages/react-native-openfeature/src/tests/offlineProvider.test.ts | Adds unit coverage for offline provider no-fetch behavior + events. |
| packages/react-native-openfeature/src/tests/offlineProvider.integration.test.ts | Adds integration coverage using real FlagsClient for offline flow edge cases. |
| packages/react-native-openfeature/README.md | Documents offline initialization flow and usage. |
| packages/core/src/index.tsx | Exposes configuration parse/serialize utilities and parsed config type publicly. |
| packages/core/src/flags/FlagsClient.ts | Returns configuration outcome status from offline context/config APIs for provider event mapping. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| constructor(options: DatadogOpenFeatureProviderOptions = {}) { | ||
| if (!options.clientName) { | ||
| options.clientName = 'default'; | ||
| } | ||
|
|
||
| this.options = options; | ||
|
|
||
| this.flagsClient = DdFlags.getClient(this.options.clientName); | ||
| } |
There was a problem hiding this comment.
This is a good point, we should not mutate the user provided options parameter.
| * 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; | ||
| }; |
8cce4b5 to
3bd5e6e
Compare
38a7e7a to
a754c6c
Compare
3bd5e6e to
752c7a6
Compare
a754c6c to
04ce353
Compare
752c7a6 to
22ebc03
Compare
04ce353 to
a9abc3c
Compare
This comment has been minimized.
This comment has been minimized.
a9abc3c to
d143bd4
Compare
| // 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. |
There was a problem hiding this comment.
nit: no network request is expected here, that was going to be at setProviderAndAwait
|
|
||
| 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. |
There was a problem hiding this comment.
I actually decided this doesn't make sense in the OfflineProvider when setConfiguration is called with precomputed variants. So instead, if the customer calls setContext with a mismatch, we'll keep serving the precomputed but emit a warning.
| this.emitConfigurationOutcome(outcome); | ||
| } | ||
|
|
||
| private emitConfigurationOutcome(outcome: ConfigurationOutcome): void { |
There was a problem hiding this comment.
In the readme below you mention an error is thrown if the user provides their own context; is that modeled here or elsewhere?
There was a problem hiding this comment.
We changed the design: a mismatched context is no longer an error. The README has been updated.
There was a problem hiding this comment.
And now I need to walk all of that back from yesterday's conversation.
c94cd7d to
c1d7a77
Compare
6133947 to
c63c75e
Compare
c3f46ee to
e0175eb
Compare
| ); | ||
| await OpenFeature.setProviderAndWait(provider); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Perhaps replacing the return with an else would look a bit cleaner.
| 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 (outcome === 'invalid') { | ||
| this.hasEmittedError = true; | ||
| this.events.emit(ProviderEvents.Error, { | ||
| message: | ||
| 'The Datadog offline provider cannot serve the loaded configuration (invalid).' | ||
| }); | ||
| } | ||
| // 'none' — no configuration engaged yet; nothing to signal. A runtime context that | ||
| // does not match a precomputed snapshot is not an error: the client ignores it | ||
| // (serving the snapshot for its embedded context) and warns. | ||
| } | ||
| } |
There was a problem hiding this comment.
| 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 (outcome === 'invalid') { | |
| this.hasEmittedError = true; | |
| this.events.emit(ProviderEvents.Error, { | |
| message: | |
| 'The Datadog offline provider cannot serve the loaded configuration (invalid).' | |
| }); | |
| } | |
| // 'none' — no configuration engaged yet; nothing to signal. A runtime context that | |
| // does not match a precomputed snapshot is not an error: the client ignores it | |
| // (serving the snapshot for its embedded context) and warns. | |
| } | |
| } | |
| private emitConfigurationOutcome(outcome: ConfigurationOutcome): void { | |
| if (outcome === 'none') return; | |
| if (outcome === 'invalid') { | |
| this.hasEmittedError = true; | |
| this.events.emit(ProviderEvents.Error, { | |
| message: 'The Datadog offline provider cannot serve the loaded configuration (invalid).' | |
| }); | |
| return; | |
| } | |
| // outcome === 'ready' | |
| if (this.hasEmittedError) { | |
| // Recover from a prior error: emit READY to clear provider status | |
| // (a bare CONFIGURATION_CHANGED would not clear it). | |
| this.hasEmittedError = false; | |
| this.events.emit(ProviderEvents.Ready); | |
| } else { | |
| this.events.emit(ProviderEvents.ConfigurationChanged); | |
| } | |
| } |
c63c75e to
c5831d8
Compare
a8387e3 to
fb2419c
Compare
fb2419c to
0778e4c
Compare
320d49f to
f7e019f
Compare
…FL-2690) 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
…+ example (PR3) Follows the PR2 change: an offline precomputed configuration no longer reports a context mismatch, so the provider no longer emits PROVIDER_ERROR for a differing runtime context — a differing context is ignored (the client warns and serves the snapshot for its embedded context). emitConfigurationOutcome now only errors on an 'invalid' configuration. example-new-architecture no longer calls OpenFeature.setContext: the bundled precomputed snapshot is served against its embedded context, so setting a runtime context is unnecessary (and would just be ignored with a warning). Tests updated accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ty-context, examples) README and the new-arch example now describe the correct behavior. A differing runtime context for a precomputed snapshot is ignored with a warning, not a PROVIDER_ERROR that serves defaults. offlineProvider.applyContext no longer emits PROVIDER_CONFIGURATION_CHANGED on a context change. A precomputed snapshot is context-independent, so a context change is not a config change and config events come from setConfiguration. isEmptyContext now treats a context whose values are all undefined as empty, so it cannot defeat the empty-context guard. The two example config helpers are unified on DEFAULT_OFFLINE_CONTEXT and drop the favoriteFruit the deleted setContext needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… invalid (PR3 review) In the documented order (setConfiguration then setProviderAndWait), setConfiguration emits its PROVIDER_ERROR before OpenFeature has subscribed, so an invalid configuration was dropped and initialize resolved anyway, leaving the provider misleadingly READY while serving only default values. initialize now rejects when a loaded configuration is invalid, so OpenFeature starts the provider in ERROR. Setting a provider first and configuring later still works (initialize resolves with no config, and a later invalid setConfiguration emits PROVIDER_ERROR with listeners present). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(PR3)
Switch the offline provider from "ignore a mismatched runtime context"
to surfacing the correct OpenFeature error state, tracking the widened
ConfigurationResult the core now returns.
- onContextChange is now synchronous and throws the precise OpenFeature
error (InvalidContextError / ProviderNotReadyError / GeneralError) so
the Web SDK transitions the provider to ERROR with no async race; a
matching/cleared context returns normally and the SDK recovers to
READY. initialize likewise throws unless the provider can serve.
- An empty context ({}, clearContext) means "no external override" and
re-adopts the configuration's embedded context everywhere (order-
independent), via resetEvaluationContextWithoutFetching.
- setConfiguration emits PROVIDER_ERROR with the spec's top-level
errorCode on failure, and emits PROVIDER_READY then
PROVIDER_CONFIGURATION_CHANGED when recovering from an error.
- Registering the provider before any configuration now initializes to
ERROR (nothing to serve) and recovers when a valid config loads.
- README: warn against setting a different context (errors + serves
coded defaults), recommend a dedicated domain + explicit empty domain
context + unique clientName, document the hooks/exposure context split,
and target Web SDK 1.8.0 semantics.
- Tests: unit tests for the synchronous throw + recovery + top-level
event errorCode; an OpenFeature-bound integration test asserting real
ERROR/READY status transitions (mismatch, clearContext, provider-first,
setConfiguration recovery).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…viders The flags-source toggle created both providers with the default clientName, so they shared one FlagsClient. Switching from offline to online then ran an online fetch on the client that held the offline configuration — unsupported, and the source of the stale-overlay edge case. Give each provider its own clientName so the demo models correct usage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- README: "clearing context recovers" only when the effective context is empty or matching; clearContext(domain) falls back to the (possibly mismatching) global context. A domain with no context of its own inherits the global context, so bind to a dedicated domain AND give it an explicit empty context. - README: warn against the non-awaiting setProvider() + immediate setConfiguration ordering (it races); configure first or await setProviderAndWait. - README/comments: "deep-equal" -> matches after normalization; align the compat note to the actual peer range (^1.7.3, verified against 1.8.0). - core: the configuration barrel comment no longer claims the helpers are unexported — configurationFromString/configurationToString and ParsedFlagsConfiguration are now part of the public entry point. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Unit tests assert the concrete thrown error classes (InvalidContextError / ProviderNotReadyError / GeneralError) instead of a bare toThrow(). - Integration test asserts the INVALID_CONTEXT evaluation error code and that recovery serves the retained value (stubbing the native TurboModule so a ready-state evaluation can track without throwing). - Add domain/global-context isolation cases: a domain with no context inherits a mismatching global context (ERROR); an explicit empty domain context isolates it from later global changes; clearing the domain context falls back to the (mismatching) global context (ERROR). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Raise the `@openfeature/web-sdk` peer (and dev) dependency to `^1.8.0`, the version the offline lifecycle behavior is developed and verified against, so the supported minimum matches what is tested. - Class JSDoc: apply the effective-context caveat (clearContext(domain) can fall back to a non-empty, mismatching global context and keep the provider ERROR), rather than implying clearing always recovers. - README: the non-awaiting setProvider + setConfiguration race settles (rejects) after recovery — reword "resolve" accordingly — and state the ^1.8.0 requirement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an integration test for the recommended sequence — a mismatching global context already set, an explicit empty domain context, then provider registration — asserting the domain provider isolates itself and stays READY. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0778e4c to
a316444
Compare
Offline feature flags — related PRs
FlagsClient.setConfiguration): feat(flags): FlagsClient.setConfiguration + context matching [PR2] (FFL-2688) #1331Summary
Adds
DatadogOfflineOpenFeatureProvider— a never-fetch OpenFeature provider for offline initialization — and the public exports for the offline API.The offline provider evaluates flags and reports exposures/RUM exactly like
DatadogOpenFeatureProvider, but never fetches configuration from the network:initialize/onContextChangeset the evaluation context without a request, and the configuration is supplied viasetConfiguration. A precomputed configuration carries the context it was computed for, so no separateOpenFeature.setContextcall is needed.DatadogCoreOpenFeatureProviderbase that owns the flag client, event emitter, and flag evaluation; only the context-sourcing (initialize/onContextChange) differs.configurationFromString,configurationToString,ParsedFlagsConfiguration. OpenFeature:DatadogOfflineOpenFeatureProviderand a re-exportedconfigurationFromString. The README documents offline usage.No native changes: the offline path uses the existing native
enable+ exposure tracking and skips the native fetch; evaluation is all JS.Context handling: a mismatch is an error state
A precomputed configuration is a single-subject snapshot served against the context it was computed for. A runtime context that does not match that embedded context (compared after normalization) cannot be served, so the provider enters the OpenFeature
ERRORstate and evaluations fall back to the app's coded defaults (this replaces the earlier "ignore the mismatch and keep serving" behavior). See #1331 for the core model and error codes.onContextChangeis synchronous and throws the precise OpenFeature error (InvalidContextError/ProviderNotReadyError/GeneralError) on a non-servable context, so the Web SDK transitions straight toERRORwith no async race (a resolving handler would be unconditionally set back toREADY). A matching context — or a cleared/empty one — returns normally and the SDK recovers toREADY.initializelikewise throws unless the provider can serve, so registering before any configuration starts inERRORand recovers when a valid config loads.setConfiguration(outside the lifecycle): emitsPROVIDER_ERROR(with the spec's top-levelerrorCode) when the configuration cannot be served, and emitsPROVIDER_READYthenPROVIDER_CONFIGURATION_CHANGEDwhen recovering from an error; a healthy (re)load emits onlyPROVIDER_CONFIGURATION_CHANGED.clearContext()/setContext({})re-adopt the embedded context (order-independent), so they recover rather than error — subject to the effective-context caveat below.@openfeature/web-sdk^1.8.0, whose static-context lifecycle it relies on: the SDK owns thePROVIDER_RECONCILING/PROVIDER_CONTEXT_CHANGEDevents, and (a documented SDK limitation) drops the error code from the lifecycle-generatedPROVIDER_ERRORevent — the thrown error type is still correct for direct callers.Recommended setup (documented in the README): bind the offline provider to a dedicated OpenFeature domain, give that domain an explicit empty context, and use a unique Datadog
clientName(separate domains otherwise shareDdFlags.getClient('default')). Note thatclearContext(domain)falls back to the global context (which may be non-empty and mismatching, keeping the provider inERROR), and the context split: OpenFeature hooks see the OpenFeature context ({}when unset) while Datadog exposure tracking uses the embedded context.Intentional limitation: the offline configuration is applied only in the JS layer and is not propagated to the native SDKs. Native propagation is out of scope for this iteration, so hybrid apps that also read flags natively will have inconsistent configuration between JS and native. The example app therefore does not call
OpenFeature.setContext, and its online/offline toggle uses distinctclientNames so the two providers don't share aFlagsClient.Tests
react-native-openfeature(27 tests) covers both providers — the offline provider never fetches, serves against the configuration's embedded context, throws synchronously to enterERRORon a mismatch (recovering on a matching/cleared context), and emitsPROVIDER_ERRORwith a top-level error code andPROVIDER_READY+PROVIDER_CONFIGURATION_CHANGEDon recovery; the online provider still fetches on context. A real-FlagsClient, OpenFeature-bound integration test asserts the SDK provider status throughERROR/READYtransitions (mismatch,clearContext, provider-first,setConfigurationrecovery) plus domain/global-context isolation. The coreflags/suite passes. Lint andtscare clean on both packages.Example App
iOS
Android