From 776605cbd4fcd15e27c5bdc72ac49c0aa403ddec Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 16:13:38 -0400 Subject: [PATCH 01/18] Add native flags configuration bridge milestone --- docs/native-ffe-react-native-poc-plan.md | 665 ++++++++++++++++++ packages/core/__mocks__/react-native.ts | 93 +++ .../reactnative/DdSdkImplementation.kt | 67 ++ .../com/datadog/reactnative/NativeFfeCore.kt | 386 ++++++++++ .../kotlin/com/datadog/reactnative/DdSdk.kt | 45 ++ .../kotlin/com/datadog/reactnative/DdSdk.kt | 45 ++ packages/core/ios/Sources/DdSdk.mm | 102 +++ .../ios/Sources/DdSdkImplementation.swift | 112 +++ packages/core/ios/Sources/NativeFfeCore.swift | 619 ++++++++++++++++ packages/core/jest/mock.js | 198 +++++- packages/core/src/DdSdkReactNative.tsx | 138 ++++ .../src/__tests__/DdSdkReactNative.test.tsx | 135 ++++ packages/core/src/index.tsx | 8 + packages/core/src/sdk/DdSdkInternal.ts | 78 ++ packages/core/src/specs/NativeDdSdk.ts | 64 ++ 15 files changed, 2753 insertions(+), 2 deletions(-) create mode 100644 docs/native-ffe-react-native-poc-plan.md create mode 100644 packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt create mode 100644 packages/core/ios/Sources/NativeFfeCore.swift diff --git a/docs/native-ffe-react-native-poc-plan.md b/docs/native-ffe-react-native-poc-plan.md new file mode 100644 index 000000000..522e45911 --- /dev/null +++ b/docs/native-ffe-react-native-poc-plan.md @@ -0,0 +1,665 @@ +# Native FF&E React Native POC Plan + +## Purpose + +Build a POC that demonstrates the React Native SDK can support Offline Initialization and Dynamic Context while keeping the durable implementation mostly native. The POC should make React Native a thin consumer of native iOS and Android primitives that can later be extracted into first-class iOS and Android SDK libraries. + +This is not a production implementation. It is an execution plan for proving the right seams: + +- Native owns portable configuration parsing, active provider state, context state, evaluation, fetch side effects, persistence, and evaluation side effects. +- React Native owns API ergonomics and bridge adaptation. +- The APIs are shaped so the native implementation could move into iOS/Android SDKs without rewriting the React Native contract. +- The POC must not require first landing, reviewing, releasing, and then consuming new iOS/Android SDK changes. Any new native code needed for the proof should be additive inside this RN repo and structured for later extraction. + +## Team Shareable Summary + +The proposal is to make Phase 1 a focused React Native push that can unblock the customer quickly, without gating delivery on new iOS and Android SDK releases. We will add the missing native capabilities as local Kotlin and Swift libraries inside the RN repo, with the same architecture we expect to extract into the iOS and Android SDKs later. React Native stays as the API/bridge layer; the new native libraries own configuration parsing, offline initialization, dynamic context, and UFC evaluation, while the existing stable native SDKs remain responsible for the customer-visible Datadog side effects: exposure emission, flag evaluation EVP emission, RUM feature-flag annotation, SDK telemetry, and any reusable persistence hooks. Phase 2 then becomes a lower-pressure extraction effort: move the new Kotlin/Swift libraries downstream into the iOS and Android SDKs, publish them, and delete the RN-local copies once RN can depend on the released native implementations. + +## Source RFCs Read + +- `/Users/leo.romanovsky/Downloads/[In Review] Portable Flag Configuration RFC.docx` +- `/Users/leo.romanovsky/Downloads/[In Review] Dynamic Context for Client SDKs RFC.docx` +- `/Users/leo.romanovsky/Downloads/[RFC] Offline Initialization for Feature Flagging.docx` + +## Research Inputs To Pin + +Use the current JavaScript implementation and shared fixture repository as the reference for native evaluator behavior: + +- `https://github.com/DataDog/openfeature-js-client` + - Node evaluator: `packages/node-server/src/configuration/evaluation.ts` + - Subject evaluation: `packages/node-server/src/configuration/evaluateForSubject.ts` + - UFC schema: `packages/node-server/src/configuration/ufc-v1.ts` + - Rule operators: `packages/node-server/src/rules/rules.ts` + - Sharding: `packages/node-server/src/shards/matchesShard.ts` and `packages/node-server/src/shards/sharders.ts` + - Configuration wire precedent: `packages/core/src/configuration/wire.ts` +- `https://github.com/DataDog/ffe-system-test-data` + - Canonical UFC input: `ufc-config.json` + - Canonical evaluation cases: `evaluation-cases/*.json` + +The POC should either add `ffe-system-test-data` as a pinned git submodule or fetch it in a deterministic test setup. Do not copy fixture JSON into the RN repo unless review explicitly chooses that tradeoff. + +Current RN dependencies already bring in shipped native Flags SDKs: + +- iOS `DatadogSDKReactNative.podspec` depends on `DatadogFlags 3.11.0`. +- Android `packages/core/android/build.gradle` depends on `com.datadoghq:dd-sdk-android-flags:3.10.0`. +- Existing RN flags bridge calls current native SDKs for precomputed assignment fetch, client state, and exposure/evaluation tracking: + - Android: `Flags.enable`, `FlagsClient`, `_FlagsInternalProxy`, `UnparsedFlag`, `FlagsClientState`. + - iOS: `Flags.enable`, `FlagsClientProtocol`, `FlagsClientInternal`, `FlagAssignment`, `FlagsEvaluationContext`. + +The preflight task is to prove which of these current native SDK pieces can be reused as-is and which missing pieces must be implemented as additive RN-local native libraries. + +## Zero Native-Release Dependency Strategy + +The biggest architectural constraint is delivery order. We need to avoid this blocked sequence: + +```text +change iOS SDK + -> review/release iOS SDK + -> change Android SDK + -> review/release Android SDK + -> update RN dependencies + -> start RN feature work +``` + +Instead, the POC should use this sequence: + +```text +inventory currently shipped iOS/Android SDK capabilities + -> call existing SDK APIs where they are usable today + -> add missing native libraries inside dd-sdk-reactnative + -> keep RN bridge thin over those libraries + -> validate with shared fixtures and example app + -> later extract the additive native libraries into iOS/Android SDK repos +``` + +Decision rule for each native capability: + +| Capability | Preferred source | Fallback if unavailable today | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| Precomputed assignment fetch | Existing `DatadogFlags` / `dd-sdk-android-flags` client fetch path | RN-local fetch adapter that mirrors current request shape through an interface. | +| Exposure/evaluation logging | Existing native `trackEvaluation` / internal tracking hooks already used by RN | RN-local `FlagsEvaluationSideEffects` fake plus adapter interface for later SDK integration. | +| RUM session correlation | Existing native Flags/RUM integration | Adapter interface that records required RUM context inputs but does not reimplement RUM in JS. | +| SDK storage path | Existing native SDK file/storage primitives if public enough | RN-local native store with the same interface and one last-good config policy. | +| HTTP transport and request construction | Existing native SDK HTTP/request builders if callable from RN package without unreleased changes | RN-local native transport with injectable fake server support; no JS fetch. | +| UFC rules evaluation | Existing native evaluator only if already shipped and callable | RN-local Kotlin and Swift evaluator implementations, fixture-backed, extracted later. | +| Portable config parse/serialize | Existing shipped parser if present | RN-local parser/serializer matching RFC wire contract. | +| Provider state machine / dynamic context | Existing state manager only if it can support rules config and context-only re-evaluation today | RN-local `NativeFfeCore` state machine that later moves into native SDKs. | + +Rules for additive RN-local libraries: + +- No React Native imports inside core evaluator, configuration, fetch, persistence, or side-effect types. +- Bridge code may only translate JS/RN values into native core inputs and serialize outputs back to JS. +- Native core code must depend on narrow ports for SDK-owned concerns: transport, storage, telemetry, RUM context, and exposure logging. +- Any use of Android `internal` packages, iOS SPI, or private-ish bridge helpers must be isolated in adapter classes and documented as a POC-only dependency. +- No dependency on unreleased Maven artifacts, unpublished Pods, local native SDK checkouts, or source changes in native SDK repos. +- Extraction should be a mechanical move: the core package/module should compile in unit tests without RN and with fake ports. + +## Existing Native SDK Reuse Targets + +Keep the hard dependency on existing iOS/Android SDKs focused on customer-visible Datadog side effects and SDK context. The evaluator and configuration state can be additive RN-local code; event emission and correlation should not be reimplemented in JS. + +Must call into existing native SDKs: + +- Exposure emission through the existing Flags pipelines. + - Android candidate: `_FlagsInternalProxy.trackFlagSnapshotEvaluation(...)`, which delegates to the native Flags evaluation path. + - iOS candidate: `FlagsClientInternal.sendFlagEvaluation(...)`, which calls the native exposure logger, evaluation logger, and RUM reporter. +- Flag evaluation emission in EVP through the existing native evaluator/event aggregation pipeline. + - Android candidates visible in the shipped AAR include `EvaluationEventsProcessor`, `EvaluationEventWriter`, and the internal aggregation model. + - iOS candidates visible in the shipped Pod include `EvaluationLogger` and `EvaluationAggregator`. +- RUM feature-flag annotation / correlation. + - Android candidate: native Flags `RumEvaluationLogger` / `DefaultRumEvaluationLogger` path. + - iOS candidate: `rumFlagEvaluationReporter.sendFlagEvaluation(...)` underneath `FlagsClient.trackEvaluation(...)`. + +Also preserve through native SDKs, either directly or as inputs to side-effect adapters: + +- Tracking consent handling for emitted data. +- SDK core identity and configuration: site, client token, environment, service, version, source, and SDK instance. +- Native event timestamps and server time offset. +- Internal SDK logging/telemetry for parse, evaluation, fetch, and persistence failures. +- Existing RUM session context when annotation/correlation is enabled. + +Optional reuse, only if callable from currently released SDKs without source changes: + +- SDK-owned storage directories for persisted last-good configuration. +- SDK HTTP client/request builders, compression, retry, and endpoint/auth helpers for configuration fetch. +- Existing precomputed assignment request builders. + +If optional reuse is not callable today, implement it as an RN-local native port with the same shape expected from the future extracted SDK library. Do not block the RN POC on making those APIs public in the iOS/Android SDK repos first. + +## RFC Requirements To Exercise + +| RFC area | Requirement | POC coverage target | +| ---------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Portable configuration | `ConfigurationWire` is an opaque JSON string that can carry precomputed assignments and rules/UFC. | Native `configurationFromString` / `configurationToString` parse and serialize a versioned wire payload. | +| Configuration loading | `setConfiguration()` loads or replaces one complete active configuration. | Native provider state machine stores active configuration and emits/debugs ready, changed, and error states. | +| Offline init | Startup can load a supplied config before network is available. | RN passes a bundled/cached wire string to native, native evaluates without network. | +| Startup then refresh | SDK can start from supplied config and later replace it with fetched config. | Native fetch returns a config, RN or native caller then applies it through the same `setConfiguration` path. | +| Fetch helpers | `fetchRulesConfiguration()` / `fetchPrecomputedConfiguration()` return config and do not mutate provider state. | Native fetch API performs HTTP, ETag, headers, auth inputs, and returns wire/config without touching active state. | +| Conditional fetch | Existing `etag` becomes `If-None-Match`; `304` returns previous config. | Native fetch accepts previous wire/config, sends `If-None-Match`, and handles `304`. | +| Dynamic context | Rules-based config stores new context immediately with no network and no stale blackout. | Native `setEvaluationContext()` mutates context only; `evaluate()` re-evaluates against same active rules. | +| Precomputed safety | Precomputed config tied to one context must not serve another context. | Native evaluates precomputed only when stored context matches; mismatch returns default/error/debug state. | +| Evaluation output | Rules path must produce metadata compatible with logging. | Native result includes value, variant, reason, error code, allocation key, `doLog`, and `extraLogging`. | +| Logging side effects | RN should keep existing native logging consistency. | Native evaluation optionally triggers native exposure/evaluation/RUM hooks through existing bridge/native paths or POC fakes. | +| Persistence | Mobile startup from last-known values is a target recipe. | Native save/load of last good wire from SDK-owned storage path; cache policy remains minimal. | +| Failure behavior | Invalid wire, unsupported kind, refresh failure, stale serving are explicit states. | Native debug state reports invalid wire, unsupported kind, stale retained config, and last fetch error. | + +## Current Counter POC Coverage + +The current counter/JSON POC is useful but insufficient. + +Covered: + +- New RN bridge methods through old and new architecture. +- Native-owned mutable state across calls. +- JS-to-native JSON request and native-to-JS JSON response. +- A tiny native-side evaluation step. + +Not covered: + +- `ConfigurationWire` shape and opaque `FlagsConfiguration`. +- `configurationFromString()` / `configurationToString()`. +- `setConfiguration()` lifecycle and provider events. +- Dynamic context behavior with no network request. +- Precomputed context mismatch protection. +- Native HTTP fetch side effects, auth, ETag, `304`, compression, request builders. +- Native persistence/offline boot. +- Evaluation metadata required for exposure logging, evaluation aggregation, and RUM. + +The next POC should replace the counter semantics with a small native flag-provider simulator. + +## Architecture Decision For The POC + +Use a native-first `NativeFfeCore` boundary on each platform. + +React Native should call into native with serialized inputs and receive serialized/debuggable outputs, but the durable state and side effects live behind a native API: + +```text +React Native JS + -> NativeDdSdk bridge methods + -> NativeFfeCore facade + -> configuration parser/serializer + -> provider state machine + -> evaluator interface + -> fetch transport + -> persistence store + -> logging side effects +``` + +Preferred native shape for the POC is closest to Dynamic Context RFC Option D: compose via a shared evaluator interface. That lets existing native `FlagsClient` logging patterns stay conceptually intact while a rules evaluator and precomputed evaluator plug into the same result type. + +## RFC-Shaped API Sketch + +Use names that are as close as possible to the RFC building blocks. The implementation can remain experimental while it lives in the RN repo, but the API shape should preview the eventual extracted iOS/Android SDK surface. + +### React Native Surface + +```ts +type NativeFfeConfigurationWire = string; + +type NativeFfeFetchOptions = { + kind: 'rules' | 'precomputed'; + endpoint: string; + clientToken?: string; + sdkKey?: string; + site?: string; + headers?: Record; + flagQueryParams?: Record; + evaluationContext?: Record; + previousConfigurationWire?: NativeFfeConfigurationWire; +}; + +type NativeFfeEvaluationContext = { + targetingKey?: string; + attributes?: Record; +}; + +type NativeFfeEvaluationResult = { + flagKey: string; + value: boolean | string | number | Record | null; + variant?: string; + reason: string; + errorCode?: string; + flagMetadata?: { + allocationKey?: string; + doLog?: boolean; + extraLogging?: Record; + configurationKind?: 'precomputed' | 'rules'; + configurationEtag?: string; + }; +}; + +type NativeFfeDebugState = { + status: 'not_ready' | 'ready' | 'stale' | 'error'; + activeConfigurationKind?: 'precomputed' | 'rules' | 'mixed'; + activeEtag?: string; + currentContext?: NativeFfeEvaluationContext; + configurationSetCount: number; + fetchCount: number; + evaluationCount: number; + lastEvent?: 'provider_ready' | 'configuration_changed' | 'provider_error'; + lastFetchRequest?: { + url: string; + method: string; + headers: Record; + }; + lastError?: string; +}; +``` + +Methods: + +```ts +DdSdkReactNative.configurationFromString(wire): Promise; +DdSdkReactNative.configurationToString(configuration): Promise; +DdSdkReactNative.fetchPrecomputedConfiguration(options): Promise; +DdSdkReactNative.fetchRulesConfiguration(options): Promise; +DdSdkReactNative.setConfiguration(configuration): Promise; +DdSdkReactNative.setEvaluationContext(context): Promise; +DdSdkReactNative.resolveBooleanEvaluation(flagKey, defaultValue): Promise; +DdSdkReactNative.resolveStringEvaluation(flagKey, defaultValue): Promise; +DdSdkReactNative.resolveNumberEvaluation(flagKey, defaultValue): Promise; +DdSdkReactNative.resolveObjectEvaluation(flagKey, defaultValue): Promise; +DdSdkReactNative.saveConfiguration(wire): Promise; +DdSdkReactNative.loadConfiguration(): Promise; +DdSdkReactNative.getProviderDebugState(): Promise; +``` + +### Native Library Boundary + +The bridge should delegate to platform-native classes that are not RN-specific beyond adapters: + +Android: + +- `NativeFfeCore` +- `FlagsConfigurationWireParser` +- `FlagsConfigurationStore` +- `FlagEvaluator` +- `PrecomputedFlagEvaluator` +- `RulesFlagEvaluator` +- `FlagsConfigurationFetcher` +- `FlagsConfigurationTransport` +- `FlagsEvaluationSideEffects` + +iOS: + +- `NativeFfeCore` +- `FlagsConfigurationWireParser` +- `FlagsConfigurationStore` +- `FlagEvaluator` +- `PrecomputedFlagEvaluator` +- `RulesFlagEvaluator` +- `FlagsConfigurationFetcher` +- `FlagsConfigurationTransport` +- `FlagsEvaluationSideEffects` + +The POC can place these inside the RN repo, but the package/module boundary should make later extraction into the iOS and Android SDKs straightforward. + +## Minimal POC Wire Format + +Use a deliberately small versioned shape that mirrors the RFC, not a full UFC schema: + +```json +{ + "version": 2, + "precomputed": { + "response": "{\"flags\":{\"checkout.enabled\":{\"value\":true,\"variant\":\"on\",\"allocationKey\":\"a1\",\"doLog\":true}}}", + "context": { + "targetingKey": "user-1", + "attributes": { "plan": "pro" } + }, + "fetchedAt": 1780000000000, + "etag": "precomputed-v1" + }, + "server": { + "response": "{\"flags\":{\"checkout.enabled\":{\"enabled\":true,\"rules\":[...]}}}", + "fetchedAt": 1780000000000, + "etag": "rules-v1" + } +} +``` + +Rules evaluation must be native and fixture-backed, not a toy predicate engine. The first implementation can be limited to the UFC v1 behavior covered by `ffe-system-test-data`, but Kotlin and Swift should both implement the same evaluator surface: + +- flag lookup by key +- enabled/disabled flags +- variation type validation for boolean, string, number, and object values +- allocation order +- allocation `startAt` / `endAt` windows +- rule conditions for `MATCHES`, `NOT_MATCHES`, `GTE`, `GT`, `LTE`, `LT`, `ONE_OF`, `NOT_ONE_OF`, and `IS_NULL` +- subject attribute construction matching Node behavior: `targetingKey` is exposed as `id`, and explicit context attributes may override `id` +- MD5 sharding using `${salt}-${targetingKey}`, the first 4 bytes of the MD5 hex output, modulo `totalShards`, and inclusive-start/exclusive-end ranges +- missing-targeting-key behavior only when sharding requires a subject key +- variant/value output +- OpenFeature-compatible reasons and error codes +- metadata including `variant`, `allocationKey`, `doLog`, split serial id, evaluation timestamp, and `extraLogging` where present + +Structure `RulesFlagEvaluator` behind an interface so the POC implementation can later be replaced by a production shared evaluator or an extracted iOS/Android SDK library, but the POC itself should prove the Kotlin and Swift implementations against shared fixtures. + +## Execution Phases + +### Phase 0: Convert The Existing Toy POC Into A Flag POC + +Goal: replace counter semantics with flag configuration semantics while preserving compile proof. + +Tasks: + +- Replace counter-specific names with POC FFE names. +- Keep JSON round trips, but use `ConfigurationWire`, context, and evaluation result objects. +- Add JS tests proving RN serializes request objects and parses native JSON responses. + +Acceptance: + +- RN call sequence demonstrates `set config -> set context -> evaluate -> set context -> evaluate`. +- No network is called during `setEvaluationContext()` in rules mode. + +### Phase 1: Native Core Boundary + +Goal: establish extractable native library structure. + +Tasks: + +- Add Android native classes under a package that does not depend on React Native except adapter methods. +- Add iOS native classes/types with the same separation. +- Add bridge adapter methods that only translate RN values to native inputs. +- Add `NativeFfeDebugState` for test visibility. + +Acceptance: + +- Most POC logic lives outside bridge method bodies. +- Unit tests can instantiate `NativeFfeCore` without React Native. + +### Phase 2: Portable Configuration Parse/Serialize + +Goal: exercise the RFC building blocks. + +Tasks: + +- Implement `configurationFromString(wire)` natively. +- Validate `version`. +- Parse precomputed and server/rules slots. +- Preserve `etag`, `fetchedAt`, and embedded context. +- Implement `configurationToString(configuration)`. +- Reject invalid JSON, unsupported version, and empty configuration. + +Acceptance: + +- Valid precomputed wire round trips. +- Valid rules wire round trips. +- Invalid wire produces provider error/debug state. +- Unsupported kind is explicit and predictable. + +### Phase 3: Provider State And Offline Init + +Goal: prove one loading path for startup, post-start, and refresh. + +Tasks: + +- Implement `setConfiguration(configuration)` in native. +- Track provider states: `not_ready`, `ready`, `stale`, `error`. +- Track events: first valid config is ready; replacement is configuration changed; invalid config is error. +- Allow evaluation immediately after loading a bundled/cached wire. + +Acceptance: + +- `configurationFromString -> setConfiguration -> evaluate` works without network. +- Replacing config changes `etag` and debug event. +- Invalid replacement does not silently corrupt previous usable state. + +### Phase 4: Kotlin And Swift UFC Evaluators + +Goal: prove native iOS and Android can own rules evaluation with cross-platform parity. + +Tasks: + +- Implement `RulesFlagEvaluator` in Kotlin. +- Implement `RulesFlagEvaluator` in Swift. +- Load UFC v1 JSON into native typed structures. +- Implement rule matching, allocation windows, type validation, MD5 sharding, variation selection, reasons, and error codes. +- Include evaluation metadata needed by exposure logging. +- Add a fixture runner that loads `ffe-system-test-data/ufc-config.json` and every `ffe-system-test-data/evaluation-cases/*.json`. +- Keep evaluator code outside RN bridge adapters. + +Acceptance: + +- Kotlin passes every shared evaluation case. +- Swift passes every shared evaluation case. +- Both platforms report the same value, reason, and error behavior for the same fixture input. +- Native tests include explicit metadata assertions for allocation key, variant, `doLog`, split serial id, timestamp, and `extraLogging` because the shared fixtures intentionally focus on value/reason. +- The implementation can be instantiated without React Native. + +### Phase 5: Dynamic Context + +Goal: model "dynamic under static" context behavior. + +Tasks: + +- Implement native `setEvaluationContext(context)`. +- In rules mode, store context and resolve immediately. +- In precomputed mode, require exact or well-defined context match. +- Add context mismatch behavior for precomputed config. +- Prove anonymous-to-authenticated and account-switch flows. + +Acceptance: + +- Rules config evaluates different values after context changes with no fetch. +- Precomputed config does not serve values for mismatched context. +- Debug state shows no fetch during rules-context changes. + +### Phase 6: Native Fetch Side Effects + +Goal: show native iOS/Android own networking infrastructure. + +Tasks: + +- Add native `fetchFlagsConfiguration(options)` returning wire/config only. +- Build request URL from endpoint/site, kind, flag query params, and precomputed context. +- Add auth/header handling inputs. +- Add `If-None-Match` from previous config etag. +- Handle `200` body, `304` previous config, and error. +- Keep fetch side-effect-free relative to active provider state. +- Use injectable transport in unit tests. +- Add one integration-style test using a local fake server if feasible. + +Acceptance: + +- Fetch increments fetch counter and records last request. +- Fetch does not change active configuration until `setConfiguration` is called. +- `304` returns the previous wire. +- Error leaves active provider untouched. + +### Phase 7: Persistence / Last-Known Startup + +Goal: prove mobile offline startup from native-owned storage. + +Tasks: + +- Add native save/load of wire string to an SDK-owned POC storage path. +- Keep policy minimal: one last-good config only. +- Load persisted wire before any network call. +- Refresh in background by explicit caller sequence: `load -> setConfiguration -> fetch -> setConfiguration`. + +Acceptance: + +- Cold-start path evaluates from persisted config. +- Refresh failure keeps prior config and marks stale/debug error. +- Storage is native-owned, not JS AsyncStorage. + +### Phase 8: Evaluation Side Effects + +Goal: preserve native logging architecture for RN. + +Tasks: + +- Add evaluation result metadata required by RFC: `variant`, `allocationKey`, `doLog`, `extraLogging`. +- Add POC `FlagsEvaluationSideEffects` interface. +- Android fake should model exposure dedup and evaluation aggregation hooks. +- iOS fake should model NSCache/message-bus/RUM hook shape. +- RN evaluation should not implement exposure batching in JS. +- Use existing native `trackEvaluation` path if practical; otherwise expose fake counters in debug state. + +Acceptance: + +- Evaluating a `doLog=true` flag invokes native side effect once per context/flag assignment key. +- Changing context changes dedup key. +- Changing configuration etag clears dedup cache. +- RN only calls evaluate; native owns the side effect. + +### Phase 9: Example App Flow + +Goal: make the POC demonstrable. + +Tasks: + +- Add an example screen or simple script path in `example-new-architecture`. +- Show: + - load bundled rules wire + - set anonymous context + - evaluate + - switch to authenticated context + - evaluate without network + - fetch updated config natively + - set fetched config + - evaluate changed result + - save/load wire +- Include debug state output for request headers, etag, context, provider state, and evaluation metadata. + +Acceptance: + +- Demo visibly distinguishes context change from fetch. +- Demo shows native fetch side effect and later explicit state mutation. + +## Validation Plan + +Validation must answer three separate questions: whether the POC exercises the architectural goals, whether evaluator behavior is correct, and whether RN integration remains thin and stable. + +### 1. Goal Coverage Validation + +Maintain a traceability table in the PR or test output that maps every goal to concrete evidence: + +| Goal | Required proof | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Offline initialization | `configurationFromString -> setConfiguration -> evaluate` succeeds from a bundled or persisted wire. | +| Dynamic context | `setEvaluationContext` changes evaluation results under rules config without incrementing fetch count. | +| Native-owned fetch | Native fetch records request URL, headers, auth, ETag, and status, but active config is unchanged. | +| Explicit config replacement | Fetched config changes behavior only after `setConfiguration`. | +| Conditional fetch | `If-None-Match` is sent and `304` returns prior wire without state mutation. | +| Native-owned persistence | Cold start loads native-stored last-good wire without JS storage. | +| Native-owned evaluation side effect | `doLog=true` evaluation invokes native side-effect fake or existing native tracking hook. | +| RN remains adapter-only | Most logic is unit-tested through native core classes without React Native. | +| JSON bridge shape | JS serializes requests and parses native JSON responses for config, context, fetch, and evaluation. | + +The example app flow must visibly exercise this sequence: + +```text +load bundled rules wire + -> set anonymous context + -> evaluate + -> set authenticated context + -> evaluate again with no fetch + -> native fetch updated config + -> prove active state is unchanged + -> set fetched config + -> evaluate changed result + -> save wire + -> load wire on cold start +``` + +Debug state is part of validation, not just demo UI. Tests should assert `configurationSetCount`, `fetchCount`, `evaluationCount`, active `etag`, current context, last provider event, and last fetch request. + +### 2. Correctness Validation + +Correctness is fixture-driven first, then integration-driven: + +- Add `ffe-system-test-data` as a pinned submodule or deterministic test dependency. +- Load `ufc-config.json` into Kotlin and Swift evaluator tests. +- Iterate every `evaluation-cases/*.json` file on both platforms. +- For every case, evaluate `flag`, `variationType`, `defaultValue`, `targetingKey`, and `attributes`. +- Assert at minimum `result.value` and `result.reason`. +- Add native-only expectations for error codes where the shared case implies `FLAG_NOT_FOUND`, `TARGETING_KEY_MISSING`, `TYPE_MISMATCH`, `PROVIDER_NOT_READY`, or `GENERAL`. +- Add metadata tests derived from the UFC input for `variant`, `allocationKey`, `doLog`, split serial id, evaluation timestamp, and `extraLogging`. +- Add bridge tests proving JS can pass boolean, string, number, object, null/default values, nested attributes, and special-character strings through the native JSON boundary. +- Add parser tests for invalid JSON, unsupported wire version, malformed flags, unknown fields, missing split shards, invalid shard bounds, microsecond dates, null targeting keys, and empty targeting keys. +- Add state-machine tests for invalid replacement retaining the previous valid config, precomputed context mismatch, refresh failure, and `304` handling. + +If a behavior is missing from `ffe-system-test-data`, add it there first when it is cross-SDK evaluator behavior. Use RN-local tests only for RN bridge behavior, native side effects, persistence, or metadata that is intentionally SDK-specific. + +### 3. Reference Implementation Validation + +Use `openfeature-js-client` Node as the research and parity reference: + +- Port behavior from `evaluate`, `evaluateForSubject`, `matchesRule`, `matchesShard`, and `MD5Sharder`. +- Keep an implementation note beside the native evaluator describing any intentional deviation from Node behavior. +- For risky cases, run the same fixture through Node, Kotlin, and Swift and compare the normalized result tuple: `value`, `reason`, `errorCode`, `variant`, `allocationKey`, `doLog`, `splitSerialId`. +- Treat parity with shared fixtures as the merge gate for the native evaluator portion of the POC. + +Required for each phase: + +- JS unit tests for RN API serialization and bridge forwarding. +- Android unit tests for native core. +- Android fixture tests for the Kotlin evaluator. +- iOS unit tests for native core. +- iOS fixture tests for the Swift evaluator. +- Android new-architecture example build. +- iOS codegen generation and iOS build when local Xcode simulator/device support is available. + +Target commands: + +```bash +yarn jest packages/core/src/__tests__/DdSdkReactNative.test.tsx --runInBand +yarn eslint +yarn --cwd packages/core prepare +cd packages/core/android && JAVA_HOME=/Users/leo.romanovsky/.sdkman/candidates/java/current ANDROID_HOME=/opt/homebrew/share/android-commandlinetools ANDROID_SDK_ROOT=/opt/homebrew/share/android-commandlinetools ./gradlew testDebugUnitTest --tests "com.datadog.reactnative.DdSdkTest" +cd example-new-architecture/android && JAVA_HOME=/Users/leo.romanovsky/.sdkman/candidates/java/current ANDROID_HOME=/opt/homebrew/share/android-commandlinetools ANDROID_SDK_ROOT=/opt/homebrew/share/android-commandlinetools ./gradlew :app:assembleDebug +cd example-new-architecture/ios && GIT_CONFIG_GLOBAL=/dev/null bundle exec pod install +cd example-new-architecture/ios && xcodebuild -workspace DdSdkReactNativeExample.xcworkspace -scheme DdSdkReactNativeExample -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath build/DerivedData CODE_SIGNING_ALLOWED=NO build +``` + +Known local caveat: the current workstation reports an Xcode/CoreSimulator mismatch for iOS simulator builds. iOS codegen can still be generated and inspected, but a full iOS build requires local Xcode/device support to be fixed. + +## What This POC Should Prove + +- RN can surface the feature without owning the core logic. +- RN can get the important existing native SDK benefits today: exposure emission, flag evaluation EVP emission, and RUM feature-flag annotation/correlation. +- iOS and Android can own the future extractable core library boundaries. +- `ConfigurationWire` can be loaded, replaced, persisted, fetched, and evaluated through one native path. +- Kotlin and Swift can evaluate UFC rules with parity against the shared fixture corpus. +- Context changes are native state changes, not network side effects, when rules config is active. +- Fetch helpers are side-effect-free relative to provider state. +- Native networking, persistence, logging, and telemetry remain native concerns. +- Existing RN customers can eventually get the feature through a bridge over native SDK capabilities rather than a parallel JS SDK. + +## What This POC Should Not Attempt + +- Production-wide UFC evaluator behavior beyond the shared fixture corpus and explicit parity cases added for this POC. +- Production auth or endpoint finalization. +- Production polling/cache policy. +- Source merging or priority rules. +- Public API naming finalization. +- Replacing existing `DdFlags` behavior for customers. +- Moving code into iOS/Android SDK repos yet. + +## Open Decisions To Force During Review + +- Should native POC use Option D evaluator composition as the recommended iOS/Android path? +- What is the exact precomputed context mismatch behavior per platform: default value, provider not ready, or evaluation error? +- What credential can fetch client-appropriate rules from mobile clients, if any? +- Is rules-based client configuration a separate distribution channel? +- Where should RN expose building blocks: `DdSdkReactNative`, `DdFlags`, or a new `DdFlagsConfiguration` namespace? +- Should fetch and persistence be public RN APIs or internal to a higher-level provider? +- What minimum metadata is mandatory for native exposure/evaluation/RUM parity? +- What max wire size should mobile/RN treat as acceptable for startup? + +## Recommended Next Step + +Implement Phases 1 through 5 first. That gives a native-first proof of the most important architecture claim: + +```text +configurationFromString(wire) + -> setConfiguration(configuration) + -> setEvaluationContext(anonymous) + -> evaluate(flag) + -> setEvaluationContext(authenticated) + -> evaluate(flag) +``` + +Only after native configuration, evaluator parity, and dynamic context are proven should the POC add native fetch and persistence. Otherwise the discussion can get distracted by endpoint/auth/cache details before the native ownership boundary is proven. diff --git a/packages/core/__mocks__/react-native.ts b/packages/core/__mocks__/react-native.ts index db10d7f31..8d8305d7b 100644 --- a/packages/core/__mocks__/react-native.ts +++ b/packages/core/__mocks__/react-native.ts @@ -15,6 +15,34 @@ import type { DdTraceType } from '../src/types'; // eslint-disable-next-line @typescript-eslint/no-var-requires const actualRN = require('react-native'); +const mockFlagsDebugState = { + status: 'ready', + activeConfigurationKind: 'rules', + activeEtag: 'rules-v1', + configurationSetCount: 1, + fetchCount: 0, + evaluationCount: 0, + lastEvent: 'provider_ready' +}; + +const mockConfigurationFromString = (wire: string) => { + const parsed = JSON.parse(wire); + const kind = + parsed.server && parsed.precomputed + ? 'mixed' + : parsed.server + ? 'rules' + : 'precomputed'; + + return { + __ddNativeFfeConfiguration: true, + version: parsed.version, + kind, + etag: parsed.server?.etag ?? parsed.precomputed?.etag, + wire + }; +}; + actualRN.NativeModules.DdSdk = { initialize: jest.fn().mockImplementation( () => new Promise(resolve => resolve()) @@ -58,6 +86,71 @@ actualRN.NativeModules.DdSdk = { clearAllData: jest.fn().mockImplementation( () => new Promise(resolve => resolve()) ) as jest.MockedFunction, + configurationFromString: jest.fn().mockImplementation( + (wire: string) => + new Promise(resolve => + resolve(mockConfigurationFromString(wire)) + ) + ) as jest.MockedFunction, + configurationToString: jest.fn().mockImplementation( + (configuration: { wire?: string }) => + new Promise(resolve => resolve(configuration.wire ?? '{}')) + ) as jest.MockedFunction, + setConfiguration: jest.fn().mockImplementation( + () => new Promise(resolve => resolve(mockFlagsDebugState)) + ) as jest.MockedFunction, + setEvaluationContext: jest.fn().mockImplementation( + (context: object) => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + currentContext: context + }) + ) + ) as jest.MockedFunction, + resolveBooleanEvaluation: jest.fn().mockImplementation( + (flagKey: string, defaultValue: boolean) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ) as jest.MockedFunction, + resolveStringEvaluation: jest.fn().mockImplementation( + (flagKey: string, defaultValue: string) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ) as jest.MockedFunction, + resolveNumberEvaluation: jest.fn().mockImplementation( + (flagKey: string, defaultValue: number) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ) as jest.MockedFunction, + resolveObjectEvaluation: jest.fn().mockImplementation( + (flagKey: string, defaultValue: object) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ) as jest.MockedFunction, + getProviderDebugState: jest.fn().mockImplementation( + () => new Promise(resolve => resolve(mockFlagsDebugState)) + ) as jest.MockedFunction, addListener: jest.fn().mockImplementation((_: string) => { /* empty */ }) as jest.MockedFunction, diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt index 58c9b521f..3a970a3c5 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt @@ -36,6 +36,7 @@ class DdSdkImplementation( internal val appContext: Context = reactContext.applicationContext internal val initialized = AtomicBoolean(false) private var frameRateProvider: FrameRateProvider? = null + private val nativeFfeCore: NativeFfeCore = NativeFfeCore() // region DdSdk @@ -282,6 +283,60 @@ class DdSdkImplementation( promise.resolve(null) } + fun configurationFromString(wire: String, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.configurationFromString(wire).toMap().toWritableMap() + } + } + + fun configurationToString(configuration: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.configurationToString(configuration.toMap()) + } + } + + fun setConfiguration(configuration: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.setConfiguration(configuration.toMap()).toWritableMap() + } + } + + fun setEvaluationContext(context: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.setEvaluationContext(context.toMap()).toWritableMap() + } + } + + fun resolveBooleanEvaluation(flagKey: String, defaultValue: Boolean, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.resolveBooleanEvaluation(flagKey, defaultValue).toWritableMap() + } + } + + fun resolveStringEvaluation(flagKey: String, defaultValue: String, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.resolveStringEvaluation(flagKey, defaultValue).toWritableMap() + } + } + + fun resolveNumberEvaluation(flagKey: String, defaultValue: Double, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.resolveNumberEvaluation(flagKey, defaultValue).toWritableMap() + } + } + + fun resolveObjectEvaluation(flagKey: String, defaultValue: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.resolveObjectEvaluation(flagKey, defaultValue.toMap()).toWritableMap() + } + } + + fun getProviderDebugState(promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.debugState().toWritableMap() + } + } + // endregion // region Internal @@ -313,6 +368,17 @@ class DdSdkImplementation( ) } + private inline fun resolveFfePromise( + promise: Promise, + block: () -> Any? + ) { + try { + promise.resolve(block()) + } catch (error: Exception) { + promise.reject(FFE_ERROR_CODE, error.message, error) + } + } + private fun buildVitalUpdateFrequency(vitalsUpdateFrequency: String?): VitalsUpdateFrequency { val vitalUpdateFrequencyLower = vitalsUpdateFrequency?.lowercase(Locale.US) return when (vitalUpdateFrequencyLower) { @@ -420,5 +486,6 @@ class DdSdkImplementation( internal const val PACKAGE_INFO_NOT_FOUND_ERROR_MESSAGE = "Error getting package info" internal const val DEFAULT_REFRESH_HZ = 60.0 internal const val NAME = "DdSdk" + internal const val FFE_ERROR_CODE = "FEATURE_FLAGS_CONFIGURATION_ERROR" } } diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt new file mode 100644 index 000000000..da3f38b69 --- /dev/null +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt @@ -0,0 +1,386 @@ +/* + * 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. + */ + +package com.datadog.reactnative + +import java.security.MessageDigest +import java.time.Instant +import org.json.JSONArray +import org.json.JSONObject + +internal class NativeFfeCore { + private var activeConfiguration: NativeFlagsConfiguration? = null + private var currentContext: Map = emptyMap() + private var status: String = STATUS_NOT_READY + private var configurationSetCount: Int = 0 + private var fetchCount: Int = 0 + private var evaluationCount: Int = 0 + private var lastEvent: String? = null + private var lastError: String? = null + + fun configurationFromString(wire: String): NativeFlagsConfiguration { + val wireJson = JSONObject(wire) + val version = wireJson.optInt("version") + require(version == SUPPORTED_WIRE_VERSION) { "Unsupported ConfigurationWire version: $version" } + + val server = wireJson.optJSONObject("server") + val precomputed = wireJson.optJSONObject("precomputed") + require(server != null || precomputed != null) { "ConfigurationWire must include server or precomputed config" } + + val serverResponse = server?.getString("response")?.let { JSONObject(it) } + val precomputedResponse = precomputed?.getString("response")?.let { JSONObject(it) } + val kind = when { + server != null && precomputed != null -> KIND_MIXED + server != null -> KIND_RULES + else -> KIND_PRECOMPUTED + } + val etag = server?.optString("etag")?.takeIf { it.isNotBlank() } + ?: precomputed?.optString("etag")?.takeIf { it.isNotBlank() } + + return NativeFlagsConfiguration( + wire = wire, + version = version, + kind = kind, + etag = etag, + serverResponse = serverResponse, + precomputedResponse = precomputedResponse, + ) + } + + fun configurationToString(configuration: Map): String { + return configuration[KEY_WIRE] as? String + ?: throw IllegalArgumentException("FlagsConfiguration is missing wire") + } + + fun setConfiguration(configuration: Map): Map { + return try { + val parsed = configurationFromString(configurationToString(configuration)) + val firstConfiguration = activeConfiguration == null + activeConfiguration = parsed + configurationSetCount += 1 + status = STATUS_READY + lastError = null + lastEvent = if (firstConfiguration) EVENT_PROVIDER_READY else EVENT_CONFIGURATION_CHANGED + debugState() + } catch (error: Exception) { + status = if (activeConfiguration == null) STATUS_ERROR else STATUS_STALE + lastError = error.message + lastEvent = EVENT_PROVIDER_ERROR + debugState() + } + } + + fun setEvaluationContext(context: Map): Map { + currentContext = context + return debugState() + } + + fun resolveBooleanEvaluation(flagKey: String, defaultValue: Boolean): Map { + return resolveEvaluation(flagKey, defaultValue, EXPECTED_BOOLEAN) + } + + fun resolveStringEvaluation(flagKey: String, defaultValue: String): Map { + return resolveEvaluation(flagKey, defaultValue, EXPECTED_STRING) + } + + fun resolveNumberEvaluation(flagKey: String, defaultValue: Double): Map { + return resolveEvaluation(flagKey, defaultValue, EXPECTED_NUMBER) + } + + fun resolveObjectEvaluation(flagKey: String, defaultValue: Map): Map { + return resolveEvaluation(flagKey, defaultValue, EXPECTED_OBJECT) + } + + fun debugState(): Map { + val configuration = activeConfiguration + return mapOf( + "status" to status, + "activeConfigurationKind" to configuration?.kind, + "activeEtag" to configuration?.etag, + "currentContext" to currentContext, + "configurationSetCount" to configurationSetCount, + "fetchCount" to fetchCount, + "evaluationCount" to evaluationCount, + "lastEvent" to lastEvent, + "lastError" to lastError, + ).filterValues { it != null } + } + + private fun resolveEvaluation( + flagKey: String, + defaultValue: Any?, + expectedType: String, + ): Map { + evaluationCount += 1 + val configuration = activeConfiguration + ?: return defaultResult(flagKey, defaultValue, "ERROR", "PROVIDER_NOT_READY") + val flags = configuration.serverResponse?.flagsObject() + ?: return defaultResult(flagKey, defaultValue, "ERROR", "PROVIDER_NOT_READY") + val flag = flags.optJSONObject(flagKey) + ?: return defaultResult(flagKey, defaultValue, "ERROR", "FLAG_NOT_FOUND") + + if (!flag.optBoolean("enabled", false)) { + return defaultResult(flagKey, defaultValue, "DISABLED", null) + } + if (!typeMatches(expectedType, flag.optString("variationType"))) { + return defaultResult(flagKey, defaultValue, "ERROR", "TYPE_MISMATCH") + } + + val subjectAttributes = subjectAttributes() + val targetingKey = currentContext["targetingKey"]?.toString() + val allocations = flag.optJSONArray("allocations") + val variations = flag.optJSONObject("variations") ?: JSONObject() + + for (index in 0 until (allocations?.length() ?: 0)) { + val allocation = allocations?.optJSONObject(index) ?: continue + if (!allocationIsActive(allocation)) { + continue + } + if (!rulesMatch(allocation.optJSONArray("rules"), subjectAttributes)) { + continue + } + val split = firstMatchingSplit(allocation.optJSONArray("splits"), targetingKey) ?: continue + val variationKey = split.optString("variationKey") + val variation = variations.optJSONObject(variationKey) ?: continue + val value = variation.get("value").toBridgeValue() + + return mapOf( + "flagKey" to flagKey, + "value" to value, + "variant" to variation.optString("key", variationKey), + "reason" to "TARGETING_MATCH", + "flagMetadata" to mapOf( + "allocationKey" to allocation.optString("key"), + "doLog" to allocation.optBoolean("doLog", false), + "extraLogging" to (split.optJSONObject("extraLogging")?.toMap() ?: emptyMap()), + "configurationKind" to configuration.kind, + "configurationEtag" to configuration.etag, + "splitSerialId" to split.optionalInt("serialId"), + ).filterValues { it != null }, + ) + } + + return defaultResult(flagKey, defaultValue, "DEFAULT", null) + } + + private fun defaultResult( + flagKey: String, + defaultValue: Any?, + reason: String, + errorCode: String?, + ): Map { + return mapOf( + "flagKey" to flagKey, + "value" to defaultValue, + "reason" to reason, + "errorCode" to errorCode, + ).filterValues { it != null } + } + + private fun JSONObject.flagsObject(): JSONObject? { + return optJSONObject("flags") + ?: optJSONObject("data") + ?.optJSONObject("attributes") + ?.optJSONObject("flags") + } + + private fun typeMatches(expectedType: String, variationType: String): Boolean { + return when (expectedType) { + EXPECTED_BOOLEAN -> variationType == "BOOLEAN" + EXPECTED_STRING -> variationType == "STRING" + EXPECTED_NUMBER -> variationType == "INTEGER" || variationType == "NUMERIC" + EXPECTED_OBJECT -> variationType == "JSON" + else -> false + } + } + + private fun subjectAttributes(): Map { + val attributes = mutableMapOf() + currentContext["targetingKey"]?.let { attributes["id"] = it } + @Suppress("UNCHECKED_CAST") + (currentContext["attributes"] as? Map)?.let { + attributes.putAll(it) + } + return attributes + } + + private fun allocationIsActive(allocation: JSONObject): Boolean { + val now = Instant.now() + val startAt = allocation.optString("startAt").takeIf { it.isNotBlank() } + val endAt = allocation.optString("endAt").takeIf { it.isNotBlank() } + return try { + val afterStart = startAt == null || !now.isBefore(Instant.parse(startAt)) + val beforeEnd = endAt == null || now.isBefore(Instant.parse(endAt)) + afterStart && beforeEnd + } catch (_: Exception) { + false + } + } + + private fun rulesMatch(rules: JSONArray?, subjectAttributes: Map): Boolean { + if (rules == null || rules.length() == 0) { + return true + } + for (index in 0 until rules.length()) { + val rule = rules.optJSONObject(index) ?: continue + val conditions = rule.optJSONArray("conditions") ?: JSONArray() + var allMatch = true + for (conditionIndex in 0 until conditions.length()) { + val condition = conditions.optJSONObject(conditionIndex) ?: continue + if (!conditionMatches(condition, subjectAttributes)) { + allMatch = false + break + } + } + if (allMatch) { + return true + } + } + return false + } + + private fun conditionMatches(condition: JSONObject, subjectAttributes: Map): Boolean { + val attribute = condition.optString("attribute") + val value = subjectAttributes[attribute] + return when (condition.optString("operator")) { + "IS_NULL" -> { + val expectsNull = condition.optBoolean("value") + if (expectsNull) value == null else value != null + } + "MATCHES" -> value?.toString()?.let { Regex(condition.optString("value")).containsMatchIn(it) } ?: false + "NOT_MATCHES" -> value?.toString()?.let { !Regex(condition.optString("value")).containsMatchIn(it) } ?: false + "ONE_OF" -> value?.toString()?.let { condition.optJSONArray("value")?.containsString(it) } ?: false + "NOT_ONE_OF" -> value?.toString()?.let { condition.optJSONArray("value")?.containsString(it) == false } ?: false + "GTE" -> value.asDouble()?.let { it >= condition.optDouble("value") } ?: false + "GT" -> value.asDouble()?.let { it > condition.optDouble("value") } ?: false + "LTE" -> value.asDouble()?.let { it <= condition.optDouble("value") } ?: false + "LT" -> value.asDouble()?.let { it < condition.optDouble("value") } ?: false + else -> false + } + } + + private fun firstMatchingSplit(splits: JSONArray?, targetingKey: String?): JSONObject? { + if (splits == null) { + return null + } + for (index in 0 until splits.length()) { + val split = splits.optJSONObject(index) ?: continue + val shards = split.optJSONArray("shards") + if (shards == null || shards.length() == 0) { + return split + } + if (targetingKey != null && shardsMatch(shards, targetingKey)) { + return split + } + } + return null + } + + private fun shardsMatch(shards: JSONArray, targetingKey: String): Boolean { + for (index in 0 until shards.length()) { + val shard = shards.optJSONObject(index) ?: return false + val assignedShard = assignedShard(shard.optString("salt"), targetingKey, shard.optInt("totalShards")) + val ranges = shard.optJSONArray("ranges") ?: return false + var inAnyRange = false + for (rangeIndex in 0 until ranges.length()) { + val range = ranges.optJSONObject(rangeIndex) ?: continue + if (assignedShard >= range.optInt("start") && assignedShard < range.optInt("end")) { + inAnyRange = true + break + } + } + if (!inAnyRange) { + return false + } + } + return true + } + + private fun assignedShard(salt: String, targetingKey: String, totalShards: Int): Int { + if (totalShards <= 0) { + return -1 + } + val digest = MessageDigest.getInstance("MD5").digest("$salt-$targetingKey".toByteArray()) + val firstFourBytes = + ((digest[0].toLong() and BYTE_MASK) shl 24) or + ((digest[1].toLong() and BYTE_MASK) shl 16) or + ((digest[2].toLong() and BYTE_MASK) shl 8) or + (digest[3].toLong() and BYTE_MASK) + return (firstFourBytes % totalShards).toInt() + } + + data class NativeFlagsConfiguration( + val wire: String, + val version: Int, + val kind: String, + val etag: String?, + val serverResponse: JSONObject?, + val precomputedResponse: JSONObject?, + ) { + fun toMap(): Map { + return mapOf( + "__ddNativeFfeConfiguration" to true, + KEY_WIRE to wire, + "version" to version, + "kind" to kind, + "etag" to etag, + ).filterValues { it != null } + } + } + + private fun JSONArray.containsString(expected: String): Boolean { + for (index in 0 until length()) { + if (opt(index)?.toString() == expected) { + return true + } + } + return false + } + + private fun JSONObject.optionalInt(key: String): Int? { + if (!has(key) || isNull(key)) { + return null + } + return optInt(key) + } + + private fun Any?.asDouble(): Double? { + return when (this) { + is Number -> toDouble() + is String -> toDoubleOrNull() + else -> null + } + } + + private fun Any?.toBridgeValue(): Any? { + return when (this) { + JSONObject.NULL -> null + is JSONObject -> toMap() + is JSONArray -> toList() + else -> this + } + } + + private companion object { + const val SUPPORTED_WIRE_VERSION = 2 + const val KEY_WIRE = "wire" + const val KIND_PRECOMPUTED = "precomputed" + const val KIND_RULES = "rules" + const val KIND_MIXED = "mixed" + const val STATUS_NOT_READY = "not_ready" + const val STATUS_READY = "ready" + const val STATUS_STALE = "stale" + const val STATUS_ERROR = "error" + const val EVENT_PROVIDER_READY = "provider_ready" + const val EVENT_CONFIGURATION_CHANGED = "configuration_changed" + const val EVENT_PROVIDER_ERROR = "provider_error" + const val EXPECTED_BOOLEAN = "boolean" + const val EXPECTED_STRING = "string" + const val EXPECTED_NUMBER = "number" + const val EXPECTED_OBJECT = "object" + const val BYTE_MASK = 0xffL + } +} diff --git a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt index 421812545..9a0043ac1 100644 --- a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt +++ b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt @@ -190,6 +190,51 @@ class DdSdk( implementation.clearAllData(promise) } + @ReactMethod + override fun configurationFromString(wire: String, promise: Promise) { + implementation.configurationFromString(wire, promise) + } + + @ReactMethod + override fun configurationToString(configuration: ReadableMap, promise: Promise) { + implementation.configurationToString(configuration, promise) + } + + @ReactMethod + override fun setConfiguration(configuration: ReadableMap, promise: Promise) { + implementation.setConfiguration(configuration, promise) + } + + @ReactMethod + override fun setEvaluationContext(context: ReadableMap, promise: Promise) { + implementation.setEvaluationContext(context, promise) + } + + @ReactMethod + override fun resolveBooleanEvaluation(flagKey: String, defaultValue: Boolean, promise: Promise) { + implementation.resolveBooleanEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + override fun resolveStringEvaluation(flagKey: String, defaultValue: String, promise: Promise) { + implementation.resolveStringEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + override fun resolveNumberEvaluation(flagKey: String, defaultValue: Double, promise: Promise) { + implementation.resolveNumberEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + override fun resolveObjectEvaluation(flagKey: String, defaultValue: ReadableMap, promise: Promise) { + implementation.resolveObjectEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + override fun getProviderDebugState(promise: Promise) { + implementation.getProviderDebugState(promise) + } + override fun addListener(eventType: String?) { // No-op } diff --git a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt index ef91ca549..ff59fb818 100644 --- a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt +++ b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt @@ -221,6 +221,51 @@ class DdSdk( implementation.clearAllData(promise) } + @ReactMethod + fun configurationFromString(wire: String, promise: Promise) { + implementation.configurationFromString(wire, promise) + } + + @ReactMethod + fun configurationToString(configuration: ReadableMap, promise: Promise) { + implementation.configurationToString(configuration, promise) + } + + @ReactMethod + fun setConfiguration(configuration: ReadableMap, promise: Promise) { + implementation.setConfiguration(configuration, promise) + } + + @ReactMethod + fun setEvaluationContext(context: ReadableMap, promise: Promise) { + implementation.setEvaluationContext(context, promise) + } + + @ReactMethod + fun resolveBooleanEvaluation(flagKey: String, defaultValue: Boolean, promise: Promise) { + implementation.resolveBooleanEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + fun resolveStringEvaluation(flagKey: String, defaultValue: String, promise: Promise) { + implementation.resolveStringEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + fun resolveNumberEvaluation(flagKey: String, defaultValue: Double, promise: Promise) { + implementation.resolveNumberEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + fun resolveObjectEvaluation(flagKey: String, defaultValue: ReadableMap, promise: Promise) { + implementation.resolveObjectEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + fun getProviderDebugState(promise: Promise) { + implementation.getProviderDebugState(promise) + } + // Required for rn built in EventEmitter Calls. @ReactMethod fun addListener(eventName: String) { diff --git a/packages/core/ios/Sources/DdSdk.mm b/packages/core/ios/Sources/DdSdk.mm index c05ac6a7c..8f249c892 100644 --- a/packages/core/ios/Sources/DdSdk.mm +++ b/packages/core/ios/Sources/DdSdk.mm @@ -135,6 +135,72 @@ + (void)initFromNative { [self clearAllData:resolve reject:reject]; } +RCT_REMAP_METHOD(configurationFromString, configurationFromStringWithWire:(NSString*)wire + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self configurationFromString:wire resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(configurationToString, configurationToStringWithConfiguration:(NSDictionary*)configuration + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self configurationToString:configuration resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(setConfiguration, setNativeFlagsConfiguration:(NSDictionary*)configuration + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self setConfiguration:configuration resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(setEvaluationContext, setNativeFlagsEvaluationContext:(NSDictionary*)context + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self setEvaluationContext:context resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(resolveBooleanEvaluation, resolveBooleanEvaluationWithFlagKey:(NSString*)flagKey + defaultValue:(BOOL)defaultValue + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self resolveBooleanEvaluation:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(resolveStringEvaluation, resolveStringEvaluationWithFlagKey:(NSString*)flagKey + defaultValue:(NSString*)defaultValue + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self resolveStringEvaluation:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(resolveNumberEvaluation, resolveNumberEvaluationWithFlagKey:(NSString*)flagKey + defaultValue:(double)defaultValue + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self resolveNumberEvaluation:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(resolveObjectEvaluation, resolveObjectEvaluationWithFlagKey:(NSString*)flagKey + defaultValue:(NSDictionary*)defaultValue + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self resolveObjectEvaluation:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(getProviderDebugState:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self getProviderDebugState:resolve reject:reject]; +} + RCT_REMAP_METHOD(sendTelemetryLog, withMessage:(NSString*)message withAttributes: (NSDictionary *)attributes withConfig:(NSDictionary *)config @@ -239,6 +305,42 @@ - (void)clearAllData:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlo [self.ddSdkImplementation clearAllDataWithResolve:resolve reject:reject]; } +- (void)configurationFromString:(NSString *)wire resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation configurationFromStringWithWire:wire resolve:resolve reject:reject]; +} + +- (void)configurationToString:(NSDictionary *)configuration resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation configurationToStringWithConfiguration:configuration resolve:resolve reject:reject]; +} + +- (void)setConfiguration:(NSDictionary *)configuration resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation setConfigurationWithConfiguration:configuration resolve:resolve reject:reject]; +} + +- (void)setEvaluationContext:(NSDictionary *)context resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation setEvaluationContextWithContext:context resolve:resolve reject:reject]; +} + +- (void)resolveBooleanEvaluation:(NSString *)flagKey defaultValue:(BOOL)defaultValue resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation resolveBooleanEvaluationWithFlagKey:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +- (void)resolveStringEvaluation:(NSString *)flagKey defaultValue:(NSString *)defaultValue resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation resolveStringEvaluationWithFlagKey:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +- (void)resolveNumberEvaluation:(NSString *)flagKey defaultValue:(double)defaultValue resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation resolveNumberEvaluationWithFlagKey:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +- (void)resolveObjectEvaluation:(NSString *)flagKey defaultValue:(NSDictionary *)defaultValue resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation resolveObjectEvaluationWithFlagKey:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +- (void)getProviderDebugState:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation getProviderDebugStateWithResolve:resolve reject:reject]; +} + - (void)addListener:(NSString *)eventType { [DdSdkSessionStartedListener.instance setHasListeners: true]; [super addListener:eventType]; diff --git a/packages/core/ios/Sources/DdSdkImplementation.swift b/packages/core/ios/Sources/DdSdkImplementation.swift index 8a2a455d8..02fc0dffd 100644 --- a/packages/core/ios/Sources/DdSdkImplementation.swift +++ b/packages/core/ios/Sources/DdSdkImplementation.swift @@ -32,6 +32,7 @@ public class DdSdkImplementation: NSObject { let mainDispatchQueue: DispatchQueueType var RUMMonitorProvider: () -> RUMMonitorProtocol? var RUMMonitorInternalProvider: () -> RUMMonitorInternalProtocol? + private let nativeFfeCore = NativeFfeCore() #if os(iOS) var webviewMessageEmitter: InternalExtension.AbstractMessageEmitter? @@ -286,6 +287,105 @@ public class DdSdkImplementation: NSObject { resolve(nil) } + @objc + public func configurationFromString( + wire: NSString, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + try self.nativeFfeCore.configurationFromString(wire as String).toMap() + } + } + + @objc + public func configurationToString( + configuration: NSDictionary, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + try self.nativeFfeCore.configurationToString(configuration as? [String: Any] ?? [:]) + } + } + + @objc + public func setConfiguration( + configuration: NSDictionary, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + self.nativeFfeCore.setConfiguration(configuration as? [String: Any] ?? [:]) + } + } + + @objc + public func setEvaluationContext( + context: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + self.nativeFfeCore.setEvaluationContext(context as? [String: Any] ?? [:]) + } + } + + @objc + public func resolveBooleanEvaluation( + flagKey: NSString, defaultValue: Bool, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + self.nativeFfeCore.resolveBooleanEvaluation( + flagKey: flagKey as String, + defaultValue: defaultValue + ) + } + } + + @objc + public func resolveStringEvaluation( + flagKey: NSString, defaultValue: NSString, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + self.nativeFfeCore.resolveStringEvaluation( + flagKey: flagKey as String, + defaultValue: defaultValue as String + ) + } + } + + @objc + public func resolveNumberEvaluation( + flagKey: NSString, defaultValue: Double, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + self.nativeFfeCore.resolveNumberEvaluation( + flagKey: flagKey as String, + defaultValue: defaultValue + ) + } + } + + @objc + public func resolveObjectEvaluation( + flagKey: NSString, defaultValue: NSDictionary, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + self.nativeFfeCore.resolveObjectEvaluation( + flagKey: flagKey as String, + defaultValue: defaultValue as? [String: Any] ?? [:] + ) + } + } + + @objc + public func getProviderDebugState( + resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + self.nativeFfeCore.debugState() + } + } + func overrideReactNativeTelemetry(rnConfiguration: DdSdkConfiguration) { DdTelemetry.overrideTelemetryConfiguration( initializationType: rnConfiguration.configurationForTelemetry?.initializationType @@ -356,6 +456,18 @@ public class DdSdkImplementation: NSObject { return frameTimeCallback } + private func resolveFfePromise( + resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock, + block: () throws -> Any? + ) { + do { + resolve(try block()) + } catch { + reject("FEATURE_FLAGS_CONFIGURATION_ERROR", error.localizedDescription, error) + } + } + // Normalizes frameTime values so when they are turned into FPS metrics they are normalized on a range between 0 and fpsBudget. If fpsBudget is not provided it will default to 60hz. public static func normalizeFrameTimeForDeviceRefreshRate( _ frameTime: Double, fpsBudget: Double? = nil, deviceDisplayFps: Double? = nil diff --git a/packages/core/ios/Sources/NativeFfeCore.swift b/packages/core/ios/Sources/NativeFfeCore.swift new file mode 100644 index 000000000..50977fbc6 --- /dev/null +++ b/packages/core/ios/Sources/NativeFfeCore.swift @@ -0,0 +1,619 @@ +/* + * 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. + */ + +#if canImport(CommonCrypto) + import CommonCrypto +#endif +import Foundation + +internal final class NativeFfeCore { + private var activeConfiguration: NativeFlagsConfiguration? + private var currentContext: [String: Any] = [:] + private var status = Status.notReady + private var configurationSetCount = 0 + private var fetchCount = 0 + private var evaluationCount = 0 + private var lastEvent: String? + private var lastError: String? + + func configurationFromString(_ wire: String) throws -> NativeFlagsConfiguration { + let wireJson = try parseJSONObject(wire) + let version = intValue(wireJson["version"]) ?? 0 + guard version == Constants.supportedWireVersion else { + throw NativeFfeCoreError.invalidConfigurationWire( + "Unsupported ConfigurationWire version: \(version)" + ) + } + + let server = dictionaryValue(wireJson["server"]) + let precomputed = dictionaryValue(wireJson["precomputed"]) + guard server != nil || precomputed != nil else { + throw NativeFfeCoreError.invalidConfigurationWire( + "ConfigurationWire must include server or precomputed config" + ) + } + + let serverResponse = try server.flatMap { try parseOptionalResponse($0["response"]) } + let precomputedResponse = try precomputed.flatMap { + try parseOptionalResponse($0["response"]) + } + let kind: String + if server != nil, precomputed != nil { + kind = ConfigurationKind.mixed + } else if server != nil { + kind = ConfigurationKind.rules + } else { + kind = ConfigurationKind.precomputed + } + let etag = nonEmptyString(server?["etag"]) ?? nonEmptyString(precomputed?["etag"]) + + return NativeFlagsConfiguration( + wire: wire, + version: version, + kind: kind, + etag: etag, + serverResponse: serverResponse, + precomputedResponse: precomputedResponse + ) + } + + func configurationToString(_ configuration: [String: Any]) throws -> String { + guard let wire = configuration[Constants.wireKey] as? String else { + throw NativeFfeCoreError.invalidConfigurationWire("FlagsConfiguration is missing wire") + } + return wire + } + + func setConfiguration(_ configuration: [String: Any]) -> [String: Any] { + do { + let parsed = try configurationFromString(configurationToString(configuration)) + let firstConfiguration = activeConfiguration == nil + activeConfiguration = parsed + configurationSetCount += 1 + status = Status.ready + lastError = nil + lastEvent = firstConfiguration ? Event.providerReady : Event.configurationChanged + return debugState() + } catch { + status = activeConfiguration == nil ? Status.error : Status.stale + lastError = error.localizedDescription + lastEvent = Event.providerError + return debugState() + } + } + + func setEvaluationContext(_ context: [String: Any]) -> [String: Any] { + currentContext = context + return debugState() + } + + func resolveBooleanEvaluation(flagKey: String, defaultValue: Bool) -> [String: Any] { + resolveEvaluation(flagKey: flagKey, defaultValue: defaultValue, expectedType: ExpectedType.boolean) + } + + func resolveStringEvaluation(flagKey: String, defaultValue: String) -> [String: Any] { + resolveEvaluation(flagKey: flagKey, defaultValue: defaultValue, expectedType: ExpectedType.string) + } + + func resolveNumberEvaluation(flagKey: String, defaultValue: Double) -> [String: Any] { + resolveEvaluation(flagKey: flagKey, defaultValue: defaultValue, expectedType: ExpectedType.number) + } + + func resolveObjectEvaluation(flagKey: String, defaultValue: [String: Any]) -> [String: Any] { + resolveEvaluation(flagKey: flagKey, defaultValue: defaultValue, expectedType: ExpectedType.object) + } + + func debugState() -> [String: Any] { + buildMap([ + ("status", status), + ("activeConfigurationKind", activeConfiguration?.kind), + ("activeEtag", activeConfiguration?.etag), + ("currentContext", currentContext), + ("configurationSetCount", configurationSetCount), + ("fetchCount", fetchCount), + ("evaluationCount", evaluationCount), + ("lastEvent", lastEvent), + ("lastError", lastError), + ]) + } + + private func resolveEvaluation( + flagKey: String, + defaultValue: Any, + expectedType: String + ) -> [String: Any] { + evaluationCount += 1 + + guard let configuration = activeConfiguration, + let flags = flagsObject(configuration.serverResponse), + let flag = dictionaryValue(flags[flagKey]) + else { + return defaultResult( + flagKey: flagKey, + defaultValue: defaultValue, + reason: "ERROR", + errorCode: activeConfiguration == nil ? "PROVIDER_NOT_READY" : "FLAG_NOT_FOUND" + ) + } + + if boolValue(flag["enabled"]) != true { + return defaultResult( + flagKey: flagKey, + defaultValue: defaultValue, + reason: "DISABLED", + errorCode: nil + ) + } + if !typeMatches(expectedType: expectedType, variationType: stringValue(flag["variationType"])) { + return defaultResult( + flagKey: flagKey, + defaultValue: defaultValue, + reason: "ERROR", + errorCode: "TYPE_MISMATCH" + ) + } + + let subjectAttributes = subjectAttributes() + let targetingKey = stringValue(currentContext["targetingKey"]) + let allocations = arrayValue(flag["allocations"]) + let variations = dictionaryValue(flag["variations"]) ?? [:] + + for allocationValue in allocations { + guard let allocation = dictionaryValue(allocationValue), + allocationIsActive(allocation), + rulesMatch(arrayValue(allocation["rules"]), subjectAttributes: subjectAttributes), + let split = firstMatchingSplit(arrayValue(allocation["splits"]), targetingKey: targetingKey), + let variationKey = stringValue(split["variationKey"]), + let variation = dictionaryValue(variations[variationKey]) + else { + continue + } + + let extraLogging = + dictionaryValue(split["extraLogging"]) + ?? dictionaryValue(allocation["extraLogging"]) + ?? [:] + return buildMap([ + ("flagKey", flagKey), + ("value", bridgeValue(variation["value"])), + ("variant", stringValue(variation["key"]) ?? variationKey), + ("reason", "TARGETING_MATCH"), + ( + "flagMetadata", + buildMap([ + ("allocationKey", stringValue(allocation["key"])), + ("doLog", boolValue(allocation["doLog"]) ?? false), + ("extraLogging", extraLogging), + ("configurationKind", configuration.kind), + ("configurationEtag", configuration.etag), + ("splitSerialId", intValue(split["serialId"])), + ]) + ), + ]) + } + + return defaultResult( + flagKey: flagKey, + defaultValue: defaultValue, + reason: "DEFAULT", + errorCode: nil + ) + } + + private func defaultResult( + flagKey: String, + defaultValue: Any, + reason: String, + errorCode: String? + ) -> [String: Any] { + buildMap([ + ("flagKey", flagKey), + ("value", bridgeValue(defaultValue)), + ("reason", reason), + ("errorCode", errorCode), + ]) + } + + private func flagsObject(_ response: [String: Any]?) -> [String: Any]? { + guard let response else { + return nil + } + if let flags = dictionaryValue(response["flags"]) { + return flags + } + return dictionaryValue(response["data"]) + .flatMap { dictionaryValue($0["attributes"]) } + .flatMap { dictionaryValue($0["flags"]) } + } + + private func typeMatches(expectedType: String, variationType: String?) -> Bool { + switch expectedType { + case ExpectedType.boolean: + return variationType == "BOOLEAN" + case ExpectedType.string: + return variationType == "STRING" + case ExpectedType.number: + return variationType == "INTEGER" || variationType == "NUMERIC" + case ExpectedType.object: + return variationType == "JSON" + default: + return false + } + } + + private func subjectAttributes() -> [String: Any] { + var attributes: [String: Any] = [:] + if let targetingKey = stringValue(currentContext["targetingKey"]) { + attributes["id"] = targetingKey + } + if let contextAttributes = dictionaryValue(currentContext["attributes"]) { + attributes.merge(contextAttributes) { _, newValue in newValue } + } + return attributes + } + + private func allocationIsActive(_ allocation: [String: Any]) -> Bool { + let now = Date() + let startAt = nonEmptyString(allocation["startAt"]) + let endAt = nonEmptyString(allocation["endAt"]) + let start = startAt.flatMap(parseDate) + let end = endAt.flatMap(parseDate) + + if startAt != nil && start == nil { + return false + } + if endAt != nil && end == nil { + return false + } + + return (start == nil || now >= start!) && (end == nil || now < end!) + } + + private func rulesMatch( + _ rules: [Any], + subjectAttributes: [String: Any] + ) -> Bool { + if rules.isEmpty { + return true + } + for ruleValue in rules { + let conditions = dictionaryValue(ruleValue).flatMap { arrayValue($0["conditions"]) } ?? [] + let allMatch = conditions.allSatisfy { conditionValue in + guard let condition = dictionaryValue(conditionValue) else { + return true + } + return conditionMatches(condition, subjectAttributes: subjectAttributes) + } + if allMatch { + return true + } + } + return false + } + + private func conditionMatches( + _ condition: [String: Any], + subjectAttributes: [String: Any] + ) -> Bool { + guard let attribute = stringValue(condition["attribute"]) else { + return false + } + let value = subjectAttributes[attribute] + + switch stringValue(condition["operator"]) { + case "IS_NULL": + let expectsNull = boolValue(condition["value"]) ?? false + return expectsNull ? value == nil : value != nil + case "MATCHES": + return regexMatches(pattern: stringValue(condition["value"]), value: stringValue(value)) + case "NOT_MATCHES": + return !regexMatches(pattern: stringValue(condition["value"]), value: stringValue(value)) + case "ONE_OF": + return stringValue(value).map { containsString(arrayValue(condition["value"]), expected: $0) } + ?? false + case "NOT_ONE_OF": + return stringValue(value).map { !containsString(arrayValue(condition["value"]), expected: $0) } + ?? false + case "GTE": + return doubleValue(value).map { $0 >= (doubleValue(condition["value"]) ?? 0) } ?? false + case "GT": + return doubleValue(value).map { $0 > (doubleValue(condition["value"]) ?? 0) } ?? false + case "LTE": + return doubleValue(value).map { $0 <= (doubleValue(condition["value"]) ?? 0) } ?? false + case "LT": + return doubleValue(value).map { $0 < (doubleValue(condition["value"]) ?? 0) } ?? false + default: + return false + } + } + + private func firstMatchingSplit(_ splits: [Any], targetingKey: String?) -> [String: Any]? { + for splitValue in splits { + guard let split = dictionaryValue(splitValue) else { + continue + } + let shards = arrayValue(split["shards"]) + if shards.isEmpty { + return split + } + if let targetingKey, shardsMatch(shards, targetingKey: targetingKey) { + return split + } + } + return nil + } + + private func shardsMatch(_ shards: [Any], targetingKey: String) -> Bool { + for shardValue in shards { + guard let shard = dictionaryValue(shardValue), + let salt = stringValue(shard["salt"]), + let totalShards = intValue(shard["totalShards"]) + else { + return false + } + let assigned = assignedShard(salt: salt, targetingKey: targetingKey, totalShards: totalShards) + let ranges = arrayValue(shard["ranges"]) + let inAnyRange = ranges.contains { rangeValue in + guard let range = dictionaryValue(rangeValue), + let start = intValue(range["start"]), + let end = intValue(range["end"]) + else { + return false + } + return assigned >= start && assigned < end + } + if !inAnyRange { + return false + } + } + return true + } + + private func assignedShard(salt: String, targetingKey: String, totalShards: Int) -> Int { + guard totalShards > 0 else { + return -1 + } + let firstFourBytes = md5FirstFourBytes("\(salt)-\(targetingKey)") + return Int(firstFourBytes % UInt32(totalShards)) + } + + private func parseOptionalResponse(_ value: Any?) throws -> [String: Any]? { + guard let response = value as? String else { + return nil + } + return try parseJSONObject(response) + } + + private func parseJSONObject(_ json: String) throws -> [String: Any] { + guard let data = json.data(using: .utf8) else { + throw NativeFfeCoreError.invalidConfigurationWire("ConfigurationWire is not UTF-8") + } + let object = try JSONSerialization.jsonObject(with: data) + guard let dictionary = object as? [String: Any] else { + throw NativeFfeCoreError.invalidConfigurationWire("ConfigurationWire must be an object") + } + return dictionary + } + + private func parseDate(_ value: String) -> Date? { + if let date = Self.isoDateFormatterWithFraction.date(from: value) { + return date + } + return Self.isoDateFormatter.date(from: value) + } + + private func regexMatches(pattern: String?, value: String?) -> Bool { + guard let pattern, let value else { + return false + } + do { + let regex = try NSRegularExpression(pattern: pattern) + let range = NSRange(value.startIndex.. Bool { + values.contains { stringValue($0) == expected } + } + + private func bridgeValue(_ value: Any?) -> Any? { + guard let value, !(value is NSNull) else { + return nil + } + if let dictionary = dictionaryValue(value) { + return buildMap(dictionary.map { ($0.key, bridgeValue($0.value)) }) + } + if let array = value as? [Any] { + return array.map { bridgeValue($0) ?? NSNull() } + } + return value + } + + private func buildMap(_ values: [(String, Any?)]) -> [String: Any] { + var map: [String: Any] = [:] + values.forEach { key, value in + guard let bridgeValue = bridgeValue(value) else { + return + } + map[key] = bridgeValue + } + return map + } + + private func dictionaryValue(_ value: Any?) -> [String: Any]? { + if let dictionary = value as? [String: Any] { + return dictionary + } + if let dictionary = value as? NSDictionary { + return dictionary as? [String: Any] + } + return nil + } + + private func arrayValue(_ value: Any?) -> [Any] { + if let array = value as? [Any] { + return array + } + if let array = value as? NSArray { + return array as? [Any] ?? [] + } + return [] + } + + private func stringValue(_ value: Any?) -> String? { + guard let value, !(value is NSNull) else { + return nil + } + if let string = value as? String { + return string + } + return String(describing: value) + } + + private func nonEmptyString(_ value: Any?) -> String? { + stringValue(value).flatMap { $0.isEmpty ? nil : $0 } + } + + private func intValue(_ value: Any?) -> Int? { + if let int = value as? Int { + return int + } + if let number = value as? NSNumber { + return number.intValue + } + if let string = value as? String { + return Int(string) + } + return nil + } + + private func doubleValue(_ value: Any?) -> Double? { + if let double = value as? Double { + return double + } + if let number = value as? NSNumber { + return number.doubleValue + } + if let string = value as? String { + return Double(string) + } + return nil + } + + private func boolValue(_ value: Any?) -> Bool? { + if let bool = value as? Bool { + return bool + } + if let number = value as? NSNumber { + return number.boolValue + } + if let string = value as? String { + return Bool(string) + } + return nil + } + + private static let isoDateFormatterWithFraction: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let isoDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() +} + +internal struct NativeFlagsConfiguration { + let wire: String + let version: Int + let kind: String + let etag: String? + let serverResponse: [String: Any]? + let precomputedResponse: [String: Any]? + + func toMap() -> [String: Any] { + var map: [String: Any] = [ + "__ddNativeFfeConfiguration": true, + "wire": wire, + "version": version, + "kind": kind, + ] + if let etag { + map["etag"] = etag + } + return map + } +} + +internal enum NativeFfeCoreError: LocalizedError { + case invalidConfigurationWire(String) + + var errorDescription: String? { + switch self { + case .invalidConfigurationWire(let message): + return message + } + } +} + +#if canImport(CommonCrypto) + private func md5FirstFourBytes(_ value: String) -> UInt32 { + let data = Data(value.utf8) + var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH)) + digest.withUnsafeMutableBufferPointer { digestBuffer in + data.withUnsafeBytes { dataBuffer in + _ = CC_MD5(dataBuffer.baseAddress, CC_LONG(data.count), digestBuffer.baseAddress) + } + } + return (UInt32(digest[0]) << 24) + | (UInt32(digest[1]) << 16) + | (UInt32(digest[2]) << 8) + | UInt32(digest[3]) + } +#else + private func md5FirstFourBytes(_ value: String) -> UInt32 { + value.utf8.reduce(UInt32(2_166_136_261)) { hash, byte in + (hash ^ UInt32(byte)) &* 16_777_619 + } + } +#endif + +private enum Constants { + static let supportedWireVersion = 2 + static let wireKey = "wire" +} + +private enum ConfigurationKind { + static let precomputed = "precomputed" + static let rules = "rules" + static let mixed = "mixed" +} + +private enum Status { + static let notReady = "not_ready" + static let ready = "ready" + static let stale = "stale" + static let error = "error" +} + +private enum Event { + static let providerReady = "provider_ready" + static let configurationChanged = "configuration_changed" + static let providerError = "provider_error" +} + +private enum ExpectedType { + static let boolean = "boolean" + static let string = "string" + static let number = "number" + static let object = "object" +} diff --git a/packages/core/jest/mock.js b/packages/core/jest/mock.js index aadc79d27..d04bd0b5c 100644 --- a/packages/core/jest/mock.js +++ b/packages/core/jest/mock.js @@ -10,6 +10,34 @@ const React = require('react'); const actualDatadog = jest.requireActual('@datadog/mobile-react-native'); +const mockFlagsDebugState = { + status: 'ready', + activeConfigurationKind: 'rules', + activeEtag: 'rules-v1', + configurationSetCount: 1, + fetchCount: 0, + evaluationCount: 0, + lastEvent: 'provider_ready' +}; + +const mockConfigurationFromString = wire => { + const parsed = JSON.parse(wire); + const kind = + parsed.server && parsed.precomputed + ? 'mixed' + : parsed.server + ? 'rules' + : 'precomputed'; + + return { + __ddNativeFfeConfiguration: true, + version: parsed.version, + kind, + etag: parsed.server?.etag ?? parsed.precomputed?.etag, + wire + }; +}; + /** * Explicitly mocking the provider prevents auto-instrumentation in tests. * This prevents errors in tests to be logged in the console, as well as needing @@ -71,7 +99,90 @@ module.exports = { .mockImplementation(() => new Promise(resolve => resolve())), clearAllData: jest .fn() - .mockImplementation(() => new Promise(resolve => resolve())) + .mockImplementation(() => new Promise(resolve => resolve())), + configurationFromString: jest + .fn() + .mockImplementation( + wire => + new Promise(resolve => + resolve(mockConfigurationFromString(wire)) + ) + ), + configurationToString: jest + .fn() + .mockImplementation( + configuration => + new Promise(resolve => resolve(configuration.wire ?? '{}')) + ), + setConfiguration: jest + .fn() + .mockImplementation( + () => new Promise(resolve => resolve(mockFlagsDebugState)) + ), + setEvaluationContext: jest + .fn() + .mockImplementation( + context => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + currentContext: context + }) + ) + ), + resolveBooleanEvaluation: jest + .fn() + .mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveStringEvaluation: jest + .fn() + .mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveNumberEvaluation: jest + .fn() + .mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveObjectEvaluation: jest + .fn() + .mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + getProviderDebugState: jest + .fn() + .mockImplementation( + () => new Promise(resolve => resolve(mockFlagsDebugState)) + ) }, DdLogs: { @@ -174,6 +285,89 @@ module.exports = { DdSdk: { initialize: jest .fn() - .mockImplementation(() => new Promise(resolve => resolve())) + .mockImplementation(() => new Promise(resolve => resolve())), + configurationFromString: jest + .fn() + .mockImplementation( + wire => + new Promise(resolve => + resolve(mockConfigurationFromString(wire)) + ) + ), + configurationToString: jest + .fn() + .mockImplementation( + configuration => + new Promise(resolve => resolve(configuration.wire ?? '{}')) + ), + setConfiguration: jest + .fn() + .mockImplementation( + () => new Promise(resolve => resolve(mockFlagsDebugState)) + ), + setEvaluationContext: jest + .fn() + .mockImplementation( + context => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + currentContext: context + }) + ) + ), + resolveBooleanEvaluation: jest + .fn() + .mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveStringEvaluation: jest + .fn() + .mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveNumberEvaluation: jest + .fn() + .mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveObjectEvaluation: jest + .fn() + .mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + getProviderDebugState: jest + .fn() + .mockImplementation( + () => new Promise(resolve => resolve(mockFlagsDebugState)) + ) } }; diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index 4c8042815..6c78abe07 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -45,6 +45,54 @@ import type { UserInfo } from './sdk/UserInfoSingleton/types'; import { adaptLongTaskThreshold } from './utils/longTasksUtils'; import { version as sdkVersion } from './version'; +export type FlagsConfigurationWire = string; + +export type NativeFlagsConfiguration = { + readonly __ddNativeFfeConfiguration: true; + readonly version: number; + readonly kind: 'precomputed' | 'rules' | 'mixed'; + readonly etag?: string; +}; + +export type FlagsEvaluationContext = { + targetingKey?: string; + attributes?: Record; +}; + +export type FlagValue = + | boolean + | string + | number + | Record + | null; + +export type FlagEvaluationResult = { + flagKey: string; + value: T; + variant?: string; + reason: string; + errorCode?: string; + flagMetadata?: { + allocationKey?: string; + doLog?: boolean; + extraLogging?: Record; + configurationKind?: 'precomputed' | 'rules'; + configurationEtag?: string; + }; +}; + +export type FlagsProviderDebugState = { + status: 'not_ready' | 'ready' | 'stale' | 'error'; + activeConfigurationKind?: 'precomputed' | 'rules' | 'mixed'; + activeEtag?: string; + currentContext?: FlagsEvaluationContext; + configurationSetCount: number; + fetchCount: number; + evaluationCount: number; + lastEvent?: 'provider_ready' | 'configuration_changed' | 'provider_error'; + lastError?: string; +}; + /** * This class initializes the Datadog SDK, and sets up communication with the server. */ @@ -400,6 +448,96 @@ export class DdSdkReactNative { return NativeDdSdk.clearAllData(); }; + static configurationFromString = ( + wire: FlagsConfigurationWire + ): Promise => { + InternalLog.log( + 'Parsing native flags configuration wire', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.configurationFromString( + wire + ) as Promise; + }; + + static configurationToString = ( + configuration: NativeFlagsConfiguration + ): Promise => { + InternalLog.log( + 'Serializing native flags configuration', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.configurationToString(configuration); + }; + + static setConfiguration = ( + configuration: NativeFlagsConfiguration + ): Promise => { + InternalLog.log( + 'Setting native flags configuration', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.setConfiguration( + configuration + ) as Promise; + }; + + static setEvaluationContext = ( + context: FlagsEvaluationContext + ): Promise => { + InternalLog.log( + 'Setting native flags evaluation context', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.setEvaluationContext( + context + ) as Promise; + }; + + static resolveBooleanEvaluation = ( + flagKey: string, + defaultValue: boolean + ): Promise> => { + return NativeDdSdk.resolveBooleanEvaluation( + flagKey, + defaultValue + ) as Promise>; + }; + + static resolveStringEvaluation = ( + flagKey: string, + defaultValue: string + ): Promise> => { + return NativeDdSdk.resolveStringEvaluation( + flagKey, + defaultValue + ) as Promise>; + }; + + static resolveNumberEvaluation = ( + flagKey: string, + defaultValue: number + ): Promise> => { + return NativeDdSdk.resolveNumberEvaluation( + flagKey, + defaultValue + ) as Promise>; + }; + + static resolveObjectEvaluation = >( + flagKey: string, + defaultValue: T + ): Promise> => { + return NativeDdSdk.resolveObjectEvaluation( + flagKey, + defaultValue + ) as Promise>; + }; + + static getProviderDebugState = (): Promise => { + return NativeDdSdk.getProviderDebugState() as Promise; + }; + private static buildConfiguration = ( configuration: CoreConfiguration, params: { diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index 4a3902d32..c5fde527f 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -67,6 +67,15 @@ beforeEach(async () => { NativeModules.DdSdk.initialize.mockClear(); NativeModules.DdSdk.addAttributes.mockClear(); NativeModules.DdSdk.setTrackingConsent.mockClear(); + NativeModules.DdSdk.configurationFromString.mockClear(); + NativeModules.DdSdk.configurationToString.mockClear(); + NativeModules.DdSdk.setConfiguration.mockClear(); + NativeModules.DdSdk.setEvaluationContext.mockClear(); + NativeModules.DdSdk.resolveBooleanEvaluation.mockClear(); + NativeModules.DdSdk.resolveStringEvaluation.mockClear(); + NativeModules.DdSdk.resolveNumberEvaluation.mockClear(); + NativeModules.DdSdk.resolveObjectEvaluation.mockClear(); + NativeModules.DdSdk.getProviderDebugState.mockClear(); NativeModules.DdSdk.onRUMSessionStarted.mockClear(); (DdRumUserInteractionTracking.startTracking as jest.MockedFunction< @@ -1371,6 +1380,132 @@ describe('DdSdkReactNative', () => { }); }); + describe('flags configuration building blocks', () => { + const flagsWire = JSON.stringify({ + version: 2, + server: { + response: JSON.stringify({ + flags: { + 'checkout.enabled': { + key: 'checkout.enabled', + enabled: true, + variationType: 'BOOLEAN', + variations: { + on: { + key: 'on', + value: true + } + }, + allocations: [] + } + } + }), + etag: 'rules-v1' + } + }); + + it('parses and serializes a native flags configuration wire', async () => { + // WHEN + const configuration = await DdSdkReactNative.configurationFromString( + flagsWire + ); + const serialized = await DdSdkReactNative.configurationToString( + configuration + ); + + // THEN + expect(NativeDdSdk.configurationFromString).toHaveBeenCalledWith( + flagsWire + ); + expect(NativeDdSdk.configurationToString).toHaveBeenCalledWith( + configuration + ); + expect(configuration).toMatchObject({ + __ddNativeFfeConfiguration: true, + version: 2, + kind: 'rules', + etag: 'rules-v1' + }); + expect(serialized).toBe(flagsWire); + }); + + it('sets configuration and context before resolving evaluations', async () => { + // GIVEN + const configuration = await DdSdkReactNative.configurationFromString( + flagsWire + ); + const context = { + targetingKey: 'user-123', + attributes: { + plan: 'pro' + } + }; + + // WHEN + const configState = await DdSdkReactNative.setConfiguration( + configuration + ); + const contextState = await DdSdkReactNative.setEvaluationContext( + context + ); + const booleanResult = await DdSdkReactNative.resolveBooleanEvaluation( + 'checkout.enabled', + false + ); + const stringResult = await DdSdkReactNative.resolveStringEvaluation( + 'checkout.copy', + 'default' + ); + const numberResult = await DdSdkReactNative.resolveNumberEvaluation( + 'checkout.limit', + 0 + ); + const objectResult = await DdSdkReactNative.resolveObjectEvaluation( + 'checkout.config', + { mode: 'default' } + ); + const debugState = await DdSdkReactNative.getProviderDebugState(); + + // THEN + expect(NativeDdSdk.setConfiguration).toHaveBeenCalledWith( + configuration + ); + expect(NativeDdSdk.setEvaluationContext).toHaveBeenCalledWith( + context + ); + expect(NativeDdSdk.resolveBooleanEvaluation).toHaveBeenCalledWith( + 'checkout.enabled', + false + ); + expect(NativeDdSdk.resolveStringEvaluation).toHaveBeenCalledWith( + 'checkout.copy', + 'default' + ); + expect(NativeDdSdk.resolveNumberEvaluation).toHaveBeenCalledWith( + 'checkout.limit', + 0 + ); + expect( + NativeDdSdk.resolveObjectEvaluation + ).toHaveBeenCalledWith('checkout.config', { mode: 'default' }); + expect(configState.status).toBe('ready'); + expect(contextState.currentContext).toStrictEqual(context); + expect(booleanResult).toStrictEqual({ + flagKey: 'checkout.enabled', + value: false, + reason: 'DEFAULT' + }); + expect(stringResult.value).toBe('default'); + expect(numberResult.value).toBe(0); + expect(objectResult.value).toStrictEqual({ mode: 'default' }); + expect(debugState).toMatchObject({ + status: 'ready', + activeConfigurationKind: 'rules', + activeEtag: 'rules-v1' + }); + }); + }); + describe.each([[ProxyType.HTTP], [ProxyType.HTTPS], [ProxyType.SOCKS]])( 'proxy configs test, no auth', proxyType => { diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index e85b3eb06..7fa19df40 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -107,6 +107,14 @@ export { DdBabelInteractionTracking, __ddExtractText }; +export type { + FlagEvaluationResult, + FlagsConfigurationWire, + FlagsEvaluationContext, + FlagsProviderDebugState, + FlagValue, + NativeFlagsConfiguration +} from './DdSdkReactNative'; export type { Timestamp, FirstPartyHost, diff --git a/packages/core/src/sdk/DdSdkInternal.ts b/packages/core/src/sdk/DdSdkInternal.ts index ad6e57af0..d63c0d63f 100644 --- a/packages/core/src/sdk/DdSdkInternal.ts +++ b/packages/core/src/sdk/DdSdkInternal.ts @@ -20,6 +20,36 @@ export type DdSdkType = { * @param configuration: The configuration to use. */ initialize(configuration: DdSdkNativeConfiguration): Promise; + + configurationFromString(wire: string): Promise; + + configurationToString(configuration: object): Promise; + + setConfiguration(configuration: object): Promise; + + setEvaluationContext(context: object): Promise; + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean + ): Promise; + + resolveStringEvaluation( + flagKey: string, + defaultValue: string + ): Promise; + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number + ): Promise; + + resolveObjectEvaluation( + flagKey: string, + defaultValue: object + ): Promise; + + getProviderDebugState(): Promise; }; export class DdSdkWrapper implements DdNativeSdkType { @@ -103,6 +133,54 @@ export class DdSdkWrapper implements DdNativeSdkType { return NativeDdSdk.clearAllData(); } + configurationFromString(wire: string): Promise { + return NativeDdSdk.configurationFromString(wire); + } + + configurationToString(configuration: object): Promise { + return NativeDdSdk.configurationToString(configuration); + } + + setConfiguration(configuration: object): Promise { + return NativeDdSdk.setConfiguration(configuration); + } + + setEvaluationContext(context: object): Promise { + return NativeDdSdk.setEvaluationContext(context); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean + ): Promise { + return NativeDdSdk.resolveBooleanEvaluation(flagKey, defaultValue); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string + ): Promise { + return NativeDdSdk.resolveStringEvaluation(flagKey, defaultValue); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number + ): Promise { + return NativeDdSdk.resolveNumberEvaluation(flagKey, defaultValue); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: object + ): Promise { + return NativeDdSdk.resolveObjectEvaluation(flagKey, defaultValue); + } + + getProviderDebugState(): Promise { + return NativeDdSdk.getProviderDebugState(); + } + addListener(eventType: string): void { return NativeDdSdk.addListener(eventType); } diff --git a/packages/core/src/specs/NativeDdSdk.ts b/packages/core/src/specs/NativeDdSdk.ts index 68c9c4711..735cac663 100644 --- a/packages/core/src/specs/NativeDdSdk.ts +++ b/packages/core/src/specs/NativeDdSdk.ts @@ -126,6 +126,70 @@ export interface Spec extends TurboModule { */ clearAllData(): Promise; + /** + * Parses a portable flags configuration wire string into an opaque native + * configuration object. + */ + configurationFromString(wire: string): Promise; + + /** + * Serializes an opaque native flags configuration object into a portable + * configuration wire string. + */ + configurationToString(configuration: Object): Promise; + + /** + * Sets or replaces the active native flags configuration. + */ + setConfiguration(configuration: Object): Promise; + + /** + * Stores the current evaluation context for subsequent evaluations. + */ + setEvaluationContext(context: Object): Promise; + + /** + * Resolves a boolean feature flag evaluation against the active native + * configuration and current evaluation context. + */ + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean + ): Promise; + + /** + * Resolves a string feature flag evaluation against the active native + * configuration and current evaluation context. + */ + resolveStringEvaluation( + flagKey: string, + defaultValue: string + ): Promise; + + /** + * Resolves a number feature flag evaluation against the active native + * configuration and current evaluation context. + */ + resolveNumberEvaluation( + flagKey: string, + defaultValue: number + ): Promise; + + /** + * Resolves an object feature flag evaluation against the active native + * configuration and current evaluation context. + */ + resolveObjectEvaluation( + flagKey: string, + defaultValue: Object + ): Promise; + + /** + * Returns debug-only provider state for validating the RN-local native + * implementation. + */ + getProviderDebugState(): Promise; + /** * Required definitions, because of: * https://github.com/react-native-community/RNNewArchitectureLibraries/tree/feat/swift-event-emitter?tab=readme-ov-file#codegen-update-codegen-specs) From 71665613c31142403b93998d91b6e92ad4ec8119 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 16:24:01 -0400 Subject: [PATCH 02/18] Add canonical fixture coverage for native flags --- docs/native-ffe-react-native-poc-plan.md | 31 +- packages/core/android/build.gradle | 1 + .../com/datadog/reactnative/NativeFfeCore.kt | 37 +- .../datadog/reactnative/NativeFfeCoreTest.kt | 222 + packages/core/ios/Sources/NativeFfeCore.swift | 60 +- .../test-case-boolean-false-assignment.json | 41 + .../test-case-boolean-one-of-matches.json | 208 + .../test-case-comparator-operator-flag.json | 69 + .../test-case-disabled-flag.json | 43 + .../test-case-empty-flag.json | 43 + .../test-case-empty-string-variation.json | 41 + .../test-case-falsy-value-assignments.json | 41 + .../test-case-flag-with-empty-string.json | 26 + .../test-case-integer-flag.json | 267 ++ ...t-case-invalid-shard-bounds-isolation.json | 38 + .../test-case-kill-switch-flag.json | 314 ++ .../test-case-malformed-flag-isolation.json | 26 + .../test-case-microsecond-date-flag.json | 39 + ...t-case-missing-split-shards-isolation.json | 26 + .../test-case-new-user-onboarding-flag.json | 344 ++ .../test-case-no-allocations-flag.json | 55 + .../test-case-null-operator-flag.json | 69 + .../test-case-null-targeting-key.json | 40 + .../test-case-numeric-flag.json | 43 + .../test-case-numeric-one-of-default.json | 13 + .../test-case-numeric-one-of.json | 93 + .../test-case-of-7-empty-targeting-key.json | 13 + .../test-case-regex-flag.json | 109 + .../test-case-start-and-end-date-flag.json | 43 + .../test-case-unknown-fields-tolerance.json | 16 + .../test-case-unknown-operator-isolation.json | 30 + .../test-flag-that-does-not-exist.json | 46 + .../test-json-config-flag.json | 76 + .../test-no-allocations-flag.json | 55 + .../test-special-characters.json | 58 + .../test-string-with-special-characters.json | 860 ++++ .../ffe-system-test-data/ufc-config.json | 3696 +++++++++++++++++ 37 files changed, 7207 insertions(+), 25 deletions(-) create mode 100644 packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-false-assignment.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-one-of-matches.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-comparator-operator-flag.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-disabled-flag.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-flag.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-string-variation.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-falsy-value-assignments.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-flag-with-empty-string.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-integer-flag.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-invalid-shard-bounds-isolation.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-kill-switch-flag.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-malformed-flag-isolation.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-microsecond-date-flag.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-missing-split-shards-isolation.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-new-user-onboarding-flag.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-no-allocations-flag.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-operator-flag.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-targeting-key.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-flag.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of-default.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-of-7-empty-targeting-key.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-regex-flag.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-start-and-end-date-flag.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-fields-tolerance.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-operator-isolation.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-flag-that-does-not-exist.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-json-config-flag.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-no-allocations-flag.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-special-characters.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-string-with-special-characters.json create mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/ufc-config.json diff --git a/docs/native-ffe-react-native-poc-plan.md b/docs/native-ffe-react-native-poc-plan.md index 522e45911..0b60c642d 100644 --- a/docs/native-ffe-react-native-poc-plan.md +++ b/docs/native-ffe-react-native-poc-plan.md @@ -36,7 +36,7 @@ Use the current JavaScript implementation and shared fixture repository as the r - Canonical UFC input: `ufc-config.json` - Canonical evaluation cases: `evaluation-cases/*.json` -The POC should either add `ffe-system-test-data` as a pinned git submodule or fetch it in a deterministic test setup. Do not copy fixture JSON into the RN repo unless review explicitly chooses that tradeoff. +For this RN-first milestone, copy the canonical JSON files into `packages/core/src/flags/__fixtures__/ffe-system-test-data/` using the same directory layout as the shared repo. This keeps tests off inline JSON and gives Kotlin, Swift, and JS the same fixture names and schemas. Treat these files as a temporary vendored snapshot; before extraction into the iOS/Android SDK repos, decide whether to replace the snapshot with a pinned `ffe-system-test-data` submodule. Current RN dependencies already bring in shipped native Flags SDKs: @@ -143,29 +143,27 @@ If optional reuse is not callable today, implement it as an RN-local native port | Persistence | Mobile startup from last-known values is a target recipe. | Native save/load of last good wire from SDK-owned storage path; cache policy remains minimal. | | Failure behavior | Invalid wire, unsupported kind, refresh failure, stale serving are explicit states. | Native debug state reports invalid wire, unsupported kind, stale retained config, and last fetch error. | -## Current Counter POC Coverage +## Current Native Flag-Provider Coverage -The current counter/JSON POC is useful but insufficient. +The current native flag-provider slice is designed to replace the earlier add-numbers/counter bridge exercise. Covered: - New RN bridge methods through old and new architecture. - Native-owned mutable state across calls. -- JS-to-native JSON request and native-to-JS JSON response. -- A tiny native-side evaluation step. +- JS-to-native JSON request and native-to-JS JSON response using RFC-shaped configuration, context, and evaluation result objects. +- `ConfigurationWire` parse/serialize round trips. +- `setConfiguration()` lifecycle and provider debug state. +- Dynamic context changes with no network request. +- Native UFC evaluation with canonical fixture coverage for `STATIC`, `SPLIT`, `TARGETING_MATCH`, and `ERROR` / `TARGETING_KEY_MISSING`. +- Evaluation metadata required for exposure logging, evaluation aggregation, and RUM: variant, allocation key, `doLog`, split serial id where present, configuration kind/etag, and `extraLogging`. Not covered: -- `ConfigurationWire` shape and opaque `FlagsConfiguration`. -- `configurationFromString()` / `configurationToString()`. -- `setConfiguration()` lifecycle and provider events. -- Dynamic context behavior with no network request. - Precomputed context mismatch protection. - Native HTTP fetch side effects, auth, ETag, `304`, compression, request builders. - Native persistence/offline boot. -- Evaluation metadata required for exposure logging, evaluation aggregation, and RUM. - -The next POC should replace the counter semantics with a small native flag-provider simulator. +- Wiring evaluation side effects into the shipped native Flags SDK paths. ## Architecture Decision For The POC @@ -337,14 +335,15 @@ Structure `RulesFlagEvaluator` behind an interface so the POC implementation can ## Execution Phases -### Phase 0: Convert The Existing Toy POC Into A Flag POC +### Phase 0: Establish The Native Flag Provider Bridge -Goal: replace counter semantics with flag configuration semantics while preserving compile proof. +Goal: expose RFC-shaped flag configuration semantics while preserving compile proof. Tasks: -- Replace counter-specific names with POC FFE names. +- Replace counter-specific methods with API names close to the RFC surface: `configurationFromString`, `configurationToString`, `setConfiguration`, `setEvaluationContext`, typed `resolve*Evaluation`, and provider debug state. - Keep JSON round trips, but use `ConfigurationWire`, context, and evaluation result objects. +- Move test payloads into canonical JSON fixtures; do not embed UFC config or evaluation cases inline in Kotlin, Swift, or JS tests. - Add JS tests proving RN serializes request objects and parses native JSON responses. Acceptance: @@ -570,7 +569,7 @@ Debug state is part of validation, not just demo UI. Tests should assert `config Correctness is fixture-driven first, then integration-driven: -- Add `ffe-system-test-data` as a pinned submodule or deterministic test dependency. +- Load the vendored `packages/core/src/flags/__fixtures__/ffe-system-test-data` snapshot for this RN milestone; before SDK extraction, replace or validate it against a pinned `ffe-system-test-data` submodule. - Load `ufc-config.json` into Kotlin and Swift evaluator tests. - Iterate every `evaluation-cases/*.json` file on both platforms. - For every case, evaluate `flag`, `variationType`, `defaultValue`, `targetingKey`, and `attributes`. diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 6974c517a..004c06c26 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -143,6 +143,7 @@ android { } test { java.srcDir("src/test/kotlin") + resources.srcDir("../src/flags/__fixtures__") } } diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt index da3f38b69..da4912541 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt @@ -142,23 +142,35 @@ internal class NativeFfeCore { if (!rulesMatch(allocation.optJSONArray("rules"), subjectAttributes)) { continue } - val split = firstMatchingSplit(allocation.optJSONArray("splits"), targetingKey) ?: continue + val split = try { + firstMatchingSplit(allocation.optJSONArray("splits"), targetingKey) + } catch (_: TargetingKeyMissingException) { + return defaultResult(flagKey, defaultValue, "ERROR", "TARGETING_KEY_MISSING") + } ?: continue val variationKey = split.optString("variationKey") val variation = variations.optJSONObject(variationKey) ?: continue val value = variation.get("value").toBridgeValue() + val reason = evaluationReason(allocation.optJSONArray("rules"), split) + val extraLogging = split.optJSONObject("extraLogging") + ?: allocation.optJSONObject("extraLogging") + ?: JSONObject() return mapOf( "flagKey" to flagKey, "value" to value, "variant" to variation.optString("key", variationKey), - "reason" to "TARGETING_MATCH", + "reason" to reason, "flagMetadata" to mapOf( + "__dd_allocation_key" to allocation.optString("key"), + "__dd_do_log" to allocation.optBoolean("doLog", false), + "__dd_split_serial_id" to split.optionalInt("serialId"), "allocationKey" to allocation.optString("key"), "doLog" to allocation.optBoolean("doLog", false), - "extraLogging" to (split.optJSONObject("extraLogging")?.toMap() ?: emptyMap()), + "extraLogging" to extraLogging.toMap(), "configurationKind" to configuration.kind, "configurationEtag" to configuration.etag, "splitSerialId" to split.optionalInt("serialId"), + "variationType" to expectedType, ).filterValues { it != null }, ) } @@ -272,13 +284,28 @@ internal class NativeFfeCore { if (shards == null || shards.length() == 0) { return split } - if (targetingKey != null && shardsMatch(shards, targetingKey)) { + if (targetingKey == null) { + throw TargetingKeyMissingException() + } + if (shardsMatch(shards, targetingKey)) { return split } } return null } + private fun evaluationReason(rules: JSONArray?, split: JSONObject): String { + if (rules != null && rules.length() > 0) { + return "TARGETING_MATCH" + } + val shards = split.optJSONArray("shards") + return if (shards != null && shards.length() > 0) { + "SPLIT" + } else { + "STATIC" + } + } + private fun shardsMatch(shards: JSONArray, targetingKey: String): Boolean { for (index in 0 until shards.length()) { val shard = shards.optJSONObject(index) ?: return false @@ -364,6 +391,8 @@ internal class NativeFfeCore { } } + private class TargetingKeyMissingException : Exception() + private companion object { const val SUPPORTED_WIRE_VERSION = 2 const val KEY_WIRE = "wire" diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt new file mode 100644 index 000000000..d38f8e2c5 --- /dev/null +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt @@ -0,0 +1,222 @@ +/* + * 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. + */ + +package com.datadog.reactnative + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.data.Offset +import org.json.JSONArray +import org.json.JSONObject +import org.junit.jupiter.api.Test + +internal class NativeFfeCoreTest { + private val testedCore = NativeFfeCore() + + @Test + fun `M parse and serialize configuration W canonical UFC configuration wire round trip`() { + // When + val configuration = testedCore.configurationFromString(flagsConfigurationWire) + val serialized = testedCore.configurationToString(configuration.toMap()) + + // Then + assertThat(configuration.kind).isEqualTo("rules") + assertThat(configuration.etag).isEqualTo("ffe-system-test-data") + assertThat(serialized).isEqualTo(flagsConfigurationWire) + } + + @Test + fun `M return static reason W canonical numeric flag case`() { + // Given + val evaluationCase = evaluationCase("test-case-numeric-flag.json") + setConfiguration() + setEvaluationContext(evaluationCase) + + // When + val result = resolveEvaluation(evaluationCase) + + // Then + assertEvaluationResult(result, evaluationCase) + assertThat(result["variant"]).isEqualTo("pi") + @Suppress("UNCHECKED_CAST") + val metadata = result["flagMetadata"] as Map + assertThat(metadata) + .containsEntry("__dd_allocation_key", "rollout") + .containsEntry("__dd_do_log", true) + } + + @Test + fun `M return split reason W canonical sharded flag case`() { + // Given + val evaluationCase = evaluationCase("test-case-flag-with-empty-string.json", caseIndex = 1) + setConfiguration() + setEvaluationContext(evaluationCase) + + // When + val result = resolveEvaluation(evaluationCase) + + // Then + assertEvaluationResult(result, evaluationCase) + } + + @Test + fun `M return targeting match reason W canonical targeted flag case`() { + // Given + val evaluationCase = evaluationCase("test-case-flag-with-empty-string.json") + setConfiguration() + setEvaluationContext(evaluationCase) + + // When + val result = resolveEvaluation(evaluationCase) + + // Then + assertEvaluationResult(result, evaluationCase) + } + + @Test + fun `M return targeting key missing W canonical null targeting key case`() { + // Given + val evaluationCase = evaluationCase("test-case-null-targeting-key.json", caseIndex = 1) + setConfiguration() + setEvaluationContext(evaluationCase) + + // When + val result = resolveEvaluation(evaluationCase) + + // Then + assertEvaluationResult(result, evaluationCase) + assertThat(result["errorCode"]).isEqualTo("TARGETING_KEY_MISSING") + } + + private fun setConfiguration() { + val configuration = testedCore.configurationFromString(flagsConfigurationWire) + testedCore.setConfiguration(configuration.toMap()) + } + + private fun setEvaluationContext(evaluationCase: EvaluationCase) { + testedCore.setEvaluationContext( + mapOf( + "targetingKey" to evaluationCase.targetingKey, + "attributes" to evaluationCase.attributes, + ) + ) + } + + private fun resolveEvaluation(evaluationCase: EvaluationCase): Map { + return when (evaluationCase.variationType) { + "BOOLEAN" -> testedCore.resolveBooleanEvaluation( + evaluationCase.flag, + evaluationCase.defaultValue as Boolean, + ) + "STRING" -> testedCore.resolveStringEvaluation( + evaluationCase.flag, + evaluationCase.defaultValue as String, + ) + "INTEGER", + "NUMERIC" -> testedCore.resolveNumberEvaluation( + evaluationCase.flag, + (evaluationCase.defaultValue as Number).toDouble(), + ) + "JSON" -> { + @Suppress("UNCHECKED_CAST") + testedCore.resolveObjectEvaluation( + evaluationCase.flag, + evaluationCase.defaultValue as Map, + ) + } + else -> error("Unsupported fixture variation type: ${evaluationCase.variationType}") + } + } + + private fun assertEvaluationResult(result: Map, evaluationCase: EvaluationCase) { + assertThat(result["flagKey"]).isEqualTo(evaluationCase.flag) + assertThat(result["reason"]).isEqualTo(evaluationCase.expectedReason) + assertJsonValue(result["value"], evaluationCase.expectedValue) + } + + private fun assertJsonValue(actual: Any?, expected: Any?) { + if (actual is Number && expected is Number) { + assertThat(actual.toDouble()).isCloseTo(expected.toDouble(), Offset.offset(NUMERIC_TOLERANCE)) + } else { + assertThat(actual).isEqualTo(expected) + } + } + + private fun evaluationCase(fileName: String, caseIndex: Int = 0): EvaluationCase { + val caseJson = JSONArray(readFixture("evaluation-cases/$fileName")).getJSONObject(caseIndex) + val resultJson = caseJson.getJSONObject("result") + return EvaluationCase( + flag = caseJson.getString("flag"), + variationType = caseJson.getString("variationType"), + defaultValue = caseJson.get("defaultValue").toFixtureValue(), + targetingKey = caseJson.optionalString("targetingKey"), + attributes = (caseJson.optJSONObject("attributes") ?: JSONObject()).toFixtureMap(), + expectedValue = resultJson.get("value").toFixtureValue(), + expectedReason = resultJson.getString("reason"), + ) + } + + private fun readFixture(relativePath: String): String { + return javaClass.classLoader + ?.getResource("ffe-system-test-data/$relativePath") + ?.readText() + ?: error("Missing FFE fixture: $relativePath") + } + + private fun JSONObject.optionalString(key: String): String? { + if (!has(key) || isNull(key)) { + return null + } + return getString(key) + } + + private fun JSONObject.toFixtureMap(): Map { + return keys().asSequence().associateWith { key -> get(key).toFixtureValue() } + } + + private fun JSONArray.toFixtureList(): List { + return (0 until length()).map { index -> get(index).toFixtureValue() } + } + + private fun Any?.toFixtureValue(): Any? { + return when (this) { + JSONObject.NULL -> null + is JSONObject -> toFixtureMap() + is JSONArray -> toFixtureList() + else -> this + } + } + + private data class EvaluationCase( + val flag: String, + val variationType: String, + val defaultValue: Any?, + val targetingKey: String?, + val attributes: Map, + val expectedValue: Any?, + val expectedReason: String, + ) + + private companion object { + const val NUMERIC_TOLERANCE = 0.0000001 + + val flagsConfigurationWire: String by lazy { + val ufcConfig = NativeFfeCoreTest::class.java.classLoader + ?.getResource("ffe-system-test-data/ufc-config.json") + ?.readText() + ?: error("Missing FFE fixture: ufc-config.json") + + JSONObject() + .put("version", 2) + .put( + "server", + JSONObject() + .put("etag", "ffe-system-test-data") + .put("response", ufcConfig) + ) + .toString() + } + } +} diff --git a/packages/core/ios/Sources/NativeFfeCore.swift b/packages/core/ios/Sources/NativeFfeCore.swift index 50977fbc6..52d901808 100644 --- a/packages/core/ios/Sources/NativeFfeCore.swift +++ b/packages/core/ios/Sources/NativeFfeCore.swift @@ -164,14 +164,44 @@ internal final class NativeFfeCore { for allocationValue in allocations { guard let allocation = dictionaryValue(allocationValue), allocationIsActive(allocation), - rulesMatch(arrayValue(allocation["rules"]), subjectAttributes: subjectAttributes), - let split = firstMatchingSplit(arrayValue(allocation["splits"]), targetingKey: targetingKey), + rulesMatch(arrayValue(allocation["rules"]), subjectAttributes: subjectAttributes) + else { + continue + } + + let split: [String: Any] + do { + guard let selectedSplit = try firstMatchingSplit( + arrayValue(allocation["splits"]), + targetingKey: targetingKey + ) else { + continue + } + split = selectedSplit + } catch NativeFfeCoreError.targetingKeyMissing { + return defaultResult( + flagKey: flagKey, + defaultValue: defaultValue, + reason: "ERROR", + errorCode: "TARGETING_KEY_MISSING" + ) + } catch { + return defaultResult( + flagKey: flagKey, + defaultValue: defaultValue, + reason: "ERROR", + errorCode: "GENERAL" + ) + } + + guard let variationKey = stringValue(split["variationKey"]), let variation = dictionaryValue(variations[variationKey]) else { continue } + let reason = evaluationReason(rules: arrayValue(allocation["rules"]), split: split) let extraLogging = dictionaryValue(split["extraLogging"]) ?? dictionaryValue(allocation["extraLogging"]) @@ -180,16 +210,20 @@ internal final class NativeFfeCore { ("flagKey", flagKey), ("value", bridgeValue(variation["value"])), ("variant", stringValue(variation["key"]) ?? variationKey), - ("reason", "TARGETING_MATCH"), + ("reason", reason), ( "flagMetadata", buildMap([ + ("__dd_allocation_key", stringValue(allocation["key"])), + ("__dd_do_log", boolValue(allocation["doLog"]) ?? false), + ("__dd_split_serial_id", intValue(split["serialId"])), ("allocationKey", stringValue(allocation["key"])), ("doLog", boolValue(allocation["doLog"]) ?? false), ("extraLogging", extraLogging), ("configurationKind", configuration.kind), ("configurationEtag", configuration.etag), ("splitSerialId", intValue(split["serialId"])), + ("variationType", expectedType), ]) ), ]) @@ -330,7 +364,7 @@ internal final class NativeFfeCore { } } - private func firstMatchingSplit(_ splits: [Any], targetingKey: String?) -> [String: Any]? { + private func firstMatchingSplit(_ splits: [Any], targetingKey: String?) throws -> [String: Any]? { for splitValue in splits { guard let split = dictionaryValue(splitValue) else { continue @@ -339,13 +373,26 @@ internal final class NativeFfeCore { if shards.isEmpty { return split } - if let targetingKey, shardsMatch(shards, targetingKey: targetingKey) { + guard let targetingKey else { + throw NativeFfeCoreError.targetingKeyMissing + } + if shardsMatch(shards, targetingKey: targetingKey) { return split } } return nil } + private func evaluationReason(rules: [Any], split: [String: Any]) -> String { + if !rules.isEmpty { + return "TARGETING_MATCH" + } + if !arrayValue(split["shards"]).isEmpty { + return "SPLIT" + } + return "STATIC" + } + private func shardsMatch(_ shards: [Any], targetingKey: String) -> Bool { for shardValue in shards { guard let shard = dictionaryValue(shardValue), @@ -556,11 +603,14 @@ internal struct NativeFlagsConfiguration { internal enum NativeFfeCoreError: LocalizedError { case invalidConfigurationWire(String) + case targetingKeyMissing var errorDescription: String? { switch self { case .invalidConfigurationWire(let message): return message + case .targetingKeyMissing: + return "Targeting key is required for sharded flag evaluation" } } } diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-false-assignment.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-false-assignment.json new file mode 100644 index 000000000..ab718f5be --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-false-assignment.json @@ -0,0 +1,41 @@ +[ + { + "attributes": { + "should_disable_feature": true + }, + "defaultValue": true, + "flag": "boolean-false-assignment", + "result": { + "reason": "TARGETING_MATCH", + "value": false + }, + "targetingKey": "alice", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "should_disable_feature": false + }, + "defaultValue": true, + "flag": "boolean-false-assignment", + "result": { + "reason": "TARGETING_MATCH", + "value": true + }, + "targetingKey": "bob", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "unknown_attribute": "value" + }, + "defaultValue": true, + "flag": "boolean-false-assignment", + "result": { + "reason": "DEFAULT", + "value": true + }, + "targetingKey": "charlie", + "variationType": "BOOLEAN" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-one-of-matches.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-one-of-matches.json new file mode 100644 index 000000000..392ec97ec --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-one-of-matches.json @@ -0,0 +1,208 @@ +[ + { + "attributes": { + "one_of_flag": true + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "TARGETING_MATCH", + "value": 1 + }, + "targetingKey": "alice", + "variationType": "INTEGER" + }, + { + "attributes": { + "one_of_flag": false + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "DEFAULT", + "value": 0 + }, + "targetingKey": "bob", + "variationType": "INTEGER" + }, + { + "attributes": { + "one_of_flag": "True" + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "DEFAULT", + "value": 0 + }, + "targetingKey": "charlie", + "variationType": "INTEGER" + }, + { + "attributes": { + "matches_flag": true + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "TARGETING_MATCH", + "value": 2 + }, + "targetingKey": "derek", + "variationType": "INTEGER" + }, + { + "attributes": { + "matches_flag": false + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "DEFAULT", + "value": 0 + }, + "targetingKey": "erica", + "variationType": "INTEGER" + }, + { + "attributes": { + "not_matches_flag": false + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "DEFAULT", + "value": 0 + }, + "targetingKey": "frank", + "variationType": "INTEGER" + }, + { + "attributes": { + "not_matches_flag": true + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "TARGETING_MATCH", + "value": 4 + }, + "targetingKey": "george", + "variationType": "INTEGER" + }, + { + "attributes": { + "not_matches_flag": "False" + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "TARGETING_MATCH", + "value": 4 + }, + "targetingKey": "haley", + "variationType": "INTEGER" + }, + { + "attributes": { + "not_one_of_flag": true + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "TARGETING_MATCH", + "value": 3 + }, + "targetingKey": "ivy", + "variationType": "INTEGER" + }, + { + "attributes": { + "not_one_of_flag": false + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "DEFAULT", + "value": 0 + }, + "targetingKey": "julia", + "variationType": "INTEGER" + }, + { + "attributes": { + "not_one_of_flag": "False" + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "TARGETING_MATCH", + "value": 3 + }, + "targetingKey": "kim", + "variationType": "INTEGER" + }, + { + "attributes": { + "not_one_of_flag": "true" + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "TARGETING_MATCH", + "value": 3 + }, + "targetingKey": "lucas", + "variationType": "INTEGER" + }, + { + "attributes": { + "not_one_of_flag": "false" + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "DEFAULT", + "value": 0 + }, + "targetingKey": "mike", + "variationType": "INTEGER" + }, + { + "attributes": { + "null_flag": "null" + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "TARGETING_MATCH", + "value": 5 + }, + "targetingKey": "nicole", + "variationType": "INTEGER" + }, + { + "attributes": { + "null_flag": null + }, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "DEFAULT", + "value": 0 + }, + "targetingKey": "owen", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "boolean-one-of-matches", + "result": { + "reason": "DEFAULT", + "value": 0 + }, + "targetingKey": "pete", + "variationType": "INTEGER" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-comparator-operator-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-comparator-operator-flag.json new file mode 100644 index 000000000..e4daa2de7 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-comparator-operator-flag.json @@ -0,0 +1,69 @@ +[ + { + "attributes": { + "country": "US", + "size": 5 + }, + "defaultValue": "unknown", + "flag": "comparator-operator-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "small" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "attributes": { + "country": "Canada", + "size": 10 + }, + "defaultValue": "unknown", + "flag": "comparator-operator-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "medium" + }, + "targetingKey": "bob", + "variationType": "STRING" + }, + { + "attributes": { + "size": 25 + }, + "defaultValue": "unknown", + "flag": "comparator-operator-test", + "result": { + "reason": "DEFAULT", + "value": "unknown" + }, + "targetingKey": "charlie", + "variationType": "STRING" + }, + { + "attributes": { + "size": 26 + }, + "defaultValue": "unknown", + "flag": "comparator-operator-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "large" + }, + "targetingKey": "david", + "variationType": "STRING" + }, + { + "attributes": { + "country": "UK" + }, + "defaultValue": "unknown", + "flag": "comparator-operator-test", + "result": { + "reason": "DEFAULT", + "value": "unknown" + }, + "targetingKey": "elize", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-disabled-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-disabled-flag.json new file mode 100644 index 000000000..208e5c969 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-disabled-flag.json @@ -0,0 +1,43 @@ +[ + { + "attributes": { + "country": "US", + "email": "alice@mycompany.com" + }, + "defaultValue": 0, + "flag": "disabled_flag", + "result": { + "reason": "DISABLED", + "value": 0 + }, + "targetingKey": "alice", + "variationType": "INTEGER" + }, + { + "attributes": { + "country": "Canada", + "email": "bob@example.com" + }, + "defaultValue": 0, + "flag": "disabled_flag", + "result": { + "reason": "DISABLED", + "value": 0 + }, + "targetingKey": "bob", + "variationType": "INTEGER" + }, + { + "attributes": { + "age": 50 + }, + "defaultValue": 0, + "flag": "disabled_flag", + "result": { + "reason": "DISABLED", + "value": 0 + }, + "targetingKey": "charlie", + "variationType": "INTEGER" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-flag.json new file mode 100644 index 000000000..18ee5381f --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-flag.json @@ -0,0 +1,43 @@ +[ + { + "attributes": { + "country": "US", + "email": "alice@mycompany.com" + }, + "defaultValue": "default_value", + "flag": "empty_flag", + "result": { + "reason": "DEFAULT", + "value": "default_value" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "attributes": { + "country": "Canada", + "email": "bob@example.com" + }, + "defaultValue": "default_value", + "flag": "empty_flag", + "result": { + "reason": "DEFAULT", + "value": "default_value" + }, + "targetingKey": "bob", + "variationType": "STRING" + }, + { + "attributes": { + "age": 50 + }, + "defaultValue": "default_value", + "flag": "empty_flag", + "result": { + "reason": "DEFAULT", + "value": "default_value" + }, + "targetingKey": "charlie", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-string-variation.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-string-variation.json new file mode 100644 index 000000000..f1fa3994a --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-string-variation.json @@ -0,0 +1,41 @@ +[ + { + "attributes": { + "content_type": "minimal" + }, + "defaultValue": "default_value", + "flag": "empty-string-variation", + "result": { + "reason": "TARGETING_MATCH", + "value": "" + }, + "targetingKey": "empty_user", + "variationType": "STRING" + }, + { + "attributes": { + "content_type": "full" + }, + "defaultValue": "default_value", + "flag": "empty-string-variation", + "result": { + "reason": "TARGETING_MATCH", + "value": "detailed_content" + }, + "targetingKey": "full_user", + "variationType": "STRING" + }, + { + "attributes": { + "content_type": "unknown" + }, + "defaultValue": "default_value", + "flag": "empty-string-variation", + "result": { + "reason": "DEFAULT", + "value": "default_value" + }, + "targetingKey": "default_user", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-falsy-value-assignments.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-falsy-value-assignments.json new file mode 100644 index 000000000..6a8cee51b --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-falsy-value-assignments.json @@ -0,0 +1,41 @@ +[ + { + "attributes": { + "plan_tier": "free" + }, + "defaultValue": 999, + "flag": "falsy-value-assignments", + "result": { + "reason": "TARGETING_MATCH", + "value": 0 + }, + "targetingKey": "zero_user", + "variationType": "INTEGER" + }, + { + "attributes": { + "plan_tier": "premium" + }, + "defaultValue": 999, + "flag": "falsy-value-assignments", + "result": { + "reason": "TARGETING_MATCH", + "value": 100 + }, + "targetingKey": "premium_user", + "variationType": "INTEGER" + }, + { + "attributes": { + "plan_tier": "trial" + }, + "defaultValue": 999, + "flag": "falsy-value-assignments", + "result": { + "reason": "DEFAULT", + "value": 999 + }, + "targetingKey": "unknown_user", + "variationType": "INTEGER" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-flag-with-empty-string.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-flag-with-empty-string.json new file mode 100644 index 000000000..6a5d9cd62 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-flag-with-empty-string.json @@ -0,0 +1,26 @@ +[ + { + "attributes": { + "country": "US" + }, + "defaultValue": "default", + "flag": "empty_string_flag", + "result": { + "reason": "TARGETING_MATCH", + "value": "" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "attributes": {}, + "defaultValue": "default", + "flag": "empty_string_flag", + "result": { + "reason": "SPLIT", + "value": "non_empty" + }, + "targetingKey": "bob", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-integer-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-integer-flag.json new file mode 100644 index 000000000..8ff11aec1 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-integer-flag.json @@ -0,0 +1,267 @@ +[ + { + "attributes": { + "country": "US", + "email": "alice@mycompany.com" + }, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "TARGETING_MATCH", + "value": 3 + }, + "targetingKey": "alice", + "variationType": "INTEGER" + }, + { + "attributes": { + "country": "Canada", + "email": "bob@example.com" + }, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "TARGETING_MATCH", + "value": 3 + }, + "targetingKey": "bob", + "variationType": "INTEGER" + }, + { + "attributes": { + "age": 50 + }, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 2 + }, + "targetingKey": "charlie", + "variationType": "INTEGER" + }, + { + "attributes": { + "age": 25, + "country": "Mexico", + "email": "test@test.com" + }, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "TARGETING_MATCH", + "value": 3 + }, + "targetingKey": "debra", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 2 + }, + "targetingKey": "1", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 2 + }, + "targetingKey": "2", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 2 + }, + "targetingKey": "3", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 2 + }, + "targetingKey": "4", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 1 + }, + "targetingKey": "5", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 2 + }, + "targetingKey": "6", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 1 + }, + "targetingKey": "7", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 1 + }, + "targetingKey": "8", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 2 + }, + "targetingKey": "9", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 2 + }, + "targetingKey": "10", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 1 + }, + "targetingKey": "11", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 1 + }, + "targetingKey": "12", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 2 + }, + "targetingKey": "13", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 1 + }, + "targetingKey": "14", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 2 + }, + "targetingKey": "15", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 2 + }, + "targetingKey": "16", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 1 + }, + "targetingKey": "17", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 1 + }, + "targetingKey": "18", + "variationType": "INTEGER" + }, + { + "attributes": {}, + "defaultValue": 0, + "flag": "integer-flag", + "result": { + "reason": "SPLIT", + "value": 1 + }, + "targetingKey": "19", + "variationType": "INTEGER" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-invalid-shard-bounds-isolation.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-invalid-shard-bounds-isolation.json new file mode 100644 index 000000000..f48423897 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-invalid-shard-bounds-isolation.json @@ -0,0 +1,38 @@ +[ + { + "description": "The malformed flag has totalShards above the unsigned 32-bit range, so SDKs should ignore that flag and return the caller default.", + "attributes": {}, + "defaultValue": "default", + "flag": "overflow-total-shards-flag", + "result": { + "reason": "DEFAULT", + "value": "default" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "description": "The malformed flag has a negative shard range start, so SDKs should ignore that flag and return the caller default.", + "attributes": {}, + "defaultValue": "default", + "flag": "negative-shard-range-start-flag", + "result": { + "reason": "DEFAULT", + "value": "default" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "description": "A valid flag in the same config must still evaluate after invalid shard-bound flags are skipped.", + "attributes": {}, + "defaultValue": "default", + "flag": "valid-flag-after-invalid-config", + "result": { + "reason": "STATIC", + "value": "expected" + }, + "targetingKey": "alice", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-kill-switch-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-kill-switch-flag.json new file mode 100644 index 000000000..3af700dc7 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-kill-switch-flag.json @@ -0,0 +1,314 @@ +[ + { + "attributes": { + "country": "US", + "email": "alice@mycompany.com" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "TARGETING_MATCH", + "value": true + }, + "targetingKey": "alice", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "country": "Canada", + "email": "bob@example.com" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "TARGETING_MATCH", + "value": true + }, + "targetingKey": "bob", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "country": "canada", + "email": "barbara@example.com" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "barbara", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "age": 40 + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "charlie", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "age": 25, + "country": "Mexico", + "email": "test@test.com" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "TARGETING_MATCH", + "value": true + }, + "targetingKey": "debra", + "variationType": "BOOLEAN" + }, + { + "attributes": {}, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "1", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "country": "Mexico" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "TARGETING_MATCH", + "value": true + }, + "targetingKey": "2", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "age": 50, + "country": "UK" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "TARGETING_MATCH", + "value": true + }, + "targetingKey": "3", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "country": "Germany" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "4", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "country": "Germany" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "5", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "country": "Germany" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "6", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "age": 12, + "country": "US" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "TARGETING_MATCH", + "value": true + }, + "targetingKey": "7", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "age": 60, + "country": "Italy" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "TARGETING_MATCH", + "value": true + }, + "targetingKey": "8", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "email": "email@email.com" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "9", + "variationType": "BOOLEAN" + }, + { + "attributes": {}, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "10", + "variationType": "BOOLEAN" + }, + { + "attributes": {}, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "11", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "country": "US" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "TARGETING_MATCH", + "value": true + }, + "targetingKey": "12", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "country": "Canada" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "TARGETING_MATCH", + "value": true + }, + "targetingKey": "13", + "variationType": "BOOLEAN" + }, + { + "attributes": {}, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "14", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "country": "Denmark" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "15", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "country": "Norway" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "16", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "country": "UK" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "17", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "country": "UK" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "18", + "variationType": "BOOLEAN" + }, + { + "attributes": { + "country": "UK" + }, + "defaultValue": false, + "flag": "kill-switch", + "result": { + "reason": "STATIC", + "value": false + }, + "targetingKey": "19", + "variationType": "BOOLEAN" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-malformed-flag-isolation.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-malformed-flag-isolation.json new file mode 100644 index 000000000..dcfa7b119 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-malformed-flag-isolation.json @@ -0,0 +1,26 @@ +[ + { + "description": "The malformed flag has allocations encoded as a string instead of an array, so SDKs should ignore that flag and return the caller default.", + "attributes": {}, + "defaultValue": "default", + "flag": "malformed-allocations-flag", + "result": { + "reason": "DEFAULT", + "value": "default" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "description": "A valid flag in the same config must still evaluate after the malformed flag is skipped.", + "attributes": {}, + "defaultValue": "default", + "flag": "valid-flag-after-invalid-config", + "result": { + "reason": "STATIC", + "value": "expected" + }, + "targetingKey": "alice", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-microsecond-date-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-microsecond-date-flag.json new file mode 100644 index 000000000..3dba337ad --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-microsecond-date-flag.json @@ -0,0 +1,39 @@ +[ + { + "attributes": {}, + "defaultValue": "unknown", + "flag": "microsecond-date-test", + "result": { + "reason": "DEFAULT", + "value": "active" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "attributes": { + "country": "US" + }, + "defaultValue": "unknown", + "flag": "microsecond-date-test", + "result": { + "reason": "DEFAULT", + "value": "active" + }, + "targetingKey": "bob", + "variationType": "STRING" + }, + { + "attributes": { + "version": "1.0.0" + }, + "defaultValue": "unknown", + "flag": "microsecond-date-test", + "result": { + "reason": "DEFAULT", + "value": "active" + }, + "targetingKey": "charlie", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-missing-split-shards-isolation.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-missing-split-shards-isolation.json new file mode 100644 index 000000000..bf474c5a1 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-missing-split-shards-isolation.json @@ -0,0 +1,26 @@ +[ + { + "description": "The malformed flag has a split that omits required shards, so SDKs should ignore that flag and return the caller default.", + "attributes": {}, + "defaultValue": "default", + "flag": "missing-split-shards-flag", + "result": { + "reason": "DEFAULT", + "value": "default" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "description": "A valid flag in the same config must still evaluate after the missing-shards flag is skipped.", + "attributes": {}, + "defaultValue": "default", + "flag": "valid-flag-after-invalid-config", + "result": { + "reason": "STATIC", + "value": "expected" + }, + "targetingKey": "alice", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-new-user-onboarding-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-new-user-onboarding-flag.json new file mode 100644 index 000000000..bb08523de --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-new-user-onboarding-flag.json @@ -0,0 +1,344 @@ +[ + { + "attributes": { + "country": "US", + "email": "alice@mycompany.com" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "green" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "attributes": { + "country": "Canada", + "email": "bob@example.com" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "DEFAULT", + "value": "default" + }, + "targetingKey": "bob", + "variationType": "STRING" + }, + { + "attributes": { + "age": 50 + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "DEFAULT", + "value": "default" + }, + "targetingKey": "charlie", + "variationType": "STRING" + }, + { + "attributes": { + "age": 25, + "country": "Mexico", + "email": "test@test.com" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "blue" + }, + "targetingKey": "debra", + "variationType": "STRING" + }, + { + "attributes": { + "age": 25, + "country": "Mexico", + "email": "test@test.com" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "purple" + }, + "targetingKey": "zach", + "variationType": "STRING" + }, + { + "attributes": { + "age": 25, + "country": "Mexico", + "email": "test@test.com", + "id": "override-id" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "blue" + }, + "targetingKey": "zach", + "variationType": "STRING" + }, + { + "attributes": { + "age": 25, + "country": "Mexico", + "email": "test@test.com" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "DEFAULT", + "value": "default" + }, + "targetingKey": "Zach", + "variationType": "STRING" + }, + { + "attributes": {}, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "DEFAULT", + "value": "default" + }, + "targetingKey": "1", + "variationType": "STRING" + }, + { + "attributes": { + "country": "Mexico" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "blue" + }, + "targetingKey": "2", + "variationType": "STRING" + }, + { + "attributes": { + "age": 33, + "country": "UK" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "control" + }, + "targetingKey": "3", + "variationType": "STRING" + }, + { + "attributes": { + "country": "Germany" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "red" + }, + "targetingKey": "4", + "variationType": "STRING" + }, + { + "attributes": { + "country": "Germany" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "yellow" + }, + "targetingKey": "5", + "variationType": "STRING" + }, + { + "attributes": { + "country": "Germany" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "yellow" + }, + "targetingKey": "6", + "variationType": "STRING" + }, + { + "attributes": { + "country": "US" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "blue" + }, + "targetingKey": "7", + "variationType": "STRING" + }, + { + "attributes": { + "country": "Italy" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "red" + }, + "targetingKey": "8", + "variationType": "STRING" + }, + { + "attributes": { + "email": "email@email.com" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "DEFAULT", + "value": "default" + }, + "targetingKey": "9", + "variationType": "STRING" + }, + { + "attributes": {}, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "DEFAULT", + "value": "default" + }, + "targetingKey": "10", + "variationType": "STRING" + }, + { + "attributes": {}, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "DEFAULT", + "value": "default" + }, + "targetingKey": "11", + "variationType": "STRING" + }, + { + "attributes": { + "country": "US" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "blue" + }, + "targetingKey": "12", + "variationType": "STRING" + }, + { + "attributes": { + "country": "Canada" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "blue" + }, + "targetingKey": "13", + "variationType": "STRING" + }, + { + "attributes": {}, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "DEFAULT", + "value": "default" + }, + "targetingKey": "14", + "variationType": "STRING" + }, + { + "attributes": { + "country": "Denmark" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "yellow" + }, + "targetingKey": "15", + "variationType": "STRING" + }, + { + "attributes": { + "country": "Norway" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "control" + }, + "targetingKey": "16", + "variationType": "STRING" + }, + { + "attributes": { + "country": "UK" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "control" + }, + "targetingKey": "17", + "variationType": "STRING" + }, + { + "attributes": { + "country": "UK" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "DEFAULT", + "value": "default" + }, + "targetingKey": "18", + "variationType": "STRING" + }, + { + "attributes": { + "country": "UK" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "red" + }, + "targetingKey": "19", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-no-allocations-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-no-allocations-flag.json new file mode 100644 index 000000000..7d985b8ef --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-no-allocations-flag.json @@ -0,0 +1,55 @@ +[ + { + "attributes": { + "country": "US", + "email": "alice@mycompany.com" + }, + "defaultValue": { + "hello": "world" + }, + "flag": "no_allocations_flag", + "result": { + "reason": "DEFAULT", + "value": { + "hello": "world" + } + }, + "targetingKey": "alice", + "variationType": "JSON" + }, + { + "attributes": { + "country": "Canada", + "email": "bob@example.com" + }, + "defaultValue": { + "hello": "world" + }, + "flag": "no_allocations_flag", + "result": { + "reason": "DEFAULT", + "value": { + "hello": "world" + } + }, + "targetingKey": "bob", + "variationType": "JSON" + }, + { + "attributes": { + "age": 50 + }, + "defaultValue": { + "hello": "world" + }, + "flag": "no_allocations_flag", + "result": { + "reason": "DEFAULT", + "value": { + "hello": "world" + } + }, + "targetingKey": "charlie", + "variationType": "JSON" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-operator-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-operator-flag.json new file mode 100644 index 000000000..8063ff8fe --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-operator-flag.json @@ -0,0 +1,69 @@ +[ + { + "attributes": { + "country": "US", + "size": 5 + }, + "defaultValue": "default-null", + "flag": "null-operator-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "old" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "attributes": { + "country": "Canada", + "size": 10 + }, + "defaultValue": "default-null", + "flag": "null-operator-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "new" + }, + "targetingKey": "bob", + "variationType": "STRING" + }, + { + "attributes": { + "size": null + }, + "defaultValue": "default-null", + "flag": "null-operator-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "old" + }, + "targetingKey": "charlie", + "variationType": "STRING" + }, + { + "attributes": { + "size": 26 + }, + "defaultValue": "default-null", + "flag": "null-operator-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "new" + }, + "targetingKey": "david", + "variationType": "STRING" + }, + { + "attributes": { + "country": "UK" + }, + "defaultValue": "default-null", + "flag": "null-operator-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "old" + }, + "targetingKey": "elize", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-targeting-key.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-targeting-key.json new file mode 100644 index 000000000..a2226d1b2 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-targeting-key.json @@ -0,0 +1,40 @@ +[ + { + "attributes": {}, + "defaultValue": 42, + "flag": "numeric_flag", + "result": { + "reason": "STATIC", + "value": 3.1415926 + }, + "targetingKey": null, + "variationType": "NUMERIC" + }, + { + "attributes": { + "country": "France" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "ERROR", + "value": "default" + }, + "targetingKey": null, + "variationType": "STRING" + }, + { + "attributes": { + "country": "France", + "email": "null@mycompany.com" + }, + "defaultValue": "default", + "flag": "new-user-onboarding", + "result": { + "reason": "TARGETING_MATCH", + "value": "green" + }, + "targetingKey": null, + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-flag.json new file mode 100644 index 000000000..cc52dcdff --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-flag.json @@ -0,0 +1,43 @@ +[ + { + "attributes": { + "country": "US", + "email": "alice@mycompany.com" + }, + "defaultValue": 0, + "flag": "numeric_flag", + "result": { + "reason": "STATIC", + "value": 3.1415926 + }, + "targetingKey": "alice", + "variationType": "NUMERIC" + }, + { + "attributes": { + "country": "Canada", + "email": "bob@example.com" + }, + "defaultValue": 0, + "flag": "numeric_flag", + "result": { + "reason": "STATIC", + "value": 3.1415926 + }, + "targetingKey": "bob", + "variationType": "NUMERIC" + }, + { + "attributes": { + "age": 50 + }, + "defaultValue": 0, + "flag": "numeric_flag", + "result": { + "reason": "STATIC", + "value": 3.1415926 + }, + "targetingKey": "charlie", + "variationType": "NUMERIC" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of-default.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of-default.json new file mode 100644 index 000000000..d8a7e8ba0 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of-default.json @@ -0,0 +1,13 @@ +[ + { + "attributes": {}, + "defaultValue": 0, + "flag": "numeric-one-of", + "result": { + "reason": "DEFAULT", + "value": 0 + }, + "targetingKey": "race-default", + "variationType": "INTEGER" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of.json new file mode 100644 index 000000000..bfa957335 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of.json @@ -0,0 +1,93 @@ +[ + { + "attributes": { + "number": 1 + }, + "defaultValue": 0, + "flag": "numeric-one-of", + "result": { + "reason": "TARGETING_MATCH", + "value": 1 + }, + "targetingKey": "alice", + "variationType": "INTEGER" + }, + { + "attributes": { + "number": 2 + }, + "defaultValue": 0, + "flag": "numeric-one-of", + "result": { + "reason": "DEFAULT", + "value": 0 + }, + "targetingKey": "bob", + "variationType": "INTEGER" + }, + { + "attributes": { + "number": 3 + }, + "defaultValue": 0, + "flag": "numeric-one-of", + "result": { + "reason": "TARGETING_MATCH", + "value": 3 + }, + "targetingKey": "charlie", + "variationType": "INTEGER" + }, + { + "attributes": { + "number": 4 + }, + "defaultValue": 0, + "flag": "numeric-one-of", + "result": { + "reason": "TARGETING_MATCH", + "value": 3 + }, + "targetingKey": "derek", + "variationType": "INTEGER" + }, + { + "attributes": { + "number": "1" + }, + "defaultValue": 0, + "flag": "numeric-one-of", + "result": { + "reason": "TARGETING_MATCH", + "value": 1 + }, + "targetingKey": "erica", + "variationType": "INTEGER" + }, + { + "attributes": { + "number": 1 + }, + "defaultValue": 0, + "flag": "numeric-one-of", + "result": { + "reason": "TARGETING_MATCH", + "value": 1 + }, + "targetingKey": "frank", + "variationType": "INTEGER" + }, + { + "attributes": { + "number": 123456789 + }, + "defaultValue": 0, + "flag": "numeric-one-of", + "result": { + "reason": "TARGETING_MATCH", + "value": 2 + }, + "targetingKey": "george", + "variationType": "INTEGER" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-of-7-empty-targeting-key.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-of-7-empty-targeting-key.json new file mode 100644 index 000000000..e99a207b6 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-of-7-empty-targeting-key.json @@ -0,0 +1,13 @@ +[ + { + "attributes": {}, + "defaultValue": "default", + "flag": "empty-targeting-key-flag", + "result": { + "reason": "STATIC", + "value": "on-value" + }, + "targetingKey": "", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-regex-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-regex-flag.json new file mode 100644 index 000000000..9b95a9014 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-regex-flag.json @@ -0,0 +1,109 @@ +[ + { + "attributes": { + "email": "user.name+tag@capture.example" + }, + "defaultValue": "none", + "flag": "regex-flag", + "result": { + "reason": "TARGETING_MATCH", + "value": "capturing-groups" + }, + "targetingKey": "capture", + "variationType": "STRING" + }, + { + "attributes": { + "email": "llega mañana temprano" + }, + "defaultValue": "none", + "flag": "regex-flag", + "result": { + "reason": "TARGETING_MATCH", + "value": "unicode-word-boundary" + }, + "targetingKey": "unicode", + "variationType": "STRING" + }, + { + "attributes": { + "email": "ALICE" + }, + "defaultValue": "none", + "flag": "regex-flag", + "result": { + "reason": "TARGETING_MATCH", + "value": "unicode-combined-inline-flags" + }, + "targetingKey": "unicode-combined", + "variationType": "STRING" + }, + { + "attributes": { + "email": "mañana" + }, + "defaultValue": "none", + "flag": "regex-flag", + "result": { + "reason": "TARGETING_MATCH", + "value": "unicode-scoped-inline-flags" + }, + "targetingKey": "unicode-scoped", + "variationType": "STRING" + }, + { + "attributes": { + "email": "alice@example.com", + "version": "1.15.0" + }, + "defaultValue": "none", + "flag": "regex-flag", + "result": { + "reason": "TARGETING_MATCH", + "value": "partial-example" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "attributes": { + "email": "bob@test.com", + "version": "0.20.1" + }, + "defaultValue": "none", + "flag": "regex-flag", + "result": { + "reason": "TARGETING_MATCH", + "value": "test" + }, + "targetingKey": "bob", + "variationType": "STRING" + }, + { + "attributes": { + "version": "2.1.13" + }, + "defaultValue": "none", + "flag": "regex-flag", + "result": { + "reason": "DEFAULT", + "value": "none" + }, + "targetingKey": "charlie", + "variationType": "STRING" + }, + { + "attributes": { + "email": "derek@gmail.com", + "version": "2.1.13" + }, + "defaultValue": "none", + "flag": "regex-flag", + "result": { + "reason": "DEFAULT", + "value": "none" + }, + "targetingKey": "derek", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-start-and-end-date-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-start-and-end-date-flag.json new file mode 100644 index 000000000..53bf8859c --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-start-and-end-date-flag.json @@ -0,0 +1,43 @@ +[ + { + "attributes": { + "country": "US", + "version": "1.15.0" + }, + "defaultValue": "unknown", + "flag": "start-and-end-date-test", + "result": { + "reason": "DEFAULT", + "value": "current" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "attributes": { + "country": "Canada", + "version": "0.20.1" + }, + "defaultValue": "unknown", + "flag": "start-and-end-date-test", + "result": { + "reason": "DEFAULT", + "value": "current" + }, + "targetingKey": "bob", + "variationType": "STRING" + }, + { + "attributes": { + "version": "2.1.13" + }, + "defaultValue": "unknown", + "flag": "start-and-end-date-test", + "result": { + "reason": "DEFAULT", + "value": "current" + }, + "targetingKey": "charlie", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-fields-tolerance.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-fields-tolerance.json new file mode 100644 index 000000000..1bc2c9145 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-fields-tolerance.json @@ -0,0 +1,16 @@ +[ + { + "description": "The flag and surrounding UFC config include unknown object fields that SDKs must ignore while evaluating the known schema fields.", + "attributes": { + "country": "US" + }, + "defaultValue": "default", + "flag": "unknown-fields-tolerance-flag", + "result": { + "reason": "TARGETING_MATCH", + "value": "on" + }, + "targetingKey": "alice", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-operator-isolation.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-operator-isolation.json new file mode 100644 index 000000000..23a3a681a --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-operator-isolation.json @@ -0,0 +1,30 @@ +[ + { + "description": "The first allocation uses an unknown operator and the second is a catch-all trap; SDKs should ignore the whole flag and return the caller default.", + "attributes": { + "country": "US" + }, + "defaultValue": "default", + "flag": "operator-grease-flag", + "result": { + "reason": "DEFAULT", + "value": "default" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "description": "A valid flag in the same config must still evaluate after the unknown-operator flag is skipped.", + "attributes": { + "country": "US" + }, + "defaultValue": "default", + "flag": "valid-flag-after-invalid-config", + "result": { + "reason": "STATIC", + "value": "expected" + }, + "targetingKey": "alice", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-flag-that-does-not-exist.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-flag-that-does-not-exist.json new file mode 100644 index 000000000..0be74f1f7 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-flag-that-does-not-exist.json @@ -0,0 +1,46 @@ +[ + { + "attributes": { + "country": "US", + "email": "alice@mycompany.com" + }, + "defaultValue": 0, + "flag": "flag-that-does-not-exist", + "result": { + "errorCode": "FLAG_NOT_FOUND", + "reason": "ERROR", + "value": 0 + }, + "targetingKey": "alice", + "variationType": "NUMERIC" + }, + { + "attributes": { + "country": "Canada", + "email": "bob@example.com" + }, + "defaultValue": 0, + "flag": "flag-that-does-not-exist", + "result": { + "errorCode": "FLAG_NOT_FOUND", + "reason": "ERROR", + "value": 0 + }, + "targetingKey": "bob", + "variationType": "NUMERIC" + }, + { + "attributes": { + "age": 50 + }, + "defaultValue": 0, + "flag": "flag-that-does-not-exist", + "result": { + "errorCode": "FLAG_NOT_FOUND", + "reason": "ERROR", + "value": 0 + }, + "targetingKey": "charlie", + "variationType": "NUMERIC" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-json-config-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-json-config-flag.json new file mode 100644 index 000000000..45a9c2ee9 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-json-config-flag.json @@ -0,0 +1,76 @@ +[ + { + "attributes": { + "country": "US", + "email": "alice@mycompany.com" + }, + "defaultValue": { + "foo": "bar" + }, + "flag": "json-config-flag", + "result": { + "reason": "SPLIT", + "value": { + "float": 1.0, + "integer": 1, + "string": "one" + } + }, + "targetingKey": "alice", + "variationType": "JSON" + }, + { + "attributes": { + "country": "Canada", + "email": "bob@example.com" + }, + "defaultValue": { + "foo": "bar" + }, + "flag": "json-config-flag", + "result": { + "reason": "SPLIT", + "value": { + "float": 2.0, + "integer": 2, + "string": "two" + } + }, + "targetingKey": "bob", + "variationType": "JSON" + }, + { + "attributes": { + "age": 50 + }, + "defaultValue": { + "foo": "bar" + }, + "flag": "json-config-flag", + "result": { + "reason": "SPLIT", + "value": { + "float": 2.0, + "integer": 2, + "string": "two" + } + }, + "targetingKey": "charlie", + "variationType": "JSON" + }, + { + "attributes": { + "Force Empty": true + }, + "defaultValue": { + "foo": "bar" + }, + "flag": "json-config-flag", + "result": { + "reason": "TARGETING_MATCH", + "value": {} + }, + "targetingKey": "diana", + "variationType": "JSON" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-no-allocations-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-no-allocations-flag.json new file mode 100644 index 000000000..2b03b9a39 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-no-allocations-flag.json @@ -0,0 +1,55 @@ +[ + { + "attributes": { + "country": "US", + "email": "alice@mycompany.com" + }, + "defaultValue": { + "message": "Hello, world!" + }, + "flag": "no_allocations_flag", + "result": { + "reason": "DEFAULT", + "value": { + "message": "Hello, world!" + } + }, + "targetingKey": "alice", + "variationType": "JSON" + }, + { + "attributes": { + "country": "Canada", + "email": "bob@example.com" + }, + "defaultValue": { + "message": "Hello, world!" + }, + "flag": "no_allocations_flag", + "result": { + "reason": "DEFAULT", + "value": { + "message": "Hello, world!" + } + }, + "targetingKey": "bob", + "variationType": "JSON" + }, + { + "attributes": { + "age": 50 + }, + "defaultValue": { + "message": "Hello, world!" + }, + "flag": "no_allocations_flag", + "result": { + "reason": "DEFAULT", + "value": { + "message": "Hello, world!" + } + }, + "targetingKey": "charlie", + "variationType": "JSON" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-special-characters.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-special-characters.json new file mode 100644 index 000000000..1ed7795bb --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-special-characters.json @@ -0,0 +1,58 @@ +[ + { + "attributes": {}, + "defaultValue": {}, + "flag": "special-characters", + "result": { + "reason": "SPLIT", + "value": { + "a": "kümmert", + "b": "schön" + } + }, + "targetingKey": "ash", + "variationType": "JSON" + }, + { + "attributes": {}, + "defaultValue": {}, + "flag": "special-characters", + "result": { + "reason": "SPLIT", + "value": { + "a": "піклуватися", + "b": "любов" + } + }, + "targetingKey": "ben", + "variationType": "JSON" + }, + { + "attributes": {}, + "defaultValue": {}, + "flag": "special-characters", + "result": { + "reason": "SPLIT", + "value": { + "a": "照顾", + "b": "漂亮" + } + }, + "targetingKey": "cameron", + "variationType": "JSON" + }, + { + "attributes": {}, + "defaultValue": {}, + "flag": "special-characters", + "result": { + "reason": "SPLIT", + "value": { + "a": "🤗", + "b": "🌸" + } + }, + "targetingKey": "darryl", + "variationType": "JSON" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-string-with-special-characters.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-string-with-special-characters.json new file mode 100644 index 000000000..32397a483 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-string-with-special-characters.json @@ -0,0 +1,860 @@ +[ + { + "attributes": { + "string_with_spaces": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": " a b c d e f " + }, + "targetingKey": "string_with_spaces", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_space": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": " " + }, + "targetingKey": "string_with_only_one_space", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_spaces": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": " " + }, + "targetingKey": "string_with_only_multiple_spaces", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_dots": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": ".a.b.c.d.e.f." + }, + "targetingKey": "string_with_dots", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_dot": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "." + }, + "targetingKey": "string_with_only_one_dot", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_dots": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "......." + }, + "targetingKey": "string_with_only_multiple_dots", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_comas": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": ",a,b,c,d,e,f," + }, + "targetingKey": "string_with_comas", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_coma": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "," + }, + "targetingKey": "string_with_only_one_coma", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_comas": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": ",,,,,,," + }, + "targetingKey": "string_with_only_multiple_comas", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_colons": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": ":a:b:c:d:e:f:" + }, + "targetingKey": "string_with_colons", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_colon": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": ":" + }, + "targetingKey": "string_with_only_one_colon", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_colons": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": ":::::::" + }, + "targetingKey": "string_with_only_multiple_colons", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_semicolons": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": ";a;b;c;d;e;f;" + }, + "targetingKey": "string_with_semicolons", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_semicolon": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": ";" + }, + "targetingKey": "string_with_only_one_semicolon", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_semicolons": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": ";;;;;;;" + }, + "targetingKey": "string_with_only_multiple_semicolons", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_slashes": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "/a/b/c/d/e/f/" + }, + "targetingKey": "string_with_slashes", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_slash": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "/" + }, + "targetingKey": "string_with_only_one_slash", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_slashes": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "///////" + }, + "targetingKey": "string_with_only_multiple_slashes", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_dashes": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "-a-b-c-d-e-f-" + }, + "targetingKey": "string_with_dashes", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_dash": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "-" + }, + "targetingKey": "string_with_only_one_dash", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_dashes": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "-------" + }, + "targetingKey": "string_with_only_multiple_dashes", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_underscores": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "_a_b_c_d_e_f_" + }, + "targetingKey": "string_with_underscores", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_underscore": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "_" + }, + "targetingKey": "string_with_only_one_underscore", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_underscores": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "_______" + }, + "targetingKey": "string_with_only_multiple_underscores", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_plus_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "+a+b+c+d+e+f+" + }, + "targetingKey": "string_with_plus_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_plus_sign": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "+" + }, + "targetingKey": "string_with_only_one_plus_sign", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_plus_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "+++++++" + }, + "targetingKey": "string_with_only_multiple_plus_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_equal_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "=a=b=c=d=e=f=" + }, + "targetingKey": "string_with_equal_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_equal_sign": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "=" + }, + "targetingKey": "string_with_only_one_equal_sign", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_equal_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "=======" + }, + "targetingKey": "string_with_only_multiple_equal_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_dollar_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "$a$b$c$d$e$f$" + }, + "targetingKey": "string_with_dollar_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_dollar_sign": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "$" + }, + "targetingKey": "string_with_only_one_dollar_sign", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_dollar_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "$$$$$$$" + }, + "targetingKey": "string_with_only_multiple_dollar_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_at_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "@a@b@c@d@e@f@" + }, + "targetingKey": "string_with_at_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_at_sign": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "@" + }, + "targetingKey": "string_with_only_one_at_sign", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_at_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "@@@@@@@" + }, + "targetingKey": "string_with_only_multiple_at_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_amp_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "\u0026a\u0026b\u0026c\u0026d\u0026e\u0026f\u0026" + }, + "targetingKey": "string_with_amp_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_amp_sign": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "\u0026" + }, + "targetingKey": "string_with_only_one_amp_sign", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_amp_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "\u0026\u0026\u0026\u0026\u0026\u0026\u0026" + }, + "targetingKey": "string_with_only_multiple_amp_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_hash_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "#a#b#c#d#e#f#" + }, + "targetingKey": "string_with_hash_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_hash_sign": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "#" + }, + "targetingKey": "string_with_only_one_hash_sign", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_hash_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "#######" + }, + "targetingKey": "string_with_only_multiple_hash_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_percentage_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "%a%b%c%d%e%f%" + }, + "targetingKey": "string_with_percentage_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_percentage_sign": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "%" + }, + "targetingKey": "string_with_only_one_percentage_sign", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_percentage_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "%%%%%%%" + }, + "targetingKey": "string_with_only_multiple_percentage_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_tilde_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "~a~b~c~d~e~f~" + }, + "targetingKey": "string_with_tilde_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_tilde_sign": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "~" + }, + "targetingKey": "string_with_only_one_tilde_sign", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_tilde_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "~~~~~~~" + }, + "targetingKey": "string_with_only_multiple_tilde_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_asterix_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "*a*b*c*d*e*f*" + }, + "targetingKey": "string_with_asterix_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_asterix_sign": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "*" + }, + "targetingKey": "string_with_only_one_asterix_sign", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_asterix_signs": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "*******" + }, + "targetingKey": "string_with_only_multiple_asterix_signs", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_single_quotes": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "'a'b'c'd'e'f'" + }, + "targetingKey": "string_with_single_quotes", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_single_quote": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "'" + }, + "targetingKey": "string_with_only_one_single_quote", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_single_quotes": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "'''''''" + }, + "targetingKey": "string_with_only_multiple_single_quotes", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_question_marks": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "?a?b?c?d?e?f?" + }, + "targetingKey": "string_with_question_marks", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_question_mark": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "?" + }, + "targetingKey": "string_with_only_one_question_mark", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_question_marks": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "???????" + }, + "targetingKey": "string_with_only_multiple_question_marks", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_exclamation_marks": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "!a!b!c!d!e!f!" + }, + "targetingKey": "string_with_exclamation_marks", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_exclamation_mark": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "!" + }, + "targetingKey": "string_with_only_one_exclamation_mark", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_exclamation_marks": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "!!!!!!!" + }, + "targetingKey": "string_with_only_multiple_exclamation_marks", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_opening_parentheses": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "(a(b(c(d(e(f(" + }, + "targetingKey": "string_with_opening_parentheses", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_opening_parenthese": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "(" + }, + "targetingKey": "string_with_only_one_opening_parenthese", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_opening_parentheses": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": "(((((((" + }, + "targetingKey": "string_with_only_multiple_opening_parentheses", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_closing_parentheses": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": ")a)b)c)d)e)f)" + }, + "targetingKey": "string_with_closing_parentheses", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_one_closing_parenthese": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": ")" + }, + "targetingKey": "string_with_only_one_closing_parenthese", + "variationType": "STRING" + }, + { + "attributes": { + "string_with_only_multiple_closing_parentheses": true + }, + "defaultValue": "default_value", + "flag": "string_flag_with_special_characters", + "result": { + "reason": "TARGETING_MATCH", + "value": ")))))))" + }, + "targetingKey": "string_with_only_multiple_closing_parentheses", + "variationType": "STRING" + } +] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/ufc-config.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/ufc-config.json new file mode 100644 index 000000000..5a7f9b95b --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data/ufc-config.json @@ -0,0 +1,3696 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test", + "unknownEnvironmentField": "ignored" + }, + "unknownTopLevelField": "ignored", + "unknownTopLevelNull": null, + "flags": { + "empty_flag": { + "key": "empty_flag", + "enabled": true, + "variationType": "STRING", + "variations": {}, + "allocations": [] + }, + "disabled_flag": { + "key": "disabled_flag", + "enabled": false, + "variationType": "INTEGER", + "variations": {}, + "allocations": [] + }, + "no_allocations_flag": { + "key": "no_allocations_flag", + "enabled": true, + "variationType": "JSON", + "variations": { + "control": { + "key": "control", + "value": { + "variant": "control" + } + }, + "treatment": { + "key": "treatment", + "value": { + "variant": "treatment" + } + } + }, + "allocations": [] + }, + "numeric_flag": { + "key": "numeric_flag", + "enabled": true, + "variationType": "NUMERIC", + "variations": { + "e": { + "key": "e", + "value": 2.7182818 + }, + "pi": { + "key": "pi", + "value": 3.1415926 + } + }, + "allocations": [ + { + "key": "rollout", + "splits": [ + { + "variationKey": "pi", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "regex-flag": { + "key": "regex-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "partial-example": { + "key": "partial-example", + "value": "partial-example" + }, + "test": { + "key": "test", + "value": "test" + }, + "capturing-groups": { + "key": "capturing-groups", + "value": "capturing-groups" + }, + "unicode-word-boundary": { + "key": "unicode-word-boundary", + "value": "unicode-word-boundary" + }, + "unicode-combined-inline-flags": { + "key": "unicode-combined-inline-flags", + "value": "unicode-combined-inline-flags" + }, + "unicode-scoped-inline-flags": { + "key": "unicode-scoped-inline-flags", + "value": "unicode-scoped-inline-flags" + } + }, + "allocations": [ + { + "key": "capturing-groups", + "rules": [ + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": "^([[:alnum:]._%+-]+)@capture\\.example$" + } + ] + } + ], + "splits": [ + { + "variationKey": "capturing-groups", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "unicode-word-boundary", + "rules": [ + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": "(?u)\\bmañana\\b" + } + ] + } + ], + "splits": [ + { + "variationKey": "unicode-word-boundary", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "unicode-combined-inline-flags", + "rules": [ + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": "(?iu)^alice$" + } + ] + } + ], + "splits": [ + { + "variationKey": "unicode-combined-inline-flags", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "unicode-scoped-inline-flags", + "rules": [ + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": "(?u:^mañana$)" + } + ] + } + ], + "splits": [ + { + "variationKey": "unicode-scoped-inline-flags", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "partial-example", + "rules": [ + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": "@example\\.com" + } + ] + } + ], + "splits": [ + { + "variationKey": "partial-example", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "test", + "rules": [ + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": ".*@test\\.com" + } + ] + } + ], + "splits": [ + { + "variationKey": "test", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "numeric-one-of": { + "key": "numeric-one-of", + "enabled": true, + "variationType": "INTEGER", + "variations": { + "1": { + "key": "1", + "value": 1 + }, + "2": { + "key": "2", + "value": 2 + }, + "3": { + "key": "3", + "value": 3 + } + }, + "allocations": [ + { + "key": "1-for-1", + "rules": [ + { + "conditions": [ + { + "attribute": "number", + "operator": "ONE_OF", + "value": [ + "1" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "1", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "2-for-123456789", + "rules": [ + { + "conditions": [ + { + "attribute": "number", + "operator": "ONE_OF", + "value": [ + "123456789" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "2", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "3-for-not-2", + "rules": [ + { + "conditions": [ + { + "attribute": "number", + "operator": "NOT_ONE_OF", + "value": [ + "2" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "3", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "boolean-one-of-matches": { + "key": "boolean-one-of-matches", + "enabled": true, + "variationType": "INTEGER", + "variations": { + "1": { + "key": "1", + "value": 1 + }, + "2": { + "key": "2", + "value": 2 + }, + "3": { + "key": "3", + "value": 3 + }, + "4": { + "key": "4", + "value": 4 + }, + "5": { + "key": "5", + "value": 5 + } + }, + "allocations": [ + { + "key": "1-for-one-of", + "rules": [ + { + "conditions": [ + { + "attribute": "one_of_flag", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "1", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "2-for-matches", + "rules": [ + { + "conditions": [ + { + "attribute": "matches_flag", + "operator": "MATCHES", + "value": "true" + } + ] + } + ], + "splits": [ + { + "variationKey": "2", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "3-for-not-one-of", + "rules": [ + { + "conditions": [ + { + "attribute": "not_one_of_flag", + "operator": "NOT_ONE_OF", + "value": [ + "false" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "3", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "4-for-not-matches", + "rules": [ + { + "conditions": [ + { + "attribute": "not_matches_flag", + "operator": "NOT_MATCHES", + "value": "false" + } + ] + } + ], + "splits": [ + { + "variationKey": "4", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "5-for-matches-null", + "rules": [ + { + "conditions": [ + { + "attribute": "null_flag", + "operator": "ONE_OF", + "value": [ + "null" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "5", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "empty_string_flag": { + "key": "empty_string_flag", + "enabled": true, + "comment": "Testing the empty string as a variation value", + "variationType": "STRING", + "variations": { + "empty_string": { + "key": "empty_string", + "value": "" + }, + "non_empty": { + "key": "non_empty", + "value": "non_empty" + } + }, + "allocations": [ + { + "key": "allocation-empty", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "MATCHES", + "value": "US" + } + ] + } + ], + "splits": [ + { + "variationKey": "empty_string", + "shards": [ + { + "salt": "allocation-empty-shards", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "allocation-test", + "rules": [], + "splits": [ + { + "variationKey": "non_empty", + "shards": [ + { + "salt": "allocation-empty-shards", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + } + ] + }, + "kill-switch": { + "key": "kill-switch", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "on": { + "key": "on", + "value": true + }, + "off": { + "key": "off", + "value": false + } + }, + "allocations": [ + { + "key": "on-for-NA", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "ONE_OF", + "value": [ + "US", + "Canada", + "Mexico" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "on", + "shards": [ + { + "salt": "some-salt", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "on-for-age-50+", + "rules": [ + { + "conditions": [ + { + "attribute": "age", + "operator": "GTE", + "value": 50 + } + ] + } + ], + "splits": [ + { + "variationKey": "on", + "shards": [ + { + "salt": "some-salt", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "off-for-all", + "rules": [], + "splits": [ + { + "variationKey": "off", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "comparator-operator-test": { + "key": "comparator-operator-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "small": { + "key": "small", + "value": "small" + }, + "medium": { + "key": "medium", + "value": "medium" + }, + "large": { + "key": "large", + "value": "large" + } + }, + "allocations": [ + { + "key": "small-size", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "LT", + "value": 10 + } + ] + } + ], + "splits": [ + { + "variationKey": "small", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "medum-size", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "GTE", + "value": 10 + }, + { + "attribute": "size", + "operator": "LTE", + "value": 20 + } + ] + } + ], + "splits": [ + { + "variationKey": "medium", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "large-size", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "GT", + "value": 25 + } + ] + } + ], + "splits": [ + { + "variationKey": "large", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "start-and-end-date-test": { + "key": "start-and-end-date-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "old": { + "key": "old", + "value": "old" + }, + "current": { + "key": "current", + "value": "current" + }, + "new": { + "key": "new", + "value": "new" + } + }, + "allocations": [ + { + "key": "old-versions", + "splits": [ + { + "variationKey": "old", + "shards": [] + } + ], + "endAt": "2002-10-31T09:00:00.594Z", + "doLog": true + }, + { + "key": "future-versions", + "splits": [ + { + "variationKey": "new", + "shards": [] + } + ], + "startAt": "2052-10-31T09:00:00.594Z", + "doLog": true + }, + { + "key": "current-versions", + "splits": [ + { + "variationKey": "current", + "shards": [] + } + ], + "startAt": "2022-10-31T09:00:00.594Z", + "endAt": "2050-10-31T09:00:00.594Z", + "doLog": true + } + ] + }, + "null-operator-test": { + "key": "null-operator-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "old": { + "key": "old", + "value": "old" + }, + "new": { + "key": "new", + "value": "new" + } + }, + "allocations": [ + { + "key": "null-operator", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "IS_NULL", + "value": true + } + ] + }, + { + "conditions": [ + { + "attribute": "size", + "operator": "LT", + "value": 10 + } + ] + } + ], + "splits": [ + { + "variationKey": "old", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "not-null-operator", + "rules": [ + { + "conditions": [ + { + "attribute": "size", + "operator": "IS_NULL", + "value": false + } + ] + } + ], + "splits": [ + { + "variationKey": "new", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "new-user-onboarding": { + "key": "new-user-onboarding", + "enabled": true, + "variationType": "STRING", + "variations": { + "control": { + "key": "control", + "value": "control" + }, + "red": { + "key": "red", + "value": "red" + }, + "blue": { + "key": "blue", + "value": "blue" + }, + "green": { + "key": "green", + "value": "green" + }, + "yellow": { + "key": "yellow", + "value": "yellow" + }, + "purple": { + "key": "purple", + "value": "purple" + } + }, + "allocations": [ + { + "key": "id rule", + "rules": [ + { + "conditions": [ + { + "attribute": "id", + "operator": "MATCHES", + "value": "zach" + } + ] + } + ], + "splits": [ + { + "variationKey": "purple", + "shards": [] + } + ], + "doLog": false + }, + { + "key": "internal users", + "rules": [ + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": "@mycompany.com" + } + ] + } + ], + "splits": [ + { + "variationKey": "green", + "shards": [] + } + ], + "doLog": false + }, + { + "key": "experiment", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "NOT_ONE_OF", + "value": [ + "US", + "Canada", + "Mexico" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "control", + "shards": [ + { + "salt": "traffic-new-user-onboarding-experiment", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 6000 + } + ] + }, + { + "salt": "split-new-user-onboarding-experiment", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 5000 + } + ] + } + ] + }, + { + "variationKey": "red", + "shards": [ + { + "salt": "traffic-new-user-onboarding-experiment", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 6000 + } + ] + }, + { + "salt": "split-new-user-onboarding-experiment", + "totalShards": 10000, + "ranges": [ + { + "start": 5000, + "end": 8000 + } + ] + } + ] + }, + { + "variationKey": "yellow", + "shards": [ + { + "salt": "traffic-new-user-onboarding-experiment", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 6000 + } + ] + }, + { + "salt": "split-new-user-onboarding-experiment", + "totalShards": 10000, + "ranges": [ + { + "start": 8000, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "rollout", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "ONE_OF", + "value": [ + "US", + "Canada", + "Mexico" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "blue", + "shards": [ + { + "salt": "split-new-user-onboarding-rollout", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 8000 + } + ] + } + ], + "extraLogging": { + "allocationvalue_type": "rollout", + "owner": "hippo" + } + } + ], + "doLog": true + } + ] + }, + "integer-flag": { + "key": "integer-flag", + "enabled": true, + "variationType": "INTEGER", + "variations": { + "one": { + "key": "one", + "value": 1 + }, + "two": { + "key": "two", + "value": 2 + }, + "three": { + "key": "three", + "value": 3 + } + }, + "allocations": [ + { + "key": "targeted allocation", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "ONE_OF", + "value": [ + "US", + "Canada", + "Mexico" + ] + } + ] + }, + { + "conditions": [ + { + "attribute": "email", + "operator": "MATCHES", + "value": ".*@example.com" + } + ] + } + ], + "splits": [ + { + "variationKey": "three", + "shards": [ + { + "salt": "full-range-salt", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "50/50 split", + "rules": [], + "splits": [ + { + "variationKey": "one", + "shards": [ + { + "salt": "split-numeric-flag-some-allocation", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 5000 + } + ] + } + ] + }, + { + "variationKey": "two", + "shards": [ + { + "salt": "split-numeric-flag-some-allocation", + "totalShards": 10000, + "ranges": [ + { + "start": 5000, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + } + ] + }, + "json-config-flag": { + "key": "json-config-flag", + "enabled": true, + "variationType": "JSON", + "variations": { + "one": { + "key": "one", + "value": { + "integer": 1, + "string": "one", + "float": 1.0 + } + }, + "two": { + "key": "two", + "value": { + "integer": 2, + "string": "two", + "float": 2.0 + } + }, + "empty": { + "key": "empty", + "value": {} + } + }, + "allocations": [ + { + "key": "Optionally Force Empty", + "rules": [ + { + "conditions": [ + { + "attribute": "Force Empty", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "empty", + "shards": [ + { + "salt": "full-range-salt", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "50/50 split", + "rules": [], + "splits": [ + { + "variationKey": "one", + "shards": [ + { + "salt": "traffic-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + }, + { + "salt": "split-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 5000 + } + ] + } + ] + }, + { + "variationKey": "two", + "shards": [ + { + "salt": "traffic-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 10000 + } + ] + }, + { + "salt": "split-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 5000, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + } + ] + }, + "special-characters": { + "key": "special-characters", + "enabled": true, + "variationType": "JSON", + "variations": { + "de": { + "key": "de", + "value": { + "a": "kümmert", + "b": "schön" + } + }, + "ua": { + "key": "ua", + "value": { + "a": "піклуватися", + "b": "любов" + } + }, + "zh": { + "key": "zh", + "value": { + "a": "照顾", + "b": "漂亮" + } + }, + "emoji": { + "key": "emoji", + "value": { + "a": "🤗", + "b": "🌸" + } + } + }, + "allocations": [ + { + "key": "allocation-test", + "splits": [ + { + "variationKey": "de", + "shards": [ + { + "salt": "split-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 0, + "end": 2500 + } + ] + } + ] + }, + { + "variationKey": "ua", + "shards": [ + { + "salt": "split-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 2500, + "end": 5000 + } + ] + } + ] + }, + { + "variationKey": "zh", + "shards": [ + { + "salt": "split-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 5000, + "end": 7500 + } + ] + } + ] + }, + { + "variationKey": "emoji", + "shards": [ + { + "salt": "split-json-flag", + "totalShards": 10000, + "ranges": [ + { + "start": 7500, + "end": 10000 + } + ] + } + ] + } + ], + "doLog": true + }, + { + "key": "allocation-default", + "splits": [ + { + "variationKey": "de", + "shards": [] + } + ], + "doLog": false + } + ] + }, + "string_flag_with_special_characters": { + "key": "string_flag_with_special_characters", + "enabled": true, + "comment": "Testing the string with special characters and spaces", + "variationType": "STRING", + "variations": { + "string_with_spaces": { + "key": "string_with_spaces", + "value": " a b c d e f " + }, + "string_with_only_one_space": { + "key": "string_with_only_one_space", + "value": " " + }, + "string_with_only_multiple_spaces": { + "key": "string_with_only_multiple_spaces", + "value": " " + }, + "string_with_dots": { + "key": "string_with_dots", + "value": ".a.b.c.d.e.f." + }, + "string_with_only_one_dot": { + "key": "string_with_only_one_dot", + "value": "." + }, + "string_with_only_multiple_dots": { + "key": "string_with_only_multiple_dots", + "value": "......." + }, + "string_with_comas": { + "key": "string_with_comas", + "value": ",a,b,c,d,e,f," + }, + "string_with_only_one_coma": { + "key": "string_with_only_one_coma", + "value": "," + }, + "string_with_only_multiple_comas": { + "key": "string_with_only_multiple_comas", + "value": ",,,,,,," + }, + "string_with_colons": { + "key": "string_with_colons", + "value": ":a:b:c:d:e:f:" + }, + "string_with_only_one_colon": { + "key": "string_with_only_one_colon", + "value": ":" + }, + "string_with_only_multiple_colons": { + "key": "string_with_only_multiple_colons", + "value": ":::::::" + }, + "string_with_semicolons": { + "key": "string_with_semicolons", + "value": ";a;b;c;d;e;f;" + }, + "string_with_only_one_semicolon": { + "key": "string_with_only_one_semicolon", + "value": ";" + }, + "string_with_only_multiple_semicolons": { + "key": "string_with_only_multiple_semicolons", + "value": ";;;;;;;" + }, + "string_with_slashes": { + "key": "string_with_slashes", + "value": "/a/b/c/d/e/f/" + }, + "string_with_only_one_slash": { + "key": "string_with_only_one_slash", + "value": "/" + }, + "string_with_only_multiple_slashes": { + "key": "string_with_only_multiple_slashes", + "value": "///////" + }, + "string_with_dashes": { + "key": "string_with_dashes", + "value": "-a-b-c-d-e-f-" + }, + "string_with_only_one_dash": { + "key": "string_with_only_one_dash", + "value": "-" + }, + "string_with_only_multiple_dashes": { + "key": "string_with_only_multiple_dashes", + "value": "-------" + }, + "string_with_underscores": { + "key": "string_with_underscores", + "value": "_a_b_c_d_e_f_" + }, + "string_with_only_one_underscore": { + "key": "string_with_only_one_underscore", + "value": "_" + }, + "string_with_only_multiple_underscores": { + "key": "string_with_only_multiple_underscores", + "value": "_______" + }, + "string_with_plus_signs": { + "key": "string_with_plus_signs", + "value": "+a+b+c+d+e+f+" + }, + "string_with_only_one_plus_sign": { + "key": "string_with_only_one_plus_sign", + "value": "+" + }, + "string_with_only_multiple_plus_signs": { + "key": "string_with_only_multiple_plus_signs", + "value": "+++++++" + }, + "string_with_equal_signs": { + "key": "string_with_equal_signs", + "value": "=a=b=c=d=e=f=" + }, + "string_with_only_one_equal_sign": { + "key": "string_with_only_one_equal_sign", + "value": "=" + }, + "string_with_only_multiple_equal_signs": { + "key": "string_with_only_multiple_equal_signs", + "value": "=======" + }, + "string_with_dollar_signs": { + "key": "string_with_dollar_signs", + "value": "$a$b$c$d$e$f$" + }, + "string_with_only_one_dollar_sign": { + "key": "string_with_only_one_dollar_sign", + "value": "$" + }, + "string_with_only_multiple_dollar_signs": { + "key": "string_with_only_multiple_dollar_signs", + "value": "$$$$$$$" + }, + "string_with_at_signs": { + "key": "string_with_at_signs", + "value": "@a@b@c@d@e@f@" + }, + "string_with_only_one_at_sign": { + "key": "string_with_only_one_at_sign", + "value": "@" + }, + "string_with_only_multiple_at_signs": { + "key": "string_with_only_multiple_at_signs", + "value": "@@@@@@@" + }, + "string_with_amp_signs": { + "key": "string_with_amp_signs", + "value": "&a&b&c&d&e&f&" + }, + "string_with_only_one_amp_sign": { + "key": "string_with_only_one_amp_sign", + "value": "&" + }, + "string_with_only_multiple_amp_signs": { + "key": "string_with_only_multiple_amp_signs", + "value": "&&&&&&&" + }, + "string_with_hash_signs": { + "key": "string_with_hash_signs", + "value": "#a#b#c#d#e#f#" + }, + "string_with_only_one_hash_sign": { + "key": "string_with_only_one_hash_sign", + "value": "#" + }, + "string_with_only_multiple_hash_signs": { + "key": "string_with_only_multiple_hash_signs", + "value": "#######" + }, + "string_with_percentage_signs": { + "key": "string_with_percentage_signs", + "value": "%a%b%c%d%e%f%" + }, + "string_with_only_one_percentage_sign": { + "key": "string_with_only_one_percentage_sign", + "value": "%" + }, + "string_with_only_multiple_percentage_signs": { + "key": "string_with_only_multiple_percentage_signs", + "value": "%%%%%%%" + }, + "string_with_tilde_signs": { + "key": "string_with_tilde_signs", + "value": "~a~b~c~d~e~f~" + }, + "string_with_only_one_tilde_sign": { + "key": "string_with_only_one_tilde_sign", + "value": "~" + }, + "string_with_only_multiple_tilde_signs": { + "key": "string_with_only_multiple_tilde_signs", + "value": "~~~~~~~" + }, + "string_with_asterix_signs": { + "key": "string_with_asterix_signs", + "value": "*a*b*c*d*e*f*" + }, + "string_with_only_one_asterix_sign": { + "key": "string_with_only_one_asterix_sign", + "value": "*" + }, + "string_with_only_multiple_asterix_signs": { + "key": "string_with_only_multiple_asterix_signs", + "value": "*******" + }, + "string_with_single_quotes": { + "key": "string_with_single_quotes", + "value": "'a'b'c'd'e'f'" + }, + "string_with_only_one_single_quote": { + "key": "string_with_only_one_single_quote", + "value": "'" + }, + "string_with_only_multiple_single_quotes": { + "key": "string_with_only_multiple_single_quotes", + "value": "'''''''" + }, + "string_with_question_marks": { + "key": "string_with_question_marks", + "value": "?a?b?c?d?e?f?" + }, + "string_with_only_one_question_mark": { + "key": "string_with_only_one_question_mark", + "value": "?" + }, + "string_with_only_multiple_question_marks": { + "key": "string_with_only_multiple_question_marks", + "value": "???????" + }, + "string_with_exclamation_marks": { + "key": "string_with_exclamation_marks", + "value": "!a!b!c!d!e!f!" + }, + "string_with_only_one_exclamation_mark": { + "key": "string_with_only_one_exclamation_mark", + "value": "!" + }, + "string_with_only_multiple_exclamation_marks": { + "key": "string_with_only_multiple_exclamation_marks", + "value": "!!!!!!!" + }, + "string_with_opening_parentheses": { + "key": "string_with_opening_parentheses", + "value": "(a(b(c(d(e(f(" + }, + "string_with_only_one_opening_parenthese": { + "key": "string_with_only_one_opening_parenthese", + "value": "(" + }, + "string_with_only_multiple_opening_parentheses": { + "key": "string_with_only_multiple_opening_parentheses", + "value": "(((((((" + }, + "string_with_closing_parentheses": { + "key": "string_with_closing_parentheses", + "value": ")a)b)c)d)e)f)" + }, + "string_with_only_one_closing_parenthese": { + "key": "string_with_only_one_closing_parenthese", + "value": ")" + }, + "string_with_only_multiple_closing_parentheses": { + "key": "string_with_only_multiple_closing_parentheses", + "value": ")))))))" + } + }, + "allocations": [ + { + "key": "allocation-test-string_with_spaces", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_spaces", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_spaces", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_space", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_space", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_space", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_spaces", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_spaces", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_spaces", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_dots", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_dots", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_dots", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_dot", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_dot", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_dot", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_dots", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_dots", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_dots", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_comas", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_comas", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_comas", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_coma", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_coma", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_coma", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_comas", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_comas", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_comas", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_colons", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_colons", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_colons", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_colon", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_colon", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_colon", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_colons", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_colons", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_colons", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_semicolons", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_semicolons", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_semicolons", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_semicolon", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_semicolon", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_semicolon", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_semicolons", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_semicolons", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_semicolons", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_slashes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_slashes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_slashes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_slash", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_slash", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_slash", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_slashes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_slashes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_slashes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_dashes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_dashes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_dashes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_dash", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_dash", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_dash", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_dashes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_dashes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_dashes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_underscores", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_underscores", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_underscores", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_underscore", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_underscore", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_underscore", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_underscores", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_underscores", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_underscores", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_plus_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_plus_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_plus_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_plus_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_plus_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_plus_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_plus_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_plus_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_plus_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_equal_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_equal_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_equal_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_equal_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_equal_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_equal_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_equal_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_equal_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_equal_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_dollar_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_dollar_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_dollar_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_dollar_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_dollar_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_dollar_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_dollar_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_dollar_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_dollar_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_at_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_at_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_at_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_at_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_at_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_at_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_at_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_at_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_at_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_amp_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_amp_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_amp_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_amp_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_amp_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_amp_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_amp_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_amp_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_amp_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_hash_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_hash_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_hash_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_hash_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_hash_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_hash_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_hash_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_hash_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_hash_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_percentage_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_percentage_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_percentage_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_percentage_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_percentage_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_percentage_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_percentage_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_percentage_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_percentage_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_tilde_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_tilde_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_tilde_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_tilde_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_tilde_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_tilde_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_tilde_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_tilde_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_tilde_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_asterix_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_asterix_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_asterix_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_asterix_sign", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_asterix_sign", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_asterix_sign", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_asterix_signs", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_asterix_signs", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_asterix_signs", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_single_quotes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_single_quotes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_single_quotes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_single_quote", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_single_quote", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_single_quote", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_single_quotes", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_single_quotes", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_single_quotes", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_question_marks", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_question_marks", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_question_marks", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_question_mark", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_question_mark", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_question_mark", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_question_marks", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_question_marks", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_question_marks", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_exclamation_marks", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_exclamation_marks", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_exclamation_marks", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_exclamation_mark", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_exclamation_mark", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_exclamation_mark", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_exclamation_marks", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_exclamation_marks", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_exclamation_marks", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_opening_parentheses", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_opening_parentheses", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_opening_parentheses", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_opening_parenthese", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_opening_parenthese", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_opening_parenthese", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_opening_parentheses", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_opening_parentheses", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_opening_parentheses", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_closing_parentheses", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_closing_parentheses", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_closing_parentheses", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_one_closing_parenthese", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_one_closing_parenthese", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_one_closing_parenthese", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "allocation-test-string_with_only_multiple_closing_parentheses", + "rules": [ + { + "conditions": [ + { + "attribute": "string_with_only_multiple_closing_parentheses", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "string_with_only_multiple_closing_parentheses", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "boolean-false-assignment": { + "key": "boolean-false-assignment", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "false-variation": { + "key": "false-variation", + "value": false + }, + "true-variation": { + "key": "true-variation", + "value": true + } + }, + "allocations": [ + { + "key": "disable-feature", + "rules": [ + { + "conditions": [ + { + "attribute": "should_disable_feature", + "operator": "ONE_OF", + "value": [ + "true" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "false-variation", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "enable-feature", + "rules": [ + { + "conditions": [ + { + "attribute": "should_disable_feature", + "operator": "ONE_OF", + "value": [ + "false" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "true-variation", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "empty-string-variation": { + "key": "empty-string-variation", + "enabled": true, + "variationType": "STRING", + "variations": { + "empty-content": { + "key": "empty-content", + "value": "" + }, + "detailed-content": { + "key": "detailed-content", + "value": "detailed_content" + } + }, + "allocations": [ + { + "key": "minimal-content", + "rules": [ + { + "conditions": [ + { + "attribute": "content_type", + "operator": "ONE_OF", + "value": [ + "minimal" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "empty-content", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "full-content", + "rules": [ + { + "conditions": [ + { + "attribute": "content_type", + "operator": "ONE_OF", + "value": [ + "full" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "detailed-content", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "falsy-value-assignments": { + "key": "falsy-value-assignments", + "enabled": true, + "variationType": "INTEGER", + "variations": { + "zero-limit": { + "key": "zero-limit", + "value": 0 + }, + "premium-limit": { + "key": "premium-limit", + "value": 100 + } + }, + "allocations": [ + { + "key": "free-tier-limit", + "rules": [ + { + "conditions": [ + { + "attribute": "plan_tier", + "operator": "ONE_OF", + "value": [ + "free" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "zero-limit", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "premium-tier-limit", + "rules": [ + { + "conditions": [ + { + "attribute": "plan_tier", + "operator": "ONE_OF", + "value": [ + "premium" + ] + } + ] + } + ], + "splits": [ + { + "variationKey": "premium-limit", + "shards": [] + } + ], + "doLog": true + } + ], + "totalShards": 10000 + }, + "empty-targeting-key-flag": { + "key": "empty-targeting-key-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "on": { + "key": "on", + "value": "on-value" + }, + "off": { + "key": "off", + "value": "off-value" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "variationKey": "on", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "microsecond-date-test": { + "key": "microsecond-date-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "expired": { + "key": "expired", + "value": "expired" + }, + "active": { + "key": "active", + "value": "active" + }, + "future": { + "key": "future", + "value": "future" + } + }, + "allocations": [ + { + "key": "expired-allocation", + "splits": [ + { + "variationKey": "expired", + "shards": [] + } + ], + "endAt": "2002-10-31T09:00:00.594321Z", + "doLog": true + }, + { + "key": "future-allocation", + "splits": [ + { + "variationKey": "future", + "shards": [] + } + ], + "startAt": "2052-10-31T09:00:00.123456Z", + "doLog": true + }, + { + "key": "active-allocation", + "splits": [ + { + "variationKey": "active", + "shards": [] + } + ], + "startAt": "2022-10-31T09:00:00.235982Z", + "endAt": "2050-10-31T09:00:00.987654Z", + "doLog": true + } + ] + }, + "unknown-fields-tolerance-flag": { + "key": "unknown-fields-tolerance-flag", + "enabled": true, + "variationType": "STRING", + "comment": "This flag deliberately contains unknown fields at every fixed-schema UFC level. SDKs must ignore those fields and evaluate the flag normally.", + "unknownFlagField": "ignored", + "variations": { + "on": { + "key": "on", + "value": "on", + "unknownVariationField": "ignored" + } + }, + "allocations": [ + { + "key": "unknown-fields-allocation", + "unknownAllocationField": "ignored", + "rules": [ + { + "unknownRuleField": "ignored", + "conditions": [ + { + "attribute": "country", + "operator": "ONE_OF", + "value": [ + "US" + ], + "unknownConditionField": "ignored" + } + ] + } + ], + "splits": [ + { + "variationKey": "on", + "unknownSplitField": "ignored", + "shards": [ + { + "salt": "unknown-fields-tolerance-flag", + "totalShards": 10000, + "unknownShardField": "ignored", + "ranges": [ + { + "start": 0, + "end": 10000, + "unknownRangeField": "ignored" + } + ] + } + ] + } + ], + "doLog": true + } + ] + }, + "operator-grease-flag": { + "key": "operator-grease-flag", + "enabled": true, + "variationType": "STRING", + "comment": "This flag deliberately uses an unknown condition operator. SDKs must ignore this flag rather than falling through to the catch-all allocation or rejecting the whole config.", + "variations": { + "trap": { + "key": "trap", + "value": "trap" + }, + "on": { + "key": "on", + "value": "on" + } + }, + "allocations": [ + { + "key": "grease-allocation", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "NOT_A_REAL_OPERATOR", + "value": "anything" + } + ] + } + ], + "splits": [ + { + "variationKey": "trap", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "variationKey": "on", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "malformed-allocations-flag": { + "key": "malformed-allocations-flag", + "enabled": true, + "variationType": "STRING", + "comment": "This flag is intentionally UFC-schema-invalid: allocations must be an array, but this fixture uses a string to verify per-flag parse error isolation.", + "variations": { + "on": { + "key": "on", + "value": "on" + } + }, + "allocations": "this-is-not-a-list" + }, + "missing-split-shards-flag": { + "key": "missing-split-shards-flag", + "enabled": true, + "variationType": "STRING", + "comment": "This flag is intentionally UFC-schema-invalid: split.shards is required even when the intended split is unsharded. SDKs must treat omitted shards differently from an explicit empty shards array.", + "variations": { + "trap": { + "key": "trap", + "value": "trap" + } + }, + "allocations": [ + { + "key": "malformed-split-allocation", + "rules": [], + "splits": [ + { + "variationKey": "trap" + } + ], + "doLog": true + } + ] + }, + "overflow-total-shards-flag": { + "key": "overflow-total-shards-flag", + "enabled": true, + "variationType": "STRING", + "comment": "This flag is intentionally UFC-schema-invalid: split.shards[].totalShards must fit in an unsigned 32-bit integer. SDKs must ignore this flag rather than overflowing or poisoning the whole config.", + "variations": { + "trap": { + "key": "trap", + "value": "trap" + } + }, + "allocations": [ + { + "key": "malformed-shard-allocation", + "rules": [], + "splits": [ + { + "variationKey": "trap", + "shards": [ + { + "salt": "overflow-total-shards-flag", + "totalShards": 4294967296, + "ranges": [ + { + "start": 0, + "end": 1 + } + ] + } + ] + } + ], + "doLog": true + } + ] + }, + "negative-shard-range-start-flag": { + "key": "negative-shard-range-start-flag", + "enabled": true, + "variationType": "STRING", + "comment": "This flag is intentionally UFC-schema-invalid: split.shards[].ranges[].start must be an unsigned integer. SDKs must ignore this flag rather than treating a negative range as matching traffic.", + "variations": { + "trap": { + "key": "trap", + "value": "trap" + } + }, + "allocations": [ + { + "key": "malformed-shard-allocation", + "rules": [], + "splits": [ + { + "variationKey": "trap", + "shards": [ + { + "salt": "negative-shard-range-start-flag", + "totalShards": 100, + "ranges": [ + { + "start": -1, + "end": 1 + } + ] + } + ] + } + ], + "doLog": true + } + ] + }, + "valid-flag-after-invalid-config": { + "key": "valid-flag-after-invalid-config", + "enabled": true, + "variationType": "STRING", + "comment": "This valid neighbor proves malformed or unsupported flags do not poison the rest of the remote config.", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} From 411347420ca6aec5af27a27a398cfe2dc37cb66b Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 16:43:15 -0400 Subject: [PATCH 03/18] Wire native flag evaluation side effects --- docs/native-ffe-react-native-poc-plan.md | 48 ++-- packages/core/__mocks__/react-native.ts | 11 +- .../reactnative/DdSdkImplementation.kt | 30 ++- .../com/datadog/reactnative/NativeFfeCore.kt | 2 + .../NativeFfeEvaluationSideEffects.kt | 177 ++++++++++++++ .../datadog/reactnative/NativeFfeCoreTest.kt | 66 ++--- .../NativeFfeEvaluationSideEffectsTest.kt | 90 +++++++ .../reactnative/NativeFfeJsonFixtures.kt | 41 ++++ .../ios/Sources/DdSdkImplementation.swift | 54 +++-- packages/core/ios/Sources/NativeFfeCore.swift | 4 + .../NativeFfeEvaluationSideEffects.swift | 225 ++++++++++++++++++ packages/core/jest/mock.js | 11 +- packages/core/src/DdSdkReactNative.tsx | 8 + .../src/__tests__/DdSdkReactNative.test.tsx | 43 +--- .../evaluation-context-user-123.json | 6 + .../skipped-default-evaluation.json | 10 + .../tracked-string-evaluation.json | 22 ++ .../native-ffe/rules-configuration-wire.json | 1 + 18 files changed, 715 insertions(+), 134 deletions(-) create mode 100644 packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffects.kt create mode 100644 packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffectsTest.kt create mode 100644 packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt create mode 100644 packages/core/ios/Sources/NativeFfeEvaluationSideEffects.swift create mode 100644 packages/core/src/flags/__fixtures__/native-ffe/evaluation-context-user-123.json create mode 100644 packages/core/src/flags/__fixtures__/native-ffe/evaluation-side-effects/skipped-default-evaluation.json create mode 100644 packages/core/src/flags/__fixtures__/native-ffe/evaluation-side-effects/tracked-string-evaluation.json create mode 100644 packages/core/src/flags/__fixtures__/native-ffe/rules-configuration-wire.json diff --git a/docs/native-ffe-react-native-poc-plan.md b/docs/native-ffe-react-native-poc-plan.md index 0b60c642d..1b3458759 100644 --- a/docs/native-ffe-react-native-poc-plan.md +++ b/docs/native-ffe-react-native-poc-plan.md @@ -139,7 +139,7 @@ If optional reuse is not callable today, implement it as an RN-local native port | Dynamic context | Rules-based config stores new context immediately with no network and no stale blackout. | Native `setEvaluationContext()` mutates context only; `evaluate()` re-evaluates against same active rules. | | Precomputed safety | Precomputed config tied to one context must not serve another context. | Native evaluates precomputed only when stored context matches; mismatch returns default/error/debug state. | | Evaluation output | Rules path must produce metadata compatible with logging. | Native result includes value, variant, reason, error code, allocation key, `doLog`, and `extraLogging`. | -| Logging side effects | RN should keep existing native logging consistency. | Native evaluation optionally triggers native exposure/evaluation/RUM hooks through existing bridge/native paths or POC fakes. | +| Logging side effects | RN should keep existing native logging consistency. | Native evaluation triggers best-effort adapters into existing native Flags SDK evaluation hooks and reports counters in debug state. | | Persistence | Mobile startup from last-known values is a target recipe. | Native save/load of last good wire from SDK-owned storage path; cache policy remains minimal. | | Failure behavior | Invalid wire, unsupported kind, refresh failure, stale serving are explicit states. | Native debug state reports invalid wire, unsupported kind, stale retained config, and last fetch error. | @@ -157,13 +157,17 @@ Covered: - Dynamic context changes with no network request. - Native UFC evaluation with canonical fixture coverage for `STATIC`, `SPLIT`, `TARGETING_MATCH`, and `ERROR` / `TARGETING_KEY_MISSING`. - Evaluation metadata required for exposure logging, evaluation aggregation, and RUM: variant, allocation key, `doLog`, split serial id where present, configuration kind/etag, and `extraLogging`. +- Best-effort native side-effect adapters that convert successful native evaluation results into the shipped Flags SDK assignment shape: + - Android: `_FlagsInternalProxy.trackFlagSnapshotEvaluation(...)`. + - iOS: `FlagsClientInternal.sendFlagEvaluation(...)`. +- Provider debug state includes evaluation side-effect counters: attempted, tracked, skipped, failed, last status, and last error. Not covered: - Precomputed context mismatch protection. - Native HTTP fetch side effects, auth, ETag, `304`, compression, request builders. - Native persistence/offline boot. -- Wiring evaluation side effects into the shipped native Flags SDK paths. +- Production-grade side-effect dedup/reset policy for rules evaluation; the current adapter proves call-in feasibility and leaves dedup/cache semantics to the native SDK extraction. ## Architecture Decision For The POC @@ -292,29 +296,9 @@ iOS: The POC can place these inside the RN repo, but the package/module boundary should make later extraction into the iOS and Android SDKs straightforward. -## Minimal POC Wire Format - -Use a deliberately small versioned shape that mirrors the RFC, not a full UFC schema: - -```json -{ - "version": 2, - "precomputed": { - "response": "{\"flags\":{\"checkout.enabled\":{\"value\":true,\"variant\":\"on\",\"allocationKey\":\"a1\",\"doLog\":true}}}", - "context": { - "targetingKey": "user-1", - "attributes": { "plan": "pro" } - }, - "fetchedAt": 1780000000000, - "etag": "precomputed-v1" - }, - "server": { - "response": "{\"flags\":{\"checkout.enabled\":{\"enabled\":true,\"rules\":[...]}}}", - "fetchedAt": 1780000000000, - "etag": "rules-v1" - } -} -``` +## Minimal RFC Wire Format + +Use a deliberately small versioned shape that mirrors the RFC wire fields without embedding ad hoc flag JSON in tests. The bridge and native tests should read `native-ffe/rules-configuration-wire.json`, generated from the vendored `ffe-system-test-data/ufc-config.json` bytes with `ffe-system-test-data` as the etag. RN-local JSON fixtures should only cover SDK-specific envelopes such as `native-ffe/evaluation-context-user-123.json` and `native-ffe/evaluation-side-effects/*.json`. Rules evaluation must be native and fixture-backed, not a toy predicate engine. The first implementation can be limited to the UFC v1 behavior covered by `ffe-system-test-data`, but Kotlin and Swift should both implement the same evaluator surface: @@ -490,17 +474,17 @@ Goal: preserve native logging architecture for RN. Tasks: - Add evaluation result metadata required by RFC: `variant`, `allocationKey`, `doLog`, `extraLogging`. -- Add POC `FlagsEvaluationSideEffects` interface. -- Android fake should model exposure dedup and evaluation aggregation hooks. -- iOS fake should model NSCache/message-bus/RUM hook shape. +- Add native `NativeFfeEvaluationSideEffects` adapters beside the RN bridge, outside the pure evaluator core. +- Android adapter converts the evaluation result into `UnparsedFlag` and calls `_FlagsInternalProxy.trackFlagSnapshotEvaluation(...)`. +- iOS adapter converts the evaluation result into `FlagAssignment` and calls `FlagsClientInternal.sendFlagEvaluation(...)`. - RN evaluation should not implement exposure batching in JS. -- Use existing native `trackEvaluation` path if practical; otherwise expose fake counters in debug state. +- Expose best-effort tracking counters in provider debug state so the POC can show whether native side effects were attempted, tracked, skipped, or failed. Acceptance: -- Evaluating a `doLog=true` flag invokes native side effect once per context/flag assignment key. -- Changing context changes dedup key. -- Changing configuration etag clears dedup cache. +- Evaluating a successful `doLog=true` flag with a targeting key invokes the shipped native Flags side-effect hook. +- Error/default evaluations skip side effects. +- Missing native Flags initialization does not fail evaluation; it increments failed counters and logs through native SDK logging. - RN only calls evaluate; native owns the side effect. ### Phase 9: Example App Flow diff --git a/packages/core/__mocks__/react-native.ts b/packages/core/__mocks__/react-native.ts index 8d8305d7b..e25732734 100644 --- a/packages/core/__mocks__/react-native.ts +++ b/packages/core/__mocks__/react-native.ts @@ -18,11 +18,18 @@ const actualRN = require('react-native'); const mockFlagsDebugState = { status: 'ready', activeConfigurationKind: 'rules', - activeEtag: 'rules-v1', + activeEtag: 'ffe-system-test-data', configurationSetCount: 1, fetchCount: 0, evaluationCount: 0, - lastEvent: 'provider_ready' + lastEvent: 'provider_ready', + evaluationSideEffects: { + attemptedCount: 0, + trackedCount: 0, + skippedCount: 0, + failedCount: 0, + lastStatus: 'skipped' + } }; const mockConfigurationFromString = (wire: string) => { diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt index 3a970a3c5..d5e849793 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt @@ -37,6 +37,7 @@ class DdSdkImplementation( internal val initialized = AtomicBoolean(false) private var frameRateProvider: FrameRateProvider? = null private val nativeFfeCore: NativeFfeCore = NativeFfeCore() + private val nativeFfeSideEffects: NativeFfeEvaluationSideEffects = NativeFfeEvaluationSideEffects() // region DdSdk @@ -309,31 +310,42 @@ class DdSdkImplementation( fun resolveBooleanEvaluation(flagKey: String, defaultValue: Boolean, promise: Promise) { resolveFfePromise(promise) { - nativeFfeCore.resolveBooleanEvaluation(flagKey, defaultValue).toWritableMap() + resolveNativeFfeEvaluation { + nativeFfeCore.resolveBooleanEvaluation(flagKey, defaultValue) + }.toWritableMap() } } fun resolveStringEvaluation(flagKey: String, defaultValue: String, promise: Promise) { resolveFfePromise(promise) { - nativeFfeCore.resolveStringEvaluation(flagKey, defaultValue).toWritableMap() + resolveNativeFfeEvaluation { + nativeFfeCore.resolveStringEvaluation(flagKey, defaultValue) + }.toWritableMap() } } fun resolveNumberEvaluation(flagKey: String, defaultValue: Double, promise: Promise) { resolveFfePromise(promise) { - nativeFfeCore.resolveNumberEvaluation(flagKey, defaultValue).toWritableMap() + resolveNativeFfeEvaluation { + nativeFfeCore.resolveNumberEvaluation(flagKey, defaultValue) + }.toWritableMap() } } fun resolveObjectEvaluation(flagKey: String, defaultValue: ReadableMap, promise: Promise) { resolveFfePromise(promise) { - nativeFfeCore.resolveObjectEvaluation(flagKey, defaultValue.toMap()).toWritableMap() + resolveNativeFfeEvaluation { + nativeFfeCore.resolveObjectEvaluation(flagKey, defaultValue.toMap()) + }.toWritableMap() } } fun getProviderDebugState(promise: Promise) { resolveFfePromise(promise) { - nativeFfeCore.debugState().toWritableMap() + ( + nativeFfeCore.debugState() + + mapOf("evaluationSideEffects" to nativeFfeSideEffects.debugState()) + ).toWritableMap() } } @@ -379,6 +391,14 @@ class DdSdkImplementation( } } + private inline fun resolveNativeFfeEvaluation( + block: () -> Map + ): Map { + val result = block() + nativeFfeSideEffects.trackEvaluation(result, nativeFfeCore.evaluationContext()) + return result + } + private fun buildVitalUpdateFrequency(vitalsUpdateFrequency: String?): VitalsUpdateFrequency { val vitalUpdateFrequencyLower = vitalsUpdateFrequency?.lowercase(Locale.US) return when (vitalUpdateFrequencyLower) { diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt index da4912541..350e52b35 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt @@ -109,6 +109,8 @@ internal class NativeFfeCore { ).filterValues { it != null } } + fun evaluationContext(): Map = currentContext.toMap() + private fun resolveEvaluation( flagKey: String, defaultValue: Any?, diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffects.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffects.kt new file mode 100644 index 000000000..1779b11a5 --- /dev/null +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffects.kt @@ -0,0 +1,177 @@ +/* + * 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. + */ + +package com.datadog.reactnative + +import com.datadog.android.Datadog +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.SdkCore +import com.datadog.android.flags.FlagsClient +import com.datadog.android.flags._FlagsInternalProxy +import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.UnparsedFlag +import org.json.JSONArray +import org.json.JSONObject + +internal class NativeFfeEvaluationSideEffects( + private val tracker: NativeFfeEvaluationTracker = DatadogFlagsEvaluationTracker(), +) { + private var attemptedCount = 0 + private var trackedCount = 0 + private var skippedCount = 0 + private var failedCount = 0 + private var lastStatus: String? = null + private var lastError: String? = null + + fun trackEvaluation(result: Map, context: Map): String { + val request = result.toTrackingRequest(context) + if (request == null) { + skippedCount += 1 + lastStatus = STATUS_SKIPPED + lastError = null + return STATUS_SKIPPED + } + + attemptedCount += 1 + return try { + tracker.track(request) + trackedCount += 1 + lastStatus = STATUS_TRACKED + lastError = null + STATUS_TRACKED + } catch (error: Exception) { + failedCount += 1 + lastStatus = STATUS_FAILED + lastError = error.message + InternalLogger.UNBOUND.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { "Native FFE evaluation side effects failed for flag '${request.flagKey}': ${error.message}" }, + ) + STATUS_FAILED + } + } + + fun debugState(): Map = mapOf( + "attemptedCount" to attemptedCount, + "trackedCount" to trackedCount, + "skippedCount" to skippedCount, + "failedCount" to failedCount, + "lastStatus" to lastStatus, + "lastError" to lastError, + ).filterValues { it != null } + + @Suppress("UNCHECKED_CAST") + private fun Map.toTrackingRequest(context: Map): NativeFfeEvaluationSideEffectRequest? { + val metadata = this["flagMetadata"] as? Map ?: return null + val flagKey = this["flagKey"] as? String ?: return null + val variationKey = this["variant"] as? String ?: return null + val reason = this["reason"] as? String ?: return null + val value = this["value"] ?: return null + val allocationKey = metadata["allocationKey"] as? String ?: return null + val targetingKey = context["targetingKey"]?.toString()?.takeIf { it.isNotBlank() } ?: return null + val clientName = context["clientName"]?.toString()?.takeIf { it.isNotBlank() } ?: DEFAULT_CLIENT_NAME + val attributes = (context["attributes"] as? Map<*, *>) + ?.mapNotNull { (key, attributeValue) -> + (key as? String)?.let { it to attributeValue.toString() } + } + ?.toMap() + ?: emptyMap() + val doLog = metadata["doLog"] as? Boolean ?: false + val extraLogging = (metadata["extraLogging"] as? Map<*, *>)?.toJSONObject() ?: JSONObject() + val variationType = metadata["variationType"] as? String ?: value.toVariationType() + + val flag = object : UnparsedFlag { + override val variationType: String = variationType + override val variationValue: String = value.toVariationValue() + override val doLog: Boolean = doLog + override val allocationKey: String = allocationKey + override val variationKey: String = variationKey + override val extraLogging: JSONObject = extraLogging + override val reason: String = reason + } + + return NativeFfeEvaluationSideEffectRequest( + clientName = clientName, + flagKey = flagKey, + flag = flag, + evaluationContext = EvaluationContext(targetingKey, attributes), + ) + } + + private fun Any.toVariationType(): String = when (this) { + is Boolean -> "boolean" + is String -> "string" + is Number -> "number" + is Map<*, *> -> "object" + else -> "object" + } + + private fun Any?.toVariationValue(): String = when (this) { + null -> "null" + is Map<*, *> -> toJSONObject().toString() + is List<*> -> toJSONArray().toString() + else -> toString() + } + + private fun Map<*, *>.toJSONObject(): JSONObject { + val jsonObject = JSONObject() + for ((key, value) in this) { + jsonObject.put(key.toString(), value.toJsonValue()) + } + return jsonObject + } + + private fun List<*>.toJSONArray(): JSONArray { + val jsonArray = JSONArray() + for (value in this) { + jsonArray.put(value.toJsonValue()) + } + return jsonArray + } + + private fun Any?.toJsonValue(): Any? = when (this) { + is Map<*, *> -> toJSONObject() + is List<*> -> toJSONArray() + null -> JSONObject.NULL + else -> this + } + + private companion object { + const val DEFAULT_CLIENT_NAME = "default" + const val STATUS_TRACKED = "tracked" + const val STATUS_SKIPPED = "skipped" + const val STATUS_FAILED = "failed" + } +} + +internal data class NativeFfeEvaluationSideEffectRequest( + val clientName: String, + val flagKey: String, + val flag: UnparsedFlag, + val evaluationContext: EvaluationContext, +) + +internal interface NativeFfeEvaluationTracker { + fun track(request: NativeFfeEvaluationSideEffectRequest) +} + +private class DatadogFlagsEvaluationTracker( + private val sdkCoreProvider: () -> SdkCore = { Datadog.getInstance() }, +) : NativeFfeEvaluationTracker { + private val clients: MutableMap = mutableMapOf() + + override fun track(request: NativeFfeEvaluationSideEffectRequest) { + val client = clients.getOrPut(request.clientName) { + FlagsClient.Builder(request.clientName, sdkCoreProvider()).build() + } + _FlagsInternalProxy(client).trackFlagSnapshotEvaluation( + request.flagKey, + request.flag, + request.evaluationContext, + ) + } +} diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt index d38f8e2c5..a2e98617e 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt @@ -20,10 +20,14 @@ internal class NativeFfeCoreTest { // When val configuration = testedCore.configurationFromString(flagsConfigurationWire) val serialized = testedCore.configurationToString(configuration.toMap()) + val embeddedUfcConfig = JSONObject(flagsConfigurationWire) + .getJSONObject("server") + .getString("response") // Then assertThat(configuration.kind).isEqualTo("rules") assertThat(configuration.etag).isEqualTo("ffe-system-test-data") + assertThat(embeddedUfcConfig).isEqualTo(canonicalUfcConfig) assertThat(serialized).isEqualTo(flagsConfigurationWire) } @@ -150,43 +154,16 @@ internal class NativeFfeCoreTest { return EvaluationCase( flag = caseJson.getString("flag"), variationType = caseJson.getString("variationType"), - defaultValue = caseJson.get("defaultValue").toFixtureValue(), - targetingKey = caseJson.optionalString("targetingKey"), - attributes = (caseJson.optJSONObject("attributes") ?: JSONObject()).toFixtureMap(), - expectedValue = resultJson.get("value").toFixtureValue(), + defaultValue = caseJson.get("defaultValue").toNativeFfeFixtureValue(), + targetingKey = caseJson.optionalNativeFfeString("targetingKey"), + attributes = (caseJson.optJSONObject("attributes") ?: JSONObject()).toNativeFfeFixtureMap(), + expectedValue = resultJson.get("value").toNativeFfeFixtureValue(), expectedReason = resultJson.getString("reason"), ) } private fun readFixture(relativePath: String): String { - return javaClass.classLoader - ?.getResource("ffe-system-test-data/$relativePath") - ?.readText() - ?: error("Missing FFE fixture: $relativePath") - } - - private fun JSONObject.optionalString(key: String): String? { - if (!has(key) || isNull(key)) { - return null - } - return getString(key) - } - - private fun JSONObject.toFixtureMap(): Map { - return keys().asSequence().associateWith { key -> get(key).toFixtureValue() } - } - - private fun JSONArray.toFixtureList(): List { - return (0 until length()).map { index -> get(index).toFixtureValue() } - } - - private fun Any?.toFixtureValue(): Any? { - return when (this) { - JSONObject.NULL -> null - is JSONObject -> toFixtureMap() - is JSONArray -> toFixtureList() - else -> this - } + return readNativeFfeFixture(javaClass, "ffe-system-test-data/$relativePath") } private data class EvaluationCase( @@ -203,20 +180,17 @@ internal class NativeFfeCoreTest { const val NUMERIC_TOLERANCE = 0.0000001 val flagsConfigurationWire: String by lazy { - val ufcConfig = NativeFfeCoreTest::class.java.classLoader - ?.getResource("ffe-system-test-data/ufc-config.json") - ?.readText() - ?: error("Missing FFE fixture: ufc-config.json") - - JSONObject() - .put("version", 2) - .put( - "server", - JSONObject() - .put("etag", "ffe-system-test-data") - .put("response", ufcConfig) - ) - .toString() + readNativeFfeFixture( + NativeFfeCoreTest::class.java, + "native-ffe/rules-configuration-wire.json" + ) + } + + val canonicalUfcConfig: String by lazy { + readNativeFfeFixture( + NativeFfeCoreTest::class.java, + "ffe-system-test-data/ufc-config.json" + ) } } } diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffectsTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffectsTest.kt new file mode 100644 index 000000000..6bb661c28 --- /dev/null +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffectsTest.kt @@ -0,0 +1,90 @@ +/* + * 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. + */ + +package com.datadog.reactnative + +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject +import org.junit.jupiter.api.Test + +internal class NativeFfeEvaluationSideEffectsTest { + private val fakeTracker = FakeEvaluationTracker() + private val testedSideEffects = NativeFfeEvaluationSideEffects(fakeTracker) + + @Test + fun `M track evaluation W successful native evaluation result`() { + // Given + val fixture = sideEffectFixture("tracked-string-evaluation.json") + + // When + val status = testedSideEffects.trackEvaluation(fixture.result, fixture.context) + + // Then + val request = fakeTracker.trackedRequest + assertThat(status).isEqualTo("tracked") + assertThat(request?.clientName).isEqualTo("default") + assertThat(request?.flagKey).isEqualTo("checkout.copy") + assertThat(request?.flag?.variationType).isEqualTo("string") + assertThat(request?.flag?.variationValue).isEqualTo("enabled") + assertThat(request?.flag?.variationKey).isEqualTo("treatment") + assertThat(request?.flag?.allocationKey).isEqualTo("pro allocation") + assertThat(request?.flag?.doLog).isEqualTo(true) + assertThat(request?.flag?.reason).isEqualTo("TARGETING_MATCH") + assertThat(request?.flag?.extraLogging?.optString("owner")).isEqualTo("feature-flags") + assertThat(testedSideEffects.debugState()) + .containsEntry("attemptedCount", 1) + .containsEntry("trackedCount", 1) + .containsEntry("skippedCount", 0) + .containsEntry("failedCount", 0) + .containsEntry("lastStatus", "tracked") + } + + @Test + fun `M skip evaluation side effects W default result`() { + // Given + val fixture = sideEffectFixture("skipped-default-evaluation.json") + + // When + val status = testedSideEffects.trackEvaluation(fixture.result, fixture.context) + + // Then + assertThat(status).isEqualTo("skipped") + assertThat(fakeTracker.trackedRequest).isNull() + assertThat(testedSideEffects.debugState()) + .containsEntry("attemptedCount", 0) + .containsEntry("trackedCount", 0) + .containsEntry("skippedCount", 1) + .containsEntry("failedCount", 0) + .containsEntry("lastStatus", "skipped") + } + + private class FakeEvaluationTracker : NativeFfeEvaluationTracker { + var trackedRequest: NativeFfeEvaluationSideEffectRequest? = null + + override fun track(request: NativeFfeEvaluationSideEffectRequest) { + trackedRequest = request + } + } + + private fun sideEffectFixture(fileName: String): SideEffectFixture { + val fixture = JSONObject( + readNativeFfeFixture( + javaClass, + "native-ffe/evaluation-side-effects/$fileName" + ) + ) + + return SideEffectFixture( + result = fixture.getJSONObject("result").toNativeFfeFixtureMap(), + context = (fixture.optJSONObject("context") ?: JSONObject()).toNativeFfeFixtureMap(), + ) + } + + private data class SideEffectFixture( + val result: Map, + val context: Map, + ) +} diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt new file mode 100644 index 000000000..d0a654e20 --- /dev/null +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt @@ -0,0 +1,41 @@ +/* + * 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. + */ + +package com.datadog.reactnative + +import org.json.JSONArray +import org.json.JSONObject + +internal fun readNativeFfeFixture(owner: Class<*>, relativePath: String): String { + return owner.classLoader + ?.getResource(relativePath) + ?.readText() + ?: error("Missing FFE fixture: $relativePath") +} + +internal fun JSONObject.optionalNativeFfeString(key: String): String? { + if (!has(key) || isNull(key)) { + return null + } + return getString(key) +} + +internal fun JSONObject.toNativeFfeFixtureMap(): Map { + return keys().asSequence().associateWith { key -> get(key).toNativeFfeFixtureValue() } +} + +internal fun JSONArray.toNativeFfeFixtureList(): List { + return (0 until length()).map { index -> get(index).toNativeFfeFixtureValue() } +} + +internal fun Any?.toNativeFfeFixtureValue(): Any? { + return when (this) { + JSONObject.NULL -> null + is JSONObject -> toNativeFfeFixtureMap() + is JSONArray -> toNativeFfeFixtureList() + else -> this + } +} diff --git a/packages/core/ios/Sources/DdSdkImplementation.swift b/packages/core/ios/Sources/DdSdkImplementation.swift index 02fc0dffd..5634230ac 100644 --- a/packages/core/ios/Sources/DdSdkImplementation.swift +++ b/packages/core/ios/Sources/DdSdkImplementation.swift @@ -33,6 +33,7 @@ public class DdSdkImplementation: NSObject { var RUMMonitorProvider: () -> RUMMonitorProtocol? var RUMMonitorInternalProvider: () -> RUMMonitorInternalProtocol? private let nativeFfeCore = NativeFfeCore() + private let nativeFfeSideEffects = NativeFfeEvaluationSideEffects() #if os(iOS) var webviewMessageEmitter: InternalExtension.AbstractMessageEmitter? @@ -331,10 +332,12 @@ public class DdSdkImplementation: NSObject { reject: RCTPromiseRejectBlock ) { resolveFfePromise(resolve: resolve, reject: reject) { - self.nativeFfeCore.resolveBooleanEvaluation( - flagKey: flagKey as String, - defaultValue: defaultValue - ) + self.resolveNativeFfeEvaluation { + self.nativeFfeCore.resolveBooleanEvaluation( + flagKey: flagKey as String, + defaultValue: defaultValue + ) + } } } @@ -344,10 +347,12 @@ public class DdSdkImplementation: NSObject { reject: RCTPromiseRejectBlock ) { resolveFfePromise(resolve: resolve, reject: reject) { - self.nativeFfeCore.resolveStringEvaluation( - flagKey: flagKey as String, - defaultValue: defaultValue as String - ) + self.resolveNativeFfeEvaluation { + self.nativeFfeCore.resolveStringEvaluation( + flagKey: flagKey as String, + defaultValue: defaultValue as String + ) + } } } @@ -357,10 +362,12 @@ public class DdSdkImplementation: NSObject { reject: RCTPromiseRejectBlock ) { resolveFfePromise(resolve: resolve, reject: reject) { - self.nativeFfeCore.resolveNumberEvaluation( - flagKey: flagKey as String, - defaultValue: defaultValue - ) + self.resolveNativeFfeEvaluation { + self.nativeFfeCore.resolveNumberEvaluation( + flagKey: flagKey as String, + defaultValue: defaultValue + ) + } } } @@ -370,10 +377,12 @@ public class DdSdkImplementation: NSObject { reject: RCTPromiseRejectBlock ) { resolveFfePromise(resolve: resolve, reject: reject) { - self.nativeFfeCore.resolveObjectEvaluation( - flagKey: flagKey as String, - defaultValue: defaultValue as? [String: Any] ?? [:] - ) + self.resolveNativeFfeEvaluation { + self.nativeFfeCore.resolveObjectEvaluation( + flagKey: flagKey as String, + defaultValue: defaultValue as? [String: Any] ?? [:] + ) + } } } @@ -382,7 +391,9 @@ public class DdSdkImplementation: NSObject { resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock ) { resolveFfePromise(resolve: resolve, reject: reject) { - self.nativeFfeCore.debugState() + var state = self.nativeFfeCore.debugState() + state["evaluationSideEffects"] = self.nativeFfeSideEffects.debugState() + return state } } @@ -468,6 +479,15 @@ public class DdSdkImplementation: NSObject { } } + private func resolveNativeFfeEvaluation(_ block: () -> [String: Any]) -> [String: Any] { + let result = block() + nativeFfeSideEffects.trackEvaluation( + result: result, + context: nativeFfeCore.evaluationContext() + ) + return result + } + // Normalizes frameTime values so when they are turned into FPS metrics they are normalized on a range between 0 and fpsBudget. If fpsBudget is not provided it will default to 60hz. public static func normalizeFrameTimeForDeviceRefreshRate( _ frameTime: Double, fpsBudget: Double? = nil, deviceDisplayFps: Double? = nil diff --git a/packages/core/ios/Sources/NativeFfeCore.swift b/packages/core/ios/Sources/NativeFfeCore.swift index 52d901808..fc1563b2a 100644 --- a/packages/core/ios/Sources/NativeFfeCore.swift +++ b/packages/core/ios/Sources/NativeFfeCore.swift @@ -120,6 +120,10 @@ internal final class NativeFfeCore { ]) } + func evaluationContext() -> [String: Any] { + currentContext + } + private func resolveEvaluation( flagKey: String, defaultValue: Any, diff --git a/packages/core/ios/Sources/NativeFfeEvaluationSideEffects.swift b/packages/core/ios/Sources/NativeFfeEvaluationSideEffects.swift new file mode 100644 index 000000000..7b5adbd80 --- /dev/null +++ b/packages/core/ios/Sources/NativeFfeEvaluationSideEffects.swift @@ -0,0 +1,225 @@ +/* + * 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 DatadogInternal +@_spi(Internal) +import DatadogFlags +import Foundation + +internal final class NativeFfeEvaluationSideEffects { + private let tracker: NativeFfeEvaluationTracking + private var attemptedCount = 0 + private var trackedCount = 0 + private var skippedCount = 0 + private var failedCount = 0 + private var lastStatus: String? + private var lastError: String? + + init(tracker: NativeFfeEvaluationTracking = DatadogFlagsEvaluationTracker()) { + self.tracker = tracker + } + + @discardableResult + func trackEvaluation(result: [String: Any], context: [String: Any]) -> String { + guard let request = buildRequest(result: result, context: context) else { + skippedCount += 1 + lastStatus = Status.skipped + lastError = nil + return Status.skipped + } + + attemptedCount += 1 + do { + try tracker.track(request) + trackedCount += 1 + lastStatus = Status.tracked + lastError = nil + return Status.tracked + } catch { + failedCount += 1 + lastStatus = Status.failed + lastError = error.localizedDescription + let message = "Native FFE evaluation side effects failed for flag " + + "'\(request.flagKey)': \(error.localizedDescription)" + consolePrint(message, .warn) + return Status.failed + } + } + + func debugState() -> [String: Any] { + buildMap([ + ("attemptedCount", attemptedCount), + ("trackedCount", trackedCount), + ("skippedCount", skippedCount), + ("failedCount", failedCount), + ("lastStatus", lastStatus), + ("lastError", lastError), + ]) + } + + private func buildMap(_ values: [(String, Any?)]) -> [String: Any] { + Dictionary( + uniqueKeysWithValues: values.compactMap { key, value in + guard let value else { + return nil + } + return (key, value) + } + ) + } + + private func buildRequest( + result: [String: Any], + context: [String: Any] + ) -> NativeFfeEvaluationSideEffectRequest? { + guard + let metadata = result["flagMetadata"] as? [String: Any], + let flagKey = stringValue(result["flagKey"]), + let variationKey = stringValue(result["variant"]), + let allocationKey = stringValue(metadata["allocationKey"]), + let reason = stringValue(result["reason"]), + let value = result["value"], + let targetingKey = stringValue(context["targetingKey"]), + !targetingKey.isEmpty + else { + return nil + } + + let attributes = dictionaryValue(context["attributes"]) ?? [:] + let evaluationContext = FlagsEvaluationContext( + targetingKey: targetingKey, + attributes: attributes.compactMapValues { AnyValue.wrap($0) } + ) + + return NativeFfeEvaluationSideEffectRequest( + clientName: stringValue(context["clientName"]) ?? Constants.defaultClientName, + flagKey: flagKey, + assignment: FlagAssignment( + allocationKey: allocationKey, + variationKey: variationKey, + variation: variation(from: value), + reason: reason, + doLog: boolValue(metadata["doLog"]) ?? false + ), + context: evaluationContext + ) + } + + private func dictionaryValue(_ value: Any?) -> [String: Any]? { + value as? [String: Any] + } + + private func stringValue(_ value: Any?) -> String? { + switch value { + case let value as String: + return value + case let value as NSNumber: + return value.stringValue + default: + return nil + } + } + + private func boolValue(_ value: Any?) -> Bool? { + switch value { + case let value as Bool: + return value + case let value as NSNumber: + return value.boolValue + default: + return nil + } + } + + private func variation(from value: Any) -> FlagAssignment.Variation { + switch value { + case let boolValue as Bool: + return .boolean(boolValue) + case let stringValue as String: + return .string(stringValue) + case let intValue as Int: + return .integer(intValue) + case let doubleValue as Double: + return .double(doubleValue) + case let numberValue as NSNumber: + if CFGetTypeID(numberValue) == CFBooleanGetTypeID() { + return .boolean(numberValue.boolValue) + } + let doubleValue = numberValue.doubleValue + if doubleValue.rounded(.towardZero) == doubleValue { + return .integer(numberValue.intValue) + } + return .double(doubleValue) + case let dictValue as [String: Any]: + return .object(AnyValue.wrap(dictValue)) + default: + return .unknown(String(describing: value)) + } + } + + private enum Constants { + static let defaultClientName = "default" + } + + private enum Status { + static let tracked = "tracked" + static let skipped = "skipped" + static let failed = "failed" + } +} + +internal struct NativeFfeEvaluationSideEffectRequest { + let clientName: String + let flagKey: String + let assignment: FlagAssignment + let context: FlagsEvaluationContext +} + +internal protocol NativeFfeEvaluationTracking { + func track(_ request: NativeFfeEvaluationSideEffectRequest) throws +} + +private final class DatadogFlagsEvaluationTracker: NativeFfeEvaluationTracking { + private let core: DatadogCoreProtocol + private var clientProviders: [String: () -> FlagsClientProtocol] = [:] + + init(core: DatadogCoreProtocol = CoreRegistry.default) { + self.core = core + } + + func track(_ request: NativeFfeEvaluationSideEffectRequest) throws { + guard let client = getClient(name: request.clientName) as? FlagsClientInternal else { + throw NativeFfeEvaluationSideEffectsError.clientNotInitialized(request.clientName) + } + + client.sendFlagEvaluation( + key: request.flagKey, + assignment: request.assignment, + context: request.context + ) + } + + private func getClient(name: String) -> FlagsClientProtocol { + if let provider = clientProviders[name] { + return provider() + } + + let client = FlagsClient.create(name: name, in: core) + clientProviders[name] = { FlagsClient.shared(named: name, in: self.core) } + return client + } +} + +private enum NativeFfeEvaluationSideEffectsError: LocalizedError { + case clientNotInitialized(String) + + var errorDescription: String? { + switch self { + case .clientNotInitialized(let clientName): + return "Flags client '\(clientName)' is not properly initialized" + } + } +} diff --git a/packages/core/jest/mock.js b/packages/core/jest/mock.js index d04bd0b5c..406e014d9 100644 --- a/packages/core/jest/mock.js +++ b/packages/core/jest/mock.js @@ -13,11 +13,18 @@ const actualDatadog = jest.requireActual('@datadog/mobile-react-native'); const mockFlagsDebugState = { status: 'ready', activeConfigurationKind: 'rules', - activeEtag: 'rules-v1', + activeEtag: 'ffe-system-test-data', configurationSetCount: 1, fetchCount: 0, evaluationCount: 0, - lastEvent: 'provider_ready' + lastEvent: 'provider_ready', + evaluationSideEffects: { + attemptedCount: 0, + trackedCount: 0, + skippedCount: 0, + failedCount: 0, + lastStatus: 'skipped' + } }; const mockConfigurationFromString = wire => { diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index 6c78abe07..c964acb3f 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -91,6 +91,14 @@ export type FlagsProviderDebugState = { evaluationCount: number; lastEvent?: 'provider_ready' | 'configuration_changed' | 'provider_error'; lastError?: string; + evaluationSideEffects?: { + attemptedCount: number; + trackedCount: number; + skippedCount: number; + failedCount: number; + lastStatus?: 'tracked' | 'skipped' | 'failed'; + lastError?: string; + }; }; /** diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index c5fde527f..b2244a6ae 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -8,6 +8,8 @@ import { version as reactNativeVersion } from 'react-native/package.json'; import { NativeModules } from 'react-native'; import { DdSdkReactNative } from '../DdSdkReactNative'; +import flagsEvaluationContext from '../flags/__fixtures__/native-ffe/evaluation-context-user-123.json'; +import rulesConfigurationWire from '../flags/__fixtures__/native-ffe/rules-configuration-wire.json'; import type { DdSdkNativeConfiguration } from '../config/features/CoreConfigurationNative'; import { CoreConfiguration } from '../config/features/CoreConfiguration'; import { LogsConfiguration } from '../config/features/LogsConfiguration'; @@ -1381,28 +1383,7 @@ describe('DdSdkReactNative', () => { }); describe('flags configuration building blocks', () => { - const flagsWire = JSON.stringify({ - version: 2, - server: { - response: JSON.stringify({ - flags: { - 'checkout.enabled': { - key: 'checkout.enabled', - enabled: true, - variationType: 'BOOLEAN', - variations: { - on: { - key: 'on', - value: true - } - }, - allocations: [] - } - } - }), - etag: 'rules-v1' - } - }); + const flagsWire = JSON.stringify(rulesConfigurationWire); it('parses and serializes a native flags configuration wire', async () => { // WHEN @@ -1424,7 +1405,7 @@ describe('DdSdkReactNative', () => { __ddNativeFfeConfiguration: true, version: 2, kind: 'rules', - etag: 'rules-v1' + etag: 'ffe-system-test-data' }); expect(serialized).toBe(flagsWire); }); @@ -1434,12 +1415,7 @@ describe('DdSdkReactNative', () => { const configuration = await DdSdkReactNative.configurationFromString( flagsWire ); - const context = { - targetingKey: 'user-123', - attributes: { - plan: 'pro' - } - }; + const context = flagsEvaluationContext; // WHEN const configState = await DdSdkReactNative.setConfiguration( @@ -1501,7 +1477,14 @@ describe('DdSdkReactNative', () => { expect(debugState).toMatchObject({ status: 'ready', activeConfigurationKind: 'rules', - activeEtag: 'rules-v1' + activeEtag: 'ffe-system-test-data', + evaluationSideEffects: { + attemptedCount: 0, + trackedCount: 0, + skippedCount: 0, + failedCount: 0, + lastStatus: 'skipped' + } }); }); }); diff --git a/packages/core/src/flags/__fixtures__/native-ffe/evaluation-context-user-123.json b/packages/core/src/flags/__fixtures__/native-ffe/evaluation-context-user-123.json new file mode 100644 index 000000000..f551c7bea --- /dev/null +++ b/packages/core/src/flags/__fixtures__/native-ffe/evaluation-context-user-123.json @@ -0,0 +1,6 @@ +{ + "targetingKey": "user-123", + "attributes": { + "plan": "pro" + } +} diff --git a/packages/core/src/flags/__fixtures__/native-ffe/evaluation-side-effects/skipped-default-evaluation.json b/packages/core/src/flags/__fixtures__/native-ffe/evaluation-side-effects/skipped-default-evaluation.json new file mode 100644 index 000000000..92476dc42 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/native-ffe/evaluation-side-effects/skipped-default-evaluation.json @@ -0,0 +1,10 @@ +{ + "result": { + "flagKey": "missing", + "value": "fallback", + "reason": "DEFAULT" + }, + "context": { + "targetingKey": "user-123" + } +} diff --git a/packages/core/src/flags/__fixtures__/native-ffe/evaluation-side-effects/tracked-string-evaluation.json b/packages/core/src/flags/__fixtures__/native-ffe/evaluation-side-effects/tracked-string-evaluation.json new file mode 100644 index 000000000..4ff621eba --- /dev/null +++ b/packages/core/src/flags/__fixtures__/native-ffe/evaluation-side-effects/tracked-string-evaluation.json @@ -0,0 +1,22 @@ +{ + "result": { + "flagKey": "checkout.copy", + "value": "enabled", + "variant": "treatment", + "reason": "TARGETING_MATCH", + "flagMetadata": { + "allocationKey": "pro allocation", + "doLog": true, + "extraLogging": { + "owner": "feature-flags" + }, + "variationType": "string" + } + }, + "context": { + "targetingKey": "user-123", + "attributes": { + "plan": "pro" + } + } +} diff --git a/packages/core/src/flags/__fixtures__/native-ffe/rules-configuration-wire.json b/packages/core/src/flags/__fixtures__/native-ffe/rules-configuration-wire.json new file mode 100644 index 000000000..0718158a0 --- /dev/null +++ b/packages/core/src/flags/__fixtures__/native-ffe/rules-configuration-wire.json @@ -0,0 +1 @@ +{"version":2,"server":{"response":"{\n \"createdAt\": \"2024-04-17T19:40:53.716Z\",\n \"format\": \"SERVER\",\n \"environment\": {\n \"name\": \"Test\",\n \"unknownEnvironmentField\": \"ignored\"\n },\n \"unknownTopLevelField\": \"ignored\",\n \"unknownTopLevelNull\": null,\n \"flags\": {\n \"empty_flag\": {\n \"key\": \"empty_flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {},\n \"allocations\": []\n },\n \"disabled_flag\": {\n \"key\": \"disabled_flag\",\n \"enabled\": false,\n \"variationType\": \"INTEGER\",\n \"variations\": {},\n \"allocations\": []\n },\n \"no_allocations_flag\": {\n \"key\": \"no_allocations_flag\",\n \"enabled\": true,\n \"variationType\": \"JSON\",\n \"variations\": {\n \"control\": {\n \"key\": \"control\",\n \"value\": {\n \"variant\": \"control\"\n }\n },\n \"treatment\": {\n \"key\": \"treatment\",\n \"value\": {\n \"variant\": \"treatment\"\n }\n }\n },\n \"allocations\": []\n },\n \"numeric_flag\": {\n \"key\": \"numeric_flag\",\n \"enabled\": true,\n \"variationType\": \"NUMERIC\",\n \"variations\": {\n \"e\": {\n \"key\": \"e\",\n \"value\": 2.7182818\n },\n \"pi\": {\n \"key\": \"pi\",\n \"value\": 3.1415926\n }\n },\n \"allocations\": [\n {\n \"key\": \"rollout\",\n \"splits\": [\n {\n \"variationKey\": \"pi\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"regex-flag\": {\n \"key\": \"regex-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"partial-example\": {\n \"key\": \"partial-example\",\n \"value\": \"partial-example\"\n },\n \"test\": {\n \"key\": \"test\",\n \"value\": \"test\"\n },\n \"capturing-groups\": {\n \"key\": \"capturing-groups\",\n \"value\": \"capturing-groups\"\n },\n \"unicode-word-boundary\": {\n \"key\": \"unicode-word-boundary\",\n \"value\": \"unicode-word-boundary\"\n },\n \"unicode-combined-inline-flags\": {\n \"key\": \"unicode-combined-inline-flags\",\n \"value\": \"unicode-combined-inline-flags\"\n },\n \"unicode-scoped-inline-flags\": {\n \"key\": \"unicode-scoped-inline-flags\",\n \"value\": \"unicode-scoped-inline-flags\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"capturing-groups\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"^([[:alnum:]._%+-]+)@capture\\\\.example$\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"capturing-groups\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"unicode-word-boundary\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"(?u)\\\\bmañana\\\\b\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"unicode-word-boundary\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"unicode-combined-inline-flags\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"(?iu)^alice$\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"unicode-combined-inline-flags\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"unicode-scoped-inline-flags\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"(?u:^mañana$)\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"unicode-scoped-inline-flags\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"partial-example\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"@example\\\\.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"partial-example\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"test\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \".*@test\\\\.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"test\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"numeric-one-of\": {\n \"key\": \"numeric-one-of\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"1\": {\n \"key\": \"1\",\n \"value\": 1\n },\n \"2\": {\n \"key\": \"2\",\n \"value\": 2\n },\n \"3\": {\n \"key\": \"3\",\n \"value\": 3\n }\n },\n \"allocations\": [\n {\n \"key\": \"1-for-1\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"number\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"1\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"1\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"2-for-123456789\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"number\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"123456789\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"2\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"3-for-not-2\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"number\",\n \"operator\": \"NOT_ONE_OF\",\n \"value\": [\n \"2\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"3\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"boolean-one-of-matches\": {\n \"key\": \"boolean-one-of-matches\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"1\": {\n \"key\": \"1\",\n \"value\": 1\n },\n \"2\": {\n \"key\": \"2\",\n \"value\": 2\n },\n \"3\": {\n \"key\": \"3\",\n \"value\": 3\n },\n \"4\": {\n \"key\": \"4\",\n \"value\": 4\n },\n \"5\": {\n \"key\": \"5\",\n \"value\": 5\n }\n },\n \"allocations\": [\n {\n \"key\": \"1-for-one-of\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"one_of_flag\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"1\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"2-for-matches\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"matches_flag\",\n \"operator\": \"MATCHES\",\n \"value\": \"true\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"2\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"3-for-not-one-of\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"not_one_of_flag\",\n \"operator\": \"NOT_ONE_OF\",\n \"value\": [\n \"false\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"3\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"4-for-not-matches\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"not_matches_flag\",\n \"operator\": \"NOT_MATCHES\",\n \"value\": \"false\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"4\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"5-for-matches-null\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"null_flag\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"null\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"5\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"empty_string_flag\": {\n \"key\": \"empty_string_flag\",\n \"enabled\": true,\n \"comment\": \"Testing the empty string as a variation value\",\n \"variationType\": \"STRING\",\n \"variations\": {\n \"empty_string\": {\n \"key\": \"empty_string\",\n \"value\": \"\"\n },\n \"non_empty\": {\n \"key\": \"non_empty\",\n \"value\": \"non_empty\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"allocation-empty\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"MATCHES\",\n \"value\": \"US\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"empty_string\",\n \"shards\": [\n {\n \"salt\": \"allocation-empty-shards\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"non_empty\",\n \"shards\": [\n {\n \"salt\": \"allocation-empty-shards\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"kill-switch\": {\n \"key\": \"kill-switch\",\n \"enabled\": true,\n \"variationType\": \"BOOLEAN\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": true\n },\n \"off\": {\n \"key\": \"off\",\n \"value\": false\n }\n },\n \"allocations\": [\n {\n \"key\": \"on-for-NA\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": [\n {\n \"salt\": \"some-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"on-for-age-50+\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"age\",\n \"operator\": \"GTE\",\n \"value\": 50\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": [\n {\n \"salt\": \"some-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"off-for-all\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"off\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"comparator-operator-test\": {\n \"key\": \"comparator-operator-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"small\": {\n \"key\": \"small\",\n \"value\": \"small\"\n },\n \"medium\": {\n \"key\": \"medium\",\n \"value\": \"medium\"\n },\n \"large\": {\n \"key\": \"large\",\n \"value\": \"large\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"small-size\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"LT\",\n \"value\": 10\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"small\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"medum-size\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"GTE\",\n \"value\": 10\n },\n {\n \"attribute\": \"size\",\n \"operator\": \"LTE\",\n \"value\": 20\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"medium\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"large-size\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"GT\",\n \"value\": 25\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"large\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"start-and-end-date-test\": {\n \"key\": \"start-and-end-date-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"old\": {\n \"key\": \"old\",\n \"value\": \"old\"\n },\n \"current\": {\n \"key\": \"current\",\n \"value\": \"current\"\n },\n \"new\": {\n \"key\": \"new\",\n \"value\": \"new\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"old-versions\",\n \"splits\": [\n {\n \"variationKey\": \"old\",\n \"shards\": []\n }\n ],\n \"endAt\": \"2002-10-31T09:00:00.594Z\",\n \"doLog\": true\n },\n {\n \"key\": \"future-versions\",\n \"splits\": [\n {\n \"variationKey\": \"new\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2052-10-31T09:00:00.594Z\",\n \"doLog\": true\n },\n {\n \"key\": \"current-versions\",\n \"splits\": [\n {\n \"variationKey\": \"current\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2022-10-31T09:00:00.594Z\",\n \"endAt\": \"2050-10-31T09:00:00.594Z\",\n \"doLog\": true\n }\n ]\n },\n \"null-operator-test\": {\n \"key\": \"null-operator-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"old\": {\n \"key\": \"old\",\n \"value\": \"old\"\n },\n \"new\": {\n \"key\": \"new\",\n \"value\": \"new\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"null-operator\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"IS_NULL\",\n \"value\": true\n }\n ]\n },\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"LT\",\n \"value\": 10\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"old\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"not-null-operator\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"IS_NULL\",\n \"value\": false\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"new\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"new-user-onboarding\": {\n \"key\": \"new-user-onboarding\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"control\": {\n \"key\": \"control\",\n \"value\": \"control\"\n },\n \"red\": {\n \"key\": \"red\",\n \"value\": \"red\"\n },\n \"blue\": {\n \"key\": \"blue\",\n \"value\": \"blue\"\n },\n \"green\": {\n \"key\": \"green\",\n \"value\": \"green\"\n },\n \"yellow\": {\n \"key\": \"yellow\",\n \"value\": \"yellow\"\n },\n \"purple\": {\n \"key\": \"purple\",\n \"value\": \"purple\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"id rule\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"id\",\n \"operator\": \"MATCHES\",\n \"value\": \"zach\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"purple\",\n \"shards\": []\n }\n ],\n \"doLog\": false\n },\n {\n \"key\": \"internal users\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"@mycompany.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"green\",\n \"shards\": []\n }\n ],\n \"doLog\": false\n },\n {\n \"key\": \"experiment\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"NOT_ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"control\",\n \"shards\": [\n {\n \"salt\": \"traffic-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 6000\n }\n ]\n },\n {\n \"salt\": \"split-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"red\",\n \"shards\": [\n {\n \"salt\": \"traffic-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 6000\n }\n ]\n },\n {\n \"salt\": \"split-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 8000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"yellow\",\n \"shards\": [\n {\n \"salt\": \"traffic-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 6000\n }\n ]\n },\n {\n \"salt\": \"split-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 8000,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"rollout\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"blue\",\n \"shards\": [\n {\n \"salt\": \"split-new-user-onboarding-rollout\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 8000\n }\n ]\n }\n ],\n \"extraLogging\": {\n \"allocationvalue_type\": \"rollout\",\n \"owner\": \"hippo\"\n }\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"integer-flag\": {\n \"key\": \"integer-flag\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"one\": {\n \"key\": \"one\",\n \"value\": 1\n },\n \"two\": {\n \"key\": \"two\",\n \"value\": 2\n },\n \"three\": {\n \"key\": \"three\",\n \"value\": 3\n }\n },\n \"allocations\": [\n {\n \"key\": \"targeted allocation\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n },\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \".*@example.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"three\",\n \"shards\": [\n {\n \"salt\": \"full-range-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"50/50 split\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"one\",\n \"shards\": [\n {\n \"salt\": \"split-numeric-flag-some-allocation\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"two\",\n \"shards\": [\n {\n \"salt\": \"split-numeric-flag-some-allocation\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"json-config-flag\": {\n \"key\": \"json-config-flag\",\n \"enabled\": true,\n \"variationType\": \"JSON\",\n \"variations\": {\n \"one\": {\n \"key\": \"one\",\n \"value\": {\n \"integer\": 1,\n \"string\": \"one\",\n \"float\": 1.0\n }\n },\n \"two\": {\n \"key\": \"two\",\n \"value\": {\n \"integer\": 2,\n \"string\": \"two\",\n \"float\": 2.0\n }\n },\n \"empty\": {\n \"key\": \"empty\",\n \"value\": {}\n }\n },\n \"allocations\": [\n {\n \"key\": \"Optionally Force Empty\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"Force Empty\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"empty\",\n \"shards\": [\n {\n \"salt\": \"full-range-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"50/50 split\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"one\",\n \"shards\": [\n {\n \"salt\": \"traffic-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n },\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"two\",\n \"shards\": [\n {\n \"salt\": \"traffic-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n },\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"special-characters\": {\n \"key\": \"special-characters\",\n \"enabled\": true,\n \"variationType\": \"JSON\",\n \"variations\": {\n \"de\": {\n \"key\": \"de\",\n \"value\": {\n \"a\": \"kümmert\",\n \"b\": \"schön\"\n }\n },\n \"ua\": {\n \"key\": \"ua\",\n \"value\": {\n \"a\": \"піклуватися\",\n \"b\": \"любов\"\n }\n },\n \"zh\": {\n \"key\": \"zh\",\n \"value\": {\n \"a\": \"照顾\",\n \"b\": \"漂亮\"\n }\n },\n \"emoji\": {\n \"key\": \"emoji\",\n \"value\": {\n \"a\": \"🤗\",\n \"b\": \"🌸\"\n }\n }\n },\n \"allocations\": [\n {\n \"key\": \"allocation-test\",\n \"splits\": [\n {\n \"variationKey\": \"de\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 2500\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"ua\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 2500,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"zh\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 7500\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"emoji\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 7500,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-default\",\n \"splits\": [\n {\n \"variationKey\": \"de\",\n \"shards\": []\n }\n ],\n \"doLog\": false\n }\n ]\n },\n \"string_flag_with_special_characters\": {\n \"key\": \"string_flag_with_special_characters\",\n \"enabled\": true,\n \"comment\": \"Testing the string with special characters and spaces\",\n \"variationType\": \"STRING\",\n \"variations\": {\n \"string_with_spaces\": {\n \"key\": \"string_with_spaces\",\n \"value\": \" a b c d e f \"\n },\n \"string_with_only_one_space\": {\n \"key\": \"string_with_only_one_space\",\n \"value\": \" \"\n },\n \"string_with_only_multiple_spaces\": {\n \"key\": \"string_with_only_multiple_spaces\",\n \"value\": \" \"\n },\n \"string_with_dots\": {\n \"key\": \"string_with_dots\",\n \"value\": \".a.b.c.d.e.f.\"\n },\n \"string_with_only_one_dot\": {\n \"key\": \"string_with_only_one_dot\",\n \"value\": \".\"\n },\n \"string_with_only_multiple_dots\": {\n \"key\": \"string_with_only_multiple_dots\",\n \"value\": \".......\"\n },\n \"string_with_comas\": {\n \"key\": \"string_with_comas\",\n \"value\": \",a,b,c,d,e,f,\"\n },\n \"string_with_only_one_coma\": {\n \"key\": \"string_with_only_one_coma\",\n \"value\": \",\"\n },\n \"string_with_only_multiple_comas\": {\n \"key\": \"string_with_only_multiple_comas\",\n \"value\": \",,,,,,,\"\n },\n \"string_with_colons\": {\n \"key\": \"string_with_colons\",\n \"value\": \":a:b:c:d:e:f:\"\n },\n \"string_with_only_one_colon\": {\n \"key\": \"string_with_only_one_colon\",\n \"value\": \":\"\n },\n \"string_with_only_multiple_colons\": {\n \"key\": \"string_with_only_multiple_colons\",\n \"value\": \":::::::\"\n },\n \"string_with_semicolons\": {\n \"key\": \"string_with_semicolons\",\n \"value\": \";a;b;c;d;e;f;\"\n },\n \"string_with_only_one_semicolon\": {\n \"key\": \"string_with_only_one_semicolon\",\n \"value\": \";\"\n },\n \"string_with_only_multiple_semicolons\": {\n \"key\": \"string_with_only_multiple_semicolons\",\n \"value\": \";;;;;;;\"\n },\n \"string_with_slashes\": {\n \"key\": \"string_with_slashes\",\n \"value\": \"/a/b/c/d/e/f/\"\n },\n \"string_with_only_one_slash\": {\n \"key\": \"string_with_only_one_slash\",\n \"value\": \"/\"\n },\n \"string_with_only_multiple_slashes\": {\n \"key\": \"string_with_only_multiple_slashes\",\n \"value\": \"///////\"\n },\n \"string_with_dashes\": {\n \"key\": \"string_with_dashes\",\n \"value\": \"-a-b-c-d-e-f-\"\n },\n \"string_with_only_one_dash\": {\n \"key\": \"string_with_only_one_dash\",\n \"value\": \"-\"\n },\n \"string_with_only_multiple_dashes\": {\n \"key\": \"string_with_only_multiple_dashes\",\n \"value\": \"-------\"\n },\n \"string_with_underscores\": {\n \"key\": \"string_with_underscores\",\n \"value\": \"_a_b_c_d_e_f_\"\n },\n \"string_with_only_one_underscore\": {\n \"key\": \"string_with_only_one_underscore\",\n \"value\": \"_\"\n },\n \"string_with_only_multiple_underscores\": {\n \"key\": \"string_with_only_multiple_underscores\",\n \"value\": \"_______\"\n },\n \"string_with_plus_signs\": {\n \"key\": \"string_with_plus_signs\",\n \"value\": \"+a+b+c+d+e+f+\"\n },\n \"string_with_only_one_plus_sign\": {\n \"key\": \"string_with_only_one_plus_sign\",\n \"value\": \"+\"\n },\n \"string_with_only_multiple_plus_signs\": {\n \"key\": \"string_with_only_multiple_plus_signs\",\n \"value\": \"+++++++\"\n },\n \"string_with_equal_signs\": {\n \"key\": \"string_with_equal_signs\",\n \"value\": \"=a=b=c=d=e=f=\"\n },\n \"string_with_only_one_equal_sign\": {\n \"key\": \"string_with_only_one_equal_sign\",\n \"value\": \"=\"\n },\n \"string_with_only_multiple_equal_signs\": {\n \"key\": \"string_with_only_multiple_equal_signs\",\n \"value\": \"=======\"\n },\n \"string_with_dollar_signs\": {\n \"key\": \"string_with_dollar_signs\",\n \"value\": \"$a$b$c$d$e$f$\"\n },\n \"string_with_only_one_dollar_sign\": {\n \"key\": \"string_with_only_one_dollar_sign\",\n \"value\": \"$\"\n },\n \"string_with_only_multiple_dollar_signs\": {\n \"key\": \"string_with_only_multiple_dollar_signs\",\n \"value\": \"$$$$$$$\"\n },\n \"string_with_at_signs\": {\n \"key\": \"string_with_at_signs\",\n \"value\": \"@a@b@c@d@e@f@\"\n },\n \"string_with_only_one_at_sign\": {\n \"key\": \"string_with_only_one_at_sign\",\n \"value\": \"@\"\n },\n \"string_with_only_multiple_at_signs\": {\n \"key\": \"string_with_only_multiple_at_signs\",\n \"value\": \"@@@@@@@\"\n },\n \"string_with_amp_signs\": {\n \"key\": \"string_with_amp_signs\",\n \"value\": \"&a&b&c&d&e&f&\"\n },\n \"string_with_only_one_amp_sign\": {\n \"key\": \"string_with_only_one_amp_sign\",\n \"value\": \"&\"\n },\n \"string_with_only_multiple_amp_signs\": {\n \"key\": \"string_with_only_multiple_amp_signs\",\n \"value\": \"&&&&&&&\"\n },\n \"string_with_hash_signs\": {\n \"key\": \"string_with_hash_signs\",\n \"value\": \"#a#b#c#d#e#f#\"\n },\n \"string_with_only_one_hash_sign\": {\n \"key\": \"string_with_only_one_hash_sign\",\n \"value\": \"#\"\n },\n \"string_with_only_multiple_hash_signs\": {\n \"key\": \"string_with_only_multiple_hash_signs\",\n \"value\": \"#######\"\n },\n \"string_with_percentage_signs\": {\n \"key\": \"string_with_percentage_signs\",\n \"value\": \"%a%b%c%d%e%f%\"\n },\n \"string_with_only_one_percentage_sign\": {\n \"key\": \"string_with_only_one_percentage_sign\",\n \"value\": \"%\"\n },\n \"string_with_only_multiple_percentage_signs\": {\n \"key\": \"string_with_only_multiple_percentage_signs\",\n \"value\": \"%%%%%%%\"\n },\n \"string_with_tilde_signs\": {\n \"key\": \"string_with_tilde_signs\",\n \"value\": \"~a~b~c~d~e~f~\"\n },\n \"string_with_only_one_tilde_sign\": {\n \"key\": \"string_with_only_one_tilde_sign\",\n \"value\": \"~\"\n },\n \"string_with_only_multiple_tilde_signs\": {\n \"key\": \"string_with_only_multiple_tilde_signs\",\n \"value\": \"~~~~~~~\"\n },\n \"string_with_asterix_signs\": {\n \"key\": \"string_with_asterix_signs\",\n \"value\": \"*a*b*c*d*e*f*\"\n },\n \"string_with_only_one_asterix_sign\": {\n \"key\": \"string_with_only_one_asterix_sign\",\n \"value\": \"*\"\n },\n \"string_with_only_multiple_asterix_signs\": {\n \"key\": \"string_with_only_multiple_asterix_signs\",\n \"value\": \"*******\"\n },\n \"string_with_single_quotes\": {\n \"key\": \"string_with_single_quotes\",\n \"value\": \"'a'b'c'd'e'f'\"\n },\n \"string_with_only_one_single_quote\": {\n \"key\": \"string_with_only_one_single_quote\",\n \"value\": \"'\"\n },\n \"string_with_only_multiple_single_quotes\": {\n \"key\": \"string_with_only_multiple_single_quotes\",\n \"value\": \"'''''''\"\n },\n \"string_with_question_marks\": {\n \"key\": \"string_with_question_marks\",\n \"value\": \"?a?b?c?d?e?f?\"\n },\n \"string_with_only_one_question_mark\": {\n \"key\": \"string_with_only_one_question_mark\",\n \"value\": \"?\"\n },\n \"string_with_only_multiple_question_marks\": {\n \"key\": \"string_with_only_multiple_question_marks\",\n \"value\": \"???????\"\n },\n \"string_with_exclamation_marks\": {\n \"key\": \"string_with_exclamation_marks\",\n \"value\": \"!a!b!c!d!e!f!\"\n },\n \"string_with_only_one_exclamation_mark\": {\n \"key\": \"string_with_only_one_exclamation_mark\",\n \"value\": \"!\"\n },\n \"string_with_only_multiple_exclamation_marks\": {\n \"key\": \"string_with_only_multiple_exclamation_marks\",\n \"value\": \"!!!!!!!\"\n },\n \"string_with_opening_parentheses\": {\n \"key\": \"string_with_opening_parentheses\",\n \"value\": \"(a(b(c(d(e(f(\"\n },\n \"string_with_only_one_opening_parenthese\": {\n \"key\": \"string_with_only_one_opening_parenthese\",\n \"value\": \"(\"\n },\n \"string_with_only_multiple_opening_parentheses\": {\n \"key\": \"string_with_only_multiple_opening_parentheses\",\n \"value\": \"(((((((\"\n },\n \"string_with_closing_parentheses\": {\n \"key\": \"string_with_closing_parentheses\",\n \"value\": \")a)b)c)d)e)f)\"\n },\n \"string_with_only_one_closing_parenthese\": {\n \"key\": \"string_with_only_one_closing_parenthese\",\n \"value\": \")\"\n },\n \"string_with_only_multiple_closing_parentheses\": {\n \"key\": \"string_with_only_multiple_closing_parentheses\",\n \"value\": \")))))))\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"allocation-test-string_with_spaces\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_spaces\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_spaces\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_space\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_space\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_space\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_spaces\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_spaces\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_spaces\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_dots\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_dots\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_dots\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_dot\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_dot\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_dot\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_dots\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_dots\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_dots\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_comas\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_comas\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_comas\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_coma\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_coma\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_coma\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_comas\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_comas\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_comas\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_colons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_colons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_colons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_colon\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_colon\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_colon\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_colons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_colons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_colons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_semicolons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_semicolons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_semicolons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_semicolon\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_semicolon\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_semicolon\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_semicolons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_semicolons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_semicolons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_slashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_slashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_slashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_slash\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_slash\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_slash\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_slashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_slashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_slashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_dashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_dashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_dashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_dash\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_dash\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_dash\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_dashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_dashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_dashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_underscores\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_underscores\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_underscores\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_underscore\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_underscore\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_underscore\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_underscores\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_underscores\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_underscores\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_plus_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_plus_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_plus_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_plus_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_plus_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_plus_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_plus_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_plus_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_plus_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_equal_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_equal_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_equal_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_equal_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_equal_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_equal_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_equal_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_equal_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_equal_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_dollar_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_dollar_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_dollar_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_dollar_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_dollar_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_dollar_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_dollar_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_dollar_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_dollar_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_at_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_at_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_at_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_at_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_at_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_at_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_at_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_at_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_at_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_amp_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_amp_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_amp_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_amp_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_amp_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_amp_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_amp_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_amp_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_amp_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_hash_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_hash_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_hash_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_hash_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_hash_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_hash_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_hash_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_hash_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_hash_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_percentage_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_percentage_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_percentage_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_percentage_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_percentage_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_percentage_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_percentage_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_percentage_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_percentage_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_tilde_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_tilde_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_tilde_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_tilde_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_tilde_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_tilde_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_tilde_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_tilde_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_tilde_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_asterix_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_asterix_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_asterix_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_asterix_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_asterix_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_asterix_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_asterix_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_asterix_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_asterix_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_single_quotes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_single_quotes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_single_quotes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_single_quote\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_single_quote\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_single_quote\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_single_quotes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_single_quotes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_single_quotes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_question_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_question_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_question_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_question_mark\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_question_mark\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_question_mark\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_question_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_question_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_question_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_exclamation_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_exclamation_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_exclamation_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_exclamation_mark\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_exclamation_mark\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_exclamation_mark\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_exclamation_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_exclamation_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_exclamation_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_opening_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_opening_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_opening_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_opening_parenthese\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_opening_parenthese\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_opening_parenthese\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_opening_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_opening_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_opening_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_closing_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_closing_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_closing_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_closing_parenthese\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_closing_parenthese\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_closing_parenthese\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_closing_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_closing_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_closing_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"boolean-false-assignment\": {\n \"key\": \"boolean-false-assignment\",\n \"enabled\": true,\n \"variationType\": \"BOOLEAN\",\n \"variations\": {\n \"false-variation\": {\n \"key\": \"false-variation\",\n \"value\": false\n },\n \"true-variation\": {\n \"key\": \"true-variation\",\n \"value\": true\n }\n },\n \"allocations\": [\n {\n \"key\": \"disable-feature\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"should_disable_feature\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"false-variation\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"enable-feature\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"should_disable_feature\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"false\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"true-variation\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ],\n \"totalShards\": 10000\n },\n \"empty-string-variation\": {\n \"key\": \"empty-string-variation\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"empty-content\": {\n \"key\": \"empty-content\",\n \"value\": \"\"\n },\n \"detailed-content\": {\n \"key\": \"detailed-content\",\n \"value\": \"detailed_content\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"minimal-content\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"content_type\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"minimal\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"empty-content\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"full-content\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"content_type\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"full\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"detailed-content\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ],\n \"totalShards\": 10000\n },\n \"falsy-value-assignments\": {\n \"key\": \"falsy-value-assignments\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"zero-limit\": {\n \"key\": \"zero-limit\",\n \"value\": 0\n },\n \"premium-limit\": {\n \"key\": \"premium-limit\",\n \"value\": 100\n }\n },\n \"allocations\": [\n {\n \"key\": \"free-tier-limit\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"plan_tier\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"free\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"zero-limit\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"premium-tier-limit\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"plan_tier\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"premium\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"premium-limit\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ],\n \"totalShards\": 10000\n },\n \"empty-targeting-key-flag\": {\n \"key\": \"empty-targeting-key-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on-value\"\n },\n \"off\": {\n \"key\": \"off\",\n \"value\": \"off-value\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"default-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"microsecond-date-test\": {\n \"key\": \"microsecond-date-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"expired\": {\n \"key\": \"expired\",\n \"value\": \"expired\"\n },\n \"active\": {\n \"key\": \"active\",\n \"value\": \"active\"\n },\n \"future\": {\n \"key\": \"future\",\n \"value\": \"future\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"expired-allocation\",\n \"splits\": [\n {\n \"variationKey\": \"expired\",\n \"shards\": []\n }\n ],\n \"endAt\": \"2002-10-31T09:00:00.594321Z\",\n \"doLog\": true\n },\n {\n \"key\": \"future-allocation\",\n \"splits\": [\n {\n \"variationKey\": \"future\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2052-10-31T09:00:00.123456Z\",\n \"doLog\": true\n },\n {\n \"key\": \"active-allocation\",\n \"splits\": [\n {\n \"variationKey\": \"active\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2022-10-31T09:00:00.235982Z\",\n \"endAt\": \"2050-10-31T09:00:00.987654Z\",\n \"doLog\": true\n }\n ]\n },\n \"unknown-fields-tolerance-flag\": {\n \"key\": \"unknown-fields-tolerance-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag deliberately contains unknown fields at every fixed-schema UFC level. SDKs must ignore those fields and evaluate the flag normally.\",\n \"unknownFlagField\": \"ignored\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on\",\n \"unknownVariationField\": \"ignored\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"unknown-fields-allocation\",\n \"unknownAllocationField\": \"ignored\",\n \"rules\": [\n {\n \"unknownRuleField\": \"ignored\",\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\"\n ],\n \"unknownConditionField\": \"ignored\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"unknownSplitField\": \"ignored\",\n \"shards\": [\n {\n \"salt\": \"unknown-fields-tolerance-flag\",\n \"totalShards\": 10000,\n \"unknownShardField\": \"ignored\",\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000,\n \"unknownRangeField\": \"ignored\"\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"operator-grease-flag\": {\n \"key\": \"operator-grease-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag deliberately uses an unknown condition operator. SDKs must ignore this flag rather than falling through to the catch-all allocation or rejecting the whole config.\",\n \"variations\": {\n \"trap\": {\n \"key\": \"trap\",\n \"value\": \"trap\"\n },\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"grease-allocation\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"NOT_A_REAL_OPERATOR\",\n \"value\": \"anything\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"trap\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"default-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"malformed-allocations-flag\": {\n \"key\": \"malformed-allocations-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag is intentionally UFC-schema-invalid: allocations must be an array, but this fixture uses a string to verify per-flag parse error isolation.\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on\"\n }\n },\n \"allocations\": \"this-is-not-a-list\"\n },\n \"missing-split-shards-flag\": {\n \"key\": \"missing-split-shards-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag is intentionally UFC-schema-invalid: split.shards is required even when the intended split is unsharded. SDKs must treat omitted shards differently from an explicit empty shards array.\",\n \"variations\": {\n \"trap\": {\n \"key\": \"trap\",\n \"value\": \"trap\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"malformed-split-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"trap\"\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"overflow-total-shards-flag\": {\n \"key\": \"overflow-total-shards-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag is intentionally UFC-schema-invalid: split.shards[].totalShards must fit in an unsigned 32-bit integer. SDKs must ignore this flag rather than overflowing or poisoning the whole config.\",\n \"variations\": {\n \"trap\": {\n \"key\": \"trap\",\n \"value\": \"trap\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"malformed-shard-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"trap\",\n \"shards\": [\n {\n \"salt\": \"overflow-total-shards-flag\",\n \"totalShards\": 4294967296,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 1\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"negative-shard-range-start-flag\": {\n \"key\": \"negative-shard-range-start-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag is intentionally UFC-schema-invalid: split.shards[].ranges[].start must be an unsigned integer. SDKs must ignore this flag rather than treating a negative range as matching traffic.\",\n \"variations\": {\n \"trap\": {\n \"key\": \"trap\",\n \"value\": \"trap\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"malformed-shard-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"trap\",\n \"shards\": [\n {\n \"salt\": \"negative-shard-range-start-flag\",\n \"totalShards\": 100,\n \"ranges\": [\n {\n \"start\": -1,\n \"end\": 1\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"valid-flag-after-invalid-config\": {\n \"key\": \"valid-flag-after-invalid-config\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This valid neighbor proves malformed or unsupported flags do not poison the rest of the remote config.\",\n \"variations\": {\n \"expected\": {\n \"key\": \"expected\",\n \"value\": \"expected\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"default-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"expected\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n }\n }\n}\n","etag":"ffe-system-test-data"}} \ No newline at end of file From 72cda4e51d22b1b1ab8bc3becb0167ce671b2c54 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 17:11:05 -0400 Subject: [PATCH 04/18] Add native flags configuration fetch --- example-new-architecture/App.tsx | 186 ++++++++- packages/core/__mocks__/react-native.ts | 38 +- .../reactnative/DdSdkImplementation.kt | 23 ++ .../NativeFfeConfigurationFetcher.kt | 285 +++++++++++++ .../com/datadog/reactnative/NativeFfeCore.kt | 29 ++ .../kotlin/com/datadog/reactnative/DdSdk.kt | 10 + .../kotlin/com/datadog/reactnative/DdSdk.kt | 10 + .../NativeFfeConfigurationFetcherTest.kt | 156 +++++++ packages/core/ios/Sources/DdSdk.mm | 22 + .../ios/Sources/DdSdkImplementation.swift | 32 ++ .../NativeFfeConfigurationFetcher.swift | 381 ++++++++++++++++++ packages/core/ios/Sources/NativeFfeCore.swift | 29 ++ packages/core/jest/mock.js | 55 ++- packages/core/src/DdSdkReactNative.tsx | 41 ++ .../src/__tests__/DdSdkReactNative.test.tsx | 56 +++ packages/core/src/sdk/DdSdkInternal.ts | 12 + packages/core/src/specs/NativeDdSdk.ts | 12 + 17 files changed, 1368 insertions(+), 9 deletions(-) create mode 100644 packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcher.kt create mode 100644 packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt create mode 100644 packages/core/ios/Sources/NativeFfeConfigurationFetcher.swift diff --git a/example-new-architecture/App.tsx b/example-new-architecture/App.tsx index 190667007..3ec220f1f 100644 --- a/example-new-architecture/App.tsx +++ b/example-new-architecture/App.tsx @@ -22,6 +22,7 @@ import React, {Suspense} from 'react'; import type {PropsWithChildren} from 'react'; import { ActivityIndicator, + Pressable, SafeAreaView, ScrollView, StatusBar, @@ -41,6 +42,21 @@ import { // @ts-ignore import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; +const NATIVE_FFE_STAGING_RULES_ENDPOINT = + 'https://dd.datad0g.com/api/v2/feature-flagging/config/rules-based'; +const NATIVE_FFE_STAGING_CLIENT_TOKEN = + 'pub542a31cc0f5b23136420667ca212045a'; +const NATIVE_FFE_USER = { + id: 'user-123', + favoriteFruit: 'apple', +}; +const NATIVE_FFE_EVALUATION_CONTEXT = { + targetingKey: NATIVE_FFE_USER.id, + attributes: { + favoriteFruit: NATIVE_FFE_USER.favoriteFruit, + }, +}; + (async () => { const config = new CoreConfiguration( CLIENT_TOKEN, @@ -94,14 +110,9 @@ 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, + targetingKey: NATIVE_FFE_USER.id, + favoriteFruit: NATIVE_FFE_USER.favoriteFruit, }); }, []); @@ -150,6 +161,8 @@ function App(): React.JSX.Element { details. + +
Edit App.tsx to change this screen and then come back to see your edits. @@ -170,6 +183,136 @@ function App(): React.JSX.Element { ); } +type NativeFfeFetchState = { + status: 'idle' | 'loading' | 'ready' | 'error'; + summary: string; + details?: string; +}; + +function NativeFfeFetchPanel({ + isDarkMode, +}: { + isDarkMode: boolean; +}): React.JSX.Element { + const [fetchState, setFetchState] = React.useState({ + status: 'idle', + summary: 'Native fetch has not run yet.', + }); + const loading = fetchState.status === 'loading'; + + const fetchNativeRulesConfiguration = React.useCallback(async () => { + setFetchState({ + status: 'loading', + summary: 'Fetching staging rules configuration from native...', + }); + + try { + const fetchedConfiguration = await DdSdkReactNative.fetchRulesConfiguration({ + endpoint: NATIVE_FFE_STAGING_RULES_ENDPOINT, + headers: { + 'Fastly-Client': '1', + 'dd-client-token': NATIVE_FFE_STAGING_CLIENT_TOKEN, + }, + flagQueryParams: { + dd_env: 'staging', + }, + }); + const serializedWire = await DdSdkReactNative.configurationToString( + fetchedConfiguration, + ); + const parsedConfiguration = await DdSdkReactNative.configurationFromString( + serializedWire, + ); + const configurationState = await DdSdkReactNative.setConfiguration( + parsedConfiguration, + ); + const contextState = await DdSdkReactNative.setEvaluationContext( + NATIVE_FFE_EVALUATION_CONTEXT, + ); + const debugState = await DdSdkReactNative.getProviderDebugState(); + + setFetchState({ + status: 'ready', + summary: `Fetched ${parsedConfiguration.kind} configuration ${ + parsedConfiguration.etag ?? 'without etag' + }.`, + details: JSON.stringify( + { + fetchedConfiguration: { + kind: fetchedConfiguration.kind, + version: fetchedConfiguration.version, + etag: fetchedConfiguration.etag, + }, + serializedWireBytes: serializedWire.length, + configurationState, + contextState, + debugState, + }, + null, + 2, + ), + }); + } catch (error) { + setFetchState({ + status: 'error', + summary: + error instanceof Error + ? error.message + : 'Native staging rules fetch failed.', + }); + } + }, []); + + return ( + + + Native FFE fetch + + + {fetchState.summary} + + [ + styles.nativeFfeButton, + loading && styles.nativeFfeButtonDisabled, + pressed && !loading && styles.nativeFfeButtonPressed, + ]}> + + {loading ? 'Fetching...' : 'Fetch staging rules'} + + + {fetchState.details ? ( + + {fetchState.details} + + ) : null} + + ); +} + type SectionProps = PropsWithChildren<{ title: string; }>; @@ -217,6 +360,35 @@ const styles = StyleSheet.create({ highlight: { fontWeight: '700', }, + nativeFfeButton: { + alignItems: 'center', + alignSelf: 'flex-start', + backgroundColor: '#2563eb', + borderRadius: 6, + marginTop: 16, + minHeight: 44, + justifyContent: 'center', + paddingHorizontal: 16, + }, + nativeFfeButtonDisabled: { + opacity: 0.6, + }, + nativeFfeButtonPressed: { + backgroundColor: '#1d4ed8', + }, + nativeFfeButtonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + nativeFfeDetails: { + borderRadius: 6, + fontFamily: 'Menlo', + fontSize: 12, + lineHeight: 16, + marginTop: 12, + padding: 12, + }, }); export default AppWithProviders; diff --git a/packages/core/__mocks__/react-native.ts b/packages/core/__mocks__/react-native.ts index e25732734..c8df0ef57 100644 --- a/packages/core/__mocks__/react-native.ts +++ b/packages/core/__mocks__/react-native.ts @@ -20,9 +20,17 @@ const mockFlagsDebugState = { activeConfigurationKind: 'rules', activeEtag: 'ffe-system-test-data', configurationSetCount: 1, - fetchCount: 0, + fetchCount: 1, evaluationCount: 0, lastEvent: 'provider_ready', + lastFetchRequest: { + url: 'https://mock.datadog.test/config', + method: 'GET', + headers: { + Accept: 'application/json' + }, + statusCode: 200 + }, evaluationSideEffects: { attemptedCount: 0, trackedCount: 0, @@ -50,6 +58,22 @@ const mockConfigurationFromString = (wire: string) => { }; }; +const mockFetchConfiguration = ( + kind: 'precomputed' | 'rules', + options: { previousConfigurationWire?: string } +) => { + return mockConfigurationFromString( + options.previousConfigurationWire ?? + JSON.stringify({ + version: 2, + [kind === 'rules' ? 'server' : 'precomputed']: { + response: '{}', + etag: 'mock-fetch' + } + }) + ); +}; + actualRN.NativeModules.DdSdk = { initialize: jest.fn().mockImplementation( () => new Promise(resolve => resolve()) @@ -103,6 +127,18 @@ actualRN.NativeModules.DdSdk = { (configuration: { wire?: string }) => new Promise(resolve => resolve(configuration.wire ?? '{}')) ) as jest.MockedFunction, + fetchRulesConfiguration: jest.fn().mockImplementation( + (options: { previousConfigurationWire?: string }) => + new Promise(resolve => + resolve(mockFetchConfiguration('rules', options)) + ) + ) as jest.MockedFunction, + fetchPrecomputedConfiguration: jest.fn().mockImplementation( + (options: { previousConfigurationWire?: string }) => + new Promise(resolve => + resolve(mockFetchConfiguration('precomputed', options)) + ) + ) as jest.MockedFunction, setConfiguration: jest.fn().mockImplementation( () => new Promise(resolve => resolve(mockFlagsDebugState)) ) as jest.MockedFunction, diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt index d5e849793..f6c5b0e42 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt @@ -37,6 +37,7 @@ class DdSdkImplementation( internal val initialized = AtomicBoolean(false) private var frameRateProvider: FrameRateProvider? = null private val nativeFfeCore: NativeFfeCore = NativeFfeCore() + private val nativeFfeConfigurationFetcher: NativeFfeConfigurationFetcher = NativeFfeConfigurationFetcher() private val nativeFfeSideEffects: NativeFfeEvaluationSideEffects = NativeFfeEvaluationSideEffects() // region DdSdk @@ -296,6 +297,26 @@ class DdSdkImplementation( } } + fun fetchRulesConfiguration(options: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.fetchConfiguration( + FFE_KIND_RULES, + options.toMap(), + nativeFfeConfigurationFetcher, + ).toMap().toWritableMap() + } + } + + fun fetchPrecomputedConfiguration(options: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.fetchConfiguration( + FFE_KIND_PRECOMPUTED, + options.toMap(), + nativeFfeConfigurationFetcher, + ).toMap().toWritableMap() + } + } + fun setConfiguration(configuration: ReadableMap, promise: Promise) { resolveFfePromise(promise) { nativeFfeCore.setConfiguration(configuration.toMap()).toWritableMap() @@ -507,5 +528,7 @@ class DdSdkImplementation( internal const val DEFAULT_REFRESH_HZ = 60.0 internal const val NAME = "DdSdk" internal const val FFE_ERROR_CODE = "FEATURE_FLAGS_CONFIGURATION_ERROR" + internal const val FFE_KIND_RULES = "rules" + internal const val FFE_KIND_PRECOMPUTED = "precomputed" } } diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcher.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcher.kt new file mode 100644 index 000000000..e89d0de99 --- /dev/null +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcher.kt @@ -0,0 +1,285 @@ +/* + * 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. + */ + +package com.datadog.reactnative + +import java.net.HttpURLConnection +import java.net.URLEncoder +import java.net.URL +import org.json.JSONArray +import org.json.JSONObject + +private const val NATIVE_FFE_KIND_RULES = "rules" +private const val NATIVE_FFE_KIND_PRECOMPUTED = "precomputed" +private const val NATIVE_FFE_HTTP_GET = "GET" +private const val NATIVE_FFE_WIRE_SECTION_SERVER = "server" + +internal class NativeFfeConfigurationFetcher( + private val transport: NativeFfeConfigurationTransport = UrlConnectionNativeFfeConfigurationTransport(), + private val clockMs: () -> Long = { System.currentTimeMillis() }, +) { + fun fetch(kind: String, options: Map): NativeFfeFetchedConfiguration { + val fetchOptions = NativeFfeFetchOptions.from(kind, options) + val request = fetchOptions.toRequest() + val response = try { + transport.execute(request) + } catch (error: Exception) { + throw NativeFfeConfigurationFetchException(request, error) + } + + val wire = when (response.statusCode) { + HTTP_NOT_MODIFIED -> fetchOptions.previousConfigurationWire + ?: throw NativeFfeConfigurationFetchException( + request, + IllegalStateException("304 response requires previousConfigurationWire"), + ) + in HTTP_SUCCESS_RANGE -> buildWire(kind, response, fetchOptions) + else -> throw NativeFfeConfigurationFetchException( + request, + IllegalStateException("Unexpected native flags fetch status: ${response.statusCode}"), + ) + } + + return NativeFfeFetchedConfiguration( + wire = wire, + request = request, + statusCode = response.statusCode, + ) + } + + private fun buildWire( + kind: String, + response: NativeFfeHttpResponse, + options: NativeFfeFetchOptions, + ): String { + val payload = JSONObject() + .put("response", response.body) + .put("fetchedAt", clockMs()) + + response.header("etag")?.takeIf { it.isNotBlank() }?.let { + payload.put("etag", it) + } + if (kind == NATIVE_FFE_KIND_PRECOMPUTED && options.evaluationContext.isNotEmpty()) { + payload.put("context", options.evaluationContext.toNativeFfeJSONObject()) + } + + return JSONObject() + .put("version", SUPPORTED_WIRE_VERSION) + .put(kind.toWireSection(), payload) + .toString() + } + + private fun NativeFfeHttpResponse.header(name: String): String? { + return headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value + } + + private companion object { + const val SUPPORTED_WIRE_VERSION = 2 + const val HTTP_NOT_MODIFIED = 304 + val HTTP_SUCCESS_RANGE = 200..299 + } +} + +internal data class NativeFfeFetchedConfiguration( + val wire: String, + val request: NativeFfeHttpRequest, + val statusCode: Int, +) + +internal data class NativeFfeHttpRequest( + val url: String, + val method: String, + val headers: Map, +) { + fun toDebugMap(statusCode: Int? = null): Map { + return mapOf( + "url" to url, + "method" to method, + "headers" to headers, + "statusCode" to statusCode, + ).filterValues { it != null } + } +} + +internal data class NativeFfeHttpResponse( + val statusCode: Int, + val headers: Map, + val body: String, +) + +internal interface NativeFfeConfigurationTransport { + fun execute(request: NativeFfeHttpRequest): NativeFfeHttpResponse +} + +internal class NativeFfeConfigurationFetchException( + val request: NativeFfeHttpRequest, + cause: Throwable, +) : Exception(cause.message, cause) + +private data class NativeFfeFetchOptions( + val kind: String, + val endpoint: String, + val clientToken: String?, + val sdkKey: String?, + val site: String?, + val headers: Map, + val flagQueryParams: Map, + val evaluationContext: Map, + val previousConfigurationWire: String?, +) { + fun toRequest(): NativeFfeHttpRequest { + val requestHeaders = linkedMapOf( + "Accept" to "application/json", + ) + + clientToken?.let { requestHeaders["DD-Client-Token"] = it } + sdkKey?.let { requestHeaders["DD-SDK-Key"] = it } + site?.let { requestHeaders["DD-Site"] = it } + previousConfigurationWire?.extractEtag(kind)?.let { requestHeaders["If-None-Match"] = it } + requestHeaders.putAll(headers) + + return NativeFfeHttpRequest( + url = buildUrl(), + method = NATIVE_FFE_HTTP_GET, + headers = requestHeaders, + ) + } + + private fun buildUrl(): String { + val queryParams = linkedMapOf("kind" to kind) + queryParams.putAll(flagQueryParams) + if (kind == NATIVE_FFE_KIND_PRECOMPUTED && evaluationContext.isNotEmpty()) { + queryParams["evaluationContext"] = evaluationContext.toNativeFfeJSONObject().toString() + } + + val query = queryParams.entries + .filter { it.value != null } + .joinToString("&") { (key, value) -> + "${key.urlEncode()}=${value.toString().urlEncode()}" + } + if (query.isBlank()) { + return endpoint + } + + val separator = if (endpoint.contains("?")) "&" else "?" + return "$endpoint$separator$query" + } + + private fun String.extractEtag(kind: String): String? { + val wireJson = JSONObject(this) + return wireJson.optJSONObject(kind.toWireSection()) + ?.optString("etag") + ?.takeIf { it.isNotBlank() } + ?: wireJson.optJSONObject(NATIVE_FFE_WIRE_SECTION_SERVER) + ?.optString("etag") + ?.takeIf { it.isNotBlank() } + ?: wireJson.optJSONObject(NATIVE_FFE_KIND_PRECOMPUTED) + ?.optString("etag") + ?.takeIf { it.isNotBlank() } + } + + companion object { + fun from(kind: String, options: Map): NativeFfeFetchOptions { + return NativeFfeFetchOptions( + kind = kind, + endpoint = options["endpoint"]?.toString()?.takeIf { it.isNotBlank() } + ?: throw IllegalArgumentException("Flags fetch requires endpoint"), + clientToken = options["clientToken"]?.toString()?.takeIf { it.isNotBlank() }, + sdkKey = options["sdkKey"]?.toString()?.takeIf { it.isNotBlank() }, + site = options["site"]?.toString()?.takeIf { it.isNotBlank() }, + headers = options["headers"].toStringMap(), + flagQueryParams = options["flagQueryParams"].toAnyMap(), + evaluationContext = options["evaluationContext"].toAnyMap(), + previousConfigurationWire = options["previousConfigurationWire"]?.toString(), + ) + } + } + +} + +private class UrlConnectionNativeFfeConfigurationTransport : NativeFfeConfigurationTransport { + override fun execute(request: NativeFfeHttpRequest): NativeFfeHttpResponse { + val connection = URL(request.url).openConnection() as HttpURLConnection + connection.requestMethod = request.method + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = TIMEOUT_MS + request.headers.forEach { (key, value) -> connection.setRequestProperty(key, value) } + + val statusCode = connection.responseCode + val stream = if (statusCode >= HTTP_BAD_REQUEST) { + connection.errorStream + } else { + connection.inputStream + } + val body = stream?.bufferedReader()?.use { it.readText() } ?: "" + val headers = connection.headerFields + .orEmpty() + .mapNotNull { (key, values) -> + key?.let { it to values.firstOrNull().orEmpty() } + } + .toMap() + + connection.disconnect() + return NativeFfeHttpResponse(statusCode, headers, body) + } + + private companion object { + const val TIMEOUT_MS = 5_000 + const val HTTP_BAD_REQUEST = 400 + } +} + +private fun Any?.toStringMap(): Map { + return (this as? Map<*, *>) + ?.mapNotNull { (key, value) -> + val stringKey = key as? String ?: return@mapNotNull null + value?.toString()?.let { stringKey to it } + } + ?.toMap() + ?: emptyMap() +} + +private fun Any?.toAnyMap(): Map { + return (this as? Map<*, *>) + ?.mapNotNull { (key, value) -> + (key as? String)?.let { it to value } + } + ?.toMap() + ?: emptyMap() +} + +private fun String.urlEncode(): String = URLEncoder.encode(this, "UTF-8") + +private fun String.toWireSection(): String { + return if (this == NATIVE_FFE_KIND_RULES) { + NATIVE_FFE_WIRE_SECTION_SERVER + } else { + this + } +} + +private fun Map<*, *>.toNativeFfeJSONObject(): JSONObject { + val jsonObject = JSONObject() + for ((key, value) in this) { + jsonObject.put(key.toString(), value.toNativeFfeJsonValue()) + } + return jsonObject +} + +private fun List<*>.toNativeFfeJSONArray(): JSONArray { + val jsonArray = JSONArray() + for (value in this) { + jsonArray.put(value.toNativeFfeJsonValue()) + } + return jsonArray +} + +private fun Any?.toNativeFfeJsonValue(): Any? = when (this) { + is Map<*, *> -> toNativeFfeJSONObject() + is List<*> -> toNativeFfeJSONArray() + null -> JSONObject.NULL + else -> this +} diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt index 350e52b35..719141d79 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt @@ -19,6 +19,7 @@ internal class NativeFfeCore { private var fetchCount: Int = 0 private var evaluationCount: Int = 0 private var lastEvent: String? = null + private var lastFetchRequest: Map? = null private var lastError: String? = null fun configurationFromString(wire: String): NativeFlagsConfiguration { @@ -55,6 +56,27 @@ internal class NativeFfeCore { ?: throw IllegalArgumentException("FlagsConfiguration is missing wire") } + fun fetchConfiguration( + kind: String, + options: Map, + fetcher: NativeFfeConfigurationFetcher, + ): NativeFlagsConfiguration { + fetchCount += 1 + return try { + val fetched = fetcher.fetch(kind, options) + lastFetchRequest = fetched.request.toDebugMap(fetched.statusCode) + lastError = null + configurationFromString(fetched.wire) + } catch (error: NativeFfeConfigurationFetchException) { + lastFetchRequest = error.request.toDebugMap() + markProviderError(error) + throw error + } catch (error: Exception) { + markProviderError(error) + throw error + } + } + fun setConfiguration(configuration: Map): Map { return try { val parsed = configurationFromString(configurationToString(configuration)) @@ -105,6 +127,7 @@ internal class NativeFfeCore { "fetchCount" to fetchCount, "evaluationCount" to evaluationCount, "lastEvent" to lastEvent, + "lastFetchRequest" to lastFetchRequest, "lastError" to lastError, ).filterValues { it != null } } @@ -341,6 +364,12 @@ internal class NativeFfeCore { return (firstFourBytes % totalShards).toInt() } + private fun markProviderError(error: Exception) { + status = if (activeConfiguration == null) STATUS_ERROR else STATUS_STALE + lastError = error.message + lastEvent = EVENT_PROVIDER_ERROR + } + data class NativeFlagsConfiguration( val wire: String, val version: Int, diff --git a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt index 9a0043ac1..b098f214c 100644 --- a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt +++ b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt @@ -200,6 +200,16 @@ class DdSdk( implementation.configurationToString(configuration, promise) } + @ReactMethod + override fun fetchRulesConfiguration(options: ReadableMap, promise: Promise) { + implementation.fetchRulesConfiguration(options, promise) + } + + @ReactMethod + override fun fetchPrecomputedConfiguration(options: ReadableMap, promise: Promise) { + implementation.fetchPrecomputedConfiguration(options, promise) + } + @ReactMethod override fun setConfiguration(configuration: ReadableMap, promise: Promise) { implementation.setConfiguration(configuration, promise) diff --git a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt index ff59fb818..522b4ed95 100644 --- a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt +++ b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt @@ -231,6 +231,16 @@ class DdSdk( implementation.configurationToString(configuration, promise) } + @ReactMethod + fun fetchRulesConfiguration(options: ReadableMap, promise: Promise) { + implementation.fetchRulesConfiguration(options, promise) + } + + @ReactMethod + fun fetchPrecomputedConfiguration(options: ReadableMap, promise: Promise) { + implementation.fetchPrecomputedConfiguration(options, promise) + } + @ReactMethod fun setConfiguration(configuration: ReadableMap, promise: Promise) { implementation.setConfiguration(configuration, promise) diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt new file mode 100644 index 000000000..af35915d9 --- /dev/null +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt @@ -0,0 +1,156 @@ +/* + * 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. + */ + +package com.datadog.reactnative + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class NativeFfeConfigurationFetcherTest { + private val fakeTransport = FakeTransport() + private val testedFetcher = NativeFfeConfigurationFetcher(fakeTransport) { FETCHED_AT_MS } + private val testedCore = NativeFfeCore() + + @Test + fun `M fetch rules configuration W http 200`() { + // Given + testedCore.setConfiguration( + testedCore.configurationFromString(rulesConfigurationWire).toMap() + ) + fakeTransport.response = NativeFfeHttpResponse( + statusCode = 200, + headers = mapOf("ETag" to "rules-v2"), + body = canonicalUfcConfig, + ) + + // When + val fetchedConfiguration = testedCore.fetchConfiguration( + "rules", + mapOf( + "endpoint" to "https://config.example.test/flags?existing=1", + "clientToken" to "client-token", + "sdkKey" to "sdk-key", + "site" to "datadoghq.com", + "headers" to mapOf("X-Test" to "true"), + "flagQueryParams" to mapOf("project" to "rn"), + "previousConfigurationWire" to rulesConfigurationWire, + ), + testedFetcher, + ) + + // Then + val request = fakeTransport.request + assertThat(fetchedConfiguration.kind).isEqualTo("rules") + assertThat(fetchedConfiguration.etag).isEqualTo("rules-v2") + assertThat(request?.method).isEqualTo("GET") + assertThat(request?.url) + .startsWith("https://config.example.test/flags?existing=1&") + .contains("kind=rules") + .contains("project=rn") + assertThat(request?.headers) + .containsEntry("Accept", "application/json") + .containsEntry("DD-Client-Token", "client-token") + .containsEntry("DD-SDK-Key", "sdk-key") + .containsEntry("DD-Site", "datadoghq.com") + .containsEntry("If-None-Match", "ffe-system-test-data") + .containsEntry("X-Test", "true") + assertThat(testedCore.debugState()) + .containsEntry("activeEtag", "ffe-system-test-data") + .containsEntry("fetchCount", 1) + @Suppress("UNCHECKED_CAST") + val lastFetchRequest = testedCore.debugState()["lastFetchRequest"] as Map + assertThat(lastFetchRequest) + .containsEntry("url", request?.url) + .containsEntry("method", "GET") + .containsEntry("statusCode", 200) + } + + @Test + fun `M return previous configuration W http 304`() { + // Given + fakeTransport.response = NativeFfeHttpResponse( + statusCode = 304, + headers = emptyMap(), + body = "", + ) + + // When + val fetchedConfiguration = testedCore.fetchConfiguration( + "rules", + mapOf( + "endpoint" to "https://config.example.test/flags", + "previousConfigurationWire" to rulesConfigurationWire, + ), + testedFetcher, + ) + + // Then + assertThat(fetchedConfiguration.kind).isEqualTo("rules") + assertThat(fetchedConfiguration.etag).isEqualTo("ffe-system-test-data") + assertThat(fetchedConfiguration.wire).isEqualTo(rulesConfigurationWire) + assertThat(testedCore.debugState()) + .containsEntry("fetchCount", 1) + .doesNotContainKey("activeConfigurationKind") + } + + @Test + fun `M fetch precomputed configuration W evaluation context`() { + // Given + fakeTransport.response = NativeFfeHttpResponse( + statusCode = 200, + headers = mapOf("etag" to "precomputed-v1"), + body = """{"flags":{}}""", + ) + + // When + val fetchedConfiguration = testedCore.fetchConfiguration( + "precomputed", + mapOf( + "endpoint" to "https://config.example.test/precomputed", + "evaluationContext" to mapOf( + "targetingKey" to "user-123", + "attributes" to mapOf("plan" to "pro"), + ), + ), + testedFetcher, + ) + + // Then + assertThat(fetchedConfiguration.kind).isEqualTo("precomputed") + assertThat(fetchedConfiguration.etag).isEqualTo("precomputed-v1") + assertThat(fakeTransport.request?.url) + .contains("kind=precomputed") + .contains("evaluationContext=") + } + + private class FakeTransport : NativeFfeConfigurationTransport { + var request: NativeFfeHttpRequest? = null + lateinit var response: NativeFfeHttpResponse + + override fun execute(request: NativeFfeHttpRequest): NativeFfeHttpResponse { + this.request = request + return response + } + } + + private companion object { + const val FETCHED_AT_MS = 1780000000000L + + val rulesConfigurationWire: String by lazy { + readNativeFfeFixture( + NativeFfeConfigurationFetcherTest::class.java, + "native-ffe/rules-configuration-wire.json" + ) + } + + val canonicalUfcConfig: String by lazy { + readNativeFfeFixture( + NativeFfeConfigurationFetcherTest::class.java, + "ffe-system-test-data/ufc-config.json" + ) + } + } +} diff --git a/packages/core/ios/Sources/DdSdk.mm b/packages/core/ios/Sources/DdSdk.mm index 8f249c892..5724dfe8f 100644 --- a/packages/core/ios/Sources/DdSdk.mm +++ b/packages/core/ios/Sources/DdSdk.mm @@ -149,6 +149,20 @@ + (void)initFromNative { [self configurationToString:configuration resolve:resolve reject:reject]; } +RCT_REMAP_METHOD(fetchRulesConfiguration, fetchRulesConfigurationWithOptions:(NSDictionary*)options + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self fetchRulesConfiguration:options resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(fetchPrecomputedConfiguration, fetchPrecomputedConfigurationWithOptions:(NSDictionary*)options + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self fetchPrecomputedConfiguration:options resolve:resolve reject:reject]; +} + RCT_REMAP_METHOD(setConfiguration, setNativeFlagsConfiguration:(NSDictionary*)configuration withResolver:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) @@ -313,6 +327,14 @@ - (void)configurationToString:(NSDictionary *)configuration resolve:(RCTPromiseR [self.ddSdkImplementation configurationToStringWithConfiguration:configuration resolve:resolve reject:reject]; } +- (void)fetchRulesConfiguration:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation fetchRulesConfigurationWithOptions:options resolve:resolve reject:reject]; +} + +- (void)fetchPrecomputedConfiguration:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation fetchPrecomputedConfigurationWithOptions:options resolve:resolve reject:reject]; +} + - (void)setConfiguration:(NSDictionary *)configuration resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self.ddSdkImplementation setConfigurationWithConfiguration:configuration resolve:resolve reject:reject]; } diff --git a/packages/core/ios/Sources/DdSdkImplementation.swift b/packages/core/ios/Sources/DdSdkImplementation.swift index 5634230ac..c25f9319b 100644 --- a/packages/core/ios/Sources/DdSdkImplementation.swift +++ b/packages/core/ios/Sources/DdSdkImplementation.swift @@ -33,6 +33,7 @@ public class DdSdkImplementation: NSObject { var RUMMonitorProvider: () -> RUMMonitorProtocol? var RUMMonitorInternalProvider: () -> RUMMonitorInternalProtocol? private let nativeFfeCore = NativeFfeCore() + private let nativeFfeConfigurationFetcher = NativeFfeConfigurationFetcher() private let nativeFfeSideEffects = NativeFfeEvaluationSideEffects() #if os(iOS) @@ -307,6 +308,32 @@ public class DdSdkImplementation: NSObject { } } + @objc + public func fetchRulesConfiguration( + options: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + try self.nativeFfeCore.fetchConfiguration( + kind: Constants.ffeKindRules, + options: options as? [String: Any] ?? [:], + fetcher: self.nativeFfeConfigurationFetcher + ).toMap() + } + } + + @objc + public func fetchPrecomputedConfiguration( + options: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + try self.nativeFfeCore.fetchConfiguration( + kind: Constants.ffeKindPrecomputed, + options: options as? [String: Any] ?? [:], + fetcher: self.nativeFfeConfigurationFetcher + ).toMap() + } + } + @objc public func setConfiguration( configuration: NSDictionary, resolve: RCTPromiseResolveBlock, @@ -512,3 +539,8 @@ public class DdSdkImplementation: NSObject { return normalizedFrameTimeMs / 1000.0 // in seconds } } + +private enum Constants { + static let ffeKindRules = "rules" + static let ffeKindPrecomputed = "precomputed" +} diff --git a/packages/core/ios/Sources/NativeFfeConfigurationFetcher.swift b/packages/core/ios/Sources/NativeFfeConfigurationFetcher.swift new file mode 100644 index 000000000..cd45fce80 --- /dev/null +++ b/packages/core/ios/Sources/NativeFfeConfigurationFetcher.swift @@ -0,0 +1,381 @@ +/* + * 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 Foundation + +internal final class NativeFfeConfigurationFetcher { + private let transport: NativeFfeConfigurationTransport + private let clockMs: () -> Int64 + + init( + transport: NativeFfeConfigurationTransport = URLSessionNativeFfeConfigurationTransport(), + clockMs: @escaping () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1_000) } + ) { + self.transport = transport + self.clockMs = clockMs + } + + func fetch(kind: String, options: [String: Any]) throws -> NativeFfeFetchedConfiguration { + let fetchOptions = try NativeFfeFetchOptions(kind: kind, options: options) + let request = try fetchOptions.toRequest() + let response: NativeFfeHTTPResponse + do { + response = try transport.execute(request) + } catch { + throw NativeFfeConfigurationFetchError(request: request, cause: error) + } + + let wire: String + if response.statusCode == Constants.httpNotModified { + guard let previousConfigurationWire = fetchOptions.previousConfigurationWire else { + throw NativeFfeConfigurationFetchError( + request: request, + cause: NativeFfeConfigurationFetchFailure( + "304 response requires previousConfigurationWire" + ) + ) + } + wire = previousConfigurationWire + } else if Constants.httpSuccessRange.contains(response.statusCode) { + wire = try buildWire(kind: kind, response: response, options: fetchOptions) + } else { + throw NativeFfeConfigurationFetchError( + request: request, + cause: NativeFfeConfigurationFetchFailure( + "Unexpected native flags fetch status: \(response.statusCode)" + ) + ) + } + + return NativeFfeFetchedConfiguration( + wire: wire, + request: request, + statusCode: response.statusCode + ) + } + + private func buildWire( + kind: String, + response: NativeFfeHTTPResponse, + options: NativeFfeFetchOptions + ) throws -> String { + var payload: [String: Any] = [ + "response": response.body, + "fetchedAt": clockMs(), + ] + + if let etag = response.header("etag"), !etag.isEmpty { + payload["etag"] = etag + } + if kind == NativeFfeConfigurationConstants.kindPrecomputed, + !options.evaluationContext.isEmpty + { + payload["context"] = options.evaluationContext + } + + return try NativeFfeJSON.encodeObject([ + "version": Constants.supportedWireVersion, + kind.toWireSection(): payload, + ]) + } + + private enum Constants { + static let supportedWireVersion = 2 + static let httpNotModified = 304 + static let httpSuccessRange = 200...299 + } +} + +internal struct NativeFfeFetchedConfiguration { + let wire: String + let request: NativeFfeHTTPRequest + let statusCode: Int +} + +internal struct NativeFfeHTTPRequest { + let url: String + let method: String + let headers: [String: String] + + func toDebugMap(statusCode: Int? = nil) -> [String: Any] { + var map: [String: Any] = [ + "url": url, + "method": method, + "headers": headers, + ] + if let statusCode { + map["statusCode"] = statusCode + } + return map + } +} + +internal struct NativeFfeHTTPResponse { + let statusCode: Int + let headers: [String: String] + let body: String + + func header(_ name: String) -> String? { + headers.first { key, _ in key.caseInsensitiveCompare(name) == .orderedSame }?.value + } +} + +internal protocol NativeFfeConfigurationTransport { + func execute(_ request: NativeFfeHTTPRequest) throws -> NativeFfeHTTPResponse +} + +internal final class NativeFfeConfigurationFetchError: Error, LocalizedError { + let request: NativeFfeHTTPRequest + private let cause: Error + + init(request: NativeFfeHTTPRequest, cause: Error) { + self.request = request + self.cause = cause + } + + var errorDescription: String? { + (cause as? LocalizedError)?.errorDescription ?? cause.localizedDescription + } +} + +private struct NativeFfeFetchOptions { + let kind: String + let endpoint: String + let clientToken: String? + let sdkKey: String? + let site: String? + let headers: [String: String] + let flagQueryParams: [String: Any] + let evaluationContext: [String: Any] + let previousConfigurationWire: String? + + init(kind: String, options: [String: Any]) throws { + guard let endpoint = stringValue(options["endpoint"]), !endpoint.isEmpty else { + throw NativeFfeConfigurationFetchFailure("Flags fetch requires endpoint") + } + self.kind = kind + self.endpoint = endpoint + self.clientToken = nonEmptyString(options["clientToken"]) + self.sdkKey = nonEmptyString(options["sdkKey"]) + self.site = nonEmptyString(options["site"]) + self.headers = stringMap(options["headers"]) + self.flagQueryParams = anyMap(options["flagQueryParams"]) + self.evaluationContext = anyMap(options["evaluationContext"]) + self.previousConfigurationWire = stringValue(options["previousConfigurationWire"]) + } + + func toRequest() throws -> NativeFfeHTTPRequest { + var requestHeaders = [ + "Accept": "application/json", + ] + + if let clientToken { + requestHeaders["DD-Client-Token"] = clientToken + } + if let sdkKey { + requestHeaders["DD-SDK-Key"] = sdkKey + } + if let site { + requestHeaders["DD-Site"] = site + } + if let previousConfigurationWire, + let etag = try extractEtag(from: previousConfigurationWire, preferredKind: kind) + { + requestHeaders["If-None-Match"] = etag + } + headers.forEach { key, value in requestHeaders[key] = value } + + return NativeFfeHTTPRequest( + url: try buildURL(), + method: NativeFfeConfigurationConstants.httpGet, + headers: requestHeaders + ) + } + + private func buildURL() throws -> String { + guard var components = URLComponents(string: endpoint) else { + throw NativeFfeConfigurationFetchFailure("Flags fetch endpoint is not a URL") + } + + var queryItems = components.queryItems ?? [] + queryItems.append(URLQueryItem(name: "kind", value: kind)) + for (key, value) in flagQueryParams where !(value is NSNull) { + queryItems.append(URLQueryItem(name: key, value: try queryString(value))) + } + if kind == NativeFfeConfigurationConstants.kindPrecomputed, + !evaluationContext.isEmpty + { + queryItems.append( + URLQueryItem( + name: "evaluationContext", + value: try NativeFfeJSON.encodeObject(evaluationContext) + ) + ) + } + components.queryItems = queryItems + + guard let url = components.url?.absoluteString else { + throw NativeFfeConfigurationFetchFailure("Flags fetch endpoint is not a URL") + } + return url + } + + private func extractEtag(from wire: String, preferredKind: String) throws -> String? { + guard let data = wire.data(using: .utf8), + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + throw NativeFfeConfigurationFetchFailure("previousConfigurationWire is not valid JSON") + } + + for section in [ + preferredKind.toWireSection(), + NativeFfeConfigurationConstants.wireSectionServer, + NativeFfeConfigurationConstants.kindPrecomputed, + ] { + if let payload = json[section] as? [String: Any], + let etag = nonEmptyString(payload["etag"]) + { + return etag + } + } + return nil + } +} + +private final class URLSessionNativeFfeConfigurationTransport: + NativeFfeConfigurationTransport +{ + func execute(_ request: NativeFfeHTTPRequest) throws -> NativeFfeHTTPResponse { + guard let url = URL(string: request.url) else { + throw NativeFfeConfigurationFetchFailure("Flags fetch URL is invalid") + } + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method + urlRequest.timeoutInterval = Constants.timeoutSeconds + request.headers.forEach { key, value in + urlRequest.setValue(value, forHTTPHeaderField: key) + } + + let semaphore = DispatchSemaphore(value: 0) + var fetchedData: Data? + var fetchedResponse: URLResponse? + var fetchedError: Error? + URLSession.shared.dataTask(with: urlRequest) { data, response, error in + fetchedData = data + fetchedResponse = response + fetchedError = error + semaphore.signal() + }.resume() + semaphore.wait() + + if let fetchedError { + throw fetchedError + } + guard let httpResponse = fetchedResponse as? HTTPURLResponse else { + throw NativeFfeConfigurationFetchFailure("Flags fetch response is not HTTP") + } + + var headers: [String: String] = [:] + httpResponse.allHeaderFields.forEach { key, value in + if let key = key as? String { + headers[key] = String(describing: value) + } + } + + return NativeFfeHTTPResponse( + statusCode: httpResponse.statusCode, + headers: headers, + body: fetchedData.flatMap { String(data: $0, encoding: .utf8) } ?? "" + ) + } + + private enum Constants { + static let timeoutSeconds: TimeInterval = 5 + } +} + +private enum NativeFfeJSON { + static func encodeObject(_ object: [String: Any]) throws -> String { + let data = try JSONSerialization.data(withJSONObject: object, options: []) + guard let encoded = String(data: data, encoding: .utf8) else { + throw NativeFfeConfigurationFetchFailure("Unable to encode flags configuration JSON") + } + return encoded + } +} + +private struct NativeFfeConfigurationFetchFailure: Error, LocalizedError { + private let message: String + + init(_ message: String) { + self.message = message + } + + var errorDescription: String? { + message + } +} + +private enum NativeFfeConfigurationConstants { + static let kindRules = "rules" + static let kindPrecomputed = "precomputed" + static let wireSectionServer = "server" + static let httpGet = "GET" +} + +private func stringMap(_ value: Any?) -> [String: String] { + anyMap(value).compactMapValues { stringValue($0) } +} + +private func anyMap(_ value: Any?) -> [String: Any] { + if let dictionary = value as? [String: Any] { + return dictionary + } + if let dictionary = value as? NSDictionary { + return dictionary as? [String: Any] ?? [:] + } + return [:] +} + +private func queryString(_ value: Any) throws -> String { + if let string = value as? String { + return string + } + if let bool = value as? Bool { + return bool ? "true" : "false" + } + if let number = value as? NSNumber { + return number.stringValue + } + if JSONSerialization.isValidJSONObject(value) { + let data = try JSONSerialization.data(withJSONObject: value, options: []) + return String(data: data, encoding: .utf8) ?? String(describing: value) + } + return String(describing: value) +} + +private func stringValue(_ value: Any?) -> String? { + guard let value, !(value is NSNull) else { + return nil + } + if let string = value as? String { + return string + } + return String(describing: value) +} + +private func nonEmptyString(_ value: Any?) -> String? { + stringValue(value).flatMap { $0.isEmpty ? nil : $0 } +} + +private extension String { + func toWireSection() -> String { + self == NativeFfeConfigurationConstants.kindRules + ? NativeFfeConfigurationConstants.wireSectionServer + : self + } +} diff --git a/packages/core/ios/Sources/NativeFfeCore.swift b/packages/core/ios/Sources/NativeFfeCore.swift index fc1563b2a..cbff2168e 100644 --- a/packages/core/ios/Sources/NativeFfeCore.swift +++ b/packages/core/ios/Sources/NativeFfeCore.swift @@ -17,6 +17,7 @@ internal final class NativeFfeCore { private var fetchCount = 0 private var evaluationCount = 0 private var lastEvent: String? + private var lastFetchRequest: [String: Any]? private var lastError: String? func configurationFromString(_ wire: String) throws -> NativeFlagsConfiguration { @@ -67,6 +68,27 @@ internal final class NativeFfeCore { return wire } + func fetchConfiguration( + kind: String, + options: [String: Any], + fetcher: NativeFfeConfigurationFetcher + ) throws -> NativeFlagsConfiguration { + fetchCount += 1 + do { + let fetched = try fetcher.fetch(kind: kind, options: options) + lastFetchRequest = fetched.request.toDebugMap(statusCode: fetched.statusCode) + lastError = nil + return try configurationFromString(fetched.wire) + } catch let error as NativeFfeConfigurationFetchError { + lastFetchRequest = error.request.toDebugMap() + markProviderError(error) + throw error + } catch { + markProviderError(error) + throw error + } + } + func setConfiguration(_ configuration: [String: Any]) -> [String: Any] { do { let parsed = try configurationFromString(configurationToString(configuration)) @@ -116,6 +138,7 @@ internal final class NativeFfeCore { ("fetchCount", fetchCount), ("evaluationCount", evaluationCount), ("lastEvent", lastEvent), + ("lastFetchRequest", lastFetchRequest), ("lastError", lastError), ]) } @@ -431,6 +454,12 @@ internal final class NativeFfeCore { return Int(firstFourBytes % UInt32(totalShards)) } + private func markProviderError(_ error: Error) { + status = activeConfiguration == nil ? Status.error : Status.stale + lastError = error.localizedDescription + lastEvent = Event.providerError + } + private func parseOptionalResponse(_ value: Any?) throws -> [String: Any]? { guard let response = value as? String else { return nil diff --git a/packages/core/jest/mock.js b/packages/core/jest/mock.js index 406e014d9..83805b89d 100644 --- a/packages/core/jest/mock.js +++ b/packages/core/jest/mock.js @@ -15,9 +15,17 @@ const mockFlagsDebugState = { activeConfigurationKind: 'rules', activeEtag: 'ffe-system-test-data', configurationSetCount: 1, - fetchCount: 0, + fetchCount: 1, evaluationCount: 0, lastEvent: 'provider_ready', + lastFetchRequest: { + url: 'https://mock.datadog.test/config', + method: 'GET', + headers: { + Accept: 'application/json' + }, + statusCode: 200 + }, evaluationSideEffects: { attemptedCount: 0, trackedCount: 0, @@ -45,6 +53,19 @@ const mockConfigurationFromString = wire => { }; }; +const mockFetchConfiguration = (kind, options) => { + return mockConfigurationFromString( + options.previousConfigurationWire ?? + JSON.stringify({ + version: 2, + [kind === 'rules' ? 'server' : 'precomputed']: { + response: '{}', + etag: 'mock-fetch' + } + }) + ); +}; + /** * Explicitly mocking the provider prevents auto-instrumentation in tests. * This prevents errors in tests to be logged in the console, as well as needing @@ -121,6 +142,22 @@ module.exports = { configuration => new Promise(resolve => resolve(configuration.wire ?? '{}')) ), + fetchRulesConfiguration: jest + .fn() + .mockImplementation( + options => + new Promise(resolve => + resolve(mockFetchConfiguration('rules', options)) + ) + ), + fetchPrecomputedConfiguration: jest + .fn() + .mockImplementation( + options => + new Promise(resolve => + resolve(mockFetchConfiguration('precomputed', options)) + ) + ), setConfiguration: jest .fn() .mockImplementation( @@ -307,6 +344,22 @@ module.exports = { configuration => new Promise(resolve => resolve(configuration.wire ?? '{}')) ), + fetchRulesConfiguration: jest + .fn() + .mockImplementation( + options => + new Promise(resolve => + resolve(mockFetchConfiguration('rules', options)) + ) + ), + fetchPrecomputedConfiguration: jest + .fn() + .mockImplementation( + options => + new Promise(resolve => + resolve(mockFetchConfiguration('precomputed', options)) + ) + ), setConfiguration: jest .fn() .mockImplementation( diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index c964acb3f..2400a4019 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -59,6 +59,17 @@ export type FlagsEvaluationContext = { attributes?: Record; }; +export type FlagsFetchOptions = { + endpoint: string; + clientToken?: string; + sdkKey?: string; + site?: string; + headers?: Record; + flagQueryParams?: Record; + evaluationContext?: FlagsEvaluationContext; + previousConfigurationWire?: FlagsConfigurationWire; +}; + export type FlagValue = | boolean | string @@ -90,6 +101,12 @@ export type FlagsProviderDebugState = { fetchCount: number; evaluationCount: number; lastEvent?: 'provider_ready' | 'configuration_changed' | 'provider_error'; + lastFetchRequest?: { + url: string; + method: string; + headers: Record; + statusCode?: number; + }; lastError?: string; evaluationSideEffects?: { attemptedCount: number; @@ -478,6 +495,30 @@ export class DdSdkReactNative { return NativeDdSdk.configurationToString(configuration); }; + static fetchRulesConfiguration = ( + options: FlagsFetchOptions + ): Promise => { + InternalLog.log( + 'Fetching native rules flags configuration', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.fetchRulesConfiguration( + options + ) as Promise; + }; + + static fetchPrecomputedConfiguration = ( + options: FlagsFetchOptions + ): Promise => { + InternalLog.log( + 'Fetching native precomputed flags configuration', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.fetchPrecomputedConfiguration( + options + ) as Promise; + }; + static setConfiguration = ( configuration: NativeFlagsConfiguration ): Promise => { diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index b2244a6ae..289ce67a6 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -71,6 +71,8 @@ beforeEach(async () => { NativeModules.DdSdk.setTrackingConsent.mockClear(); NativeModules.DdSdk.configurationFromString.mockClear(); NativeModules.DdSdk.configurationToString.mockClear(); + NativeModules.DdSdk.fetchRulesConfiguration.mockClear(); + NativeModules.DdSdk.fetchPrecomputedConfiguration.mockClear(); NativeModules.DdSdk.setConfiguration.mockClear(); NativeModules.DdSdk.setEvaluationContext.mockClear(); NativeModules.DdSdk.resolveBooleanEvaluation.mockClear(); @@ -1478,6 +1480,15 @@ describe('DdSdkReactNative', () => { status: 'ready', activeConfigurationKind: 'rules', activeEtag: 'ffe-system-test-data', + fetchCount: 1, + lastFetchRequest: { + url: 'https://mock.datadog.test/config', + method: 'GET', + headers: { + Accept: 'application/json' + }, + statusCode: 200 + }, evaluationSideEffects: { attemptedCount: 0, trackedCount: 0, @@ -1487,6 +1498,51 @@ describe('DdSdkReactNative', () => { } }); }); + + it('fetches configurations natively without setting active state', async () => { + // GIVEN + const options = { + endpoint: 'https://mock.datadog.test/config', + clientToken: 'client-token', + headers: { + 'X-Test': 'true' + }, + previousConfigurationWire: flagsWire + }; + + // WHEN + const rulesConfiguration = await DdSdkReactNative.fetchRulesConfiguration( + options + ); + const precomputedOptions = { + endpoint: options.endpoint, + clientToken: options.clientToken, + headers: options.headers, + evaluationContext: flagsEvaluationContext + }; + const precomputedConfiguration = await DdSdkReactNative.fetchPrecomputedConfiguration( + precomputedOptions + ); + + // THEN + expect(NativeDdSdk.fetchRulesConfiguration).toHaveBeenCalledWith( + options + ); + expect( + NativeDdSdk.fetchPrecomputedConfiguration + ).toHaveBeenCalledWith(precomputedOptions); + expect(NativeDdSdk.setConfiguration).not.toHaveBeenCalled(); + expect(rulesConfiguration).toMatchObject({ + __ddNativeFfeConfiguration: true, + kind: 'rules', + etag: 'ffe-system-test-data' + }); + expect(precomputedConfiguration).toMatchObject({ + __ddNativeFfeConfiguration: true, + kind: 'precomputed', + etag: 'mock-fetch' + }); + }); }); describe.each([[ProxyType.HTTP], [ProxyType.HTTPS], [ProxyType.SOCKS]])( diff --git a/packages/core/src/sdk/DdSdkInternal.ts b/packages/core/src/sdk/DdSdkInternal.ts index d63c0d63f..443ae6e6a 100644 --- a/packages/core/src/sdk/DdSdkInternal.ts +++ b/packages/core/src/sdk/DdSdkInternal.ts @@ -25,6 +25,10 @@ export type DdSdkType = { configurationToString(configuration: object): Promise; + fetchRulesConfiguration(options: object): Promise; + + fetchPrecomputedConfiguration(options: object): Promise; + setConfiguration(configuration: object): Promise; setEvaluationContext(context: object): Promise; @@ -141,6 +145,14 @@ export class DdSdkWrapper implements DdNativeSdkType { return NativeDdSdk.configurationToString(configuration); } + fetchRulesConfiguration(options: object): Promise { + return NativeDdSdk.fetchRulesConfiguration(options); + } + + fetchPrecomputedConfiguration(options: object): Promise { + return NativeDdSdk.fetchPrecomputedConfiguration(options); + } + setConfiguration(configuration: object): Promise { return NativeDdSdk.setConfiguration(configuration); } diff --git a/packages/core/src/specs/NativeDdSdk.ts b/packages/core/src/specs/NativeDdSdk.ts index 735cac663..655761a83 100644 --- a/packages/core/src/specs/NativeDdSdk.ts +++ b/packages/core/src/specs/NativeDdSdk.ts @@ -138,6 +138,18 @@ export interface Spec extends TurboModule { */ configurationToString(configuration: Object): Promise; + /** + * Fetches a rules-based portable flags configuration without mutating + * active provider state. + */ + fetchRulesConfiguration(options: Object): Promise; + + /** + * Fetches a precomputed portable flags configuration without mutating + * active provider state. + */ + fetchPrecomputedConfiguration(options: Object): Promise; + /** * Sets or replaces the active native flags configuration. */ From b13563be1494fe8bdc452c9eae319246dd26ed3b Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 17:34:16 -0400 Subject: [PATCH 05/18] Add native flags configuration persistence --- docs/native-ffe-react-native-poc-plan.md | 69 +++++++---- example-new-architecture/App.tsx | 26 +++- packages/core/__mocks__/react-native.ts | 33 +++++ .../reactnative/DdSdkImplementation.kt | 21 ++++ .../NativeFfeConfigurationStore.kt | 85 +++++++++++++ .../com/datadog/reactnative/NativeFfeCore.kt | 70 +++++++++++ .../kotlin/com/datadog/reactnative/DdSdk.kt | 10 ++ .../kotlin/com/datadog/reactnative/DdSdk.kt | 10 ++ .../datadog/reactnative/NativeFfeCoreTest.kt | 40 ++++++ packages/core/ios/Sources/DdSdk.mm | 23 ++++ .../ios/Sources/DdSdkImplementation.swift | 27 ++++ .../Sources/NativeFfeConfigurationStore.swift | 115 ++++++++++++++++++ packages/core/ios/Sources/NativeFfeCore.swift | 82 +++++++++++++ packages/core/jest/mock.js | 72 +++++++++++ packages/core/src/DdSdkReactNative.tsx | 40 ++++++ .../src/__tests__/DdSdkReactNative.test.tsx | 48 ++++++++ packages/core/src/sdk/DdSdkInternal.ts | 12 ++ packages/core/src/specs/NativeDdSdk.ts | 12 ++ 18 files changed, 767 insertions(+), 28 deletions(-) create mode 100644 packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt create mode 100644 packages/core/ios/Sources/NativeFfeConfigurationStore.swift diff --git a/docs/native-ffe-react-native-poc-plan.md b/docs/native-ffe-react-native-poc-plan.md index 1b3458759..d79979bc3 100644 --- a/docs/native-ffe-react-native-poc-plan.md +++ b/docs/native-ffe-react-native-poc-plan.md @@ -101,14 +101,20 @@ Keep the hard dependency on existing iOS/Android SDKs focused on customer-visibl Must call into existing native SDKs: - Exposure emission through the existing Flags pipelines. - - Android candidate: `_FlagsInternalProxy.trackFlagSnapshotEvaluation(...)`, which delegates to the native Flags evaluation path. + - Android path: `_FlagsInternalProxy.trackFlagSnapshotEvaluation(...)` delegates to `DatadogFlagsClient.trackFlagSnapshotEvaluation`, which enters the same native `trackResolution` path as shipped Android flag resolution. - iOS candidate: `FlagsClientInternal.sendFlagEvaluation(...)`, which calls the native exposure logger, evaluation logger, and RUM reporter. - Flag evaluation emission in EVP through the existing native evaluator/event aggregation pipeline. - - Android candidates visible in the shipped AAR include `EvaluationEventsProcessor`, `EvaluationEventWriter`, and the internal aggregation model. - - iOS candidates visible in the shipped Pod include `EvaluationLogger` and `EvaluationAggregator`. + - Android path: `DatadogFlagsClient.trackResolution` calls `writeEvaluationEvent(...)` when `trackEvaluations` is enabled, reusing the shipped evaluation feature and writer pipeline. + - iOS path: `FlagsClient.trackEvaluation(...)` calls `evaluationLogger.logEvaluation(...)`, reusing the shipped evaluation aggregation pipeline. - RUM feature-flag annotation / correlation. - - Android candidate: native Flags `RumEvaluationLogger` / `DefaultRumEvaluationLogger` path. - - iOS candidate: `rumFlagEvaluationReporter.sendFlagEvaluation(...)` underneath `FlagsClient.trackEvaluation(...)`. + - Android path: `DatadogFlagsClient.trackResolution` calls `RumEvaluationLogger.logEvaluation(...)` when RUM integration is enabled; `DefaultRumEvaluationLogger` sends a native `RumFlagEvaluationMessage`. + - iOS path: `rumFlagEvaluationReporter.sendFlagEvaluation(...)` underneath `FlagsClient.trackEvaluation(...)`, consumed by Datadog RUM's flag evaluation receiver. + +Validated reuse evidence for the RN-local native side-effect adapter: + +- Android shipped AAR bytecode confirms `trackFlagSnapshotEvaluation -> trackResolution -> writeExposureEvent`, `RumEvaluationLogger.logEvaluation`, and `writeEvaluationEvent`. +- iOS shipped Pod source confirms `sendFlagEvaluation -> trackEvaluation -> exposureLogger.logExposure`, `evaluationLogger.logEvaluation`, and `rumFlagEvaluationReporter.sendFlagEvaluation`. +- The RN-local evaluator should therefore call the existing native Flags tracking hook once per successful `doLog`/trackable evaluation and must not add a parallel direct RUM annotation call that could double-count. Also preserve through native SDKs, either directly or as inputs to side-effect adapters: @@ -126,6 +132,8 @@ Optional reuse, only if callable from currently released SDKs without source cha If optional reuse is not callable today, implement it as an RN-local native port with the same shape expected from the future extracted SDK library. Do not block the RN POC on making those APIs public in the iOS/Android SDK repos first. +Current storage finding: released iOS and Android Flags SDKs persist precomputed flag state on disk through native SDK data-store abstractions. iOS uses `FeatureScope.flagsDataStore` over Datadog `DataStore`, keyed by flags client name. Android uses `FlagsPersistenceManager` over `DataStoreHandler`, with a `flags-state-{instanceName}` key. The rules-based `ConfigurationWire` has no released save/load API yet, so the RN-local implementation should persist the downloaded rules configuration on native disk with equivalent semantics: versioned payload, per-client/slot keying, native-owned I/O, and explicit `loadConfiguration -> setConfiguration` activation. When the Kotlin/Swift libraries move into the native SDK repos, replace the RN-local file store with the SDK feature data store. + ## RFC Requirements To Exercise | RFC area | Requirement | POC coverage target | @@ -139,8 +147,8 @@ If optional reuse is not callable today, implement it as an RN-local native port | Dynamic context | Rules-based config stores new context immediately with no network and no stale blackout. | Native `setEvaluationContext()` mutates context only; `evaluate()` re-evaluates against same active rules. | | Precomputed safety | Precomputed config tied to one context must not serve another context. | Native evaluates precomputed only when stored context matches; mismatch returns default/error/debug state. | | Evaluation output | Rules path must produce metadata compatible with logging. | Native result includes value, variant, reason, error code, allocation key, `doLog`, and `extraLogging`. | -| Logging side effects | RN should keep existing native logging consistency. | Native evaluation triggers best-effort adapters into existing native Flags SDK evaluation hooks and reports counters in debug state. | -| Persistence | Mobile startup from last-known values is a target recipe. | Native save/load of last good wire from SDK-owned storage path; cache policy remains minimal. | +| Logging side effects | RN should keep existing native logging consistency. | Native evaluation triggers the existing native Flags SDK tracking hook and reports attempted/tracked/skipped/failed counters in debug state. | +| Persistence | Mobile startup from last-known values is a target recipe. | Native `saveConfiguration()` / `loadConfiguration()` persist last-good wire on disk; caller still explicitly activates via `setConfiguration()`. | | Failure behavior | Invalid wire, unsupported kind, refresh failure, stale serving are explicit states. | Native debug state reports invalid wire, unsupported kind, stale retained config, and last fetch error. | ## Current Native Flag-Provider Coverage @@ -523,11 +531,11 @@ Maintain a traceability table in the PR or test output that maps every goal to c | ----------------------------------- | ------------------------------------------------------------------------------------------------------ | | Offline initialization | `configurationFromString -> setConfiguration -> evaluate` succeeds from a bundled or persisted wire. | | Dynamic context | `setEvaluationContext` changes evaluation results under rules config without incrementing fetch count. | -| Native-owned fetch | Native fetch records request URL, headers, auth, ETag, and status, but active config is unchanged. | +| Native-owned fetch | Native fetch records request URL, headers, auth, ETag, query params, and status, but active config is unchanged. | | Explicit config replacement | Fetched config changes behavior only after `setConfiguration`. | | Conditional fetch | `If-None-Match` is sent and `304` returns prior wire without state mutation. | -| Native-owned persistence | Cold start loads native-stored last-good wire without JS storage. | -| Native-owned evaluation side effect | `doLog=true` evaluation invokes native side-effect fake or existing native tracking hook. | +| Native-owned persistence | Cold start loads native-stored last-good wire without JS storage, then explicitly activates it. | +| Native-owned evaluation side effect | `doLog=true` evaluation invokes the existing native Flags tracking hook, which reuses exposure, EVP, and RUM annotation paths. | | RN remains adapter-only | Most logic is unit-tested through native core classes without React Native. | | JSON bridge shape | JS serializes requests and parses native JSON responses for config, context, fetch, and evaluation. | @@ -541,13 +549,22 @@ load bundled rules wire -> evaluate again with no fetch -> native fetch updated config -> prove active state is unchanged - -> set fetched config + -> save fetched config to native disk + -> load fetched config from native disk + -> set loaded config -> evaluate changed result - -> save wire - -> load wire on cold start + -> load wire on cold start without JS storage ``` -Debug state is part of validation, not just demo UI. Tests should assert `configurationSetCount`, `fetchCount`, `evaluationCount`, active `etag`, current context, last provider event, and last fetch request. +Debug state is part of validation, not just demo UI. Tests should assert `configurationSetCount`, `configurationSaveCount`, `configurationLoadCount`, `fetchCount`, `evaluationCount`, active `etag`, current context, last provider event, last fetch request, and last storage operation. + +The example app fetch panel should use the staging Fastly route while this is being shared with the team: + +```text +GET https://dd.datad0g.com/api/v2/feature-flagging/config/rules-based?dd_env=staging +Fastly-Client: 1 +dd-client-token: pub542a31cc0f5b23136420667ca212045a +``` ### 2. Correctness Validation @@ -588,16 +605,15 @@ Required for each phase: Target commands: ```bash -yarn jest packages/core/src/__tests__/DdSdkReactNative.test.tsx --runInBand -yarn eslint +yarn --cwd packages/core test DdSdkReactNative.test.tsx --runInBand yarn --cwd packages/core prepare -cd packages/core/android && JAVA_HOME=/Users/leo.romanovsky/.sdkman/candidates/java/current ANDROID_HOME=/opt/homebrew/share/android-commandlinetools ANDROID_SDK_ROOT=/opt/homebrew/share/android-commandlinetools ./gradlew testDebugUnitTest --tests "com.datadog.reactnative.DdSdkTest" -cd example-new-architecture/android && JAVA_HOME=/Users/leo.romanovsky/.sdkman/candidates/java/current ANDROID_HOME=/opt/homebrew/share/android-commandlinetools ANDROID_SDK_ROOT=/opt/homebrew/share/android-commandlinetools ./gradlew :app:assembleDebug +JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ANDROID_HOME=/opt/homebrew/share/android-commandlinetools ./gradlew testDebugUnitTest --tests "com.datadog.reactnative.NativeFfeCoreTest" --tests "com.datadog.reactnative.NativeFfeConfigurationFetcherTest" +yarn --cwd example-new-architecture tsc --noEmit cd example-new-architecture/ios && GIT_CONFIG_GLOBAL=/dev/null bundle exec pod install -cd example-new-architecture/ios && xcodebuild -workspace DdSdkReactNativeExample.xcworkspace -scheme DdSdkReactNativeExample -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath build/DerivedData CODE_SIGNING_ALLOWED=NO build +cd example-new-architecture/ios && xcodebuild -workspace DdSdkReactNativeExample.xcworkspace -scheme DatadogSDKReactNative -configuration Debug -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath build/DerivedData CODE_SIGNING_ALLOWED=NO build ``` -Known local caveat: the current workstation reports an Xcode/CoreSimulator mismatch for iOS simulator builds. iOS codegen can still be generated and inspected, but a full iOS build requires local Xcode/device support to be fixed. +Known local caveat: the current workstation reports an Xcode/CoreSimulator mismatch warning for iOS simulator support. The RN package build has been validated with the Mac Catalyst destination above. ## What This POC Should Prove @@ -634,7 +650,9 @@ Known local caveat: the current workstation reports an Xcode/CoreSimulator misma ## Recommended Next Step -Implement Phases 1 through 5 first. That gives a native-first proof of the most important architecture claim: +The current branch has implemented the first native-first slice through configuration parsing, dynamic context, rules/precomputed evaluation, native fetch, native disk persistence, JSON fixture coverage, example-app wiring, and existing native Flags SDK side-effect reuse. The next implementation slice should finish the remaining extraction-oriented pieces: broader Swift fixture automation, a review note that records the confirmed native reuse points above, and a later swap from the RN-local file store to the native SDK data-store abstractions when this code moves downstream. + +The minimum demo sequence remains: ```text configurationFromString(wire) @@ -643,6 +661,11 @@ configurationFromString(wire) -> evaluate(flag) -> setEvaluationContext(authenticated) -> evaluate(flag) + -> fetchRulesConfiguration({ dd_env: "staging" }) + -> confirm fetch did not mutate active configuration + -> saveConfiguration(fetchedConfiguration) + -> loadConfiguration() + -> confirm load did not mutate active configuration + -> setConfiguration(loadedConfiguration) + -> evaluate(flag) ``` - -Only after native configuration, evaluator parity, and dynamic context are proven should the POC add native fetch and persistence. Otherwise the discussion can get distracted by endpoint/auth/cache details before the native ownership boundary is proven. diff --git a/example-new-architecture/App.tsx b/example-new-architecture/App.tsx index 3ec220f1f..15fd67b30 100644 --- a/example-new-architecture/App.tsx +++ b/example-new-architecture/App.tsx @@ -46,6 +46,9 @@ const NATIVE_FFE_STAGING_RULES_ENDPOINT = 'https://dd.datad0g.com/api/v2/feature-flagging/config/rules-based'; const NATIVE_FFE_STAGING_CLIENT_TOKEN = 'pub542a31cc0f5b23136420667ca212045a'; +const NATIVE_FFE_STORAGE_OPTIONS = { + slot: 'default', +}; const NATIVE_FFE_USER = { id: 'user-123', favoriteFruit: 'apple', @@ -220,11 +223,18 @@ function NativeFfeFetchPanel({ const serializedWire = await DdSdkReactNative.configurationToString( fetchedConfiguration, ); - const parsedConfiguration = await DdSdkReactNative.configurationFromString( + const fetchedParsedConfiguration = await DdSdkReactNative.configurationFromString( serializedWire, ); + const saveState = await DdSdkReactNative.saveConfiguration( + fetchedParsedConfiguration, + NATIVE_FFE_STORAGE_OPTIONS, + ); + const loadedConfiguration = await DdSdkReactNative.loadConfiguration( + NATIVE_FFE_STORAGE_OPTIONS, + ); const configurationState = await DdSdkReactNative.setConfiguration( - parsedConfiguration, + loadedConfiguration, ); const contextState = await DdSdkReactNative.setEvaluationContext( NATIVE_FFE_EVALUATION_CONTEXT, @@ -233,9 +243,9 @@ function NativeFfeFetchPanel({ setFetchState({ status: 'ready', - summary: `Fetched ${parsedConfiguration.kind} configuration ${ - parsedConfiguration.etag ?? 'without etag' - }.`, + summary: `Fetched, saved, loaded, and activated ${ + loadedConfiguration.kind + } configuration ${loadedConfiguration.etag ?? 'without etag'}.`, details: JSON.stringify( { fetchedConfiguration: { @@ -244,6 +254,12 @@ function NativeFfeFetchPanel({ etag: fetchedConfiguration.etag, }, serializedWireBytes: serializedWire.length, + saveState, + loadedConfiguration: { + kind: loadedConfiguration.kind, + version: loadedConfiguration.version, + etag: loadedConfiguration.etag, + }, configurationState, contextState, debugState, diff --git a/packages/core/__mocks__/react-native.ts b/packages/core/__mocks__/react-native.ts index c8df0ef57..626ccbdcd 100644 --- a/packages/core/__mocks__/react-native.ts +++ b/packages/core/__mocks__/react-native.ts @@ -20,6 +20,8 @@ const mockFlagsDebugState = { activeConfigurationKind: 'rules', activeEtag: 'ffe-system-test-data', configurationSetCount: 1, + configurationSaveCount: 0, + configurationLoadCount: 0, fetchCount: 1, evaluationCount: 0, lastEvent: 'provider_ready', @@ -139,6 +141,37 @@ actualRN.NativeModules.DdSdk = { resolve(mockFetchConfiguration('precomputed', options)) ) ) as jest.MockedFunction, + saveConfiguration: jest.fn().mockImplementation( + (configuration: { wire?: string }, options: { slot?: string }) => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + configurationSaveCount: 1, + lastStorage: { + operation: 'save', + status: 'stored', + key: `flags-configuration-${options.slot ?? 'default'}`, + wireBytes: (configuration.wire ?? '').length + } + }) + ) + ) as jest.MockedFunction, + loadConfiguration: jest.fn().mockImplementation( + () => + new Promise(resolve => + resolve( + mockConfigurationFromString( + JSON.stringify({ + version: 2, + server: { + response: '{}', + etag: 'stored' + } + }) + ) + ) + ) + ) as jest.MockedFunction, setConfiguration: jest.fn().mockImplementation( () => new Promise(resolve => resolve(mockFlagsDebugState)) ) as jest.MockedFunction, diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt index f6c5b0e42..d5055dd65 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt @@ -38,6 +38,8 @@ class DdSdkImplementation( private var frameRateProvider: FrameRateProvider? = null private val nativeFfeCore: NativeFfeCore = NativeFfeCore() private val nativeFfeConfigurationFetcher: NativeFfeConfigurationFetcher = NativeFfeConfigurationFetcher() + private val nativeFfeConfigurationStore: NativeFfeConfigurationStore = + FileNativeFfeConfigurationStore(appContext) private val nativeFfeSideEffects: NativeFfeEvaluationSideEffects = NativeFfeEvaluationSideEffects() // region DdSdk @@ -317,6 +319,25 @@ class DdSdkImplementation( } } + fun saveConfiguration(configuration: ReadableMap, options: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.saveConfiguration( + configuration.toMap(), + options.toMap(), + nativeFfeConfigurationStore, + ).toWritableMap() + } + } + + fun loadConfiguration(options: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.loadConfiguration( + options.toMap(), + nativeFfeConfigurationStore, + ).toMap().toWritableMap() + } + } + fun setConfiguration(configuration: ReadableMap, promise: Promise) { resolveFfePromise(promise) { nativeFfeCore.setConfiguration(configuration.toMap()).toWritableMap() diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt new file mode 100644 index 000000000..c0fd660c6 --- /dev/null +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt @@ -0,0 +1,85 @@ +/* + * 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. + */ + +package com.datadog.reactnative + +import android.content.Context +import java.io.File +import org.json.JSONObject + +internal interface NativeFfeConfigurationStore { + fun save(slot: String, wire: String): NativeFfeStoredConfiguration + fun load(slot: String): NativeFfeStoredConfiguration? +} + +internal data class NativeFfeStoredConfiguration( + val key: String, + val wire: String, + val updatedAtMs: Long, +) + +internal class FileNativeFfeConfigurationStore( + private val rootDirectory: File, + private val clockMs: () -> Long = { System.currentTimeMillis() }, +) : NativeFfeConfigurationStore { + constructor(context: Context) : this( + File(context.filesDir, "${ROOT_DIRECTORY}/${CONFIGURATIONS_DIRECTORY}") + ) + + override fun save(slot: String, wire: String): NativeFfeStoredConfiguration { + rootDirectory.mkdirs() + val key = slot.toStorageKey() + val updatedAtMs = clockMs() + val payload = JSONObject() + .put("version", PAYLOAD_VERSION) + .put("key", key) + .put("updatedAtMs", updatedAtMs) + .put("wire", wire) + .toString() + fileForKey(key).writeText(payload, Charsets.UTF_8) + return NativeFfeStoredConfiguration(key, wire, updatedAtMs) + } + + override fun load(slot: String): NativeFfeStoredConfiguration? { + val key = slot.toStorageKey() + val file = fileForKey(key) + if (!file.exists()) { + return null + } + val payload = JSONObject(file.readText(Charsets.UTF_8)) + require(payload.optInt("version") == PAYLOAD_VERSION) { + "Unsupported stored flags configuration version: ${payload.optInt("version")}" + } + return NativeFfeStoredConfiguration( + key = key, + wire = payload.getString("wire"), + updatedAtMs = payload.optLong("updatedAtMs"), + ) + } + + private fun fileForKey(key: String): File { + return File(rootDirectory, "$key.json") + } + + private fun String.toStorageKey(): String { + val sanitized = takeIf { it.isNotBlank() } + ?.replace(STORAGE_KEY_ALLOWED_PATTERN, "_") + ?.trim('_') + ?.takeIf { it.isNotBlank() } + ?: DEFAULT_SLOT + return "${KEY_PREFIX}-${sanitized.take(MAX_SLOT_LENGTH)}" + } + + private companion object { + const val ROOT_DIRECTORY = "datadog/native-ffe" + const val CONFIGURATIONS_DIRECTORY = "configurations" + const val PAYLOAD_VERSION = 1 + const val DEFAULT_SLOT = "default" + const val KEY_PREFIX = "flags-configuration" + const val MAX_SLOT_LENGTH = 80 + val STORAGE_KEY_ALLOWED_PATTERN = Regex("[^A-Za-z0-9._-]") + } +} diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt index 719141d79..1b9d0d729 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt @@ -16,10 +16,13 @@ internal class NativeFfeCore { private var currentContext: Map = emptyMap() private var status: String = STATUS_NOT_READY private var configurationSetCount: Int = 0 + private var configurationSaveCount: Int = 0 + private var configurationLoadCount: Int = 0 private var fetchCount: Int = 0 private var evaluationCount: Int = 0 private var lastEvent: String? = null private var lastFetchRequest: Map? = null + private var lastStorage: Map? = null private var lastError: String? = null fun configurationFromString(wire: String): NativeFlagsConfiguration { @@ -77,6 +80,49 @@ internal class NativeFfeCore { } } + fun saveConfiguration( + configuration: Map, + options: Map, + store: NativeFfeConfigurationStore, + ): Map { + configurationSaveCount += 1 + return try { + val wire = configurationToString(configuration) + val stored = store.save(options.toStorageSlot(), wire) + lastStorage = stored.toDebugMap(OPERATION_SAVE) + lastError = null + debugState() + } catch (error: Exception) { + lastStorage = mapOf( + "operation" to OPERATION_SAVE, + "status" to STATUS_FAILED, + ) + markProviderError(error) + throw error + } + } + + fun loadConfiguration( + options: Map, + store: NativeFfeConfigurationStore, + ): NativeFlagsConfiguration { + configurationLoadCount += 1 + return try { + val stored = store.load(options.toStorageSlot()) + ?: throw IllegalStateException("No stored flags configuration for slot '${options.toStorageSlot()}'") + lastStorage = stored.toDebugMap(OPERATION_LOAD) + lastError = null + configurationFromString(stored.wire) + } catch (error: Exception) { + lastStorage = mapOf( + "operation" to OPERATION_LOAD, + "status" to STATUS_FAILED, + ) + markProviderError(error) + throw error + } + } + fun setConfiguration(configuration: Map): Map { return try { val parsed = configurationFromString(configurationToString(configuration)) @@ -124,10 +170,13 @@ internal class NativeFfeCore { "activeEtag" to configuration?.etag, "currentContext" to currentContext, "configurationSetCount" to configurationSetCount, + "configurationSaveCount" to configurationSaveCount, + "configurationLoadCount" to configurationLoadCount, "fetchCount" to fetchCount, "evaluationCount" to evaluationCount, "lastEvent" to lastEvent, "lastFetchRequest" to lastFetchRequest, + "lastStorage" to lastStorage, "lastError" to lastError, ).filterValues { it != null } } @@ -370,6 +419,22 @@ internal class NativeFfeCore { lastEvent = EVENT_PROVIDER_ERROR } + private fun Map.toStorageSlot(): String { + return (this["slot"] as? String) + ?: (this["clientName"] as? String) + ?: DEFAULT_STORAGE_SLOT + } + + private fun NativeFfeStoredConfiguration.toDebugMap(operation: String): Map { + return mapOf( + "operation" to operation, + "status" to STATUS_STORED, + "key" to key, + "updatedAtMs" to updatedAtMs, + "wireBytes" to wire.toByteArray(Charsets.UTF_8).size, + ) + } + data class NativeFlagsConfiguration( val wire: String, val version: Int, @@ -434,9 +499,14 @@ internal class NativeFfeCore { const val STATUS_READY = "ready" const val STATUS_STALE = "stale" const val STATUS_ERROR = "error" + const val STATUS_STORED = "stored" + const val STATUS_FAILED = "failed" const val EVENT_PROVIDER_READY = "provider_ready" const val EVENT_CONFIGURATION_CHANGED = "configuration_changed" const val EVENT_PROVIDER_ERROR = "provider_error" + const val OPERATION_SAVE = "save" + const val OPERATION_LOAD = "load" + const val DEFAULT_STORAGE_SLOT = "default" const val EXPECTED_BOOLEAN = "boolean" const val EXPECTED_STRING = "string" const val EXPECTED_NUMBER = "number" diff --git a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt index b098f214c..233db5646 100644 --- a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt +++ b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt @@ -210,6 +210,16 @@ class DdSdk( implementation.fetchPrecomputedConfiguration(options, promise) } + @ReactMethod + override fun saveConfiguration(configuration: ReadableMap, options: ReadableMap, promise: Promise) { + implementation.saveConfiguration(configuration, options, promise) + } + + @ReactMethod + override fun loadConfiguration(options: ReadableMap, promise: Promise) { + implementation.loadConfiguration(options, promise) + } + @ReactMethod override fun setConfiguration(configuration: ReadableMap, promise: Promise) { implementation.setConfiguration(configuration, promise) diff --git a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt index 522b4ed95..7e6a6d2f4 100644 --- a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt +++ b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt @@ -241,6 +241,16 @@ class DdSdk( implementation.fetchPrecomputedConfiguration(options, promise) } + @ReactMethod + fun saveConfiguration(configuration: ReadableMap, options: ReadableMap, promise: Promise) { + implementation.saveConfiguration(configuration, options, promise) + } + + @ReactMethod + fun loadConfiguration(options: ReadableMap, promise: Promise) { + implementation.loadConfiguration(options, promise) + } + @ReactMethod fun setConfiguration(configuration: ReadableMap, promise: Promise) { implementation.setConfiguration(configuration, promise) diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt index a2e98617e..1e6d8793d 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt @@ -6,11 +6,13 @@ package com.datadog.reactnative +import java.io.File import org.assertj.core.api.Assertions.assertThat import org.assertj.core.data.Offset import org.json.JSONArray import org.json.JSONObject import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir internal class NativeFfeCoreTest { private val testedCore = NativeFfeCore() @@ -31,6 +33,43 @@ internal class NativeFfeCoreTest { assertThat(serialized).isEqualTo(flagsConfigurationWire) } + @Test + fun `M save and load configuration W native disk store`(@TempDir tempDir: File) { + // Given + val store = FileNativeFfeConfigurationStore(tempDir) { STORED_AT_MS } + val configuration = testedCore.configurationFromString(flagsConfigurationWire) + + // When + val saveState = testedCore.saveConfiguration( + configuration.toMap(), + mapOf("slot" to "default"), + store, + ) + val loadedConfiguration = testedCore.loadConfiguration( + mapOf("slot" to "default"), + store, + ) + val activatedState = testedCore.setConfiguration(loadedConfiguration.toMap()) + + // Then + assertThat(testedCore.configurationToString(loadedConfiguration.toMap())) + .isEqualTo(flagsConfigurationWire) + assertThat(saveState) + .containsEntry("configurationSaveCount", 1) + .containsEntry("configurationLoadCount", 0) + .doesNotContainKey("activeConfigurationKind") + @Suppress("UNCHECKED_CAST") + val lastSave = saveState["lastStorage"] as Map + assertThat(lastSave) + .containsEntry("operation", "save") + .containsEntry("status", "stored") + .containsEntry("key", "flags-configuration-default") + .containsEntry("updatedAtMs", STORED_AT_MS) + assertThat(activatedState) + .containsEntry("activeConfigurationKind", "rules") + .containsEntry("configurationLoadCount", 1) + } + @Test fun `M return static reason W canonical numeric flag case`() { // Given @@ -178,6 +217,7 @@ internal class NativeFfeCoreTest { private companion object { const val NUMERIC_TOLERANCE = 0.0000001 + const val STORED_AT_MS = 1780000000000L val flagsConfigurationWire: String by lazy { readNativeFfeFixture( diff --git a/packages/core/ios/Sources/DdSdk.mm b/packages/core/ios/Sources/DdSdk.mm index 5724dfe8f..3d6429113 100644 --- a/packages/core/ios/Sources/DdSdk.mm +++ b/packages/core/ios/Sources/DdSdk.mm @@ -163,6 +163,21 @@ + (void)initFromNative { [self fetchPrecomputedConfiguration:options resolve:resolve reject:reject]; } +RCT_REMAP_METHOD(saveConfiguration, saveConfigurationWithConfiguration:(NSDictionary*)configuration + options:(NSDictionary*)options + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self saveConfiguration:configuration options:options resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(loadConfiguration, loadConfigurationWithOptions:(NSDictionary*)options + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self loadConfiguration:options resolve:resolve reject:reject]; +} + RCT_REMAP_METHOD(setConfiguration, setNativeFlagsConfiguration:(NSDictionary*)configuration withResolver:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) @@ -335,6 +350,14 @@ - (void)fetchPrecomputedConfiguration:(NSDictionary *)options resolve:(RCTPromis [self.ddSdkImplementation fetchPrecomputedConfigurationWithOptions:options resolve:resolve reject:reject]; } +- (void)saveConfiguration:(NSDictionary *)configuration options:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation saveConfigurationWithConfiguration:configuration options:options resolve:resolve reject:reject]; +} + +- (void)loadConfiguration:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation loadConfigurationWithOptions:options resolve:resolve reject:reject]; +} + - (void)setConfiguration:(NSDictionary *)configuration resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self.ddSdkImplementation setConfigurationWithConfiguration:configuration resolve:resolve reject:reject]; } diff --git a/packages/core/ios/Sources/DdSdkImplementation.swift b/packages/core/ios/Sources/DdSdkImplementation.swift index c25f9319b..b1641a0f6 100644 --- a/packages/core/ios/Sources/DdSdkImplementation.swift +++ b/packages/core/ios/Sources/DdSdkImplementation.swift @@ -34,6 +34,7 @@ public class DdSdkImplementation: NSObject { var RUMMonitorInternalProvider: () -> RUMMonitorInternalProtocol? private let nativeFfeCore = NativeFfeCore() private let nativeFfeConfigurationFetcher = NativeFfeConfigurationFetcher() + private let nativeFfeConfigurationStore = FileNativeFfeConfigurationStore() private let nativeFfeSideEffects = NativeFfeEvaluationSideEffects() #if os(iOS) @@ -334,6 +335,32 @@ public class DdSdkImplementation: NSObject { } } + @objc + public func saveConfiguration( + configuration: NSDictionary, options: NSDictionary, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + try self.nativeFfeCore.saveConfiguration( + configuration as? [String: Any] ?? [:], + options: options as? [String: Any] ?? [:], + store: self.nativeFfeConfigurationStore + ) + } + } + + @objc + public func loadConfiguration( + options: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + try self.nativeFfeCore.loadConfiguration( + options: options as? [String: Any] ?? [:], + store: self.nativeFfeConfigurationStore + ).toMap() + } + } + @objc public func setConfiguration( configuration: NSDictionary, resolve: RCTPromiseResolveBlock, diff --git a/packages/core/ios/Sources/NativeFfeConfigurationStore.swift b/packages/core/ios/Sources/NativeFfeConfigurationStore.swift new file mode 100644 index 000000000..41721ee80 --- /dev/null +++ b/packages/core/ios/Sources/NativeFfeConfigurationStore.swift @@ -0,0 +1,115 @@ +/* + * 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 Foundation + +internal protocol NativeFfeConfigurationStoring { + func save(slot: String, wire: String) throws -> NativeFfeStoredConfiguration + func load(slot: String) throws -> NativeFfeStoredConfiguration? +} + +internal struct NativeFfeStoredConfiguration { + let key: String + let wire: String + let updatedAtMs: Int64 +} + +internal final class FileNativeFfeConfigurationStore: NativeFfeConfigurationStoring { + private let rootDirectory: URL + private let clockMs: () -> Int64 + + init( + rootDirectory: URL = FileNativeFfeConfigurationStore.defaultRootDirectory(), + clockMs: @escaping () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1_000) } + ) { + self.rootDirectory = rootDirectory + self.clockMs = clockMs + } + + func save(slot: String, wire: String) throws -> NativeFfeStoredConfiguration { + try FileManager.default.createDirectory( + at: rootDirectory, + withIntermediateDirectories: true + ) + let key = storageKey(for: slot) + let updatedAtMs = clockMs() + let payload: [String: Any] = [ + "version": Constants.payloadVersion, + "key": key, + "updatedAtMs": updatedAtMs, + "wire": wire, + ] + let data = try JSONSerialization.data(withJSONObject: payload) + try data.write(to: fileURL(forKey: key), options: .atomic) + return NativeFfeStoredConfiguration(key: key, wire: wire, updatedAtMs: updatedAtMs) + } + + func load(slot: String) throws -> NativeFfeStoredConfiguration? { + let key = storageKey(for: slot) + let url = fileURL(forKey: key) + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + let data = try Data(contentsOf: url) + guard + let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = payload["version"] as? Int, + version == Constants.payloadVersion, + let wire = payload["wire"] as? String + else { + throw NativeFfeConfigurationStoreError.invalidPayload + } + let updatedAtMs = + (payload["updatedAtMs"] as? Int64) + ?? (payload["updatedAtMs"] as? NSNumber)?.int64Value + ?? 0 + return NativeFfeStoredConfiguration(key: key, wire: wire, updatedAtMs: updatedAtMs) + } + + private func fileURL(forKey key: String) -> URL { + rootDirectory.appendingPathComponent("\(key).json") + } + + private func storageKey(for slot: String) -> String { + let rawSlot = slot.isEmpty ? Constants.defaultSlot : slot + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + let sanitized = rawSlot.unicodeScalars.map { scalar in + allowed.contains(scalar) ? Character(scalar).description : "_" + }.joined().trimmingCharacters(in: CharacterSet(charactersIn: "_")) + let normalized = sanitized.isEmpty ? Constants.defaultSlot : sanitized + return "\(Constants.keyPrefix)-\(String(normalized.prefix(Constants.maxSlotLength)))" + } + + private static func defaultRootDirectory() -> URL { + let base = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + return base + .appendingPathComponent(Constants.rootDirectory) + .appendingPathComponent(Constants.configurationsDirectory) + } + + private enum Constants { + static let rootDirectory = "Datadog/native-ffe" + static let configurationsDirectory = "configurations" + static let payloadVersion = 1 + static let defaultSlot = "default" + static let keyPrefix = "flags-configuration" + static let maxSlotLength = 80 + } +} + +private enum NativeFfeConfigurationStoreError: LocalizedError { + case invalidPayload + + var errorDescription: String? { + switch self { + case .invalidPayload: + return "Stored flags configuration payload is invalid" + } + } +} diff --git a/packages/core/ios/Sources/NativeFfeCore.swift b/packages/core/ios/Sources/NativeFfeCore.swift index cbff2168e..c795e72a9 100644 --- a/packages/core/ios/Sources/NativeFfeCore.swift +++ b/packages/core/ios/Sources/NativeFfeCore.swift @@ -14,10 +14,13 @@ internal final class NativeFfeCore { private var currentContext: [String: Any] = [:] private var status = Status.notReady private var configurationSetCount = 0 + private var configurationSaveCount = 0 + private var configurationLoadCount = 0 private var fetchCount = 0 private var evaluationCount = 0 private var lastEvent: String? private var lastFetchRequest: [String: Any]? + private var lastStorage: [String: Any]? private var lastError: String? func configurationFromString(_ wire: String) throws -> NativeFlagsConfiguration { @@ -89,6 +92,53 @@ internal final class NativeFfeCore { } } + func saveConfiguration( + _ configuration: [String: Any], + options: [String: Any], + store: NativeFfeConfigurationStoring + ) throws -> [String: Any] { + configurationSaveCount += 1 + do { + let wire = try configurationToString(configuration) + let stored = try store.save(slot: storageSlot(options), wire: wire) + lastStorage = stored.toDebugMap(operation: Operation.save) + lastError = nil + return debugState() + } catch { + lastStorage = [ + "operation": Operation.save, + "status": StorageStatus.failed, + ] + markProviderError(error) + throw error + } + } + + func loadConfiguration( + options: [String: Any], + store: NativeFfeConfigurationStoring + ) throws -> NativeFlagsConfiguration { + configurationLoadCount += 1 + do { + let slot = storageSlot(options) + guard let stored = try store.load(slot: slot) else { + throw NativeFfeCoreError.invalidConfigurationWire( + "No stored flags configuration for slot '\(slot)'" + ) + } + lastStorage = stored.toDebugMap(operation: Operation.load) + lastError = nil + return try configurationFromString(stored.wire) + } catch { + lastStorage = [ + "operation": Operation.load, + "status": StorageStatus.failed, + ] + markProviderError(error) + throw error + } + } + func setConfiguration(_ configuration: [String: Any]) -> [String: Any] { do { let parsed = try configurationFromString(configurationToString(configuration)) @@ -135,10 +185,13 @@ internal final class NativeFfeCore { ("activeEtag", activeConfiguration?.etag), ("currentContext", currentContext), ("configurationSetCount", configurationSetCount), + ("configurationSaveCount", configurationSaveCount), + ("configurationLoadCount", configurationLoadCount), ("fetchCount", fetchCount), ("evaluationCount", evaluationCount), ("lastEvent", lastEvent), ("lastFetchRequest", lastFetchRequest), + ("lastStorage", lastStorage), ("lastError", lastError), ]) } @@ -460,6 +513,12 @@ internal final class NativeFfeCore { lastEvent = Event.providerError } + private func storageSlot(_ options: [String: Any]) -> String { + stringValue(options["slot"]) + ?? stringValue(options["clientName"]) + ?? Constants.defaultStorageSlot + } + private func parseOptionalResponse(_ value: Any?) throws -> [String: Any]? { guard let response = value as? String else { return nil @@ -673,6 +732,7 @@ internal enum NativeFfeCoreError: LocalizedError { private enum Constants { static let supportedWireVersion = 2 static let wireKey = "wire" + static let defaultStorageSlot = "default" } private enum ConfigurationKind { @@ -694,9 +754,31 @@ private enum Event { static let providerError = "provider_error" } +private enum Operation { + static let save = "save" + static let load = "load" +} + +private enum StorageStatus { + static let stored = "stored" + static let failed = "failed" +} + private enum ExpectedType { static let boolean = "boolean" static let string = "string" static let number = "number" static let object = "object" } + +private extension NativeFfeStoredConfiguration { + func toDebugMap(operation: String) -> [String: Any] { + [ + "operation": operation, + "status": StorageStatus.stored, + "key": key, + "updatedAtMs": updatedAtMs, + "wireBytes": wire.data(using: .utf8)?.count ?? 0, + ] + } +} diff --git a/packages/core/jest/mock.js b/packages/core/jest/mock.js index 83805b89d..060c343b3 100644 --- a/packages/core/jest/mock.js +++ b/packages/core/jest/mock.js @@ -15,6 +15,8 @@ const mockFlagsDebugState = { activeConfigurationKind: 'rules', activeEtag: 'ffe-system-test-data', configurationSetCount: 1, + configurationSaveCount: 0, + configurationLoadCount: 0, fetchCount: 1, evaluationCount: 0, lastEvent: 'provider_ready', @@ -158,6 +160,41 @@ module.exports = { resolve(mockFetchConfiguration('precomputed', options)) ) ), + saveConfiguration: jest + .fn() + .mockImplementation( + (configuration, options) => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + configurationSaveCount: 1, + lastStorage: { + operation: 'save', + status: 'stored', + key: `flags-configuration-${options.slot ?? 'default'}`, + wireBytes: (configuration.wire ?? '').length + } + }) + ) + ), + loadConfiguration: jest + .fn() + .mockImplementation( + () => + new Promise(resolve => + resolve( + mockConfigurationFromString( + JSON.stringify({ + version: 2, + server: { + response: '{}', + etag: 'stored' + } + }) + ) + ) + ) + ), setConfiguration: jest .fn() .mockImplementation( @@ -360,6 +397,41 @@ module.exports = { resolve(mockFetchConfiguration('precomputed', options)) ) ), + saveConfiguration: jest + .fn() + .mockImplementation( + (configuration, options) => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + configurationSaveCount: 1, + lastStorage: { + operation: 'save', + status: 'stored', + key: `flags-configuration-${options.slot ?? 'default'}`, + wireBytes: (configuration.wire ?? '').length + } + }) + ) + ), + loadConfiguration: jest + .fn() + .mockImplementation( + () => + new Promise(resolve => + resolve( + mockConfigurationFromString( + JSON.stringify({ + version: 2, + server: { + response: '{}', + etag: 'stored' + } + }) + ) + ) + ) + ), setConfiguration: jest .fn() .mockImplementation( diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index 2400a4019..2e6c048d2 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -70,6 +70,11 @@ export type FlagsFetchOptions = { previousConfigurationWire?: FlagsConfigurationWire; }; +export type FlagsConfigurationStorageOptions = { + slot?: string; + clientName?: string; +}; + export type FlagValue = | boolean | string @@ -98,6 +103,8 @@ export type FlagsProviderDebugState = { activeEtag?: string; currentContext?: FlagsEvaluationContext; configurationSetCount: number; + configurationSaveCount: number; + configurationLoadCount: number; fetchCount: number; evaluationCount: number; lastEvent?: 'provider_ready' | 'configuration_changed' | 'provider_error'; @@ -107,6 +114,13 @@ export type FlagsProviderDebugState = { headers: Record; statusCode?: number; }; + lastStorage?: { + operation: 'save' | 'load'; + status: 'stored' | 'failed'; + key?: string; + updatedAtMs?: number; + wireBytes?: number; + }; lastError?: string; evaluationSideEffects?: { attemptedCount: number; @@ -519,6 +533,32 @@ export class DdSdkReactNative { ) as Promise; }; + static saveConfiguration = ( + configuration: NativeFlagsConfiguration, + options: FlagsConfigurationStorageOptions = {} + ): Promise => { + InternalLog.log( + 'Saving native flags configuration', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.saveConfiguration( + configuration, + options + ) as Promise; + }; + + static loadConfiguration = ( + options: FlagsConfigurationStorageOptions = {} + ): Promise => { + InternalLog.log( + 'Loading native flags configuration', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.loadConfiguration( + options + ) as Promise; + }; + static setConfiguration = ( configuration: NativeFlagsConfiguration ): Promise => { diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index 289ce67a6..eb603c4a3 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -73,6 +73,8 @@ beforeEach(async () => { NativeModules.DdSdk.configurationToString.mockClear(); NativeModules.DdSdk.fetchRulesConfiguration.mockClear(); NativeModules.DdSdk.fetchPrecomputedConfiguration.mockClear(); + NativeModules.DdSdk.saveConfiguration.mockClear(); + NativeModules.DdSdk.loadConfiguration.mockClear(); NativeModules.DdSdk.setConfiguration.mockClear(); NativeModules.DdSdk.setEvaluationContext.mockClear(); NativeModules.DdSdk.resolveBooleanEvaluation.mockClear(); @@ -1543,6 +1545,52 @@ describe('DdSdkReactNative', () => { etag: 'mock-fetch' }); }); + + it('saves and loads configuration from native disk before explicit activation', async () => { + // GIVEN + const configuration = await DdSdkReactNative.configurationFromString( + flagsWire + ); + const storageOptions = { slot: 'default' }; + + // WHEN + const saveState = await DdSdkReactNative.saveConfiguration( + configuration, + storageOptions + ); + const loadedConfiguration = await DdSdkReactNative.loadConfiguration( + storageOptions + ); + const loadedState = await DdSdkReactNative.setConfiguration( + loadedConfiguration + ); + + // THEN + expect(NativeDdSdk.saveConfiguration).toHaveBeenCalledWith( + configuration, + storageOptions + ); + expect(NativeDdSdk.loadConfiguration).toHaveBeenCalledWith( + storageOptions + ); + expect(NativeDdSdk.setConfiguration).toHaveBeenCalledWith( + loadedConfiguration + ); + expect(saveState).toMatchObject({ + configurationSaveCount: 1, + lastStorage: { + operation: 'save', + status: 'stored', + key: 'flags-configuration-default' + } + }); + expect(loadedConfiguration).toMatchObject({ + __ddNativeFfeConfiguration: true, + kind: 'rules', + etag: 'stored' + }); + expect(loadedState.status).toBe('ready'); + }); }); describe.each([[ProxyType.HTTP], [ProxyType.HTTPS], [ProxyType.SOCKS]])( diff --git a/packages/core/src/sdk/DdSdkInternal.ts b/packages/core/src/sdk/DdSdkInternal.ts index 443ae6e6a..961906c5e 100644 --- a/packages/core/src/sdk/DdSdkInternal.ts +++ b/packages/core/src/sdk/DdSdkInternal.ts @@ -29,6 +29,10 @@ export type DdSdkType = { fetchPrecomputedConfiguration(options: object): Promise; + saveConfiguration(configuration: object, options: object): Promise; + + loadConfiguration(options: object): Promise; + setConfiguration(configuration: object): Promise; setEvaluationContext(context: object): Promise; @@ -153,6 +157,14 @@ export class DdSdkWrapper implements DdNativeSdkType { return NativeDdSdk.fetchPrecomputedConfiguration(options); } + saveConfiguration(configuration: object, options: object): Promise { + return NativeDdSdk.saveConfiguration(configuration, options); + } + + loadConfiguration(options: object): Promise { + return NativeDdSdk.loadConfiguration(options); + } + setConfiguration(configuration: object): Promise { return NativeDdSdk.setConfiguration(configuration); } diff --git a/packages/core/src/specs/NativeDdSdk.ts b/packages/core/src/specs/NativeDdSdk.ts index 655761a83..c747d9e0c 100644 --- a/packages/core/src/specs/NativeDdSdk.ts +++ b/packages/core/src/specs/NativeDdSdk.ts @@ -150,6 +150,18 @@ export interface Spec extends TurboModule { */ fetchPrecomputedConfiguration(options: Object): Promise; + /** + * Persists a portable flags configuration to native disk storage without + * mutating active provider state. + */ + saveConfiguration(configuration: Object, options: Object): Promise; + + /** + * Loads a portable flags configuration from native disk storage without + * mutating active provider state. + */ + loadConfiguration(options: Object): Promise; + /** * Sets or replaces the active native flags configuration. */ From 958b64c01095d95554a8fdc5c12a1fad87446fff Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 17:58:26 -0400 Subject: [PATCH 06/18] Store native flags config in SDK data stores --- docs/native-ffe-react-native-poc-plan.md | 12 +- example-new-architecture/ios/Podfile.lock | 2 +- packages/core/DatadogSDKReactNative.podspec | 2 +- .../reactnative/DdSdkImplementation.kt | 4 +- .../NativeFfeConfigurationStore.kt | 200 ++++++++-- .../datadog/reactnative/NativeFfeCoreTest.kt | 83 +++++ .../ios/Sources/DdSdkImplementation.swift | 4 +- .../Sources/NativeFfeConfigurationStore.swift | 183 +++++++-- .../core/ios/Tests/NativeFfeCoreTests.swift | 348 ++++++++++++++++++ .../NativeFfeEvaluationSideEffectsTests.swift | 90 +++++ .../ios/Tests/NativeFfeTestFixtures.swift | 50 +++ 11 files changed, 902 insertions(+), 76 deletions(-) create mode 100644 packages/core/ios/Tests/NativeFfeCoreTests.swift create mode 100644 packages/core/ios/Tests/NativeFfeEvaluationSideEffectsTests.swift create mode 100644 packages/core/ios/Tests/NativeFfeTestFixtures.swift diff --git a/docs/native-ffe-react-native-poc-plan.md b/docs/native-ffe-react-native-poc-plan.md index d79979bc3..5eced4c45 100644 --- a/docs/native-ffe-react-native-poc-plan.md +++ b/docs/native-ffe-react-native-poc-plan.md @@ -132,7 +132,7 @@ Optional reuse, only if callable from currently released SDKs without source cha If optional reuse is not callable today, implement it as an RN-local native port with the same shape expected from the future extracted SDK library. Do not block the RN POC on making those APIs public in the iOS/Android SDK repos first. -Current storage finding: released iOS and Android Flags SDKs persist precomputed flag state on disk through native SDK data-store abstractions. iOS uses `FeatureScope.flagsDataStore` over Datadog `DataStore`, keyed by flags client name. Android uses `FlagsPersistenceManager` over `DataStoreHandler`, with a `flags-state-{instanceName}` key. The rules-based `ConfigurationWire` has no released save/load API yet, so the RN-local implementation should persist the downloaded rules configuration on native disk with equivalent semantics: versioned payload, per-client/slot keying, native-owned I/O, and explicit `loadConfiguration -> setConfiguration` activation. When the Kotlin/Swift libraries move into the native SDK repos, replace the RN-local file store with the SDK feature data store. +Current storage finding: released iOS and Android Flags SDKs persist precomputed flag state on disk through native SDK data-store abstractions. iOS uses `FeatureScope.flagsDataStore` over Datadog `DataStore`, keyed by flags client name. Android uses `FlagsPersistenceManager` over `DataStoreHandler`, with a `flags-state-{instanceName}` key. The rules-based `ConfigurationWire` has no released save/load API yet, so the RN-local implementation should persist the downloaded rules configuration on native disk with equivalent semantics: versioned payload, per-client/slot keying, native-owned I/O, and explicit `loadConfiguration -> setConfiguration` activation. The current branch now prefers the existing native SDK feature data-store namespace when available: iOS writes through `CoreRegistry.default.scope(for: flags).dataStore`, and Android writes through the Flags `FeatureScope.dataStore` obtained from `FeatureSdkCore`. The app-private file store remains only as an RN-local fallback for uninitialized SDK/test scenarios. ## RFC Requirements To Exercise @@ -464,7 +464,7 @@ Goal: prove mobile offline startup from native-owned storage. Tasks: -- Add native save/load of wire string to an SDK-owned POC storage path. +- Add native save/load of wire string to the existing SDK feature data-store path when available. - Keep policy minimal: one last-good config only. - Load persisted wire before any network call. - Refresh in background by explicit caller sequence: `load -> setConfiguration -> fetch -> setConfiguration`. @@ -473,7 +473,7 @@ Acceptance: - Cold-start path evaluates from persisted config. - Refresh failure keeps prior config and marks stale/debug error. -- Storage is native-owned, not JS AsyncStorage. +- Storage is native-owned, not JS AsyncStorage; adapter tests prove the Datadog feature data-store path and fallback behavior. ### Phase 8: Evaluation Side Effects @@ -599,6 +599,7 @@ Required for each phase: - Android fixture tests for the Kotlin evaluator. - iOS unit tests for native core. - iOS fixture tests for the Swift evaluator. +- iOS side-effect adapter tests for the existing native Flags tracking request shape. - Android new-architecture example build. - iOS codegen generation and iOS build when local Xcode simulator/device support is available. @@ -611,9 +612,10 @@ JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ANDROID yarn --cwd example-new-architecture tsc --noEmit cd example-new-architecture/ios && GIT_CONFIG_GLOBAL=/dev/null bundle exec pod install cd example-new-architecture/ios && xcodebuild -workspace DdSdkReactNativeExample.xcworkspace -scheme DatadogSDKReactNative -configuration Debug -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath build/DerivedData CODE_SIGNING_ALLOWED=NO build +cd example-new-architecture/ios && xcodebuild test -workspace DdSdkReactNativeExample.xcworkspace -scheme DatadogSDKReactNative-Unit-Tests -configuration Debug -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath build/DerivedData CODE_SIGNING_ALLOWED=NO -only-testing:DatadogSDKReactNative-Unit-Tests/NativeFfeCoreTests -only-testing:DatadogSDKReactNative-Unit-Tests/NativeFfeEvaluationSideEffectsTests ``` -Known local caveat: the current workstation reports an Xcode/CoreSimulator mismatch warning for iOS simulator support. The RN package build has been validated with the Mac Catalyst destination above. +Known local caveat: the current workstation reports an Xcode/CoreSimulator mismatch warning for iOS simulator support. The RN package build and selected native FF&E iOS tests have been validated with the Mac Catalyst destinations above. ## What This POC Should Prove @@ -650,7 +652,7 @@ Known local caveat: the current workstation reports an Xcode/CoreSimulator misma ## Recommended Next Step -The current branch has implemented the first native-first slice through configuration parsing, dynamic context, rules/precomputed evaluation, native fetch, native disk persistence, JSON fixture coverage, example-app wiring, and existing native Flags SDK side-effect reuse. The next implementation slice should finish the remaining extraction-oriented pieces: broader Swift fixture automation, a review note that records the confirmed native reuse points above, and a later swap from the RN-local file store to the native SDK data-store abstractions when this code moves downstream. +The current branch has implemented the first native-first slice through configuration parsing, dynamic context, rules/precomputed evaluation, native fetch, native SDK data-store-backed persistence with app-private fallback, shared JSON fixture coverage on Kotlin and Swift, example-app wiring, and existing native Flags SDK side-effect reuse. The next implementation slice should finish the remaining extraction-oriented pieces: a review note that records the confirmed native reuse points above, broader fixture iteration across the full shared corpus instead of the current representative cases, and a later cleanup of RN-local fallbacks when this code moves downstream. The minimum demo sequence remains: diff --git a/example-new-architecture/ios/Podfile.lock b/example-new-architecture/ios/Podfile.lock index cc0c0b9dd..2b2fea464 100644 --- a/example-new-architecture/ios/Podfile.lock +++ b/example-new-architecture/ios/Podfile.lock @@ -1872,7 +1872,7 @@ SPEC CHECKSUMS: DatadogInternal: 00709affd9889ca9e8fd96e85e39ad865e651c32 DatadogLogs: ef98708261f8f7ba82d15360f9951d43f578163b DatadogRUM: 67d130164f4a7663e05a3d02803be9e9dd1abbb7 - DatadogSDKReactNative: c2877e376b00655cf51363ab6cdc0fd013eb7e3b + DatadogSDKReactNative: bf53c1c065e3dea976d71d45d6015ddd173762f4 DatadogTrace: 17f50647107755fba79fc2298bf4cb282e0efb1f DatadogWebViewTracking: 1290fce6010bf65b5e0e0ffa9048b448782e469c DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 diff --git a/packages/core/DatadogSDKReactNative.podspec b/packages/core/DatadogSDKReactNative.podspec index 9fa6f9b59..80ecfb303 100644 --- a/packages/core/DatadogSDKReactNative.podspec +++ b/packages/core/DatadogSDKReactNative.podspec @@ -31,7 +31,7 @@ Pod::Spec.new do |s| s.test_spec 'Tests' do |test_spec| test_spec.source_files = 'ios/Tests/**/*.{swift,json}' - test_spec.resources = 'ios/Tests/Fixtures' + test_spec.resources = ['ios/Tests/Fixtures', 'src/flags/__fixtures__'] test_spec.platforms = { :ios => "13.4", :tvos => "13.4" } end diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt index d5055dd65..edcfb48d9 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt @@ -39,7 +39,9 @@ class DdSdkImplementation( private val nativeFfeCore: NativeFfeCore = NativeFfeCore() private val nativeFfeConfigurationFetcher: NativeFfeConfigurationFetcher = NativeFfeConfigurationFetcher() private val nativeFfeConfigurationStore: NativeFfeConfigurationStore = - FileNativeFfeConfigurationStore(appContext) + DatadogDataStoreNativeFfeConfigurationStore( + fallbackStore = FileNativeFfeConfigurationStore(appContext) + ) private val nativeFfeSideEffects: NativeFfeEvaluationSideEffects = NativeFfeEvaluationSideEffects() // region DdSdk diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt index c0fd660c6..263da444c 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt @@ -7,7 +7,19 @@ package com.datadog.reactnative import android.content.Context +import com.datadog.android.Datadog +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.api.storage.datastore.DataStoreHandler +import com.datadog.android.api.storage.datastore.DataStoreReadCallback +import com.datadog.android.api.storage.datastore.DataStoreWriteCallback +import com.datadog.android.core.internal.persistence.Deserializer +import com.datadog.android.core.persistence.Serializer +import com.datadog.android.core.persistence.datastore.DataStoreContent import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference import org.json.JSONObject internal interface NativeFfeConfigurationStore { @@ -31,55 +43,181 @@ internal class FileNativeFfeConfigurationStore( override fun save(slot: String, wire: String): NativeFfeStoredConfiguration { rootDirectory.mkdirs() - val key = slot.toStorageKey() + val key = slot.toNativeFfeStorageKey() val updatedAtMs = clockMs() - val payload = JSONObject() - .put("version", PAYLOAD_VERSION) - .put("key", key) - .put("updatedAtMs", updatedAtMs) - .put("wire", wire) - .toString() + val payload = nativeFfeStoragePayload(key, wire, updatedAtMs) fileForKey(key).writeText(payload, Charsets.UTF_8) return NativeFfeStoredConfiguration(key, wire, updatedAtMs) } override fun load(slot: String): NativeFfeStoredConfiguration? { - val key = slot.toStorageKey() + val key = slot.toNativeFfeStorageKey() val file = fileForKey(key) if (!file.exists()) { return null } - val payload = JSONObject(file.readText(Charsets.UTF_8)) - require(payload.optInt("version") == PAYLOAD_VERSION) { - "Unsupported stored flags configuration version: ${payload.optInt("version")}" - } - return NativeFfeStoredConfiguration( - key = key, - wire = payload.getString("wire"), - updatedAtMs = payload.optLong("updatedAtMs"), - ) + return nativeFfeStoredConfigurationFromPayload(key, file.readText(Charsets.UTF_8)) } private fun fileForKey(key: String): File { return File(rootDirectory, "$key.json") } - private fun String.toStorageKey(): String { - val sanitized = takeIf { it.isNotBlank() } - ?.replace(STORAGE_KEY_ALLOWED_PATTERN, "_") - ?.trim('_') - ?.takeIf { it.isNotBlank() } - ?: DEFAULT_SLOT - return "${KEY_PREFIX}-${sanitized.take(MAX_SLOT_LENGTH)}" - } - private companion object { const val ROOT_DIRECTORY = "datadog/native-ffe" const val CONFIGURATIONS_DIRECTORY = "configurations" - const val PAYLOAD_VERSION = 1 - const val DEFAULT_SLOT = "default" - const val KEY_PREFIX = "flags-configuration" - const val MAX_SLOT_LENGTH = 80 - val STORAGE_KEY_ALLOWED_PATTERN = Regex("[^A-Za-z0-9._-]") } } + +internal class DatadogDataStoreNativeFfeConfigurationStore( + private val dataStoreProvider: () -> DataStoreHandler? = { defaultFlagsDataStore() }, + private val fallbackStore: NativeFfeConfigurationStore? = null, + private val clockMs: () -> Long = { System.currentTimeMillis() }, + private val timeoutMs: Long = DATASTORE_TIMEOUT_MS, +) : NativeFfeConfigurationStore { + override fun save(slot: String, wire: String): NativeFfeStoredConfiguration { + val dataStore = dataStoreProvider() ?: return fallbackSave(slot, wire) + val key = slot.toNativeFfeStorageKey() + val updatedAtMs = clockMs() + val payload = nativeFfeStoragePayload(key, wire, updatedAtMs) + val outcome = AtomicReference() + val latch = CountDownLatch(1) + + try { + dataStore.setValue( + key, + payload, + NATIVE_FFE_STORAGE_PAYLOAD_VERSION, + object : DataStoreWriteCallback { + override fun onSuccess() { + outcome.set(true) + latch.countDown() + } + + override fun onFailure() { + outcome.set(false) + latch.countDown() + } + }, + STRING_SERIALIZER + ) + } catch (_: Exception) { + return fallbackSave(slot, wire) + } + + if (latch.await(timeoutMs, TimeUnit.MILLISECONDS) && outcome.get() == true) { + return NativeFfeStoredConfiguration(key, wire, updatedAtMs) + } + return fallbackSave(slot, wire) + } + + override fun load(slot: String): NativeFfeStoredConfiguration? { + val dataStore = dataStoreProvider() ?: return fallbackStore?.load(slot) + val key = slot.toNativeFfeStorageKey() + val outcome = AtomicReference() + val failed = AtomicReference(false) + val latch = CountDownLatch(1) + + try { + dataStore.value( + key, + NATIVE_FFE_STORAGE_PAYLOAD_VERSION, + object : DataStoreReadCallback { + override fun onSuccess(dataStoreContent: DataStoreContent?) { + val payload = dataStoreContent?.data + if (payload == null) { + failed.set(true) + } else { + try { + outcome.set(nativeFfeStoredConfigurationFromPayload(key, payload)) + } catch (_: Exception) { + failed.set(true) + } + } + latch.countDown() + } + + override fun onFailure() { + failed.set(true) + latch.countDown() + } + }, + STRING_DESERIALIZER + ) + } catch (_: Exception) { + return fallbackStore?.load(slot) + } + + return if (latch.await(timeoutMs, TimeUnit.MILLISECONDS) && failed.get() == false) { + outcome.get() + } else { + fallbackStore?.load(slot) + } + } + + private fun fallbackSave(slot: String, wire: String): NativeFfeStoredConfiguration { + return fallbackStore?.save(slot, wire) + ?: throw IllegalStateException("Datadog Flags data store is not available") + } + + private companion object { + const val DATASTORE_TIMEOUT_MS = 3_000L + + val STRING_SERIALIZER = object : Serializer { + override fun serialize(model: String): String = model + } + + val STRING_DESERIALIZER = object : Deserializer { + override fun deserialize(model: String): String = model + } + + fun defaultFlagsDataStore(): DataStoreHandler? { + return try { + (Datadog.getInstance() as? FeatureSdkCore) + ?.getFeature(Feature.FLAGS_FEATURE_NAME) + ?.dataStore + } catch (_: Throwable) { + null + } + } + } +} + +private const val NATIVE_FFE_STORAGE_PAYLOAD_VERSION = 1 +private const val DEFAULT_SLOT = "default" +private const val KEY_PREFIX = "flags-configuration" +private const val MAX_SLOT_LENGTH = 80 +private val STORAGE_KEY_ALLOWED_PATTERN = Regex("[^A-Za-z0-9._-]") + +private fun String.toNativeFfeStorageKey(): String { + val sanitized = takeIf { it.isNotBlank() } + ?.replace(STORAGE_KEY_ALLOWED_PATTERN, "_") + ?.trim('_') + ?.takeIf { it.isNotBlank() } + ?: DEFAULT_SLOT + return "${KEY_PREFIX}-${sanitized.take(MAX_SLOT_LENGTH)}" +} + +private fun nativeFfeStoragePayload(key: String, wire: String, updatedAtMs: Long): String { + return JSONObject() + .put("version", NATIVE_FFE_STORAGE_PAYLOAD_VERSION) + .put("key", key) + .put("updatedAtMs", updatedAtMs) + .put("wire", wire) + .toString() +} + +private fun nativeFfeStoredConfigurationFromPayload( + expectedKey: String, + payloadString: String, +): NativeFfeStoredConfiguration { + val payload = JSONObject(payloadString) + require(payload.optInt("version") == NATIVE_FFE_STORAGE_PAYLOAD_VERSION) { + "Unsupported stored flags configuration version: ${payload.optInt("version")}" + } + return NativeFfeStoredConfiguration( + key = payload.optString("key", expectedKey), + wire = payload.getString("wire"), + updatedAtMs = payload.optLong("updatedAtMs"), + ) +} diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt index 1e6d8793d..75d60c2d2 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt @@ -6,6 +6,12 @@ package com.datadog.reactnative +import com.datadog.android.api.storage.datastore.DataStoreHandler +import com.datadog.android.api.storage.datastore.DataStoreReadCallback +import com.datadog.android.api.storage.datastore.DataStoreWriteCallback +import com.datadog.android.core.internal.persistence.Deserializer +import com.datadog.android.core.persistence.Serializer +import com.datadog.android.core.persistence.datastore.DataStoreContent import java.io.File import org.assertj.core.api.Assertions.assertThat import org.assertj.core.data.Offset @@ -70,6 +76,40 @@ internal class NativeFfeCoreTest { .containsEntry("configurationLoadCount", 1) } + @Test + fun `M save and load configuration W Datadog Flags data store`() { + // Given + val dataStore = FakeDataStoreHandler() + val store = DatadogDataStoreNativeFfeConfigurationStore( + dataStoreProvider = { dataStore }, + clockMs = { STORED_AT_MS }, + ) + val configuration = testedCore.configurationFromString(flagsConfigurationWire) + + // When + val saveState = testedCore.saveConfiguration( + configuration.toMap(), + mapOf("slot" to "default"), + store, + ) + val loadedConfiguration = testedCore.loadConfiguration( + mapOf("slot" to "default"), + store, + ) + + // Then + assertThat(testedCore.configurationToString(loadedConfiguration.toMap())) + .isEqualTo(flagsConfigurationWire) + @Suppress("UNCHECKED_CAST") + val lastSave = saveState["lastStorage"] as Map + assertThat(lastSave) + .containsEntry("operation", "save") + .containsEntry("status", "stored") + .containsEntry("key", "flags-configuration-default") + .containsEntry("updatedAtMs", STORED_AT_MS) + assertThat(dataStore.values).containsKey("flags-configuration-default") + } + @Test fun `M return static reason W canonical numeric flag case`() { // Given @@ -234,3 +274,46 @@ internal class NativeFfeCoreTest { } } } + +private class FakeDataStoreHandler : DataStoreHandler { + val values = mutableMapOf>() + + override fun setValue( + key: String, + data: T, + version: Int, + callback: DataStoreWriteCallback?, + serializer: Serializer, + ) { + values[key] = version to (serializer.serialize(data) ?: "") + callback?.onSuccess() + } + + override fun value( + key: String, + version: Int?, + callback: DataStoreReadCallback, + deserializer: Deserializer, + ) { + val stored = values[key] ?: return callback.onFailure() + if (version != null && stored.first != version) { + callback.onFailure() + return + } + callback.onSuccess( + DataStoreContent( + stored.first, + deserializer.deserialize(stored.second), + ) + ) + } + + override fun removeValue(key: String, callback: DataStoreWriteCallback?) { + values.remove(key) + callback?.onSuccess() + } + + override fun clearAllData() { + values.clear() + } +} diff --git a/packages/core/ios/Sources/DdSdkImplementation.swift b/packages/core/ios/Sources/DdSdkImplementation.swift index b1641a0f6..b59cdc373 100644 --- a/packages/core/ios/Sources/DdSdkImplementation.swift +++ b/packages/core/ios/Sources/DdSdkImplementation.swift @@ -34,7 +34,9 @@ public class DdSdkImplementation: NSObject { var RUMMonitorInternalProvider: () -> RUMMonitorInternalProtocol? private let nativeFfeCore = NativeFfeCore() private let nativeFfeConfigurationFetcher = NativeFfeConfigurationFetcher() - private let nativeFfeConfigurationStore = FileNativeFfeConfigurationStore() + private let nativeFfeConfigurationStore = DatadogDataStoreNativeFfeConfigurationStore( + fallbackStore: FileNativeFfeConfigurationStore() + ) private let nativeFfeSideEffects = NativeFfeEvaluationSideEffects() #if os(iOS) diff --git a/packages/core/ios/Sources/NativeFfeConfigurationStore.swift b/packages/core/ios/Sources/NativeFfeConfigurationStore.swift index 41721ee80..b24e39860 100644 --- a/packages/core/ios/Sources/NativeFfeConfigurationStore.swift +++ b/packages/core/ios/Sources/NativeFfeConfigurationStore.swift @@ -4,6 +4,8 @@ * Copyright 2016-Present Datadog, Inc. */ +import DatadogCore +import DatadogInternal import Foundation internal protocol NativeFfeConfigurationStoring { @@ -34,55 +36,27 @@ internal final class FileNativeFfeConfigurationStore: NativeFfeConfigurationStor at: rootDirectory, withIntermediateDirectories: true ) - let key = storageKey(for: slot) + let key = nativeFfeStorageKey(for: slot) let updatedAtMs = clockMs() - let payload: [String: Any] = [ - "version": Constants.payloadVersion, - "key": key, - "updatedAtMs": updatedAtMs, - "wire": wire, - ] - let data = try JSONSerialization.data(withJSONObject: payload) + let data = try nativeFfeStoragePayload(key: key, wire: wire, updatedAtMs: updatedAtMs) try data.write(to: fileURL(forKey: key), options: .atomic) return NativeFfeStoredConfiguration(key: key, wire: wire, updatedAtMs: updatedAtMs) } func load(slot: String) throws -> NativeFfeStoredConfiguration? { - let key = storageKey(for: slot) + let key = nativeFfeStorageKey(for: slot) let url = fileURL(forKey: key) guard FileManager.default.fileExists(atPath: url.path) else { return nil } let data = try Data(contentsOf: url) - guard - let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let version = payload["version"] as? Int, - version == Constants.payloadVersion, - let wire = payload["wire"] as? String - else { - throw NativeFfeConfigurationStoreError.invalidPayload - } - let updatedAtMs = - (payload["updatedAtMs"] as? Int64) - ?? (payload["updatedAtMs"] as? NSNumber)?.int64Value - ?? 0 - return NativeFfeStoredConfiguration(key: key, wire: wire, updatedAtMs: updatedAtMs) + return try nativeFfeStoredConfiguration(from: data, expectedKey: key) } private func fileURL(forKey key: String) -> URL { rootDirectory.appendingPathComponent("\(key).json") } - private func storageKey(for slot: String) -> String { - let rawSlot = slot.isEmpty ? Constants.defaultSlot : slot - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) - let sanitized = rawSlot.unicodeScalars.map { scalar in - allowed.contains(scalar) ? Character(scalar).description : "_" - }.joined().trimmingCharacters(in: CharacterSet(charactersIn: "_")) - let normalized = sanitized.isEmpty ? Constants.defaultSlot : sanitized - return "\(Constants.keyPrefix)-\(String(normalized.prefix(Constants.maxSlotLength)))" - } - private static func defaultRootDirectory() -> URL { let base = FileManager.default.urls( for: .applicationSupportDirectory, @@ -96,18 +70,155 @@ internal final class FileNativeFfeConfigurationStore: NativeFfeConfigurationStor private enum Constants { static let rootDirectory = "Datadog/native-ffe" static let configurationsDirectory = "configurations" - static let payloadVersion = 1 - static let defaultSlot = "default" - static let keyPrefix = "flags-configuration" - static let maxSlotLength = 80 } } +internal final class DatadogDataStoreNativeFfeConfigurationStore: NativeFfeConfigurationStoring { + private let dataStoreProvider: () -> DataStore? + private let fallbackStore: NativeFfeConfigurationStoring? + private let clockMs: () -> Int64 + private let timeout: DispatchTimeInterval + + init( + dataStoreProvider: @escaping () -> DataStore? = { + guard Datadog.isInitialized(instanceName: CoreRegistry.defaultInstanceName) else { + return nil + } + return CoreRegistry.default.scope(for: NativeFfeFlagsFeature.self).dataStore + }, + fallbackStore: NativeFfeConfigurationStoring? = nil, + clockMs: @escaping () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1_000) }, + timeout: DispatchTimeInterval = .seconds(3) + ) { + self.dataStoreProvider = dataStoreProvider + self.fallbackStore = fallbackStore + self.clockMs = clockMs + self.timeout = timeout + } + + func save(slot: String, wire: String) throws -> NativeFfeStoredConfiguration { + guard let dataStore = dataStoreProvider() else { + return try fallbackSave(slot: slot, wire: wire) + } + let key = nativeFfeStorageKey(for: slot) + let updatedAtMs = clockMs() + let payload = try nativeFfeStoragePayload(key: key, wire: wire, updatedAtMs: updatedAtMs) + dataStore.setValue(payload, forKey: key, version: Constants.payloadVersion) + dataStore.flush() + return NativeFfeStoredConfiguration(key: key, wire: wire, updatedAtMs: updatedAtMs) + } + + func load(slot: String) throws -> NativeFfeStoredConfiguration? { + guard let dataStore = dataStoreProvider() else { + return try fallbackStore?.load(slot: slot) + } + let key = nativeFfeStorageKey(for: slot) + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + + dataStore.value(forKey: key) { valueResult in + switch valueResult { + case .value(let data, let version): + guard version == Constants.payloadVersion else { + result = .failure(NativeFfeConfigurationStoreError.invalidPayload) + break + } + result = Result { + try nativeFfeStoredConfiguration(from: data, expectedKey: key) + } + case .noValue: + result = .success(nil) + case .error(let error): + result = .failure(error) + } + semaphore.signal() + } + + guard semaphore.wait(timeout: .now() + timeout) == .success else { + return try fallbackStore?.load(slot: slot) + } + + switch result { + case .success(let stored): + if let stored { + return stored + } + return try fallbackStore?.load(slot: slot) + case .failure: + return try fallbackStore?.load(slot: slot) + case .none: + return try fallbackStore?.load(slot: slot) + } + } + + private func fallbackSave(slot: String, wire: String) throws -> NativeFfeStoredConfiguration { + guard let fallbackStore else { + throw NativeFfeConfigurationStoreError.dataStoreUnavailable + } + return try fallbackStore.save(slot: slot, wire: wire) + } +} + +private struct NativeFfeFlagsFeature: DatadogFeature { + static let name = "flags" + let messageReceiver: FeatureMessageReceiver = NOPFeatureMessageReceiver() +} + +private enum Constants { + static let payloadVersion: DataStoreKeyVersion = 1 + static let defaultSlot = "default" + static let keyPrefix = "flags-configuration" + static let maxSlotLength = 80 +} + +private func nativeFfeStorageKey(for slot: String) -> String { + let rawSlot = slot.isEmpty ? Constants.defaultSlot : slot + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + let sanitized = rawSlot.unicodeScalars.map { scalar in + allowed.contains(scalar) ? Character(scalar).description : "_" + }.joined().trimmingCharacters(in: CharacterSet(charactersIn: "_")) + let normalized = sanitized.isEmpty ? Constants.defaultSlot : sanitized + return "\(Constants.keyPrefix)-\(String(normalized.prefix(Constants.maxSlotLength)))" +} + +private func nativeFfeStoragePayload(key: String, wire: String, updatedAtMs: Int64) throws -> Data { + let payload: [String: Any] = [ + "version": Int(Constants.payloadVersion), + "key": key, + "updatedAtMs": updatedAtMs, + "wire": wire, + ] + return try JSONSerialization.data(withJSONObject: payload) +} + +private func nativeFfeStoredConfiguration( + from data: Data, + expectedKey: String +) throws -> NativeFfeStoredConfiguration { + guard + let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = payload["version"] as? Int, + version == Int(Constants.payloadVersion), + let wire = payload["wire"] as? String + else { + throw NativeFfeConfigurationStoreError.invalidPayload + } + let key = (payload["key"] as? String) ?? expectedKey + let updatedAtMs = + (payload["updatedAtMs"] as? Int64) + ?? (payload["updatedAtMs"] as? NSNumber)?.int64Value + ?? 0 + return NativeFfeStoredConfiguration(key: key, wire: wire, updatedAtMs: updatedAtMs) +} + private enum NativeFfeConfigurationStoreError: LocalizedError { + case dataStoreUnavailable case invalidPayload var errorDescription: String? { switch self { + case .dataStoreUnavailable: + return "Datadog Flags data store is not available" case .invalidPayload: return "Stored flags configuration payload is invalid" } diff --git a/packages/core/ios/Tests/NativeFfeCoreTests.swift b/packages/core/ios/Tests/NativeFfeCoreTests.swift new file mode 100644 index 000000000..9bf099c19 --- /dev/null +++ b/packages/core/ios/Tests/NativeFfeCoreTests.swift @@ -0,0 +1,348 @@ +/* + * 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 DatadogInternal +import Foundation +import XCTest + +@testable import DatadogSDKReactNative + +final class NativeFfeCoreTests: XCTestCase { + private let numericTolerance = 0.0000001 + private let storedAtMs: Int64 = 1_780_000_000_000 + + func testParseAndSerializeConfigurationWithCanonicalUfcConfigurationWireRoundTrip() throws { + let testedCore = NativeFfeCore() + + let configuration = try testedCore.configurationFromString(Self.flagsConfigurationWire) + let serialized = try testedCore.configurationToString(configuration.toMap()) + let wireJson = try Self.jsonObject(Self.flagsConfigurationWire) + let server = try XCTUnwrap(wireJson["server"] as? [String: Any]) + let embeddedUfcConfig = try XCTUnwrap(server["response"] as? String) + + XCTAssertEqual(configuration.kind, "rules") + XCTAssertEqual(configuration.etag, "ffe-system-test-data") + XCTAssertEqual(embeddedUfcConfig, Self.canonicalUfcConfig) + XCTAssertEqual(serialized, Self.flagsConfigurationWire) + } + + func testSaveAndLoadConfigurationWithNativeDiskStore() throws { + let testedCore = NativeFfeCore() + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("native-ffe-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: tempDirectory) } + let store = FileNativeFfeConfigurationStore(rootDirectory: tempDirectory) { + self.storedAtMs + } + let configuration = try testedCore.configurationFromString(Self.flagsConfigurationWire) + + let saveState = try testedCore.saveConfiguration( + configuration.toMap(), + options: ["slot": "default"], + store: store + ) + let loadedConfiguration = try testedCore.loadConfiguration( + options: ["slot": "default"], + store: store + ) + let activatedState = testedCore.setConfiguration(loadedConfiguration.toMap()) + + XCTAssertEqual(try testedCore.configurationToString(loadedConfiguration.toMap()), Self.flagsConfigurationWire) + XCTAssertEqual(saveState["configurationSaveCount"] as? Int, 1) + XCTAssertEqual(saveState["configurationLoadCount"] as? Int, 0) + XCTAssertNil(saveState["activeConfigurationKind"]) + let lastSave = try XCTUnwrap(saveState["lastStorage"] as? [String: Any]) + XCTAssertEqual(lastSave["operation"] as? String, "save") + XCTAssertEqual(lastSave["status"] as? String, "stored") + XCTAssertEqual(lastSave["key"] as? String, "flags-configuration-default") + XCTAssertEqual(lastSave["updatedAtMs"] as? Int64, storedAtMs) + XCTAssertEqual(activatedState["activeConfigurationKind"] as? String, "rules") + XCTAssertEqual(activatedState["configurationLoadCount"] as? Int, 1) + } + + func testSaveAndLoadConfigurationWithDatadogFlagsDataStore() throws { + let testedCore = NativeFfeCore() + let dataStore = NativeFfeFakeDataStore() + let store = DatadogDataStoreNativeFfeConfigurationStore( + dataStoreProvider: { dataStore }, + clockMs: { self.storedAtMs } + ) + let configuration = try testedCore.configurationFromString(Self.flagsConfigurationWire) + + let saveState = try testedCore.saveConfiguration( + configuration.toMap(), + options: ["slot": "default"], + store: store + ) + let loadedConfiguration = try testedCore.loadConfiguration( + options: ["slot": "default"], + store: store + ) + + XCTAssertEqual(try testedCore.configurationToString(loadedConfiguration.toMap()), Self.flagsConfigurationWire) + let lastSave = try XCTUnwrap(saveState["lastStorage"] as? [String: Any]) + XCTAssertEqual(lastSave["operation"] as? String, "save") + XCTAssertEqual(lastSave["status"] as? String, "stored") + XCTAssertEqual(lastSave["key"] as? String, "flags-configuration-default") + XCTAssertEqual(lastSave["updatedAtMs"] as? Int64, storedAtMs) + XCTAssertNotNil(dataStore.values["flags-configuration-default"]) + } + + func testReturnStaticReasonWithCanonicalNumericFlagCase() throws { + let testedCore = try configuredCore() + let evaluationCase = try Self.evaluationCase("test-case-numeric-flag.json") + _ = testedCore.setEvaluationContext(evaluationCase.context) + + let result = try resolveEvaluation(evaluationCase, with: testedCore) + + assertEvaluationResult(result, evaluationCase) + XCTAssertEqual(result["variant"] as? String, "pi") + let metadata = try XCTUnwrap(result["flagMetadata"] as? [String: Any]) + XCTAssertEqual(metadata["__dd_allocation_key"] as? String, "rollout") + XCTAssertEqual(metadata["__dd_do_log"] as? Bool, true) + } + + func testReturnSplitReasonWithCanonicalShardedFlagCase() throws { + let testedCore = try configuredCore() + let evaluationCase = try Self.evaluationCase( + "test-case-flag-with-empty-string.json", + caseIndex: 1 + ) + _ = testedCore.setEvaluationContext(evaluationCase.context) + + let result = try resolveEvaluation(evaluationCase, with: testedCore) + + assertEvaluationResult(result, evaluationCase) + } + + func testReturnTargetingMatchReasonWithCanonicalTargetedFlagCase() throws { + let testedCore = try configuredCore() + let evaluationCase = try Self.evaluationCase("test-case-flag-with-empty-string.json") + _ = testedCore.setEvaluationContext(evaluationCase.context) + + let result = try resolveEvaluation(evaluationCase, with: testedCore) + + assertEvaluationResult(result, evaluationCase) + } + + func testReturnTargetingKeyMissingWithCanonicalNullTargetingKeyCase() throws { + let testedCore = try configuredCore() + let evaluationCase = try Self.evaluationCase( + "test-case-null-targeting-key.json", + caseIndex: 1 + ) + _ = testedCore.setEvaluationContext(evaluationCase.context) + + let result = try resolveEvaluation(evaluationCase, with: testedCore) + + assertEvaluationResult(result, evaluationCase) + XCTAssertEqual(result["errorCode"] as? String, "TARGETING_KEY_MISSING") + } + + private func configuredCore() throws -> NativeFfeCore { + let testedCore = NativeFfeCore() + let configuration = try testedCore.configurationFromString(Self.flagsConfigurationWire) + _ = testedCore.setConfiguration(configuration.toMap()) + return testedCore + } + + private func resolveEvaluation( + _ evaluationCase: EvaluationCase, + with testedCore: NativeFfeCore + ) throws -> [String: Any] { + switch evaluationCase.variationType { + case "BOOLEAN": + return testedCore.resolveBooleanEvaluation( + flagKey: evaluationCase.flag, + defaultValue: try XCTUnwrap(evaluationCase.defaultValue as? Bool) + ) + case "STRING": + return testedCore.resolveStringEvaluation( + flagKey: evaluationCase.flag, + defaultValue: try XCTUnwrap(evaluationCase.defaultValue as? String) + ) + case "INTEGER", "NUMERIC": + return testedCore.resolveNumberEvaluation( + flagKey: evaluationCase.flag, + defaultValue: try XCTUnwrap(doubleValue(evaluationCase.defaultValue)) + ) + case "JSON": + return testedCore.resolveObjectEvaluation( + flagKey: evaluationCase.flag, + defaultValue: try XCTUnwrap(evaluationCase.defaultValue as? [String: Any]) + ) + default: + XCTFail("Unsupported fixture variation type: \(evaluationCase.variationType)") + return [:] + } + } + + private func assertEvaluationResult( + _ result: [String: Any], + _ evaluationCase: EvaluationCase, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(result["flagKey"] as? String, evaluationCase.flag, file: file, line: line) + XCTAssertEqual(result["reason"] as? String, evaluationCase.expectedReason, file: file, line: line) + assertJsonValue(result["value"], evaluationCase.expectedValue, file: file, line: line) + } + + private func assertJsonValue( + _ actual: Any?, + _ expected: Any?, + file: StaticString = #filePath, + line: UInt = #line + ) { + if let actualNumber = doubleValue(actual), let expectedNumber = doubleValue(expected) { + XCTAssertEqual(actualNumber, expectedNumber, accuracy: numericTolerance, file: file, line: line) + return + } + XCTAssertTrue( + jsonValuesEqual(actual, expected), + "Expected \(String(describing: expected)), got \(String(describing: actual))", + file: file, + line: line + ) + } + + private func jsonValuesEqual(_ actual: Any?, _ expected: Any?) -> Bool { + if isNull(actual), isNull(expected) { + return true + } + if let actualBool = actual as? Bool, let expectedBool = expected as? Bool { + return actualBool == expectedBool + } + if let actualNumber = doubleValue(actual), let expectedNumber = doubleValue(expected) { + return abs(actualNumber - expectedNumber) <= numericTolerance + } + if let actualString = actual as? String, let expectedString = expected as? String { + return actualString == expectedString + } + if let actualArray = actual as? [Any], let expectedArray = expected as? [Any] { + guard actualArray.count == expectedArray.count else { + return false + } + return zip(actualArray, expectedArray).allSatisfy(jsonValuesEqual) + } + if let actualDictionary = actual as? [String: Any], + let expectedDictionary = expected as? [String: Any] { + guard Set(actualDictionary.keys) == Set(expectedDictionary.keys) else { + return false + } + return actualDictionary.keys.allSatisfy { + jsonValuesEqual(actualDictionary[$0], expectedDictionary[$0]) + } + } + return false + } + + private func isNull(_ value: Any?) -> Bool { + value == nil || value is NSNull + } + + private func doubleValue(_ value: Any?) -> Double? { + if let number = value as? NSNumber { + guard CFGetTypeID(number) != CFBooleanGetTypeID() else { + return nil + } + return number.doubleValue + } + if let double = value as? Double { + return double + } + if let int = value as? Int { + return Double(int) + } + return nil + } + + private struct EvaluationCase { + let flag: String + let variationType: String + let defaultValue: Any + let targetingKey: String? + let attributes: [String: Any] + let expectedValue: Any + let expectedReason: String + + var context: [String: Any] { + var context: [String: Any] = [ + "attributes": attributes, + ] + if let targetingKey { + context["targetingKey"] = targetingKey + } + return context + } + } + + private static func evaluationCase( + _ fileName: String, + caseIndex: Int = 0 + ) throws -> EvaluationCase { + let cases = try NativeFfeTestFixtures.jsonArray( + "ffe-system-test-data/evaluation-cases/\(fileName)" + ) + let caseJson = try XCTUnwrap(cases[caseIndex] as? [String: Any]) + let resultJson = try XCTUnwrap(caseJson["result"] as? [String: Any]) + return EvaluationCase( + flag: try XCTUnwrap(caseJson["flag"] as? String), + variationType: try XCTUnwrap(caseJson["variationType"] as? String), + defaultValue: try XCTUnwrap(caseJson["defaultValue"]), + targetingKey: optionalString(caseJson["targetingKey"]), + attributes: (caseJson["attributes"] as? [String: Any]) ?? [:], + expectedValue: try XCTUnwrap(resultJson["value"]), + expectedReason: try XCTUnwrap(resultJson["reason"] as? String) + ) + } + + private static func optionalString(_ value: Any?) -> String? { + guard let value, !(value is NSNull) else { + return nil + } + return String(describing: value) + } + + private static func jsonObject(_ json: String) throws -> [String: Any] { + let data = try XCTUnwrap(json.data(using: .utf8)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private static let flagsConfigurationWire: String = { + try! NativeFfeTestFixtures.readString("native-ffe/rules-configuration-wire.json") + }() + + private static let canonicalUfcConfig: String = { + try! NativeFfeTestFixtures.readString("ffe-system-test-data/ufc-config.json") + }() +} + +private final class NativeFfeFakeDataStore: DataStore { + var values: [String: (data: Data, version: DataStoreKeyVersion)] = [:] + + func setValue(_ value: Data, forKey key: String, version: DataStoreKeyVersion) { + values[key] = (value, version) + } + + func value(forKey key: String, callback: @escaping (DataStoreValueResult) -> Void) { + guard let value = values[key] else { + callback(.noValue) + return + } + callback(.value(value.data, value.version)) + } + + func removeValue(forKey key: String) { + values.removeValue(forKey: key) + } + + func clearAllData() { + values.removeAll() + } + + func flush() {} +} diff --git a/packages/core/ios/Tests/NativeFfeEvaluationSideEffectsTests.swift b/packages/core/ios/Tests/NativeFfeEvaluationSideEffectsTests.swift new file mode 100644 index 000000000..67dd4c613 --- /dev/null +++ b/packages/core/ios/Tests/NativeFfeEvaluationSideEffectsTests.swift @@ -0,0 +1,90 @@ +/* + * 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 Foundation +import XCTest +@_spi(Internal) +import DatadogFlags + +@testable import DatadogSDKReactNative + +final class NativeFfeEvaluationSideEffectsTests: XCTestCase { + private let fakeTracker = FakeEvaluationTracker() + + func testTrackEvaluationWithSuccessfulNativeEvaluationResult() throws { + let testedSideEffects = NativeFfeEvaluationSideEffects(tracker: fakeTracker) + let fixture = try sideEffectFixture("tracked-string-evaluation.json") + + let status = testedSideEffects.trackEvaluation( + result: fixture.result, + context: fixture.context + ) + + let request = try XCTUnwrap(fakeTracker.trackedRequest) + XCTAssertEqual(status, "tracked") + XCTAssertEqual(request.clientName, "default") + XCTAssertEqual(request.flagKey, "checkout.copy") + XCTAssertEqual(request.assignment.variationKey, "treatment") + XCTAssertEqual(request.assignment.allocationKey, "pro allocation") + XCTAssertEqual(request.assignment.reason, "TARGETING_MATCH") + XCTAssertEqual(request.assignment.doLog, true) + if case .string(let variationValue) = request.assignment.variation { + XCTAssertEqual(variationValue, "enabled") + } else { + XCTFail("Expected string variation") + } + XCTAssertEqual(request.context.targetingKey, "user-123") + + let debugState = testedSideEffects.debugState() + XCTAssertEqual(debugState["attemptedCount"] as? Int, 1) + XCTAssertEqual(debugState["trackedCount"] as? Int, 1) + XCTAssertEqual(debugState["skippedCount"] as? Int, 0) + XCTAssertEqual(debugState["failedCount"] as? Int, 0) + XCTAssertEqual(debugState["lastStatus"] as? String, "tracked") + } + + func testSkipEvaluationSideEffectsWithDefaultResult() throws { + let testedSideEffects = NativeFfeEvaluationSideEffects(tracker: fakeTracker) + let fixture = try sideEffectFixture("skipped-default-evaluation.json") + + let status = testedSideEffects.trackEvaluation( + result: fixture.result, + context: fixture.context + ) + + XCTAssertEqual(status, "skipped") + XCTAssertNil(fakeTracker.trackedRequest) + let debugState = testedSideEffects.debugState() + XCTAssertEqual(debugState["attemptedCount"] as? Int, 0) + XCTAssertEqual(debugState["trackedCount"] as? Int, 0) + XCTAssertEqual(debugState["skippedCount"] as? Int, 1) + XCTAssertEqual(debugState["failedCount"] as? Int, 0) + XCTAssertEqual(debugState["lastStatus"] as? String, "skipped") + } + + private func sideEffectFixture(_ fileName: String) throws -> SideEffectFixture { + let fixture = try NativeFfeTestFixtures.jsonObject( + "native-ffe/evaluation-side-effects/\(fileName)" + ) + return SideEffectFixture( + result: try XCTUnwrap(fixture["result"] as? [String: Any]), + context: (fixture["context"] as? [String: Any]) ?? [:] + ) + } + + private struct SideEffectFixture { + let result: [String: Any] + let context: [String: Any] + } + + private final class FakeEvaluationTracker: NativeFfeEvaluationTracking { + var trackedRequest: NativeFfeEvaluationSideEffectRequest? + + func track(_ request: NativeFfeEvaluationSideEffectRequest) throws { + trackedRequest = request + } + } +} diff --git a/packages/core/ios/Tests/NativeFfeTestFixtures.swift b/packages/core/ios/Tests/NativeFfeTestFixtures.swift new file mode 100644 index 000000000..48683b10b --- /dev/null +++ b/packages/core/ios/Tests/NativeFfeTestFixtures.swift @@ -0,0 +1,50 @@ +/* + * 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 Foundation +import XCTest + +enum NativeFfeTestFixtures { + static func jsonObject(_ relativePath: String) throws -> [String: Any] { + let fixture = try readString(relativePath) + let data = try XCTUnwrap(fixture.data(using: .utf8)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + static func jsonArray(_ relativePath: String) throws -> [Any] { + let fixture = try readString(relativePath) + let data = try XCTUnwrap(fixture.data(using: .utf8)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [Any]) + } + + static func readString(_ relativePath: String) throws -> String { + let bundle = Bundle(for: BundleToken.self) + let candidates = [ + "__fixtures__/\(relativePath)", + relativePath, + ] + for candidate in candidates { + let nsPath = candidate as NSString + let resourcePath = nsPath.deletingPathExtension + let resourceExtension = nsPath.pathExtension.isEmpty ? "json" : nsPath.pathExtension + if let url = bundle.url(forResource: resourcePath, withExtension: resourceExtension) { + return try String(contentsOf: url, encoding: .utf8) + } + } + + let sourceFile = URL(fileURLWithPath: #filePath) + let packageRoot = sourceFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let localFixture = packageRoot + .appendingPathComponent("src/flags/__fixtures__") + .appendingPathComponent(relativePath) + return try String(contentsOf: localFixture, encoding: .utf8) + } + + private final class BundleToken {} +} From 0102491b750a7ba84a959690919bf81920a4e272 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 18:28:59 -0400 Subject: [PATCH 07/18] Store native flags config as parsed models --- .gitmodules | 3 + .../com/datadog/reactnative/NativeFfeCore.kt | 386 +- .../datadog/reactnative/NativeFfeCoreTest.kt | 92 +- .../reactnative/NativeFfeJsonFixtures.kt | 13 + packages/core/ios/Sources/NativeFfeCore.swift | 412 +- .../core/ios/Tests/NativeFfeCoreTests.swift | 66 +- .../ios/Tests/NativeFfeTestFixtures.swift | 41 +- .../flags/__fixtures__/ffe-system-test-data | 1 + .../test-case-boolean-false-assignment.json | 41 - .../test-case-boolean-one-of-matches.json | 208 - .../test-case-comparator-operator-flag.json | 69 - .../test-case-disabled-flag.json | 43 - .../test-case-empty-flag.json | 43 - .../test-case-empty-string-variation.json | 41 - .../test-case-falsy-value-assignments.json | 41 - .../test-case-flag-with-empty-string.json | 26 - .../test-case-integer-flag.json | 267 -- ...t-case-invalid-shard-bounds-isolation.json | 38 - .../test-case-kill-switch-flag.json | 314 -- .../test-case-malformed-flag-isolation.json | 26 - .../test-case-microsecond-date-flag.json | 39 - ...t-case-missing-split-shards-isolation.json | 26 - .../test-case-new-user-onboarding-flag.json | 344 -- .../test-case-no-allocations-flag.json | 55 - .../test-case-null-operator-flag.json | 69 - .../test-case-null-targeting-key.json | 40 - .../test-case-numeric-flag.json | 43 - .../test-case-numeric-one-of-default.json | 13 - .../test-case-numeric-one-of.json | 93 - .../test-case-of-7-empty-targeting-key.json | 13 - .../test-case-regex-flag.json | 109 - .../test-case-start-and-end-date-flag.json | 43 - .../test-case-unknown-fields-tolerance.json | 16 - .../test-case-unknown-operator-isolation.json | 30 - .../test-flag-that-does-not-exist.json | 46 - .../test-json-config-flag.json | 76 - .../test-no-allocations-flag.json | 55 - .../test-special-characters.json | 58 - .../test-string-with-special-characters.json | 860 ---- .../ffe-system-test-data/ufc-config.json | 3696 ----------------- .../native-ffe/rules-configuration-wire.json | 2 +- 41 files changed, 817 insertions(+), 7080 deletions(-) create mode 100644 .gitmodules create mode 160000 packages/core/src/flags/__fixtures__/ffe-system-test-data delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-false-assignment.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-one-of-matches.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-comparator-operator-flag.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-disabled-flag.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-flag.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-string-variation.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-falsy-value-assignments.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-flag-with-empty-string.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-integer-flag.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-invalid-shard-bounds-isolation.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-kill-switch-flag.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-malformed-flag-isolation.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-microsecond-date-flag.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-missing-split-shards-isolation.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-new-user-onboarding-flag.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-no-allocations-flag.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-operator-flag.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-targeting-key.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-flag.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of-default.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-of-7-empty-targeting-key.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-regex-flag.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-start-and-end-date-flag.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-fields-tolerance.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-operator-isolation.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-flag-that-does-not-exist.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-json-config-flag.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-no-allocations-flag.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-special-characters.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-string-with-special-characters.json delete mode 100644 packages/core/src/flags/__fixtures__/ffe-system-test-data/ufc-config.json diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..c1300d309 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "packages/core/src/flags/__fixtures__/ffe-system-test-data"] + path = packages/core/src/flags/__fixtures__/ffe-system-test-data + url = https://github.com/DataDog/ffe-system-test-data.git diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt index 1b9d0d729..68299f600 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt @@ -51,6 +51,7 @@ internal class NativeFfeCore { etag = etag, serverResponse = serverResponse, precomputedResponse = precomputedResponse, + serverFlags = serverResponse?.flagsObject()?.toNativeFlags(), ) } @@ -191,59 +192,55 @@ internal class NativeFfeCore { evaluationCount += 1 val configuration = activeConfiguration ?: return defaultResult(flagKey, defaultValue, "ERROR", "PROVIDER_NOT_READY") - val flags = configuration.serverResponse?.flagsObject() + val flags = configuration.serverFlags ?: return defaultResult(flagKey, defaultValue, "ERROR", "PROVIDER_NOT_READY") - val flag = flags.optJSONObject(flagKey) + val flag = flags[flagKey] ?: return defaultResult(flagKey, defaultValue, "ERROR", "FLAG_NOT_FOUND") - if (!flag.optBoolean("enabled", false)) { + if (!flag.enabled) { return defaultResult(flagKey, defaultValue, "DISABLED", null) } - if (!typeMatches(expectedType, flag.optString("variationType"))) { + if (!typeMatches(expectedType, flag.variationType)) { return defaultResult(flagKey, defaultValue, "ERROR", "TYPE_MISMATCH") } + if (flag.unsupported) { + return defaultResult(flagKey, defaultValue, "DEFAULT", null) + } val subjectAttributes = subjectAttributes() val targetingKey = currentContext["targetingKey"]?.toString() - val allocations = flag.optJSONArray("allocations") - val variations = flag.optJSONObject("variations") ?: JSONObject() - for (index in 0 until (allocations?.length() ?: 0)) { - val allocation = allocations?.optJSONObject(index) ?: continue - if (!allocationIsActive(allocation)) { + for (allocation in flag.allocations) { + if (!allocation.isActive()) { continue } - if (!rulesMatch(allocation.optJSONArray("rules"), subjectAttributes)) { + if (!rulesMatch(allocation.rules, subjectAttributes)) { continue } val split = try { - firstMatchingSplit(allocation.optJSONArray("splits"), targetingKey) + firstMatchingSplit(allocation.splits, targetingKey) } catch (_: TargetingKeyMissingException) { return defaultResult(flagKey, defaultValue, "ERROR", "TARGETING_KEY_MISSING") } ?: continue - val variationKey = split.optString("variationKey") - val variation = variations.optJSONObject(variationKey) ?: continue - val value = variation.get("value").toBridgeValue() - val reason = evaluationReason(allocation.optJSONArray("rules"), split) - val extraLogging = split.optJSONObject("extraLogging") - ?: allocation.optJSONObject("extraLogging") - ?: JSONObject() + val variation = flag.variations[split.variationKey] ?: continue + val reason = evaluationReason(allocation, split) + val extraLogging = split.extraLogging ?: allocation.extraLogging ?: emptyMap() return mapOf( "flagKey" to flagKey, - "value" to value, - "variant" to variation.optString("key", variationKey), + "value" to variation.value, + "variant" to variation.key, "reason" to reason, "flagMetadata" to mapOf( - "__dd_allocation_key" to allocation.optString("key"), - "__dd_do_log" to allocation.optBoolean("doLog", false), - "__dd_split_serial_id" to split.optionalInt("serialId"), - "allocationKey" to allocation.optString("key"), - "doLog" to allocation.optBoolean("doLog", false), - "extraLogging" to extraLogging.toMap(), + "__dd_allocation_key" to allocation.key, + "__dd_do_log" to allocation.doLog, + "__dd_split_serial_id" to split.serialId, + "allocationKey" to allocation.key, + "doLog" to allocation.doLog, + "extraLogging" to extraLogging, "configurationKind" to configuration.kind, "configurationEtag" to configuration.etag, - "splitSerialId" to split.optionalInt("serialId"), + "splitSerialId" to split.serialId, "variationType" to expectedType, ).filterValues { it != null }, ) @@ -293,102 +290,83 @@ internal class NativeFfeCore { return attributes } - private fun allocationIsActive(allocation: JSONObject): Boolean { + private fun NativeAllocation.isActive(): Boolean { val now = Instant.now() - val startAt = allocation.optString("startAt").takeIf { it.isNotBlank() } - val endAt = allocation.optString("endAt").takeIf { it.isNotBlank() } - return try { - val afterStart = startAt == null || !now.isBefore(Instant.parse(startAt)) - val beforeEnd = endAt == null || now.isBefore(Instant.parse(endAt)) - afterStart && beforeEnd - } catch (_: Exception) { - false - } + return !hasInvalidDate && + (startAt == null || !now.isBefore(startAt)) && + (endAt == null || now.isBefore(endAt)) } - private fun rulesMatch(rules: JSONArray?, subjectAttributes: Map): Boolean { - if (rules == null || rules.length() == 0) { + private fun rulesMatch(rules: List, subjectAttributes: Map): Boolean { + if (rules.isEmpty()) { return true } - for (index in 0 until rules.length()) { - val rule = rules.optJSONObject(index) ?: continue - val conditions = rule.optJSONArray("conditions") ?: JSONArray() - var allMatch = true - for (conditionIndex in 0 until conditions.length()) { - val condition = conditions.optJSONObject(conditionIndex) ?: continue - if (!conditionMatches(condition, subjectAttributes)) { - allMatch = false - break - } - } - if (allMatch) { + for (rule in rules) { + if (rule.conditions.all { conditionMatches(it, subjectAttributes) }) { return true } } return false } - private fun conditionMatches(condition: JSONObject, subjectAttributes: Map): Boolean { - val attribute = condition.optString("attribute") + private fun conditionMatches(condition: NativeCondition, subjectAttributes: Map): Boolean { + val attribute = condition.attribute val value = subjectAttributes[attribute] - return when (condition.optString("operator")) { + return when (condition.operator) { "IS_NULL" -> { - val expectsNull = condition.optBoolean("value") + val expectsNull = condition.value as? Boolean ?: false if (expectsNull) value == null else value != null } - "MATCHES" -> value?.toString()?.let { Regex(condition.optString("value")).containsMatchIn(it) } ?: false - "NOT_MATCHES" -> value?.toString()?.let { !Regex(condition.optString("value")).containsMatchIn(it) } ?: false - "ONE_OF" -> value?.toString()?.let { condition.optJSONArray("value")?.containsString(it) } ?: false - "NOT_ONE_OF" -> value?.toString()?.let { condition.optJSONArray("value")?.containsString(it) == false } ?: false - "GTE" -> value.asDouble()?.let { it >= condition.optDouble("value") } ?: false - "GT" -> value.asDouble()?.let { it > condition.optDouble("value") } ?: false - "LTE" -> value.asDouble()?.let { it <= condition.optDouble("value") } ?: false - "LT" -> value.asDouble()?.let { it < condition.optDouble("value") } ?: false + "MATCHES" -> regexMatches(condition.value.toString(), value?.toString()) + "NOT_MATCHES" -> value?.toString()?.let { !regexMatches(condition.value.toString(), it) } ?: false + "ONE_OF" -> value?.toString()?.let { condition.value.containsString(it) } ?: false + "NOT_ONE_OF" -> value?.toString()?.let { !condition.value.containsString(it) } ?: false + "GTE" -> value.asDouble()?.let { it >= (condition.value.asDouble() ?: 0.0) } ?: false + "GT" -> value.asDouble()?.let { it > (condition.value.asDouble() ?: 0.0) } ?: false + "LTE" -> value.asDouble()?.let { it <= (condition.value.asDouble() ?: 0.0) } ?: false + "LT" -> value.asDouble()?.let { it < (condition.value.asDouble() ?: 0.0) } ?: false else -> false } } - private fun firstMatchingSplit(splits: JSONArray?, targetingKey: String?): JSONObject? { - if (splits == null) { - return null - } - for (index in 0 until splits.length()) { - val split = splits.optJSONObject(index) ?: continue - val shards = split.optJSONArray("shards") - if (shards == null || shards.length() == 0) { + private fun firstMatchingSplit(splits: List, targetingKey: String?): NativeSplit? { + for (split in splits) { + if (split.shards.isEmpty()) { return split } if (targetingKey == null) { throw TargetingKeyMissingException() } - if (shardsMatch(shards, targetingKey)) { + if (shardsMatch(split.shards, targetingKey)) { return split } } return null } - private fun evaluationReason(rules: JSONArray?, split: JSONObject): String { - if (rules != null && rules.length() > 0) { + private fun evaluationReason(allocation: NativeAllocation, split: NativeSplit): String { + if (allocation.rules.isNotEmpty()) { return "TARGETING_MATCH" } - val shards = split.optJSONArray("shards") - return if (shards != null && shards.length() > 0) { + if (allocation.startAt != null || allocation.endAt != null) { + return "DEFAULT" + } + return if (split.shards.isNotEmpty()) { "SPLIT" } else { "STATIC" } } - private fun shardsMatch(shards: JSONArray, targetingKey: String): Boolean { - for (index in 0 until shards.length()) { - val shard = shards.optJSONObject(index) ?: return false - val assignedShard = assignedShard(shard.optString("salt"), targetingKey, shard.optInt("totalShards")) - val ranges = shard.optJSONArray("ranges") ?: return false + private fun shardsMatch(shards: List, targetingKey: String): Boolean { + for (shard in shards) { + val totalShards = shard.totalShards + val assignedShard = assignedShard(shard.salt, targetingKey, totalShards) var inAnyRange = false - for (rangeIndex in 0 until ranges.length()) { - val range = ranges.optJSONObject(rangeIndex) ?: continue - if (assignedShard >= range.optInt("start") && assignedShard < range.optInt("end")) { + for (range in shard.ranges) { + val start = range.start + val end = range.end + if (assignedShard >= start && assignedShard < end) { inAnyRange = true break } @@ -400,7 +378,7 @@ internal class NativeFfeCore { return true } - private fun assignedShard(salt: String, targetingKey: String, totalShards: Int): Int { + private fun assignedShard(salt: String, targetingKey: String, totalShards: Long): Long { if (totalShards <= 0) { return -1 } @@ -410,7 +388,22 @@ internal class NativeFfeCore { ((digest[1].toLong() and BYTE_MASK) shl 16) or ((digest[2].toLong() and BYTE_MASK) shl 8) or (digest[3].toLong() and BYTE_MASK) - return (firstFourBytes % totalShards).toInt() + return firstFourBytes % totalShards + } + + private fun regexMatches(pattern: String, value: String?): Boolean { + if (value == null) { + return false + } + return try { + Regex(pattern.toJavaRegexPattern()).containsMatchIn(value) + } catch (_: Exception) { + false + } + } + + private fun String.toJavaRegexPattern(): String { + return replace("[:alnum:]", "\\p{Alnum}") } private fun markProviderError(error: Exception) { @@ -442,6 +435,7 @@ internal class NativeFfeCore { val etag: String?, val serverResponse: JSONObject?, val precomputedResponse: JSONObject?, + val serverFlags: Map?, ) { fun toMap(): Map { return mapOf( @@ -454,6 +448,203 @@ internal class NativeFfeCore { } } + data class NativeFlag( + val key: String, + val enabled: Boolean, + val variationType: String, + val variations: Map, + val allocations: List, + val unsupported: Boolean, + ) + + data class NativeVariation( + val key: String, + val value: Any?, + ) + + data class NativeAllocation( + val key: String?, + val rules: List, + val splits: List, + val doLog: Boolean, + val extraLogging: Map?, + val startAt: Instant?, + val endAt: Instant?, + val hasInvalidDate: Boolean, + ) + + data class NativeRule( + val conditions: List, + ) + + data class NativeCondition( + val attribute: String, + val operator: String, + val value: Any?, + ) + + data class NativeSplit( + val variationKey: String, + val shards: List, + val serialId: Int?, + val extraLogging: Map?, + ) + + data class NativeShard( + val salt: String, + val totalShards: Long, + val ranges: List, + ) + + data class NativeShardRange( + val start: Long, + val end: Long, + ) + + private fun JSONObject.toNativeFlags(): Map { + return keys().asSequence().mapNotNull { key -> + optJSONObject(key)?.toNativeFlag(key)?.let { key to it } + }.toMap() + } + + private fun JSONObject.toNativeFlag(fallbackKey: String): NativeFlag { + val variations = optJSONObject("variations")?.toNativeVariations() ?: emptyMap() + val allocationsValue = opt("allocations") + val allocations = optJSONArray("allocations")?.toNativeAllocations() ?: emptyList() + val unsupported = allocationsValue != null && allocationsValue !is JSONArray || + allocations.any { allocation -> allocation.rules.any { rule -> rule.hasUnsupportedOperator() } } + return NativeFlag( + key = optString("key").takeIf { it.isNotBlank() } ?: fallbackKey, + enabled = optBoolean("enabled", false), + variationType = optString("variationType"), + variations = variations, + allocations = allocations, + unsupported = unsupported, + ) + } + + private fun JSONObject.toNativeVariations(): Map { + return keys().asSequence().mapNotNull { key -> + val variation = optJSONObject(key) ?: return@mapNotNull null + key to NativeVariation( + key = variation.optString("key").takeIf { it.isNotBlank() } ?: key, + value = variation.opt("value").toBridgeValue(), + ) + }.toMap() + } + + private fun JSONArray.toNativeAllocations(): List { + return (0 until length()).mapNotNull { index -> optJSONObject(index)?.toNativeAllocation() } + } + + private fun JSONObject.toNativeAllocation(): NativeAllocation { + val startAt = optString("startAt").takeIf { it.isNotBlank() } + val endAt = optString("endAt").takeIf { it.isNotBlank() } + val parsedStartAt = startAt?.toInstantOrNull() + val parsedEndAt = endAt?.toInstantOrNull() + return NativeAllocation( + key = optString("key").takeIf { it.isNotBlank() }, + rules = optJSONArray("rules")?.toNativeRules() ?: emptyList(), + splits = optJSONArray("splits")?.toNativeSplits() ?: emptyList(), + doLog = optBoolean("doLog", false), + extraLogging = optJSONObject("extraLogging")?.toMap(), + startAt = parsedStartAt, + endAt = parsedEndAt, + hasInvalidDate = (startAt != null && parsedStartAt == null) || (endAt != null && parsedEndAt == null), + ) + } + + private fun JSONArray.toNativeRules(): List { + return (0 until length()).mapNotNull { index -> optJSONObject(index)?.toNativeRule() } + } + + private fun JSONObject.toNativeRule(): NativeRule { + return NativeRule( + conditions = optJSONArray("conditions")?.toNativeConditions() ?: emptyList(), + ) + } + + private fun JSONArray.toNativeConditions(): List { + return (0 until length()).mapNotNull { index -> optJSONObject(index)?.toNativeCondition() } + } + + private fun JSONObject.toNativeCondition(): NativeCondition? { + val operator = optString("operator") + return NativeCondition( + attribute = optString("attribute"), + operator = operator, + value = opt("value").toBridgeValue(), + ) + } + + private fun NativeRule.hasUnsupportedOperator(): Boolean { + return conditions.any { it.operator !in KNOWN_CONDITION_OPERATORS } + } + + private fun JSONArray.toNativeSplits(): List { + return (0 until length()).mapNotNull { index -> optJSONObject(index)?.toNativeSplit() } + } + + private fun JSONObject.toNativeSplit(): NativeSplit? { + val shards = optJSONArray("shards") ?: return null + val nativeShards = shards.toNativeShards() ?: return null + return NativeSplit( + variationKey = optString("variationKey"), + shards = nativeShards, + serialId = optionalInt("serialId"), + extraLogging = optJSONObject("extraLogging")?.toMap(), + ) + } + + private fun JSONArray.toNativeShards(): List? { + return (0 until length()).map { index -> + optJSONObject(index)?.toNativeShard() ?: return null + } + } + + private fun JSONObject.toNativeShard(): NativeShard? { + val totalShards = optionalLong("totalShards") ?: return null + if (totalShards <= 0 || totalShards > MAX_UNSIGNED_INT) { + return null + } + val ranges = optJSONArray("ranges")?.toNativeShardRanges(totalShards) ?: return null + return NativeShard( + salt = optString("salt"), + totalShards = totalShards, + ranges = ranges, + ) + } + + private fun JSONArray.toNativeShardRanges(totalShards: Long): List? { + return (0 until length()).map { index -> + val range = optJSONObject(index) ?: return null + val start = range.optionalLong("start") ?: return null + val end = range.optionalLong("end") ?: return null + if (start < 0 || end < 0 || start >= end || end > totalShards) { + return null + } + NativeShardRange(start, end) + } + } + + private fun String.toInstantOrNull(): Instant? { + return try { + Instant.parse(this) + } catch (_: Exception) { + null + } + } + + private fun Any?.containsString(expected: String): Boolean { + if (this is List<*>) { + return any { it?.toString() == expected } + } + if (this is JSONArray) { + return containsString(expected) + } + return false + } + private fun JSONArray.containsString(expected: String): Boolean { for (index in 0 until length()) { if (opt(index)?.toString() == expected) { @@ -470,6 +661,17 @@ internal class NativeFfeCore { return optInt(key) } + private fun JSONObject.optionalLong(key: String): Long? { + if (!has(key) || isNull(key)) { + return null + } + return when (val value = opt(key)) { + is Number -> value.toLong() + is String -> value.toLongOrNull() + else -> null + } + } + private fun Any?.asDouble(): Double? { return when (this) { is Number -> toDouble() @@ -512,5 +714,17 @@ internal class NativeFfeCore { const val EXPECTED_NUMBER = "number" const val EXPECTED_OBJECT = "object" const val BYTE_MASK = 0xffL + const val MAX_UNSIGNED_INT = 4_294_967_295L + val KNOWN_CONDITION_OPERATORS = setOf( + "IS_NULL", + "MATCHES", + "NOT_MATCHES", + "ONE_OF", + "NOT_ONE_OF", + "GTE", + "GT", + "LTE", + "LT", + ) } } diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt index 75d60c2d2..28c482abd 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt @@ -173,6 +173,30 @@ internal class NativeFfeCoreTest { assertThat(result["errorCode"]).isEqualTo("TARGETING_KEY_MISSING") } + @Test + fun `M match shared evaluation corpus W canonical UFC rules configuration`() { + // Given + setConfiguration() + val failures = mutableListOf() + + evaluationCaseFileNames().forEach { fileName -> + evaluationCases(fileName).forEach { evaluationCase -> + try { + setEvaluationContext(evaluationCase) + val result = resolveEvaluation(evaluationCase) + evaluationMismatch(result, evaluationCase)?.let { failures += it } + } catch (failure: AssertionError) { + failures += "${evaluationCase.source}: ${failure.message}" + } catch (failure: Exception) { + failures += "${evaluationCase.source}: ${failure.message}" + } + } + } + + // Then + assertThat(failures).isEmpty() + } + private fun setConfiguration() { val configuration = testedCore.configurationFromString(flagsConfigurationWire) testedCore.setConfiguration(configuration.toMap()) @@ -217,20 +241,77 @@ internal class NativeFfeCoreTest { assertThat(result["flagKey"]).isEqualTo(evaluationCase.flag) assertThat(result["reason"]).isEqualTo(evaluationCase.expectedReason) assertJsonValue(result["value"], evaluationCase.expectedValue) + if (evaluationCase.expectedErrorCode != null) { + assertThat(result["errorCode"]).isEqualTo(evaluationCase.expectedErrorCode) + } + } + + private fun evaluationMismatch(result: Map, evaluationCase: EvaluationCase): String? { + if (result["flagKey"] != evaluationCase.flag) { + return "${evaluationCase.source}: flagKey expected ${evaluationCase.flag}, got ${result["flagKey"]}" + } + if (result["reason"] != evaluationCase.expectedReason) { + return "${evaluationCase.source}: reason expected ${evaluationCase.expectedReason}, got ${result["reason"]}" + } + if (!jsonValuesEqual(result["value"], evaluationCase.expectedValue)) { + return "${evaluationCase.source}: value expected ${evaluationCase.expectedValue}, got ${result["value"]}" + } + if (evaluationCase.expectedErrorCode != null && result["errorCode"] != evaluationCase.expectedErrorCode) { + return "${evaluationCase.source}: errorCode expected ${evaluationCase.expectedErrorCode}, got ${result["errorCode"]}" + } + return null } private fun assertJsonValue(actual: Any?, expected: Any?) { if (actual is Number && expected is Number) { assertThat(actual.toDouble()).isCloseTo(expected.toDouble(), Offset.offset(NUMERIC_TOLERANCE)) } else { - assertThat(actual).isEqualTo(expected) + assertThat(jsonValuesEqual(actual, expected)) + .withFailMessage("Expected <%s>, got <%s>", expected, actual) + .isTrue() + } + } + + @Suppress("UNCHECKED_CAST") + private fun jsonValuesEqual(actual: Any?, expected: Any?): Boolean { + if (actual == null || expected == null) { + return actual == null && expected == null + } + if (actual is Number && expected is Number) { + return kotlin.math.abs(actual.toDouble() - expected.toDouble()) <= NUMERIC_TOLERANCE + } + if (actual is List<*> && expected is List<*>) { + return actual.size == expected.size && + actual.indices.all { jsonValuesEqual(actual[it], expected[it]) } + } + if (actual is Map<*, *> && expected is Map<*, *>) { + val actualMap = actual as Map + val expectedMap = expected as Map + return actualMap.keys == expectedMap.keys && + actualMap.keys.all { jsonValuesEqual(actualMap[it], expectedMap[it]) } } + return actual == expected } private fun evaluationCase(fileName: String, caseIndex: Int = 0): EvaluationCase { - val caseJson = JSONArray(readFixture("evaluation-cases/$fileName")).getJSONObject(caseIndex) + return evaluationCases(fileName)[caseIndex] + } + + private fun evaluationCases(fileName: String): List { + val casesJson = JSONArray(readFixture("evaluation-cases/$fileName")) + return (0 until casesJson.length()).map { caseIndex -> + evaluationCase(fileName, caseIndex, casesJson.getJSONObject(caseIndex)) + } + } + + private fun evaluationCase( + fileName: String, + caseIndex: Int, + caseJson: JSONObject, + ): EvaluationCase { val resultJson = caseJson.getJSONObject("result") return EvaluationCase( + source = "$fileName[$caseIndex]", flag = caseJson.getString("flag"), variationType = caseJson.getString("variationType"), defaultValue = caseJson.get("defaultValue").toNativeFfeFixtureValue(), @@ -238,14 +319,20 @@ internal class NativeFfeCoreTest { attributes = (caseJson.optJSONObject("attributes") ?: JSONObject()).toNativeFfeFixtureMap(), expectedValue = resultJson.get("value").toNativeFfeFixtureValue(), expectedReason = resultJson.getString("reason"), + expectedErrorCode = resultJson.optionalNativeFfeString("errorCode"), ) } + private fun evaluationCaseFileNames(): List { + return listNativeFfeFixtureFiles(javaClass, "ffe-system-test-data/evaluation-cases") + } + private fun readFixture(relativePath: String): String { return readNativeFfeFixture(javaClass, "ffe-system-test-data/$relativePath") } private data class EvaluationCase( + val source: String, val flag: String, val variationType: String, val defaultValue: Any?, @@ -253,6 +340,7 @@ internal class NativeFfeCoreTest { val attributes: Map, val expectedValue: Any?, val expectedReason: String, + val expectedErrorCode: String?, ) private companion object { diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt index d0a654e20..210a51983 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt @@ -6,6 +6,7 @@ package com.datadog.reactnative +import java.io.File import org.json.JSONArray import org.json.JSONObject @@ -16,6 +17,18 @@ internal fun readNativeFfeFixture(owner: Class<*>, relativePath: String): String ?: error("Missing FFE fixture: $relativePath") } +internal fun listNativeFfeFixtureFiles(owner: Class<*>, relativeDirectory: String): List { + val resource = owner.classLoader + ?.getResource(relativeDirectory) + ?: error("Missing FFE fixture directory: $relativeDirectory") + val directory = File(resource.toURI()) + return directory + .listFiles { file -> file.isFile && file.extension == "json" } + ?.map { it.name } + ?.sorted() + ?: error("Missing FFE fixture files: $relativeDirectory") +} + internal fun JSONObject.optionalNativeFfeString(key: String): String? { if (!has(key) || isNull(key)) { return null diff --git a/packages/core/ios/Sources/NativeFfeCore.swift b/packages/core/ios/Sources/NativeFfeCore.swift index c795e72a9..18527ee44 100644 --- a/packages/core/ios/Sources/NativeFfeCore.swift +++ b/packages/core/ios/Sources/NativeFfeCore.swift @@ -60,7 +60,8 @@ internal final class NativeFfeCore { kind: kind, etag: etag, serverResponse: serverResponse, - precomputedResponse: precomputedResponse + precomputedResponse: precomputedResponse, + serverFlags: flagsObject(serverResponse).map(nativeFlags) ) } @@ -208,8 +209,8 @@ internal final class NativeFfeCore { evaluationCount += 1 guard let configuration = activeConfiguration, - let flags = flagsObject(configuration.serverResponse), - let flag = dictionaryValue(flags[flagKey]) + let flags = configuration.serverFlags, + let flag = flags[flagKey] else { return defaultResult( flagKey: flagKey, @@ -219,7 +220,7 @@ internal final class NativeFfeCore { ) } - if boolValue(flag["enabled"]) != true { + if !flag.enabled { return defaultResult( flagKey: flagKey, defaultValue: defaultValue, @@ -227,7 +228,7 @@ internal final class NativeFfeCore { errorCode: nil ) } - if !typeMatches(expectedType: expectedType, variationType: stringValue(flag["variationType"])) { + if !typeMatches(expectedType: expectedType, variationType: flag.variationType) { return defaultResult( flagKey: flagKey, defaultValue: defaultValue, @@ -235,24 +236,29 @@ internal final class NativeFfeCore { errorCode: "TYPE_MISMATCH" ) } + if flag.unsupported { + return defaultResult( + flagKey: flagKey, + defaultValue: defaultValue, + reason: "DEFAULT", + errorCode: nil + ) + } let subjectAttributes = subjectAttributes() let targetingKey = stringValue(currentContext["targetingKey"]) - let allocations = arrayValue(flag["allocations"]) - let variations = dictionaryValue(flag["variations"]) ?? [:] - for allocationValue in allocations { - guard let allocation = dictionaryValue(allocationValue), - allocationIsActive(allocation), - rulesMatch(arrayValue(allocation["rules"]), subjectAttributes: subjectAttributes) + for allocation in flag.allocations { + guard allocationIsActive(allocation), + rulesMatch(allocation.rules, subjectAttributes: subjectAttributes) else { continue } - let split: [String: Any] + let split: NativeSplit do { guard let selectedSplit = try firstMatchingSplit( - arrayValue(allocation["splits"]), + allocation.splits, targetingKey: targetingKey ) else { continue @@ -274,35 +280,29 @@ internal final class NativeFfeCore { ) } - guard - let variationKey = stringValue(split["variationKey"]), - let variation = dictionaryValue(variations[variationKey]) - else { + guard let variation = flag.variations[split.variationKey] else { continue } - let reason = evaluationReason(rules: arrayValue(allocation["rules"]), split: split) - let extraLogging = - dictionaryValue(split["extraLogging"]) - ?? dictionaryValue(allocation["extraLogging"]) - ?? [:] + let reason = evaluationReason(allocation: allocation, split: split) + let extraLogging = split.extraLogging ?? allocation.extraLogging ?? [:] return buildMap([ ("flagKey", flagKey), - ("value", bridgeValue(variation["value"])), - ("variant", stringValue(variation["key"]) ?? variationKey), + ("value", bridgeValue(variation.value)), + ("variant", variation.key), ("reason", reason), ( "flagMetadata", buildMap([ - ("__dd_allocation_key", stringValue(allocation["key"])), - ("__dd_do_log", boolValue(allocation["doLog"]) ?? false), - ("__dd_split_serial_id", intValue(split["serialId"])), - ("allocationKey", stringValue(allocation["key"])), - ("doLog", boolValue(allocation["doLog"]) ?? false), + ("__dd_allocation_key", allocation.key), + ("__dd_do_log", allocation.doLog), + ("__dd_split_serial_id", split.serialId), + ("allocationKey", allocation.key), + ("doLog", allocation.doLog), ("extraLogging", extraLogging), ("configurationKind", configuration.kind), ("configurationEtag", configuration.etag), - ("splitSerialId", intValue(split["serialId"])), + ("splitSerialId", split.serialId), ("variationType", expectedType), ]) ), @@ -369,39 +369,24 @@ internal final class NativeFfeCore { return attributes } - private func allocationIsActive(_ allocation: [String: Any]) -> Bool { + private func allocationIsActive(_ allocation: NativeAllocation) -> Bool { let now = Date() - let startAt = nonEmptyString(allocation["startAt"]) - let endAt = nonEmptyString(allocation["endAt"]) - let start = startAt.flatMap(parseDate) - let end = endAt.flatMap(parseDate) - - if startAt != nil && start == nil { - return false - } - if endAt != nil && end == nil { + if allocation.hasInvalidDate { return false } - - return (start == nil || now >= start!) && (end == nil || now < end!) + return (allocation.startAt == nil || now >= allocation.startAt!) + && (allocation.endAt == nil || now < allocation.endAt!) } private func rulesMatch( - _ rules: [Any], + _ rules: [NativeRule], subjectAttributes: [String: Any] ) -> Bool { if rules.isEmpty { return true } - for ruleValue in rules { - let conditions = dictionaryValue(ruleValue).flatMap { arrayValue($0["conditions"]) } ?? [] - let allMatch = conditions.allSatisfy { conditionValue in - guard let condition = dictionaryValue(conditionValue) else { - return true - } - return conditionMatches(condition, subjectAttributes: subjectAttributes) - } - if allMatch { + for rule in rules { + if rule.conditions.allSatisfy({ conditionMatches($0, subjectAttributes: subjectAttributes) }) { return true } } @@ -409,88 +394,78 @@ internal final class NativeFfeCore { } private func conditionMatches( - _ condition: [String: Any], + _ condition: NativeCondition, subjectAttributes: [String: Any] ) -> Bool { - guard let attribute = stringValue(condition["attribute"]) else { - return false - } - let value = subjectAttributes[attribute] + let value = subjectAttributes[condition.attribute] - switch stringValue(condition["operator"]) { + switch condition.operator { case "IS_NULL": - let expectsNull = boolValue(condition["value"]) ?? false - return expectsNull ? value == nil : value != nil + let expectsNull = boolValue(condition.value) ?? false + return expectsNull ? isNull(value) : !isNull(value) case "MATCHES": - return regexMatches(pattern: stringValue(condition["value"]), value: stringValue(value)) + return regexMatches(pattern: stringValue(condition.value), value: stringValue(value)) case "NOT_MATCHES": - return !regexMatches(pattern: stringValue(condition["value"]), value: stringValue(value)) + guard let subjectValue = stringValue(value) else { + return false + } + return !regexMatches(pattern: stringValue(condition.value), value: subjectValue) case "ONE_OF": - return stringValue(value).map { containsString(arrayValue(condition["value"]), expected: $0) } + return stringValue(value).map { containsString(arrayValue(condition.value), expected: $0) } ?? false case "NOT_ONE_OF": - return stringValue(value).map { !containsString(arrayValue(condition["value"]), expected: $0) } + return stringValue(value).map { !containsString(arrayValue(condition.value), expected: $0) } ?? false case "GTE": - return doubleValue(value).map { $0 >= (doubleValue(condition["value"]) ?? 0) } ?? false + return doubleValue(value).map { $0 >= (doubleValue(condition.value) ?? 0) } ?? false case "GT": - return doubleValue(value).map { $0 > (doubleValue(condition["value"]) ?? 0) } ?? false + return doubleValue(value).map { $0 > (doubleValue(condition.value) ?? 0) } ?? false case "LTE": - return doubleValue(value).map { $0 <= (doubleValue(condition["value"]) ?? 0) } ?? false + return doubleValue(value).map { $0 <= (doubleValue(condition.value) ?? 0) } ?? false case "LT": - return doubleValue(value).map { $0 < (doubleValue(condition["value"]) ?? 0) } ?? false + return doubleValue(value).map { $0 < (doubleValue(condition.value) ?? 0) } ?? false default: return false } } - private func firstMatchingSplit(_ splits: [Any], targetingKey: String?) throws -> [String: Any]? { - for splitValue in splits { - guard let split = dictionaryValue(splitValue) else { - continue - } - let shards = arrayValue(split["shards"]) - if shards.isEmpty { + private func firstMatchingSplit(_ splits: [NativeSplit], targetingKey: String?) throws -> NativeSplit? { + for split in splits { + if split.shards.isEmpty { return split } guard let targetingKey else { throw NativeFfeCoreError.targetingKeyMissing } - if shardsMatch(shards, targetingKey: targetingKey) { + if shardsMatch(split.shards, targetingKey: targetingKey) { return split } } return nil } - private func evaluationReason(rules: [Any], split: [String: Any]) -> String { - if !rules.isEmpty { + private func evaluationReason(allocation: NativeAllocation, split: NativeSplit) -> String { + if !allocation.rules.isEmpty { return "TARGETING_MATCH" } - if !arrayValue(split["shards"]).isEmpty { + if allocation.startAt != nil || allocation.endAt != nil { + return "DEFAULT" + } + if !split.shards.isEmpty { return "SPLIT" } return "STATIC" } - private func shardsMatch(_ shards: [Any], targetingKey: String) -> Bool { - for shardValue in shards { - guard let shard = dictionaryValue(shardValue), - let salt = stringValue(shard["salt"]), - let totalShards = intValue(shard["totalShards"]) - else { - return false - } - let assigned = assignedShard(salt: salt, targetingKey: targetingKey, totalShards: totalShards) - let ranges = arrayValue(shard["ranges"]) - let inAnyRange = ranges.contains { rangeValue in - guard let range = dictionaryValue(rangeValue), - let start = intValue(range["start"]), - let end = intValue(range["end"]) - else { - return false - } - return assigned >= start && assigned < end + private func shardsMatch(_ shards: [NativeShard], targetingKey: String) -> Bool { + for shard in shards { + let assigned = assignedShard( + salt: shard.salt, + targetingKey: targetingKey, + totalShards: shard.totalShards + ) + let inAnyRange = shard.ranges.contains { range in + assigned >= range.start && assigned < range.end } if !inAnyRange { return false @@ -499,12 +474,9 @@ internal final class NativeFfeCore { return true } - private func assignedShard(salt: String, targetingKey: String, totalShards: Int) -> Int { - guard totalShards > 0 else { - return -1 - } + private func assignedShard(salt: String, targetingKey: String, totalShards: UInt32) -> UInt32 { let firstFourBytes = md5FirstFourBytes("\(salt)-\(targetingKey)") - return Int(firstFourBytes % UInt32(totalShards)) + return firstFourBytes % totalShards } private func markProviderError(_ error: Error) { @@ -519,6 +491,145 @@ internal final class NativeFfeCore { ?? Constants.defaultStorageSlot } + private func nativeFlags(_ flags: [String: Any]) -> [String: NativeFlag] { + flags.compactMapValues { value in + dictionaryValue(value).map(nativeFlag) + } + } + + private func nativeFlag(_ flag: [String: Any]) -> NativeFlag { + let allocations = arrayValue(flag["allocations"]).compactMap(nativeAllocation) + let malformedAllocations = flag["allocations"] != nil && arrayValueOrNil(flag["allocations"]) == nil + let unsupportedOperator = allocations.contains { allocation in + allocation.rules.contains { rule in + rule.conditions.contains { !KnownConditionOperators.values.contains($0.operator) } + } + } + return NativeFlag( + key: stringValue(flag["key"]) ?? "", + enabled: boolValue(flag["enabled"]) ?? false, + variationType: stringValue(flag["variationType"]) ?? "", + variations: dictionaryValue(flag["variations"]).map(nativeVariations) ?? [:], + allocations: allocations, + unsupported: malformedAllocations || unsupportedOperator + ) + } + + private func nativeVariations(_ variations: [String: Any]) -> [String: NativeVariation] { + variations.compactMapValues { value in + guard let variation = dictionaryValue(value) else { + return nil + } + return NativeVariation( + key: stringValue(variation["key"]) ?? "", + value: bridgeValue(variation["value"]) + ) + } + } + + private func nativeAllocation(_ value: Any) -> NativeAllocation? { + guard let allocation = dictionaryValue(value) else { + return nil + } + let startAt = nonEmptyString(allocation["startAt"]) + let endAt = nonEmptyString(allocation["endAt"]) + let parsedStartAt = startAt.flatMap(parseDate) + let parsedEndAt = endAt.flatMap(parseDate) + return NativeAllocation( + key: stringValue(allocation["key"]), + rules: arrayValue(allocation["rules"]).compactMap(nativeRule), + splits: arrayValue(allocation["splits"]).compactMap(nativeSplit), + doLog: boolValue(allocation["doLog"]) ?? false, + extraLogging: dictionaryValue(allocation["extraLogging"]), + startAt: parsedStartAt, + endAt: parsedEndAt, + hasInvalidDate: (startAt != nil && parsedStartAt == nil) || (endAt != nil && parsedEndAt == nil) + ) + } + + private func nativeRule(_ value: Any) -> NativeRule? { + guard let rule = dictionaryValue(value) else { + return nil + } + return NativeRule( + conditions: arrayValue(rule["conditions"]).compactMap(nativeCondition) + ) + } + + private func nativeCondition(_ value: Any) -> NativeCondition? { + guard let condition = dictionaryValue(value), + let attribute = stringValue(condition["attribute"]), + let conditionOperator = stringValue(condition["operator"]) + else { + return nil + } + return NativeCondition( + attribute: attribute, + operator: conditionOperator, + value: bridgeValue(condition["value"]) + ) + } + + private func nativeSplit(_ value: Any) -> NativeSplit? { + guard let split = dictionaryValue(value), + let shardsValue = split["shards"], + let shardsArray = arrayValueOrNil(shardsValue) + else { + return nil + } + guard let shards = nativeShards(shardsArray) else { + return nil + } + return NativeSplit( + variationKey: stringValue(split["variationKey"]) ?? "", + shards: shards, + serialId: intValue(split["serialId"]), + extraLogging: dictionaryValue(split["extraLogging"]) + ) + } + + private func nativeShards(_ shards: [Any]) -> [NativeShard]? { + var nativeShards: [NativeShard] = [] + for value in shards { + guard let nativeShard = nativeShard(value) else { + return nil + } + nativeShards.append(nativeShard) + } + return nativeShards + } + + private func nativeShard(_ value: Any) -> NativeShard? { + guard let shard = dictionaryValue(value), + let salt = stringValue(shard["salt"]), + let totalShards = uint32Value(shard["totalShards"]), + totalShards > 0, + let ranges = nativeShardRanges(arrayValue(shard["ranges"]), totalShards: totalShards) + else { + return nil + } + return NativeShard(salt: salt, totalShards: totalShards, ranges: ranges) + } + + private func nativeShardRanges( + _ ranges: [Any], + totalShards: UInt32 + ) -> [NativeShardRange]? { + var nativeRanges: [NativeShardRange] = [] + for value in ranges { + guard let range = dictionaryValue(value), + let start = uint32Value(range["start"]), + let end = uint32Value(range["end"]), + start < end, + end <= totalShards + else { + return nil + } + nativeRanges.append(NativeShardRange(start: start, end: end)) + } + return nativeRanges + } + private func parseOptionalResponse(_ value: Any?) throws -> [String: Any]? { guard let response = value as? String else { return nil @@ -596,13 +707,17 @@ internal final class NativeFfeCore { } private func arrayValue(_ value: Any?) -> [Any] { + arrayValueOrNil(value) ?? [] + } + + private func arrayValueOrNil(_ value: Any?) -> [Any]? { if let array = value as? [Any] { return array } if let array = value as? NSArray { - return array as? [Any] ?? [] + return array as? [Any] } - return [] + return nil } private func stringValue(_ value: Any?) -> String? { @@ -612,9 +727,17 @@ internal final class NativeFfeCore { if let string = value as? String { return string } + if let number = value as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue ? "true" : "false" + } return String(describing: value) } + private func isNull(_ value: Any?) -> Bool { + value == nil || value is NSNull + } + private func nonEmptyString(_ value: Any?) -> String? { stringValue(value).flatMap { $0.isEmpty ? nil : $0 } } @@ -632,6 +755,19 @@ internal final class NativeFfeCore { return nil } + private func uint32Value(_ value: Any?) -> UInt32? { + if let int = intValue(value), int >= 0 { + return UInt32(exactly: int) + } + if let number = value as? NSNumber { + return UInt32(exactly: number.uint64Value) + } + if let string = value as? String { + return UInt32(string) + } + return nil + } + private func doubleValue(_ value: Any?) -> Double? { if let double = value as? Double { return double @@ -678,6 +814,7 @@ internal struct NativeFlagsConfiguration { let etag: String? let serverResponse: [String: Any]? let precomputedResponse: [String: Any]? + let serverFlags: [String: NativeFlag]? func toMap() -> [String: Any] { var map: [String: Any] = [ @@ -693,6 +830,59 @@ internal struct NativeFlagsConfiguration { } } +internal struct NativeFlag { + let key: String + let enabled: Bool + let variationType: String + let variations: [String: NativeVariation] + let allocations: [NativeAllocation] + let unsupported: Bool +} + +internal struct NativeVariation { + let key: String + let value: Any? +} + +internal struct NativeAllocation { + let key: String? + let rules: [NativeRule] + let splits: [NativeSplit] + let doLog: Bool + let extraLogging: [String: Any]? + let startAt: Date? + let endAt: Date? + let hasInvalidDate: Bool +} + +internal struct NativeRule { + let conditions: [NativeCondition] +} + +internal struct NativeCondition { + let attribute: String + let `operator`: String + let value: Any? +} + +internal struct NativeSplit { + let variationKey: String + let shards: [NativeShard] + let serialId: Int? + let extraLogging: [String: Any]? +} + +internal struct NativeShard { + let salt: String + let totalShards: UInt32 + let ranges: [NativeShardRange] +} + +internal struct NativeShardRange { + let start: UInt32 + let end: UInt32 +} + internal enum NativeFfeCoreError: LocalizedError { case invalidConfigurationWire(String) case targetingKeyMissing @@ -771,6 +961,20 @@ private enum ExpectedType { static let object = "object" } +private enum KnownConditionOperators { + static let values: Set = [ + "IS_NULL", + "MATCHES", + "NOT_MATCHES", + "ONE_OF", + "NOT_ONE_OF", + "GTE", + "GT", + "LTE", + "LT", + ] +} + private extension NativeFfeStoredConfiguration { func toDebugMap(operation: String) -> [String: Any] { [ diff --git a/packages/core/ios/Tests/NativeFfeCoreTests.swift b/packages/core/ios/Tests/NativeFfeCoreTests.swift index 9bf099c19..4ad305e2e 100644 --- a/packages/core/ios/Tests/NativeFfeCoreTests.swift +++ b/packages/core/ios/Tests/NativeFfeCoreTests.swift @@ -142,6 +142,25 @@ final class NativeFfeCoreTests: XCTestCase { XCTAssertEqual(result["errorCode"] as? String, "TARGETING_KEY_MISSING") } + func testMatchSharedEvaluationCorpusWithCanonicalUfcRulesConfiguration() throws { + let testedCore = try configuredCore() + var failures: [String] = [] + + for evaluationCase in try Self.allEvaluationCases() { + do { + _ = testedCore.setEvaluationContext(evaluationCase.context) + let result = try resolveEvaluation(evaluationCase, with: testedCore) + if let mismatch = evaluationMismatch(result, evaluationCase) { + failures.append(mismatch) + } + } catch { + failures.append("\(evaluationCase.source): \(error.localizedDescription)") + } + } + + XCTAssertTrue(failures.isEmpty, failures.joined(separator: "\n")) + } + private func configuredCore() throws -> NativeFfeCore { let testedCore = NativeFfeCore() let configuration = try testedCore.configurationFromString(Self.flagsConfigurationWire) @@ -189,6 +208,26 @@ final class NativeFfeCoreTests: XCTestCase { XCTAssertEqual(result["flagKey"] as? String, evaluationCase.flag, file: file, line: line) XCTAssertEqual(result["reason"] as? String, evaluationCase.expectedReason, file: file, line: line) assertJsonValue(result["value"], evaluationCase.expectedValue, file: file, line: line) + if let expectedErrorCode = evaluationCase.expectedErrorCode { + XCTAssertEqual(result["errorCode"] as? String, expectedErrorCode, file: file, line: line) + } + } + + private func evaluationMismatch(_ result: [String: Any], _ evaluationCase: EvaluationCase) -> String? { + if result["flagKey"] as? String != evaluationCase.flag { + return "\(evaluationCase.source): flagKey expected \(evaluationCase.flag), got \(String(describing: result["flagKey"]))" + } + if result["reason"] as? String != evaluationCase.expectedReason { + return "\(evaluationCase.source): reason expected \(evaluationCase.expectedReason), got \(String(describing: result["reason"]))" + } + if !jsonValuesEqual(result["value"], evaluationCase.expectedValue) { + return "\(evaluationCase.source): value expected \(String(describing: evaluationCase.expectedValue)), got \(String(describing: result["value"]))" + } + if let expectedErrorCode = evaluationCase.expectedErrorCode, + result["errorCode"] as? String != expectedErrorCode { + return "\(evaluationCase.source): errorCode expected \(String(describing: evaluationCase.expectedErrorCode)), got \(String(describing: result["errorCode"]))" + } + return nil } private func assertJsonValue( @@ -261,6 +300,7 @@ final class NativeFfeCoreTests: XCTestCase { } private struct EvaluationCase { + let source: String let flag: String let variationType: String let defaultValue: Any @@ -268,6 +308,7 @@ final class NativeFfeCoreTests: XCTestCase { let attributes: [String: Any] let expectedValue: Any let expectedReason: String + let expectedErrorCode: String? var context: [String: Any] { var context: [String: Any] = [ @@ -284,22 +325,43 @@ final class NativeFfeCoreTests: XCTestCase { _ fileName: String, caseIndex: Int = 0 ) throws -> EvaluationCase { + try evaluationCases(fileName)[caseIndex] + } + + private static func evaluationCases(_ fileName: String) throws -> [EvaluationCase] { let cases = try NativeFfeTestFixtures.jsonArray( "ffe-system-test-data/evaluation-cases/\(fileName)" ) - let caseJson = try XCTUnwrap(cases[caseIndex] as? [String: Any]) + return try cases.enumerated().map { index, value in + let caseJson = try XCTUnwrap(value as? [String: Any]) + return try evaluationCase(fileName, caseIndex: index, caseJson: caseJson) + } + } + + private static func evaluationCase( + _ fileName: String, + caseIndex: Int, + caseJson: [String: Any] + ) throws -> EvaluationCase { let resultJson = try XCTUnwrap(caseJson["result"] as? [String: Any]) return EvaluationCase( + source: "\(fileName)[\(caseIndex)]", flag: try XCTUnwrap(caseJson["flag"] as? String), variationType: try XCTUnwrap(caseJson["variationType"] as? String), defaultValue: try XCTUnwrap(caseJson["defaultValue"]), targetingKey: optionalString(caseJson["targetingKey"]), attributes: (caseJson["attributes"] as? [String: Any]) ?? [:], expectedValue: try XCTUnwrap(resultJson["value"]), - expectedReason: try XCTUnwrap(resultJson["reason"] as? String) + expectedReason: try XCTUnwrap(resultJson["reason"] as? String), + expectedErrorCode: optionalString(resultJson["errorCode"]) ) } + private static func allEvaluationCases() throws -> [EvaluationCase] { + try NativeFfeTestFixtures.fileNames(in: "ffe-system-test-data/evaluation-cases") + .flatMap { try evaluationCases($0) } + } + private static func optionalString(_ value: Any?) -> String? { guard let value, !(value is NSNull) else { return nil diff --git a/packages/core/ios/Tests/NativeFfeTestFixtures.swift b/packages/core/ios/Tests/NativeFfeTestFixtures.swift index 48683b10b..b01134a07 100644 --- a/packages/core/ios/Tests/NativeFfeTestFixtures.swift +++ b/packages/core/ios/Tests/NativeFfeTestFixtures.swift @@ -20,6 +20,18 @@ enum NativeFfeTestFixtures { return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [Any]) } + static func fileNames(in relativeDirectory: String) throws -> [String] { + for directory in bundleDirectoryCandidates(relativeDirectory) { + if FileManager.default.fileExists(atPath: directory.path) { + return try jsonFileNames(in: directory) + } + } + + let localDirectory = packageFixtureRoot() + .appendingPathComponent(relativeDirectory) + return try jsonFileNames(in: localDirectory) + } + static func readString(_ relativePath: String) throws -> String { let bundle = Bundle(for: BundleToken.self) let candidates = [ @@ -35,15 +47,36 @@ enum NativeFfeTestFixtures { } } + let localFixture = packageFixtureRoot() + .appendingPathComponent(relativePath) + return try String(contentsOf: localFixture, encoding: .utf8) + } + + private static func bundleDirectoryCandidates(_ relativeDirectory: String) -> [URL] { + let bundle = Bundle(for: BundleToken.self) + return [ + bundle.resourceURL?.appendingPathComponent("__fixtures__").appendingPathComponent(relativeDirectory), + bundle.resourceURL?.appendingPathComponent(relativeDirectory), + ].compactMap { $0 } + } + + private static func jsonFileNames(in directory: URL) throws -> [String] { + try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) + .filter { $0.pathExtension == "json" } + .map(\.lastPathComponent) + .sorted() + } + + private static func packageFixtureRoot() -> URL { let sourceFile = URL(fileURLWithPath: #filePath) let packageRoot = sourceFile .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() - let localFixture = packageRoot - .appendingPathComponent("src/flags/__fixtures__") - .appendingPathComponent(relativePath) - return try String(contentsOf: localFixture, encoding: .utf8) + return packageRoot.appendingPathComponent("src/flags/__fixtures__") } private final class BundleToken {} diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data b/packages/core/src/flags/__fixtures__/ffe-system-test-data new file mode 160000 index 000000000..6c7f63b8f --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data @@ -0,0 +1 @@ +Subproject commit 6c7f63b8f89b8d636d4686c7c2a3b9481c41e485 diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-false-assignment.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-false-assignment.json deleted file mode 100644 index ab718f5be..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-false-assignment.json +++ /dev/null @@ -1,41 +0,0 @@ -[ - { - "attributes": { - "should_disable_feature": true - }, - "defaultValue": true, - "flag": "boolean-false-assignment", - "result": { - "reason": "TARGETING_MATCH", - "value": false - }, - "targetingKey": "alice", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "should_disable_feature": false - }, - "defaultValue": true, - "flag": "boolean-false-assignment", - "result": { - "reason": "TARGETING_MATCH", - "value": true - }, - "targetingKey": "bob", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "unknown_attribute": "value" - }, - "defaultValue": true, - "flag": "boolean-false-assignment", - "result": { - "reason": "DEFAULT", - "value": true - }, - "targetingKey": "charlie", - "variationType": "BOOLEAN" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-one-of-matches.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-one-of-matches.json deleted file mode 100644 index 392ec97ec..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-one-of-matches.json +++ /dev/null @@ -1,208 +0,0 @@ -[ - { - "attributes": { - "one_of_flag": true - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "TARGETING_MATCH", - "value": 1 - }, - "targetingKey": "alice", - "variationType": "INTEGER" - }, - { - "attributes": { - "one_of_flag": false - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "DEFAULT", - "value": 0 - }, - "targetingKey": "bob", - "variationType": "INTEGER" - }, - { - "attributes": { - "one_of_flag": "True" - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "DEFAULT", - "value": 0 - }, - "targetingKey": "charlie", - "variationType": "INTEGER" - }, - { - "attributes": { - "matches_flag": true - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "TARGETING_MATCH", - "value": 2 - }, - "targetingKey": "derek", - "variationType": "INTEGER" - }, - { - "attributes": { - "matches_flag": false - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "DEFAULT", - "value": 0 - }, - "targetingKey": "erica", - "variationType": "INTEGER" - }, - { - "attributes": { - "not_matches_flag": false - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "DEFAULT", - "value": 0 - }, - "targetingKey": "frank", - "variationType": "INTEGER" - }, - { - "attributes": { - "not_matches_flag": true - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "TARGETING_MATCH", - "value": 4 - }, - "targetingKey": "george", - "variationType": "INTEGER" - }, - { - "attributes": { - "not_matches_flag": "False" - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "TARGETING_MATCH", - "value": 4 - }, - "targetingKey": "haley", - "variationType": "INTEGER" - }, - { - "attributes": { - "not_one_of_flag": true - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "TARGETING_MATCH", - "value": 3 - }, - "targetingKey": "ivy", - "variationType": "INTEGER" - }, - { - "attributes": { - "not_one_of_flag": false - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "DEFAULT", - "value": 0 - }, - "targetingKey": "julia", - "variationType": "INTEGER" - }, - { - "attributes": { - "not_one_of_flag": "False" - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "TARGETING_MATCH", - "value": 3 - }, - "targetingKey": "kim", - "variationType": "INTEGER" - }, - { - "attributes": { - "not_one_of_flag": "true" - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "TARGETING_MATCH", - "value": 3 - }, - "targetingKey": "lucas", - "variationType": "INTEGER" - }, - { - "attributes": { - "not_one_of_flag": "false" - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "DEFAULT", - "value": 0 - }, - "targetingKey": "mike", - "variationType": "INTEGER" - }, - { - "attributes": { - "null_flag": "null" - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "TARGETING_MATCH", - "value": 5 - }, - "targetingKey": "nicole", - "variationType": "INTEGER" - }, - { - "attributes": { - "null_flag": null - }, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "DEFAULT", - "value": 0 - }, - "targetingKey": "owen", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "boolean-one-of-matches", - "result": { - "reason": "DEFAULT", - "value": 0 - }, - "targetingKey": "pete", - "variationType": "INTEGER" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-comparator-operator-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-comparator-operator-flag.json deleted file mode 100644 index e4daa2de7..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-comparator-operator-flag.json +++ /dev/null @@ -1,69 +0,0 @@ -[ - { - "attributes": { - "country": "US", - "size": 5 - }, - "defaultValue": "unknown", - "flag": "comparator-operator-test", - "result": { - "reason": "TARGETING_MATCH", - "value": "small" - }, - "targetingKey": "alice", - "variationType": "STRING" - }, - { - "attributes": { - "country": "Canada", - "size": 10 - }, - "defaultValue": "unknown", - "flag": "comparator-operator-test", - "result": { - "reason": "TARGETING_MATCH", - "value": "medium" - }, - "targetingKey": "bob", - "variationType": "STRING" - }, - { - "attributes": { - "size": 25 - }, - "defaultValue": "unknown", - "flag": "comparator-operator-test", - "result": { - "reason": "DEFAULT", - "value": "unknown" - }, - "targetingKey": "charlie", - "variationType": "STRING" - }, - { - "attributes": { - "size": 26 - }, - "defaultValue": "unknown", - "flag": "comparator-operator-test", - "result": { - "reason": "TARGETING_MATCH", - "value": "large" - }, - "targetingKey": "david", - "variationType": "STRING" - }, - { - "attributes": { - "country": "UK" - }, - "defaultValue": "unknown", - "flag": "comparator-operator-test", - "result": { - "reason": "DEFAULT", - "value": "unknown" - }, - "targetingKey": "elize", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-disabled-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-disabled-flag.json deleted file mode 100644 index 208e5c969..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-disabled-flag.json +++ /dev/null @@ -1,43 +0,0 @@ -[ - { - "attributes": { - "country": "US", - "email": "alice@mycompany.com" - }, - "defaultValue": 0, - "flag": "disabled_flag", - "result": { - "reason": "DISABLED", - "value": 0 - }, - "targetingKey": "alice", - "variationType": "INTEGER" - }, - { - "attributes": { - "country": "Canada", - "email": "bob@example.com" - }, - "defaultValue": 0, - "flag": "disabled_flag", - "result": { - "reason": "DISABLED", - "value": 0 - }, - "targetingKey": "bob", - "variationType": "INTEGER" - }, - { - "attributes": { - "age": 50 - }, - "defaultValue": 0, - "flag": "disabled_flag", - "result": { - "reason": "DISABLED", - "value": 0 - }, - "targetingKey": "charlie", - "variationType": "INTEGER" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-flag.json deleted file mode 100644 index 18ee5381f..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-flag.json +++ /dev/null @@ -1,43 +0,0 @@ -[ - { - "attributes": { - "country": "US", - "email": "alice@mycompany.com" - }, - "defaultValue": "default_value", - "flag": "empty_flag", - "result": { - "reason": "DEFAULT", - "value": "default_value" - }, - "targetingKey": "alice", - "variationType": "STRING" - }, - { - "attributes": { - "country": "Canada", - "email": "bob@example.com" - }, - "defaultValue": "default_value", - "flag": "empty_flag", - "result": { - "reason": "DEFAULT", - "value": "default_value" - }, - "targetingKey": "bob", - "variationType": "STRING" - }, - { - "attributes": { - "age": 50 - }, - "defaultValue": "default_value", - "flag": "empty_flag", - "result": { - "reason": "DEFAULT", - "value": "default_value" - }, - "targetingKey": "charlie", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-string-variation.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-string-variation.json deleted file mode 100644 index f1fa3994a..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-string-variation.json +++ /dev/null @@ -1,41 +0,0 @@ -[ - { - "attributes": { - "content_type": "minimal" - }, - "defaultValue": "default_value", - "flag": "empty-string-variation", - "result": { - "reason": "TARGETING_MATCH", - "value": "" - }, - "targetingKey": "empty_user", - "variationType": "STRING" - }, - { - "attributes": { - "content_type": "full" - }, - "defaultValue": "default_value", - "flag": "empty-string-variation", - "result": { - "reason": "TARGETING_MATCH", - "value": "detailed_content" - }, - "targetingKey": "full_user", - "variationType": "STRING" - }, - { - "attributes": { - "content_type": "unknown" - }, - "defaultValue": "default_value", - "flag": "empty-string-variation", - "result": { - "reason": "DEFAULT", - "value": "default_value" - }, - "targetingKey": "default_user", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-falsy-value-assignments.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-falsy-value-assignments.json deleted file mode 100644 index 6a8cee51b..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-falsy-value-assignments.json +++ /dev/null @@ -1,41 +0,0 @@ -[ - { - "attributes": { - "plan_tier": "free" - }, - "defaultValue": 999, - "flag": "falsy-value-assignments", - "result": { - "reason": "TARGETING_MATCH", - "value": 0 - }, - "targetingKey": "zero_user", - "variationType": "INTEGER" - }, - { - "attributes": { - "plan_tier": "premium" - }, - "defaultValue": 999, - "flag": "falsy-value-assignments", - "result": { - "reason": "TARGETING_MATCH", - "value": 100 - }, - "targetingKey": "premium_user", - "variationType": "INTEGER" - }, - { - "attributes": { - "plan_tier": "trial" - }, - "defaultValue": 999, - "flag": "falsy-value-assignments", - "result": { - "reason": "DEFAULT", - "value": 999 - }, - "targetingKey": "unknown_user", - "variationType": "INTEGER" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-flag-with-empty-string.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-flag-with-empty-string.json deleted file mode 100644 index 6a5d9cd62..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-flag-with-empty-string.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "attributes": { - "country": "US" - }, - "defaultValue": "default", - "flag": "empty_string_flag", - "result": { - "reason": "TARGETING_MATCH", - "value": "" - }, - "targetingKey": "alice", - "variationType": "STRING" - }, - { - "attributes": {}, - "defaultValue": "default", - "flag": "empty_string_flag", - "result": { - "reason": "SPLIT", - "value": "non_empty" - }, - "targetingKey": "bob", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-integer-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-integer-flag.json deleted file mode 100644 index 8ff11aec1..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-integer-flag.json +++ /dev/null @@ -1,267 +0,0 @@ -[ - { - "attributes": { - "country": "US", - "email": "alice@mycompany.com" - }, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "TARGETING_MATCH", - "value": 3 - }, - "targetingKey": "alice", - "variationType": "INTEGER" - }, - { - "attributes": { - "country": "Canada", - "email": "bob@example.com" - }, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "TARGETING_MATCH", - "value": 3 - }, - "targetingKey": "bob", - "variationType": "INTEGER" - }, - { - "attributes": { - "age": 50 - }, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 2 - }, - "targetingKey": "charlie", - "variationType": "INTEGER" - }, - { - "attributes": { - "age": 25, - "country": "Mexico", - "email": "test@test.com" - }, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "TARGETING_MATCH", - "value": 3 - }, - "targetingKey": "debra", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 2 - }, - "targetingKey": "1", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 2 - }, - "targetingKey": "2", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 2 - }, - "targetingKey": "3", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 2 - }, - "targetingKey": "4", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 1 - }, - "targetingKey": "5", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 2 - }, - "targetingKey": "6", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 1 - }, - "targetingKey": "7", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 1 - }, - "targetingKey": "8", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 2 - }, - "targetingKey": "9", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 2 - }, - "targetingKey": "10", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 1 - }, - "targetingKey": "11", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 1 - }, - "targetingKey": "12", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 2 - }, - "targetingKey": "13", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 1 - }, - "targetingKey": "14", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 2 - }, - "targetingKey": "15", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 2 - }, - "targetingKey": "16", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 1 - }, - "targetingKey": "17", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 1 - }, - "targetingKey": "18", - "variationType": "INTEGER" - }, - { - "attributes": {}, - "defaultValue": 0, - "flag": "integer-flag", - "result": { - "reason": "SPLIT", - "value": 1 - }, - "targetingKey": "19", - "variationType": "INTEGER" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-invalid-shard-bounds-isolation.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-invalid-shard-bounds-isolation.json deleted file mode 100644 index f48423897..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-invalid-shard-bounds-isolation.json +++ /dev/null @@ -1,38 +0,0 @@ -[ - { - "description": "The malformed flag has totalShards above the unsigned 32-bit range, so SDKs should ignore that flag and return the caller default.", - "attributes": {}, - "defaultValue": "default", - "flag": "overflow-total-shards-flag", - "result": { - "reason": "DEFAULT", - "value": "default" - }, - "targetingKey": "alice", - "variationType": "STRING" - }, - { - "description": "The malformed flag has a negative shard range start, so SDKs should ignore that flag and return the caller default.", - "attributes": {}, - "defaultValue": "default", - "flag": "negative-shard-range-start-flag", - "result": { - "reason": "DEFAULT", - "value": "default" - }, - "targetingKey": "alice", - "variationType": "STRING" - }, - { - "description": "A valid flag in the same config must still evaluate after invalid shard-bound flags are skipped.", - "attributes": {}, - "defaultValue": "default", - "flag": "valid-flag-after-invalid-config", - "result": { - "reason": "STATIC", - "value": "expected" - }, - "targetingKey": "alice", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-kill-switch-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-kill-switch-flag.json deleted file mode 100644 index 3af700dc7..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-kill-switch-flag.json +++ /dev/null @@ -1,314 +0,0 @@ -[ - { - "attributes": { - "country": "US", - "email": "alice@mycompany.com" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "TARGETING_MATCH", - "value": true - }, - "targetingKey": "alice", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "country": "Canada", - "email": "bob@example.com" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "TARGETING_MATCH", - "value": true - }, - "targetingKey": "bob", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "country": "canada", - "email": "barbara@example.com" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "barbara", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "age": 40 - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "charlie", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "age": 25, - "country": "Mexico", - "email": "test@test.com" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "TARGETING_MATCH", - "value": true - }, - "targetingKey": "debra", - "variationType": "BOOLEAN" - }, - { - "attributes": {}, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "1", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "country": "Mexico" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "TARGETING_MATCH", - "value": true - }, - "targetingKey": "2", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "age": 50, - "country": "UK" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "TARGETING_MATCH", - "value": true - }, - "targetingKey": "3", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "country": "Germany" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "4", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "country": "Germany" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "5", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "country": "Germany" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "6", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "age": 12, - "country": "US" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "TARGETING_MATCH", - "value": true - }, - "targetingKey": "7", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "age": 60, - "country": "Italy" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "TARGETING_MATCH", - "value": true - }, - "targetingKey": "8", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "email": "email@email.com" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "9", - "variationType": "BOOLEAN" - }, - { - "attributes": {}, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "10", - "variationType": "BOOLEAN" - }, - { - "attributes": {}, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "11", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "country": "US" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "TARGETING_MATCH", - "value": true - }, - "targetingKey": "12", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "country": "Canada" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "TARGETING_MATCH", - "value": true - }, - "targetingKey": "13", - "variationType": "BOOLEAN" - }, - { - "attributes": {}, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "14", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "country": "Denmark" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "15", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "country": "Norway" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "16", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "country": "UK" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "17", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "country": "UK" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "18", - "variationType": "BOOLEAN" - }, - { - "attributes": { - "country": "UK" - }, - "defaultValue": false, - "flag": "kill-switch", - "result": { - "reason": "STATIC", - "value": false - }, - "targetingKey": "19", - "variationType": "BOOLEAN" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-malformed-flag-isolation.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-malformed-flag-isolation.json deleted file mode 100644 index dcfa7b119..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-malformed-flag-isolation.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "description": "The malformed flag has allocations encoded as a string instead of an array, so SDKs should ignore that flag and return the caller default.", - "attributes": {}, - "defaultValue": "default", - "flag": "malformed-allocations-flag", - "result": { - "reason": "DEFAULT", - "value": "default" - }, - "targetingKey": "alice", - "variationType": "STRING" - }, - { - "description": "A valid flag in the same config must still evaluate after the malformed flag is skipped.", - "attributes": {}, - "defaultValue": "default", - "flag": "valid-flag-after-invalid-config", - "result": { - "reason": "STATIC", - "value": "expected" - }, - "targetingKey": "alice", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-microsecond-date-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-microsecond-date-flag.json deleted file mode 100644 index 3dba337ad..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-microsecond-date-flag.json +++ /dev/null @@ -1,39 +0,0 @@ -[ - { - "attributes": {}, - "defaultValue": "unknown", - "flag": "microsecond-date-test", - "result": { - "reason": "DEFAULT", - "value": "active" - }, - "targetingKey": "alice", - "variationType": "STRING" - }, - { - "attributes": { - "country": "US" - }, - "defaultValue": "unknown", - "flag": "microsecond-date-test", - "result": { - "reason": "DEFAULT", - "value": "active" - }, - "targetingKey": "bob", - "variationType": "STRING" - }, - { - "attributes": { - "version": "1.0.0" - }, - "defaultValue": "unknown", - "flag": "microsecond-date-test", - "result": { - "reason": "DEFAULT", - "value": "active" - }, - "targetingKey": "charlie", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-missing-split-shards-isolation.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-missing-split-shards-isolation.json deleted file mode 100644 index bf474c5a1..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-missing-split-shards-isolation.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "description": "The malformed flag has a split that omits required shards, so SDKs should ignore that flag and return the caller default.", - "attributes": {}, - "defaultValue": "default", - "flag": "missing-split-shards-flag", - "result": { - "reason": "DEFAULT", - "value": "default" - }, - "targetingKey": "alice", - "variationType": "STRING" - }, - { - "description": "A valid flag in the same config must still evaluate after the missing-shards flag is skipped.", - "attributes": {}, - "defaultValue": "default", - "flag": "valid-flag-after-invalid-config", - "result": { - "reason": "STATIC", - "value": "expected" - }, - "targetingKey": "alice", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-new-user-onboarding-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-new-user-onboarding-flag.json deleted file mode 100644 index bb08523de..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-new-user-onboarding-flag.json +++ /dev/null @@ -1,344 +0,0 @@ -[ - { - "attributes": { - "country": "US", - "email": "alice@mycompany.com" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "green" - }, - "targetingKey": "alice", - "variationType": "STRING" - }, - { - "attributes": { - "country": "Canada", - "email": "bob@example.com" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "DEFAULT", - "value": "default" - }, - "targetingKey": "bob", - "variationType": "STRING" - }, - { - "attributes": { - "age": 50 - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "DEFAULT", - "value": "default" - }, - "targetingKey": "charlie", - "variationType": "STRING" - }, - { - "attributes": { - "age": 25, - "country": "Mexico", - "email": "test@test.com" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "blue" - }, - "targetingKey": "debra", - "variationType": "STRING" - }, - { - "attributes": { - "age": 25, - "country": "Mexico", - "email": "test@test.com" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "purple" - }, - "targetingKey": "zach", - "variationType": "STRING" - }, - { - "attributes": { - "age": 25, - "country": "Mexico", - "email": "test@test.com", - "id": "override-id" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "blue" - }, - "targetingKey": "zach", - "variationType": "STRING" - }, - { - "attributes": { - "age": 25, - "country": "Mexico", - "email": "test@test.com" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "DEFAULT", - "value": "default" - }, - "targetingKey": "Zach", - "variationType": "STRING" - }, - { - "attributes": {}, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "DEFAULT", - "value": "default" - }, - "targetingKey": "1", - "variationType": "STRING" - }, - { - "attributes": { - "country": "Mexico" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "blue" - }, - "targetingKey": "2", - "variationType": "STRING" - }, - { - "attributes": { - "age": 33, - "country": "UK" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "control" - }, - "targetingKey": "3", - "variationType": "STRING" - }, - { - "attributes": { - "country": "Germany" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "red" - }, - "targetingKey": "4", - "variationType": "STRING" - }, - { - "attributes": { - "country": "Germany" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "yellow" - }, - "targetingKey": "5", - "variationType": "STRING" - }, - { - "attributes": { - "country": "Germany" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "yellow" - }, - "targetingKey": "6", - "variationType": "STRING" - }, - { - "attributes": { - "country": "US" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "blue" - }, - "targetingKey": "7", - "variationType": "STRING" - }, - { - "attributes": { - "country": "Italy" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "red" - }, - "targetingKey": "8", - "variationType": "STRING" - }, - { - "attributes": { - "email": "email@email.com" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "DEFAULT", - "value": "default" - }, - "targetingKey": "9", - "variationType": "STRING" - }, - { - "attributes": {}, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "DEFAULT", - "value": "default" - }, - "targetingKey": "10", - "variationType": "STRING" - }, - { - "attributes": {}, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "DEFAULT", - "value": "default" - }, - "targetingKey": "11", - "variationType": "STRING" - }, - { - "attributes": { - "country": "US" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "blue" - }, - "targetingKey": "12", - "variationType": "STRING" - }, - { - "attributes": { - "country": "Canada" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "blue" - }, - "targetingKey": "13", - "variationType": "STRING" - }, - { - "attributes": {}, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "DEFAULT", - "value": "default" - }, - "targetingKey": "14", - "variationType": "STRING" - }, - { - "attributes": { - "country": "Denmark" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "yellow" - }, - "targetingKey": "15", - "variationType": "STRING" - }, - { - "attributes": { - "country": "Norway" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "control" - }, - "targetingKey": "16", - "variationType": "STRING" - }, - { - "attributes": { - "country": "UK" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "control" - }, - "targetingKey": "17", - "variationType": "STRING" - }, - { - "attributes": { - "country": "UK" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "DEFAULT", - "value": "default" - }, - "targetingKey": "18", - "variationType": "STRING" - }, - { - "attributes": { - "country": "UK" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "red" - }, - "targetingKey": "19", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-no-allocations-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-no-allocations-flag.json deleted file mode 100644 index 7d985b8ef..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-no-allocations-flag.json +++ /dev/null @@ -1,55 +0,0 @@ -[ - { - "attributes": { - "country": "US", - "email": "alice@mycompany.com" - }, - "defaultValue": { - "hello": "world" - }, - "flag": "no_allocations_flag", - "result": { - "reason": "DEFAULT", - "value": { - "hello": "world" - } - }, - "targetingKey": "alice", - "variationType": "JSON" - }, - { - "attributes": { - "country": "Canada", - "email": "bob@example.com" - }, - "defaultValue": { - "hello": "world" - }, - "flag": "no_allocations_flag", - "result": { - "reason": "DEFAULT", - "value": { - "hello": "world" - } - }, - "targetingKey": "bob", - "variationType": "JSON" - }, - { - "attributes": { - "age": 50 - }, - "defaultValue": { - "hello": "world" - }, - "flag": "no_allocations_flag", - "result": { - "reason": "DEFAULT", - "value": { - "hello": "world" - } - }, - "targetingKey": "charlie", - "variationType": "JSON" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-operator-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-operator-flag.json deleted file mode 100644 index 8063ff8fe..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-operator-flag.json +++ /dev/null @@ -1,69 +0,0 @@ -[ - { - "attributes": { - "country": "US", - "size": 5 - }, - "defaultValue": "default-null", - "flag": "null-operator-test", - "result": { - "reason": "TARGETING_MATCH", - "value": "old" - }, - "targetingKey": "alice", - "variationType": "STRING" - }, - { - "attributes": { - "country": "Canada", - "size": 10 - }, - "defaultValue": "default-null", - "flag": "null-operator-test", - "result": { - "reason": "TARGETING_MATCH", - "value": "new" - }, - "targetingKey": "bob", - "variationType": "STRING" - }, - { - "attributes": { - "size": null - }, - "defaultValue": "default-null", - "flag": "null-operator-test", - "result": { - "reason": "TARGETING_MATCH", - "value": "old" - }, - "targetingKey": "charlie", - "variationType": "STRING" - }, - { - "attributes": { - "size": 26 - }, - "defaultValue": "default-null", - "flag": "null-operator-test", - "result": { - "reason": "TARGETING_MATCH", - "value": "new" - }, - "targetingKey": "david", - "variationType": "STRING" - }, - { - "attributes": { - "country": "UK" - }, - "defaultValue": "default-null", - "flag": "null-operator-test", - "result": { - "reason": "TARGETING_MATCH", - "value": "old" - }, - "targetingKey": "elize", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-targeting-key.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-targeting-key.json deleted file mode 100644 index a2226d1b2..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-targeting-key.json +++ /dev/null @@ -1,40 +0,0 @@ -[ - { - "attributes": {}, - "defaultValue": 42, - "flag": "numeric_flag", - "result": { - "reason": "STATIC", - "value": 3.1415926 - }, - "targetingKey": null, - "variationType": "NUMERIC" - }, - { - "attributes": { - "country": "France" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "ERROR", - "value": "default" - }, - "targetingKey": null, - "variationType": "STRING" - }, - { - "attributes": { - "country": "France", - "email": "null@mycompany.com" - }, - "defaultValue": "default", - "flag": "new-user-onboarding", - "result": { - "reason": "TARGETING_MATCH", - "value": "green" - }, - "targetingKey": null, - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-flag.json deleted file mode 100644 index cc52dcdff..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-flag.json +++ /dev/null @@ -1,43 +0,0 @@ -[ - { - "attributes": { - "country": "US", - "email": "alice@mycompany.com" - }, - "defaultValue": 0, - "flag": "numeric_flag", - "result": { - "reason": "STATIC", - "value": 3.1415926 - }, - "targetingKey": "alice", - "variationType": "NUMERIC" - }, - { - "attributes": { - "country": "Canada", - "email": "bob@example.com" - }, - "defaultValue": 0, - "flag": "numeric_flag", - "result": { - "reason": "STATIC", - "value": 3.1415926 - }, - "targetingKey": "bob", - "variationType": "NUMERIC" - }, - { - "attributes": { - "age": 50 - }, - "defaultValue": 0, - "flag": "numeric_flag", - "result": { - "reason": "STATIC", - "value": 3.1415926 - }, - "targetingKey": "charlie", - "variationType": "NUMERIC" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of-default.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of-default.json deleted file mode 100644 index d8a7e8ba0..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of-default.json +++ /dev/null @@ -1,13 +0,0 @@ -[ - { - "attributes": {}, - "defaultValue": 0, - "flag": "numeric-one-of", - "result": { - "reason": "DEFAULT", - "value": 0 - }, - "targetingKey": "race-default", - "variationType": "INTEGER" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of.json deleted file mode 100644 index bfa957335..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of.json +++ /dev/null @@ -1,93 +0,0 @@ -[ - { - "attributes": { - "number": 1 - }, - "defaultValue": 0, - "flag": "numeric-one-of", - "result": { - "reason": "TARGETING_MATCH", - "value": 1 - }, - "targetingKey": "alice", - "variationType": "INTEGER" - }, - { - "attributes": { - "number": 2 - }, - "defaultValue": 0, - "flag": "numeric-one-of", - "result": { - "reason": "DEFAULT", - "value": 0 - }, - "targetingKey": "bob", - "variationType": "INTEGER" - }, - { - "attributes": { - "number": 3 - }, - "defaultValue": 0, - "flag": "numeric-one-of", - "result": { - "reason": "TARGETING_MATCH", - "value": 3 - }, - "targetingKey": "charlie", - "variationType": "INTEGER" - }, - { - "attributes": { - "number": 4 - }, - "defaultValue": 0, - "flag": "numeric-one-of", - "result": { - "reason": "TARGETING_MATCH", - "value": 3 - }, - "targetingKey": "derek", - "variationType": "INTEGER" - }, - { - "attributes": { - "number": "1" - }, - "defaultValue": 0, - "flag": "numeric-one-of", - "result": { - "reason": "TARGETING_MATCH", - "value": 1 - }, - "targetingKey": "erica", - "variationType": "INTEGER" - }, - { - "attributes": { - "number": 1 - }, - "defaultValue": 0, - "flag": "numeric-one-of", - "result": { - "reason": "TARGETING_MATCH", - "value": 1 - }, - "targetingKey": "frank", - "variationType": "INTEGER" - }, - { - "attributes": { - "number": 123456789 - }, - "defaultValue": 0, - "flag": "numeric-one-of", - "result": { - "reason": "TARGETING_MATCH", - "value": 2 - }, - "targetingKey": "george", - "variationType": "INTEGER" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-of-7-empty-targeting-key.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-of-7-empty-targeting-key.json deleted file mode 100644 index e99a207b6..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-of-7-empty-targeting-key.json +++ /dev/null @@ -1,13 +0,0 @@ -[ - { - "attributes": {}, - "defaultValue": "default", - "flag": "empty-targeting-key-flag", - "result": { - "reason": "STATIC", - "value": "on-value" - }, - "targetingKey": "", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-regex-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-regex-flag.json deleted file mode 100644 index 9b95a9014..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-regex-flag.json +++ /dev/null @@ -1,109 +0,0 @@ -[ - { - "attributes": { - "email": "user.name+tag@capture.example" - }, - "defaultValue": "none", - "flag": "regex-flag", - "result": { - "reason": "TARGETING_MATCH", - "value": "capturing-groups" - }, - "targetingKey": "capture", - "variationType": "STRING" - }, - { - "attributes": { - "email": "llega mañana temprano" - }, - "defaultValue": "none", - "flag": "regex-flag", - "result": { - "reason": "TARGETING_MATCH", - "value": "unicode-word-boundary" - }, - "targetingKey": "unicode", - "variationType": "STRING" - }, - { - "attributes": { - "email": "ALICE" - }, - "defaultValue": "none", - "flag": "regex-flag", - "result": { - "reason": "TARGETING_MATCH", - "value": "unicode-combined-inline-flags" - }, - "targetingKey": "unicode-combined", - "variationType": "STRING" - }, - { - "attributes": { - "email": "mañana" - }, - "defaultValue": "none", - "flag": "regex-flag", - "result": { - "reason": "TARGETING_MATCH", - "value": "unicode-scoped-inline-flags" - }, - "targetingKey": "unicode-scoped", - "variationType": "STRING" - }, - { - "attributes": { - "email": "alice@example.com", - "version": "1.15.0" - }, - "defaultValue": "none", - "flag": "regex-flag", - "result": { - "reason": "TARGETING_MATCH", - "value": "partial-example" - }, - "targetingKey": "alice", - "variationType": "STRING" - }, - { - "attributes": { - "email": "bob@test.com", - "version": "0.20.1" - }, - "defaultValue": "none", - "flag": "regex-flag", - "result": { - "reason": "TARGETING_MATCH", - "value": "test" - }, - "targetingKey": "bob", - "variationType": "STRING" - }, - { - "attributes": { - "version": "2.1.13" - }, - "defaultValue": "none", - "flag": "regex-flag", - "result": { - "reason": "DEFAULT", - "value": "none" - }, - "targetingKey": "charlie", - "variationType": "STRING" - }, - { - "attributes": { - "email": "derek@gmail.com", - "version": "2.1.13" - }, - "defaultValue": "none", - "flag": "regex-flag", - "result": { - "reason": "DEFAULT", - "value": "none" - }, - "targetingKey": "derek", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-start-and-end-date-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-start-and-end-date-flag.json deleted file mode 100644 index 53bf8859c..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-start-and-end-date-flag.json +++ /dev/null @@ -1,43 +0,0 @@ -[ - { - "attributes": { - "country": "US", - "version": "1.15.0" - }, - "defaultValue": "unknown", - "flag": "start-and-end-date-test", - "result": { - "reason": "DEFAULT", - "value": "current" - }, - "targetingKey": "alice", - "variationType": "STRING" - }, - { - "attributes": { - "country": "Canada", - "version": "0.20.1" - }, - "defaultValue": "unknown", - "flag": "start-and-end-date-test", - "result": { - "reason": "DEFAULT", - "value": "current" - }, - "targetingKey": "bob", - "variationType": "STRING" - }, - { - "attributes": { - "version": "2.1.13" - }, - "defaultValue": "unknown", - "flag": "start-and-end-date-test", - "result": { - "reason": "DEFAULT", - "value": "current" - }, - "targetingKey": "charlie", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-fields-tolerance.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-fields-tolerance.json deleted file mode 100644 index 1bc2c9145..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-fields-tolerance.json +++ /dev/null @@ -1,16 +0,0 @@ -[ - { - "description": "The flag and surrounding UFC config include unknown object fields that SDKs must ignore while evaluating the known schema fields.", - "attributes": { - "country": "US" - }, - "defaultValue": "default", - "flag": "unknown-fields-tolerance-flag", - "result": { - "reason": "TARGETING_MATCH", - "value": "on" - }, - "targetingKey": "alice", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-operator-isolation.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-operator-isolation.json deleted file mode 100644 index 23a3a681a..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-operator-isolation.json +++ /dev/null @@ -1,30 +0,0 @@ -[ - { - "description": "The first allocation uses an unknown operator and the second is a catch-all trap; SDKs should ignore the whole flag and return the caller default.", - "attributes": { - "country": "US" - }, - "defaultValue": "default", - "flag": "operator-grease-flag", - "result": { - "reason": "DEFAULT", - "value": "default" - }, - "targetingKey": "alice", - "variationType": "STRING" - }, - { - "description": "A valid flag in the same config must still evaluate after the unknown-operator flag is skipped.", - "attributes": { - "country": "US" - }, - "defaultValue": "default", - "flag": "valid-flag-after-invalid-config", - "result": { - "reason": "STATIC", - "value": "expected" - }, - "targetingKey": "alice", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-flag-that-does-not-exist.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-flag-that-does-not-exist.json deleted file mode 100644 index 0be74f1f7..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-flag-that-does-not-exist.json +++ /dev/null @@ -1,46 +0,0 @@ -[ - { - "attributes": { - "country": "US", - "email": "alice@mycompany.com" - }, - "defaultValue": 0, - "flag": "flag-that-does-not-exist", - "result": { - "errorCode": "FLAG_NOT_FOUND", - "reason": "ERROR", - "value": 0 - }, - "targetingKey": "alice", - "variationType": "NUMERIC" - }, - { - "attributes": { - "country": "Canada", - "email": "bob@example.com" - }, - "defaultValue": 0, - "flag": "flag-that-does-not-exist", - "result": { - "errorCode": "FLAG_NOT_FOUND", - "reason": "ERROR", - "value": 0 - }, - "targetingKey": "bob", - "variationType": "NUMERIC" - }, - { - "attributes": { - "age": 50 - }, - "defaultValue": 0, - "flag": "flag-that-does-not-exist", - "result": { - "errorCode": "FLAG_NOT_FOUND", - "reason": "ERROR", - "value": 0 - }, - "targetingKey": "charlie", - "variationType": "NUMERIC" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-json-config-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-json-config-flag.json deleted file mode 100644 index 45a9c2ee9..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-json-config-flag.json +++ /dev/null @@ -1,76 +0,0 @@ -[ - { - "attributes": { - "country": "US", - "email": "alice@mycompany.com" - }, - "defaultValue": { - "foo": "bar" - }, - "flag": "json-config-flag", - "result": { - "reason": "SPLIT", - "value": { - "float": 1.0, - "integer": 1, - "string": "one" - } - }, - "targetingKey": "alice", - "variationType": "JSON" - }, - { - "attributes": { - "country": "Canada", - "email": "bob@example.com" - }, - "defaultValue": { - "foo": "bar" - }, - "flag": "json-config-flag", - "result": { - "reason": "SPLIT", - "value": { - "float": 2.0, - "integer": 2, - "string": "two" - } - }, - "targetingKey": "bob", - "variationType": "JSON" - }, - { - "attributes": { - "age": 50 - }, - "defaultValue": { - "foo": "bar" - }, - "flag": "json-config-flag", - "result": { - "reason": "SPLIT", - "value": { - "float": 2.0, - "integer": 2, - "string": "two" - } - }, - "targetingKey": "charlie", - "variationType": "JSON" - }, - { - "attributes": { - "Force Empty": true - }, - "defaultValue": { - "foo": "bar" - }, - "flag": "json-config-flag", - "result": { - "reason": "TARGETING_MATCH", - "value": {} - }, - "targetingKey": "diana", - "variationType": "JSON" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-no-allocations-flag.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-no-allocations-flag.json deleted file mode 100644 index 2b03b9a39..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-no-allocations-flag.json +++ /dev/null @@ -1,55 +0,0 @@ -[ - { - "attributes": { - "country": "US", - "email": "alice@mycompany.com" - }, - "defaultValue": { - "message": "Hello, world!" - }, - "flag": "no_allocations_flag", - "result": { - "reason": "DEFAULT", - "value": { - "message": "Hello, world!" - } - }, - "targetingKey": "alice", - "variationType": "JSON" - }, - { - "attributes": { - "country": "Canada", - "email": "bob@example.com" - }, - "defaultValue": { - "message": "Hello, world!" - }, - "flag": "no_allocations_flag", - "result": { - "reason": "DEFAULT", - "value": { - "message": "Hello, world!" - } - }, - "targetingKey": "bob", - "variationType": "JSON" - }, - { - "attributes": { - "age": 50 - }, - "defaultValue": { - "message": "Hello, world!" - }, - "flag": "no_allocations_flag", - "result": { - "reason": "DEFAULT", - "value": { - "message": "Hello, world!" - } - }, - "targetingKey": "charlie", - "variationType": "JSON" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-special-characters.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-special-characters.json deleted file mode 100644 index 1ed7795bb..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-special-characters.json +++ /dev/null @@ -1,58 +0,0 @@ -[ - { - "attributes": {}, - "defaultValue": {}, - "flag": "special-characters", - "result": { - "reason": "SPLIT", - "value": { - "a": "kümmert", - "b": "schön" - } - }, - "targetingKey": "ash", - "variationType": "JSON" - }, - { - "attributes": {}, - "defaultValue": {}, - "flag": "special-characters", - "result": { - "reason": "SPLIT", - "value": { - "a": "піклуватися", - "b": "любов" - } - }, - "targetingKey": "ben", - "variationType": "JSON" - }, - { - "attributes": {}, - "defaultValue": {}, - "flag": "special-characters", - "result": { - "reason": "SPLIT", - "value": { - "a": "照顾", - "b": "漂亮" - } - }, - "targetingKey": "cameron", - "variationType": "JSON" - }, - { - "attributes": {}, - "defaultValue": {}, - "flag": "special-characters", - "result": { - "reason": "SPLIT", - "value": { - "a": "🤗", - "b": "🌸" - } - }, - "targetingKey": "darryl", - "variationType": "JSON" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-string-with-special-characters.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-string-with-special-characters.json deleted file mode 100644 index 32397a483..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-string-with-special-characters.json +++ /dev/null @@ -1,860 +0,0 @@ -[ - { - "attributes": { - "string_with_spaces": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": " a b c d e f " - }, - "targetingKey": "string_with_spaces", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_space": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": " " - }, - "targetingKey": "string_with_only_one_space", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_spaces": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": " " - }, - "targetingKey": "string_with_only_multiple_spaces", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_dots": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": ".a.b.c.d.e.f." - }, - "targetingKey": "string_with_dots", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_dot": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "." - }, - "targetingKey": "string_with_only_one_dot", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_dots": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "......." - }, - "targetingKey": "string_with_only_multiple_dots", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_comas": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": ",a,b,c,d,e,f," - }, - "targetingKey": "string_with_comas", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_coma": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "," - }, - "targetingKey": "string_with_only_one_coma", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_comas": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": ",,,,,,," - }, - "targetingKey": "string_with_only_multiple_comas", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_colons": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": ":a:b:c:d:e:f:" - }, - "targetingKey": "string_with_colons", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_colon": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": ":" - }, - "targetingKey": "string_with_only_one_colon", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_colons": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": ":::::::" - }, - "targetingKey": "string_with_only_multiple_colons", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_semicolons": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": ";a;b;c;d;e;f;" - }, - "targetingKey": "string_with_semicolons", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_semicolon": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": ";" - }, - "targetingKey": "string_with_only_one_semicolon", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_semicolons": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": ";;;;;;;" - }, - "targetingKey": "string_with_only_multiple_semicolons", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_slashes": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "/a/b/c/d/e/f/" - }, - "targetingKey": "string_with_slashes", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_slash": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "/" - }, - "targetingKey": "string_with_only_one_slash", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_slashes": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "///////" - }, - "targetingKey": "string_with_only_multiple_slashes", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_dashes": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "-a-b-c-d-e-f-" - }, - "targetingKey": "string_with_dashes", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_dash": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "-" - }, - "targetingKey": "string_with_only_one_dash", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_dashes": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "-------" - }, - "targetingKey": "string_with_only_multiple_dashes", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_underscores": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "_a_b_c_d_e_f_" - }, - "targetingKey": "string_with_underscores", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_underscore": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "_" - }, - "targetingKey": "string_with_only_one_underscore", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_underscores": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "_______" - }, - "targetingKey": "string_with_only_multiple_underscores", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_plus_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "+a+b+c+d+e+f+" - }, - "targetingKey": "string_with_plus_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_plus_sign": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "+" - }, - "targetingKey": "string_with_only_one_plus_sign", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_plus_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "+++++++" - }, - "targetingKey": "string_with_only_multiple_plus_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_equal_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "=a=b=c=d=e=f=" - }, - "targetingKey": "string_with_equal_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_equal_sign": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "=" - }, - "targetingKey": "string_with_only_one_equal_sign", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_equal_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "=======" - }, - "targetingKey": "string_with_only_multiple_equal_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_dollar_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "$a$b$c$d$e$f$" - }, - "targetingKey": "string_with_dollar_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_dollar_sign": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "$" - }, - "targetingKey": "string_with_only_one_dollar_sign", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_dollar_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "$$$$$$$" - }, - "targetingKey": "string_with_only_multiple_dollar_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_at_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "@a@b@c@d@e@f@" - }, - "targetingKey": "string_with_at_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_at_sign": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "@" - }, - "targetingKey": "string_with_only_one_at_sign", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_at_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "@@@@@@@" - }, - "targetingKey": "string_with_only_multiple_at_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_amp_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "\u0026a\u0026b\u0026c\u0026d\u0026e\u0026f\u0026" - }, - "targetingKey": "string_with_amp_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_amp_sign": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "\u0026" - }, - "targetingKey": "string_with_only_one_amp_sign", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_amp_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "\u0026\u0026\u0026\u0026\u0026\u0026\u0026" - }, - "targetingKey": "string_with_only_multiple_amp_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_hash_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "#a#b#c#d#e#f#" - }, - "targetingKey": "string_with_hash_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_hash_sign": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "#" - }, - "targetingKey": "string_with_only_one_hash_sign", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_hash_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "#######" - }, - "targetingKey": "string_with_only_multiple_hash_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_percentage_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "%a%b%c%d%e%f%" - }, - "targetingKey": "string_with_percentage_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_percentage_sign": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "%" - }, - "targetingKey": "string_with_only_one_percentage_sign", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_percentage_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "%%%%%%%" - }, - "targetingKey": "string_with_only_multiple_percentage_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_tilde_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "~a~b~c~d~e~f~" - }, - "targetingKey": "string_with_tilde_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_tilde_sign": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "~" - }, - "targetingKey": "string_with_only_one_tilde_sign", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_tilde_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "~~~~~~~" - }, - "targetingKey": "string_with_only_multiple_tilde_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_asterix_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "*a*b*c*d*e*f*" - }, - "targetingKey": "string_with_asterix_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_asterix_sign": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "*" - }, - "targetingKey": "string_with_only_one_asterix_sign", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_asterix_signs": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "*******" - }, - "targetingKey": "string_with_only_multiple_asterix_signs", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_single_quotes": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "'a'b'c'd'e'f'" - }, - "targetingKey": "string_with_single_quotes", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_single_quote": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "'" - }, - "targetingKey": "string_with_only_one_single_quote", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_single_quotes": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "'''''''" - }, - "targetingKey": "string_with_only_multiple_single_quotes", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_question_marks": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "?a?b?c?d?e?f?" - }, - "targetingKey": "string_with_question_marks", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_question_mark": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "?" - }, - "targetingKey": "string_with_only_one_question_mark", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_question_marks": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "???????" - }, - "targetingKey": "string_with_only_multiple_question_marks", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_exclamation_marks": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "!a!b!c!d!e!f!" - }, - "targetingKey": "string_with_exclamation_marks", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_exclamation_mark": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "!" - }, - "targetingKey": "string_with_only_one_exclamation_mark", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_exclamation_marks": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "!!!!!!!" - }, - "targetingKey": "string_with_only_multiple_exclamation_marks", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_opening_parentheses": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "(a(b(c(d(e(f(" - }, - "targetingKey": "string_with_opening_parentheses", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_opening_parenthese": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "(" - }, - "targetingKey": "string_with_only_one_opening_parenthese", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_opening_parentheses": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": "(((((((" - }, - "targetingKey": "string_with_only_multiple_opening_parentheses", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_closing_parentheses": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": ")a)b)c)d)e)f)" - }, - "targetingKey": "string_with_closing_parentheses", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_one_closing_parenthese": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": ")" - }, - "targetingKey": "string_with_only_one_closing_parenthese", - "variationType": "STRING" - }, - { - "attributes": { - "string_with_only_multiple_closing_parentheses": true - }, - "defaultValue": "default_value", - "flag": "string_flag_with_special_characters", - "result": { - "reason": "TARGETING_MATCH", - "value": ")))))))" - }, - "targetingKey": "string_with_only_multiple_closing_parentheses", - "variationType": "STRING" - } -] diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data/ufc-config.json b/packages/core/src/flags/__fixtures__/ffe-system-test-data/ufc-config.json deleted file mode 100644 index 5a7f9b95b..000000000 --- a/packages/core/src/flags/__fixtures__/ffe-system-test-data/ufc-config.json +++ /dev/null @@ -1,3696 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test", - "unknownEnvironmentField": "ignored" - }, - "unknownTopLevelField": "ignored", - "unknownTopLevelNull": null, - "flags": { - "empty_flag": { - "key": "empty_flag", - "enabled": true, - "variationType": "STRING", - "variations": {}, - "allocations": [] - }, - "disabled_flag": { - "key": "disabled_flag", - "enabled": false, - "variationType": "INTEGER", - "variations": {}, - "allocations": [] - }, - "no_allocations_flag": { - "key": "no_allocations_flag", - "enabled": true, - "variationType": "JSON", - "variations": { - "control": { - "key": "control", - "value": { - "variant": "control" - } - }, - "treatment": { - "key": "treatment", - "value": { - "variant": "treatment" - } - } - }, - "allocations": [] - }, - "numeric_flag": { - "key": "numeric_flag", - "enabled": true, - "variationType": "NUMERIC", - "variations": { - "e": { - "key": "e", - "value": 2.7182818 - }, - "pi": { - "key": "pi", - "value": 3.1415926 - } - }, - "allocations": [ - { - "key": "rollout", - "splits": [ - { - "variationKey": "pi", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "regex-flag": { - "key": "regex-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "partial-example": { - "key": "partial-example", - "value": "partial-example" - }, - "test": { - "key": "test", - "value": "test" - }, - "capturing-groups": { - "key": "capturing-groups", - "value": "capturing-groups" - }, - "unicode-word-boundary": { - "key": "unicode-word-boundary", - "value": "unicode-word-boundary" - }, - "unicode-combined-inline-flags": { - "key": "unicode-combined-inline-flags", - "value": "unicode-combined-inline-flags" - }, - "unicode-scoped-inline-flags": { - "key": "unicode-scoped-inline-flags", - "value": "unicode-scoped-inline-flags" - } - }, - "allocations": [ - { - "key": "capturing-groups", - "rules": [ - { - "conditions": [ - { - "attribute": "email", - "operator": "MATCHES", - "value": "^([[:alnum:]._%+-]+)@capture\\.example$" - } - ] - } - ], - "splits": [ - { - "variationKey": "capturing-groups", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "unicode-word-boundary", - "rules": [ - { - "conditions": [ - { - "attribute": "email", - "operator": "MATCHES", - "value": "(?u)\\bmañana\\b" - } - ] - } - ], - "splits": [ - { - "variationKey": "unicode-word-boundary", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "unicode-combined-inline-flags", - "rules": [ - { - "conditions": [ - { - "attribute": "email", - "operator": "MATCHES", - "value": "(?iu)^alice$" - } - ] - } - ], - "splits": [ - { - "variationKey": "unicode-combined-inline-flags", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "unicode-scoped-inline-flags", - "rules": [ - { - "conditions": [ - { - "attribute": "email", - "operator": "MATCHES", - "value": "(?u:^mañana$)" - } - ] - } - ], - "splits": [ - { - "variationKey": "unicode-scoped-inline-flags", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "partial-example", - "rules": [ - { - "conditions": [ - { - "attribute": "email", - "operator": "MATCHES", - "value": "@example\\.com" - } - ] - } - ], - "splits": [ - { - "variationKey": "partial-example", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "test", - "rules": [ - { - "conditions": [ - { - "attribute": "email", - "operator": "MATCHES", - "value": ".*@test\\.com" - } - ] - } - ], - "splits": [ - { - "variationKey": "test", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "numeric-one-of": { - "key": "numeric-one-of", - "enabled": true, - "variationType": "INTEGER", - "variations": { - "1": { - "key": "1", - "value": 1 - }, - "2": { - "key": "2", - "value": 2 - }, - "3": { - "key": "3", - "value": 3 - } - }, - "allocations": [ - { - "key": "1-for-1", - "rules": [ - { - "conditions": [ - { - "attribute": "number", - "operator": "ONE_OF", - "value": [ - "1" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "1", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "2-for-123456789", - "rules": [ - { - "conditions": [ - { - "attribute": "number", - "operator": "ONE_OF", - "value": [ - "123456789" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "2", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "3-for-not-2", - "rules": [ - { - "conditions": [ - { - "attribute": "number", - "operator": "NOT_ONE_OF", - "value": [ - "2" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "3", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "boolean-one-of-matches": { - "key": "boolean-one-of-matches", - "enabled": true, - "variationType": "INTEGER", - "variations": { - "1": { - "key": "1", - "value": 1 - }, - "2": { - "key": "2", - "value": 2 - }, - "3": { - "key": "3", - "value": 3 - }, - "4": { - "key": "4", - "value": 4 - }, - "5": { - "key": "5", - "value": 5 - } - }, - "allocations": [ - { - "key": "1-for-one-of", - "rules": [ - { - "conditions": [ - { - "attribute": "one_of_flag", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "1", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "2-for-matches", - "rules": [ - { - "conditions": [ - { - "attribute": "matches_flag", - "operator": "MATCHES", - "value": "true" - } - ] - } - ], - "splits": [ - { - "variationKey": "2", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "3-for-not-one-of", - "rules": [ - { - "conditions": [ - { - "attribute": "not_one_of_flag", - "operator": "NOT_ONE_OF", - "value": [ - "false" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "3", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "4-for-not-matches", - "rules": [ - { - "conditions": [ - { - "attribute": "not_matches_flag", - "operator": "NOT_MATCHES", - "value": "false" - } - ] - } - ], - "splits": [ - { - "variationKey": "4", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "5-for-matches-null", - "rules": [ - { - "conditions": [ - { - "attribute": "null_flag", - "operator": "ONE_OF", - "value": [ - "null" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "5", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "empty_string_flag": { - "key": "empty_string_flag", - "enabled": true, - "comment": "Testing the empty string as a variation value", - "variationType": "STRING", - "variations": { - "empty_string": { - "key": "empty_string", - "value": "" - }, - "non_empty": { - "key": "non_empty", - "value": "non_empty" - } - }, - "allocations": [ - { - "key": "allocation-empty", - "rules": [ - { - "conditions": [ - { - "attribute": "country", - "operator": "MATCHES", - "value": "US" - } - ] - } - ], - "splits": [ - { - "variationKey": "empty_string", - "shards": [ - { - "salt": "allocation-empty-shards", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 10000 - } - ] - } - ] - } - ], - "doLog": true - }, - { - "key": "allocation-test", - "rules": [], - "splits": [ - { - "variationKey": "non_empty", - "shards": [ - { - "salt": "allocation-empty-shards", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 10000 - } - ] - } - ] - } - ], - "doLog": true - } - ] - }, - "kill-switch": { - "key": "kill-switch", - "enabled": true, - "variationType": "BOOLEAN", - "variations": { - "on": { - "key": "on", - "value": true - }, - "off": { - "key": "off", - "value": false - } - }, - "allocations": [ - { - "key": "on-for-NA", - "rules": [ - { - "conditions": [ - { - "attribute": "country", - "operator": "ONE_OF", - "value": [ - "US", - "Canada", - "Mexico" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "on", - "shards": [ - { - "salt": "some-salt", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 10000 - } - ] - } - ] - } - ], - "doLog": true - }, - { - "key": "on-for-age-50+", - "rules": [ - { - "conditions": [ - { - "attribute": "age", - "operator": "GTE", - "value": 50 - } - ] - } - ], - "splits": [ - { - "variationKey": "on", - "shards": [ - { - "salt": "some-salt", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 10000 - } - ] - } - ] - } - ], - "doLog": true - }, - { - "key": "off-for-all", - "rules": [], - "splits": [ - { - "variationKey": "off", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "comparator-operator-test": { - "key": "comparator-operator-test", - "enabled": true, - "variationType": "STRING", - "variations": { - "small": { - "key": "small", - "value": "small" - }, - "medium": { - "key": "medium", - "value": "medium" - }, - "large": { - "key": "large", - "value": "large" - } - }, - "allocations": [ - { - "key": "small-size", - "rules": [ - { - "conditions": [ - { - "attribute": "size", - "operator": "LT", - "value": 10 - } - ] - } - ], - "splits": [ - { - "variationKey": "small", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "medum-size", - "rules": [ - { - "conditions": [ - { - "attribute": "size", - "operator": "GTE", - "value": 10 - }, - { - "attribute": "size", - "operator": "LTE", - "value": 20 - } - ] - } - ], - "splits": [ - { - "variationKey": "medium", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "large-size", - "rules": [ - { - "conditions": [ - { - "attribute": "size", - "operator": "GT", - "value": 25 - } - ] - } - ], - "splits": [ - { - "variationKey": "large", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "start-and-end-date-test": { - "key": "start-and-end-date-test", - "enabled": true, - "variationType": "STRING", - "variations": { - "old": { - "key": "old", - "value": "old" - }, - "current": { - "key": "current", - "value": "current" - }, - "new": { - "key": "new", - "value": "new" - } - }, - "allocations": [ - { - "key": "old-versions", - "splits": [ - { - "variationKey": "old", - "shards": [] - } - ], - "endAt": "2002-10-31T09:00:00.594Z", - "doLog": true - }, - { - "key": "future-versions", - "splits": [ - { - "variationKey": "new", - "shards": [] - } - ], - "startAt": "2052-10-31T09:00:00.594Z", - "doLog": true - }, - { - "key": "current-versions", - "splits": [ - { - "variationKey": "current", - "shards": [] - } - ], - "startAt": "2022-10-31T09:00:00.594Z", - "endAt": "2050-10-31T09:00:00.594Z", - "doLog": true - } - ] - }, - "null-operator-test": { - "key": "null-operator-test", - "enabled": true, - "variationType": "STRING", - "variations": { - "old": { - "key": "old", - "value": "old" - }, - "new": { - "key": "new", - "value": "new" - } - }, - "allocations": [ - { - "key": "null-operator", - "rules": [ - { - "conditions": [ - { - "attribute": "size", - "operator": "IS_NULL", - "value": true - } - ] - }, - { - "conditions": [ - { - "attribute": "size", - "operator": "LT", - "value": 10 - } - ] - } - ], - "splits": [ - { - "variationKey": "old", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "not-null-operator", - "rules": [ - { - "conditions": [ - { - "attribute": "size", - "operator": "IS_NULL", - "value": false - } - ] - } - ], - "splits": [ - { - "variationKey": "new", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "new-user-onboarding": { - "key": "new-user-onboarding", - "enabled": true, - "variationType": "STRING", - "variations": { - "control": { - "key": "control", - "value": "control" - }, - "red": { - "key": "red", - "value": "red" - }, - "blue": { - "key": "blue", - "value": "blue" - }, - "green": { - "key": "green", - "value": "green" - }, - "yellow": { - "key": "yellow", - "value": "yellow" - }, - "purple": { - "key": "purple", - "value": "purple" - } - }, - "allocations": [ - { - "key": "id rule", - "rules": [ - { - "conditions": [ - { - "attribute": "id", - "operator": "MATCHES", - "value": "zach" - } - ] - } - ], - "splits": [ - { - "variationKey": "purple", - "shards": [] - } - ], - "doLog": false - }, - { - "key": "internal users", - "rules": [ - { - "conditions": [ - { - "attribute": "email", - "operator": "MATCHES", - "value": "@mycompany.com" - } - ] - } - ], - "splits": [ - { - "variationKey": "green", - "shards": [] - } - ], - "doLog": false - }, - { - "key": "experiment", - "rules": [ - { - "conditions": [ - { - "attribute": "country", - "operator": "NOT_ONE_OF", - "value": [ - "US", - "Canada", - "Mexico" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "control", - "shards": [ - { - "salt": "traffic-new-user-onboarding-experiment", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 6000 - } - ] - }, - { - "salt": "split-new-user-onboarding-experiment", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 5000 - } - ] - } - ] - }, - { - "variationKey": "red", - "shards": [ - { - "salt": "traffic-new-user-onboarding-experiment", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 6000 - } - ] - }, - { - "salt": "split-new-user-onboarding-experiment", - "totalShards": 10000, - "ranges": [ - { - "start": 5000, - "end": 8000 - } - ] - } - ] - }, - { - "variationKey": "yellow", - "shards": [ - { - "salt": "traffic-new-user-onboarding-experiment", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 6000 - } - ] - }, - { - "salt": "split-new-user-onboarding-experiment", - "totalShards": 10000, - "ranges": [ - { - "start": 8000, - "end": 10000 - } - ] - } - ] - } - ], - "doLog": true - }, - { - "key": "rollout", - "rules": [ - { - "conditions": [ - { - "attribute": "country", - "operator": "ONE_OF", - "value": [ - "US", - "Canada", - "Mexico" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "blue", - "shards": [ - { - "salt": "split-new-user-onboarding-rollout", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 8000 - } - ] - } - ], - "extraLogging": { - "allocationvalue_type": "rollout", - "owner": "hippo" - } - } - ], - "doLog": true - } - ] - }, - "integer-flag": { - "key": "integer-flag", - "enabled": true, - "variationType": "INTEGER", - "variations": { - "one": { - "key": "one", - "value": 1 - }, - "two": { - "key": "two", - "value": 2 - }, - "three": { - "key": "three", - "value": 3 - } - }, - "allocations": [ - { - "key": "targeted allocation", - "rules": [ - { - "conditions": [ - { - "attribute": "country", - "operator": "ONE_OF", - "value": [ - "US", - "Canada", - "Mexico" - ] - } - ] - }, - { - "conditions": [ - { - "attribute": "email", - "operator": "MATCHES", - "value": ".*@example.com" - } - ] - } - ], - "splits": [ - { - "variationKey": "three", - "shards": [ - { - "salt": "full-range-salt", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 10000 - } - ] - } - ] - } - ], - "doLog": true - }, - { - "key": "50/50 split", - "rules": [], - "splits": [ - { - "variationKey": "one", - "shards": [ - { - "salt": "split-numeric-flag-some-allocation", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 5000 - } - ] - } - ] - }, - { - "variationKey": "two", - "shards": [ - { - "salt": "split-numeric-flag-some-allocation", - "totalShards": 10000, - "ranges": [ - { - "start": 5000, - "end": 10000 - } - ] - } - ] - } - ], - "doLog": true - } - ] - }, - "json-config-flag": { - "key": "json-config-flag", - "enabled": true, - "variationType": "JSON", - "variations": { - "one": { - "key": "one", - "value": { - "integer": 1, - "string": "one", - "float": 1.0 - } - }, - "two": { - "key": "two", - "value": { - "integer": 2, - "string": "two", - "float": 2.0 - } - }, - "empty": { - "key": "empty", - "value": {} - } - }, - "allocations": [ - { - "key": "Optionally Force Empty", - "rules": [ - { - "conditions": [ - { - "attribute": "Force Empty", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "empty", - "shards": [ - { - "salt": "full-range-salt", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 10000 - } - ] - } - ] - } - ], - "doLog": true - }, - { - "key": "50/50 split", - "rules": [], - "splits": [ - { - "variationKey": "one", - "shards": [ - { - "salt": "traffic-json-flag", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 10000 - } - ] - }, - { - "salt": "split-json-flag", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 5000 - } - ] - } - ] - }, - { - "variationKey": "two", - "shards": [ - { - "salt": "traffic-json-flag", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 10000 - } - ] - }, - { - "salt": "split-json-flag", - "totalShards": 10000, - "ranges": [ - { - "start": 5000, - "end": 10000 - } - ] - } - ] - } - ], - "doLog": true - } - ] - }, - "special-characters": { - "key": "special-characters", - "enabled": true, - "variationType": "JSON", - "variations": { - "de": { - "key": "de", - "value": { - "a": "kümmert", - "b": "schön" - } - }, - "ua": { - "key": "ua", - "value": { - "a": "піклуватися", - "b": "любов" - } - }, - "zh": { - "key": "zh", - "value": { - "a": "照顾", - "b": "漂亮" - } - }, - "emoji": { - "key": "emoji", - "value": { - "a": "🤗", - "b": "🌸" - } - } - }, - "allocations": [ - { - "key": "allocation-test", - "splits": [ - { - "variationKey": "de", - "shards": [ - { - "salt": "split-json-flag", - "totalShards": 10000, - "ranges": [ - { - "start": 0, - "end": 2500 - } - ] - } - ] - }, - { - "variationKey": "ua", - "shards": [ - { - "salt": "split-json-flag", - "totalShards": 10000, - "ranges": [ - { - "start": 2500, - "end": 5000 - } - ] - } - ] - }, - { - "variationKey": "zh", - "shards": [ - { - "salt": "split-json-flag", - "totalShards": 10000, - "ranges": [ - { - "start": 5000, - "end": 7500 - } - ] - } - ] - }, - { - "variationKey": "emoji", - "shards": [ - { - "salt": "split-json-flag", - "totalShards": 10000, - "ranges": [ - { - "start": 7500, - "end": 10000 - } - ] - } - ] - } - ], - "doLog": true - }, - { - "key": "allocation-default", - "splits": [ - { - "variationKey": "de", - "shards": [] - } - ], - "doLog": false - } - ] - }, - "string_flag_with_special_characters": { - "key": "string_flag_with_special_characters", - "enabled": true, - "comment": "Testing the string with special characters and spaces", - "variationType": "STRING", - "variations": { - "string_with_spaces": { - "key": "string_with_spaces", - "value": " a b c d e f " - }, - "string_with_only_one_space": { - "key": "string_with_only_one_space", - "value": " " - }, - "string_with_only_multiple_spaces": { - "key": "string_with_only_multiple_spaces", - "value": " " - }, - "string_with_dots": { - "key": "string_with_dots", - "value": ".a.b.c.d.e.f." - }, - "string_with_only_one_dot": { - "key": "string_with_only_one_dot", - "value": "." - }, - "string_with_only_multiple_dots": { - "key": "string_with_only_multiple_dots", - "value": "......." - }, - "string_with_comas": { - "key": "string_with_comas", - "value": ",a,b,c,d,e,f," - }, - "string_with_only_one_coma": { - "key": "string_with_only_one_coma", - "value": "," - }, - "string_with_only_multiple_comas": { - "key": "string_with_only_multiple_comas", - "value": ",,,,,,," - }, - "string_with_colons": { - "key": "string_with_colons", - "value": ":a:b:c:d:e:f:" - }, - "string_with_only_one_colon": { - "key": "string_with_only_one_colon", - "value": ":" - }, - "string_with_only_multiple_colons": { - "key": "string_with_only_multiple_colons", - "value": ":::::::" - }, - "string_with_semicolons": { - "key": "string_with_semicolons", - "value": ";a;b;c;d;e;f;" - }, - "string_with_only_one_semicolon": { - "key": "string_with_only_one_semicolon", - "value": ";" - }, - "string_with_only_multiple_semicolons": { - "key": "string_with_only_multiple_semicolons", - "value": ";;;;;;;" - }, - "string_with_slashes": { - "key": "string_with_slashes", - "value": "/a/b/c/d/e/f/" - }, - "string_with_only_one_slash": { - "key": "string_with_only_one_slash", - "value": "/" - }, - "string_with_only_multiple_slashes": { - "key": "string_with_only_multiple_slashes", - "value": "///////" - }, - "string_with_dashes": { - "key": "string_with_dashes", - "value": "-a-b-c-d-e-f-" - }, - "string_with_only_one_dash": { - "key": "string_with_only_one_dash", - "value": "-" - }, - "string_with_only_multiple_dashes": { - "key": "string_with_only_multiple_dashes", - "value": "-------" - }, - "string_with_underscores": { - "key": "string_with_underscores", - "value": "_a_b_c_d_e_f_" - }, - "string_with_only_one_underscore": { - "key": "string_with_only_one_underscore", - "value": "_" - }, - "string_with_only_multiple_underscores": { - "key": "string_with_only_multiple_underscores", - "value": "_______" - }, - "string_with_plus_signs": { - "key": "string_with_plus_signs", - "value": "+a+b+c+d+e+f+" - }, - "string_with_only_one_plus_sign": { - "key": "string_with_only_one_plus_sign", - "value": "+" - }, - "string_with_only_multiple_plus_signs": { - "key": "string_with_only_multiple_plus_signs", - "value": "+++++++" - }, - "string_with_equal_signs": { - "key": "string_with_equal_signs", - "value": "=a=b=c=d=e=f=" - }, - "string_with_only_one_equal_sign": { - "key": "string_with_only_one_equal_sign", - "value": "=" - }, - "string_with_only_multiple_equal_signs": { - "key": "string_with_only_multiple_equal_signs", - "value": "=======" - }, - "string_with_dollar_signs": { - "key": "string_with_dollar_signs", - "value": "$a$b$c$d$e$f$" - }, - "string_with_only_one_dollar_sign": { - "key": "string_with_only_one_dollar_sign", - "value": "$" - }, - "string_with_only_multiple_dollar_signs": { - "key": "string_with_only_multiple_dollar_signs", - "value": "$$$$$$$" - }, - "string_with_at_signs": { - "key": "string_with_at_signs", - "value": "@a@b@c@d@e@f@" - }, - "string_with_only_one_at_sign": { - "key": "string_with_only_one_at_sign", - "value": "@" - }, - "string_with_only_multiple_at_signs": { - "key": "string_with_only_multiple_at_signs", - "value": "@@@@@@@" - }, - "string_with_amp_signs": { - "key": "string_with_amp_signs", - "value": "&a&b&c&d&e&f&" - }, - "string_with_only_one_amp_sign": { - "key": "string_with_only_one_amp_sign", - "value": "&" - }, - "string_with_only_multiple_amp_signs": { - "key": "string_with_only_multiple_amp_signs", - "value": "&&&&&&&" - }, - "string_with_hash_signs": { - "key": "string_with_hash_signs", - "value": "#a#b#c#d#e#f#" - }, - "string_with_only_one_hash_sign": { - "key": "string_with_only_one_hash_sign", - "value": "#" - }, - "string_with_only_multiple_hash_signs": { - "key": "string_with_only_multiple_hash_signs", - "value": "#######" - }, - "string_with_percentage_signs": { - "key": "string_with_percentage_signs", - "value": "%a%b%c%d%e%f%" - }, - "string_with_only_one_percentage_sign": { - "key": "string_with_only_one_percentage_sign", - "value": "%" - }, - "string_with_only_multiple_percentage_signs": { - "key": "string_with_only_multiple_percentage_signs", - "value": "%%%%%%%" - }, - "string_with_tilde_signs": { - "key": "string_with_tilde_signs", - "value": "~a~b~c~d~e~f~" - }, - "string_with_only_one_tilde_sign": { - "key": "string_with_only_one_tilde_sign", - "value": "~" - }, - "string_with_only_multiple_tilde_signs": { - "key": "string_with_only_multiple_tilde_signs", - "value": "~~~~~~~" - }, - "string_with_asterix_signs": { - "key": "string_with_asterix_signs", - "value": "*a*b*c*d*e*f*" - }, - "string_with_only_one_asterix_sign": { - "key": "string_with_only_one_asterix_sign", - "value": "*" - }, - "string_with_only_multiple_asterix_signs": { - "key": "string_with_only_multiple_asterix_signs", - "value": "*******" - }, - "string_with_single_quotes": { - "key": "string_with_single_quotes", - "value": "'a'b'c'd'e'f'" - }, - "string_with_only_one_single_quote": { - "key": "string_with_only_one_single_quote", - "value": "'" - }, - "string_with_only_multiple_single_quotes": { - "key": "string_with_only_multiple_single_quotes", - "value": "'''''''" - }, - "string_with_question_marks": { - "key": "string_with_question_marks", - "value": "?a?b?c?d?e?f?" - }, - "string_with_only_one_question_mark": { - "key": "string_with_only_one_question_mark", - "value": "?" - }, - "string_with_only_multiple_question_marks": { - "key": "string_with_only_multiple_question_marks", - "value": "???????" - }, - "string_with_exclamation_marks": { - "key": "string_with_exclamation_marks", - "value": "!a!b!c!d!e!f!" - }, - "string_with_only_one_exclamation_mark": { - "key": "string_with_only_one_exclamation_mark", - "value": "!" - }, - "string_with_only_multiple_exclamation_marks": { - "key": "string_with_only_multiple_exclamation_marks", - "value": "!!!!!!!" - }, - "string_with_opening_parentheses": { - "key": "string_with_opening_parentheses", - "value": "(a(b(c(d(e(f(" - }, - "string_with_only_one_opening_parenthese": { - "key": "string_with_only_one_opening_parenthese", - "value": "(" - }, - "string_with_only_multiple_opening_parentheses": { - "key": "string_with_only_multiple_opening_parentheses", - "value": "(((((((" - }, - "string_with_closing_parentheses": { - "key": "string_with_closing_parentheses", - "value": ")a)b)c)d)e)f)" - }, - "string_with_only_one_closing_parenthese": { - "key": "string_with_only_one_closing_parenthese", - "value": ")" - }, - "string_with_only_multiple_closing_parentheses": { - "key": "string_with_only_multiple_closing_parentheses", - "value": ")))))))" - } - }, - "allocations": [ - { - "key": "allocation-test-string_with_spaces", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_spaces", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_spaces", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_space", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_space", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_space", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_spaces", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_spaces", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_spaces", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_dots", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_dots", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_dots", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_dot", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_dot", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_dot", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_dots", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_dots", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_dots", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_comas", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_comas", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_comas", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_coma", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_coma", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_coma", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_comas", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_comas", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_comas", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_colons", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_colons", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_colons", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_colon", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_colon", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_colon", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_colons", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_colons", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_colons", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_semicolons", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_semicolons", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_semicolons", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_semicolon", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_semicolon", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_semicolon", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_semicolons", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_semicolons", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_semicolons", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_slashes", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_slashes", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_slashes", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_slash", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_slash", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_slash", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_slashes", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_slashes", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_slashes", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_dashes", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_dashes", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_dashes", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_dash", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_dash", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_dash", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_dashes", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_dashes", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_dashes", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_underscores", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_underscores", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_underscores", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_underscore", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_underscore", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_underscore", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_underscores", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_underscores", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_underscores", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_plus_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_plus_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_plus_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_plus_sign", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_plus_sign", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_plus_sign", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_plus_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_plus_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_plus_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_equal_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_equal_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_equal_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_equal_sign", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_equal_sign", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_equal_sign", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_equal_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_equal_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_equal_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_dollar_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_dollar_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_dollar_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_dollar_sign", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_dollar_sign", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_dollar_sign", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_dollar_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_dollar_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_dollar_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_at_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_at_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_at_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_at_sign", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_at_sign", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_at_sign", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_at_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_at_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_at_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_amp_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_amp_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_amp_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_amp_sign", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_amp_sign", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_amp_sign", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_amp_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_amp_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_amp_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_hash_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_hash_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_hash_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_hash_sign", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_hash_sign", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_hash_sign", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_hash_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_hash_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_hash_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_percentage_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_percentage_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_percentage_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_percentage_sign", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_percentage_sign", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_percentage_sign", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_percentage_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_percentage_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_percentage_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_tilde_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_tilde_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_tilde_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_tilde_sign", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_tilde_sign", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_tilde_sign", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_tilde_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_tilde_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_tilde_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_asterix_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_asterix_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_asterix_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_asterix_sign", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_asterix_sign", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_asterix_sign", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_asterix_signs", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_asterix_signs", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_asterix_signs", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_single_quotes", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_single_quotes", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_single_quotes", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_single_quote", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_single_quote", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_single_quote", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_single_quotes", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_single_quotes", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_single_quotes", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_question_marks", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_question_marks", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_question_marks", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_question_mark", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_question_mark", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_question_mark", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_question_marks", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_question_marks", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_question_marks", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_exclamation_marks", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_exclamation_marks", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_exclamation_marks", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_exclamation_mark", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_exclamation_mark", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_exclamation_mark", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_exclamation_marks", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_exclamation_marks", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_exclamation_marks", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_opening_parentheses", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_opening_parentheses", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_opening_parentheses", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_opening_parenthese", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_opening_parenthese", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_opening_parenthese", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_opening_parentheses", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_opening_parentheses", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_opening_parentheses", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_closing_parentheses", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_closing_parentheses", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_closing_parentheses", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_one_closing_parenthese", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_one_closing_parenthese", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_one_closing_parenthese", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "allocation-test-string_with_only_multiple_closing_parentheses", - "rules": [ - { - "conditions": [ - { - "attribute": "string_with_only_multiple_closing_parentheses", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "string_with_only_multiple_closing_parentheses", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "boolean-false-assignment": { - "key": "boolean-false-assignment", - "enabled": true, - "variationType": "BOOLEAN", - "variations": { - "false-variation": { - "key": "false-variation", - "value": false - }, - "true-variation": { - "key": "true-variation", - "value": true - } - }, - "allocations": [ - { - "key": "disable-feature", - "rules": [ - { - "conditions": [ - { - "attribute": "should_disable_feature", - "operator": "ONE_OF", - "value": [ - "true" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "false-variation", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "enable-feature", - "rules": [ - { - "conditions": [ - { - "attribute": "should_disable_feature", - "operator": "ONE_OF", - "value": [ - "false" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "true-variation", - "shards": [] - } - ], - "doLog": true - } - ], - "totalShards": 10000 - }, - "empty-string-variation": { - "key": "empty-string-variation", - "enabled": true, - "variationType": "STRING", - "variations": { - "empty-content": { - "key": "empty-content", - "value": "" - }, - "detailed-content": { - "key": "detailed-content", - "value": "detailed_content" - } - }, - "allocations": [ - { - "key": "minimal-content", - "rules": [ - { - "conditions": [ - { - "attribute": "content_type", - "operator": "ONE_OF", - "value": [ - "minimal" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "empty-content", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "full-content", - "rules": [ - { - "conditions": [ - { - "attribute": "content_type", - "operator": "ONE_OF", - "value": [ - "full" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "detailed-content", - "shards": [] - } - ], - "doLog": true - } - ], - "totalShards": 10000 - }, - "falsy-value-assignments": { - "key": "falsy-value-assignments", - "enabled": true, - "variationType": "INTEGER", - "variations": { - "zero-limit": { - "key": "zero-limit", - "value": 0 - }, - "premium-limit": { - "key": "premium-limit", - "value": 100 - } - }, - "allocations": [ - { - "key": "free-tier-limit", - "rules": [ - { - "conditions": [ - { - "attribute": "plan_tier", - "operator": "ONE_OF", - "value": [ - "free" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "zero-limit", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "premium-tier-limit", - "rules": [ - { - "conditions": [ - { - "attribute": "plan_tier", - "operator": "ONE_OF", - "value": [ - "premium" - ] - } - ] - } - ], - "splits": [ - { - "variationKey": "premium-limit", - "shards": [] - } - ], - "doLog": true - } - ], - "totalShards": 10000 - }, - "empty-targeting-key-flag": { - "key": "empty-targeting-key-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "on": { - "key": "on", - "value": "on-value" - }, - "off": { - "key": "off", - "value": "off-value" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "variationKey": "on", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "microsecond-date-test": { - "key": "microsecond-date-test", - "enabled": true, - "variationType": "STRING", - "variations": { - "expired": { - "key": "expired", - "value": "expired" - }, - "active": { - "key": "active", - "value": "active" - }, - "future": { - "key": "future", - "value": "future" - } - }, - "allocations": [ - { - "key": "expired-allocation", - "splits": [ - { - "variationKey": "expired", - "shards": [] - } - ], - "endAt": "2002-10-31T09:00:00.594321Z", - "doLog": true - }, - { - "key": "future-allocation", - "splits": [ - { - "variationKey": "future", - "shards": [] - } - ], - "startAt": "2052-10-31T09:00:00.123456Z", - "doLog": true - }, - { - "key": "active-allocation", - "splits": [ - { - "variationKey": "active", - "shards": [] - } - ], - "startAt": "2022-10-31T09:00:00.235982Z", - "endAt": "2050-10-31T09:00:00.987654Z", - "doLog": true - } - ] - }, - "unknown-fields-tolerance-flag": { - "key": "unknown-fields-tolerance-flag", - "enabled": true, - "variationType": "STRING", - "comment": "This flag deliberately contains unknown fields at every fixed-schema UFC level. SDKs must ignore those fields and evaluate the flag normally.", - "unknownFlagField": "ignored", - "variations": { - "on": { - "key": "on", - "value": "on", - "unknownVariationField": "ignored" - } - }, - "allocations": [ - { - "key": "unknown-fields-allocation", - "unknownAllocationField": "ignored", - "rules": [ - { - "unknownRuleField": "ignored", - "conditions": [ - { - "attribute": "country", - "operator": "ONE_OF", - "value": [ - "US" - ], - "unknownConditionField": "ignored" - } - ] - } - ], - "splits": [ - { - "variationKey": "on", - "unknownSplitField": "ignored", - "shards": [ - { - "salt": "unknown-fields-tolerance-flag", - "totalShards": 10000, - "unknownShardField": "ignored", - "ranges": [ - { - "start": 0, - "end": 10000, - "unknownRangeField": "ignored" - } - ] - } - ] - } - ], - "doLog": true - } - ] - }, - "operator-grease-flag": { - "key": "operator-grease-flag", - "enabled": true, - "variationType": "STRING", - "comment": "This flag deliberately uses an unknown condition operator. SDKs must ignore this flag rather than falling through to the catch-all allocation or rejecting the whole config.", - "variations": { - "trap": { - "key": "trap", - "value": "trap" - }, - "on": { - "key": "on", - "value": "on" - } - }, - "allocations": [ - { - "key": "grease-allocation", - "rules": [ - { - "conditions": [ - { - "attribute": "country", - "operator": "NOT_A_REAL_OPERATOR", - "value": "anything" - } - ] - } - ], - "splits": [ - { - "variationKey": "trap", - "shards": [] - } - ], - "doLog": true - }, - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "variationKey": "on", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "malformed-allocations-flag": { - "key": "malformed-allocations-flag", - "enabled": true, - "variationType": "STRING", - "comment": "This flag is intentionally UFC-schema-invalid: allocations must be an array, but this fixture uses a string to verify per-flag parse error isolation.", - "variations": { - "on": { - "key": "on", - "value": "on" - } - }, - "allocations": "this-is-not-a-list" - }, - "missing-split-shards-flag": { - "key": "missing-split-shards-flag", - "enabled": true, - "variationType": "STRING", - "comment": "This flag is intentionally UFC-schema-invalid: split.shards is required even when the intended split is unsharded. SDKs must treat omitted shards differently from an explicit empty shards array.", - "variations": { - "trap": { - "key": "trap", - "value": "trap" - } - }, - "allocations": [ - { - "key": "malformed-split-allocation", - "rules": [], - "splits": [ - { - "variationKey": "trap" - } - ], - "doLog": true - } - ] - }, - "overflow-total-shards-flag": { - "key": "overflow-total-shards-flag", - "enabled": true, - "variationType": "STRING", - "comment": "This flag is intentionally UFC-schema-invalid: split.shards[].totalShards must fit in an unsigned 32-bit integer. SDKs must ignore this flag rather than overflowing or poisoning the whole config.", - "variations": { - "trap": { - "key": "trap", - "value": "trap" - } - }, - "allocations": [ - { - "key": "malformed-shard-allocation", - "rules": [], - "splits": [ - { - "variationKey": "trap", - "shards": [ - { - "salt": "overflow-total-shards-flag", - "totalShards": 4294967296, - "ranges": [ - { - "start": 0, - "end": 1 - } - ] - } - ] - } - ], - "doLog": true - } - ] - }, - "negative-shard-range-start-flag": { - "key": "negative-shard-range-start-flag", - "enabled": true, - "variationType": "STRING", - "comment": "This flag is intentionally UFC-schema-invalid: split.shards[].ranges[].start must be an unsigned integer. SDKs must ignore this flag rather than treating a negative range as matching traffic.", - "variations": { - "trap": { - "key": "trap", - "value": "trap" - } - }, - "allocations": [ - { - "key": "malformed-shard-allocation", - "rules": [], - "splits": [ - { - "variationKey": "trap", - "shards": [ - { - "salt": "negative-shard-range-start-flag", - "totalShards": 100, - "ranges": [ - { - "start": -1, - "end": 1 - } - ] - } - ] - } - ], - "doLog": true - } - ] - }, - "valid-flag-after-invalid-config": { - "key": "valid-flag-after-invalid-config", - "enabled": true, - "variationType": "STRING", - "comment": "This valid neighbor proves malformed or unsupported flags do not poison the rest of the remote config.", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/packages/core/src/flags/__fixtures__/native-ffe/rules-configuration-wire.json b/packages/core/src/flags/__fixtures__/native-ffe/rules-configuration-wire.json index 0718158a0..63c3ca653 100644 --- a/packages/core/src/flags/__fixtures__/native-ffe/rules-configuration-wire.json +++ b/packages/core/src/flags/__fixtures__/native-ffe/rules-configuration-wire.json @@ -1 +1 @@ -{"version":2,"server":{"response":"{\n \"createdAt\": \"2024-04-17T19:40:53.716Z\",\n \"format\": \"SERVER\",\n \"environment\": {\n \"name\": \"Test\",\n \"unknownEnvironmentField\": \"ignored\"\n },\n \"unknownTopLevelField\": \"ignored\",\n \"unknownTopLevelNull\": null,\n \"flags\": {\n \"empty_flag\": {\n \"key\": \"empty_flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {},\n \"allocations\": []\n },\n \"disabled_flag\": {\n \"key\": \"disabled_flag\",\n \"enabled\": false,\n \"variationType\": \"INTEGER\",\n \"variations\": {},\n \"allocations\": []\n },\n \"no_allocations_flag\": {\n \"key\": \"no_allocations_flag\",\n \"enabled\": true,\n \"variationType\": \"JSON\",\n \"variations\": {\n \"control\": {\n \"key\": \"control\",\n \"value\": {\n \"variant\": \"control\"\n }\n },\n \"treatment\": {\n \"key\": \"treatment\",\n \"value\": {\n \"variant\": \"treatment\"\n }\n }\n },\n \"allocations\": []\n },\n \"numeric_flag\": {\n \"key\": \"numeric_flag\",\n \"enabled\": true,\n \"variationType\": \"NUMERIC\",\n \"variations\": {\n \"e\": {\n \"key\": \"e\",\n \"value\": 2.7182818\n },\n \"pi\": {\n \"key\": \"pi\",\n \"value\": 3.1415926\n }\n },\n \"allocations\": [\n {\n \"key\": \"rollout\",\n \"splits\": [\n {\n \"variationKey\": \"pi\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"regex-flag\": {\n \"key\": \"regex-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"partial-example\": {\n \"key\": \"partial-example\",\n \"value\": \"partial-example\"\n },\n \"test\": {\n \"key\": \"test\",\n \"value\": \"test\"\n },\n \"capturing-groups\": {\n \"key\": \"capturing-groups\",\n \"value\": \"capturing-groups\"\n },\n \"unicode-word-boundary\": {\n \"key\": \"unicode-word-boundary\",\n \"value\": \"unicode-word-boundary\"\n },\n \"unicode-combined-inline-flags\": {\n \"key\": \"unicode-combined-inline-flags\",\n \"value\": \"unicode-combined-inline-flags\"\n },\n \"unicode-scoped-inline-flags\": {\n \"key\": \"unicode-scoped-inline-flags\",\n \"value\": \"unicode-scoped-inline-flags\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"capturing-groups\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"^([[:alnum:]._%+-]+)@capture\\\\.example$\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"capturing-groups\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"unicode-word-boundary\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"(?u)\\\\bmañana\\\\b\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"unicode-word-boundary\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"unicode-combined-inline-flags\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"(?iu)^alice$\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"unicode-combined-inline-flags\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"unicode-scoped-inline-flags\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"(?u:^mañana$)\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"unicode-scoped-inline-flags\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"partial-example\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"@example\\\\.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"partial-example\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"test\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \".*@test\\\\.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"test\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"numeric-one-of\": {\n \"key\": \"numeric-one-of\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"1\": {\n \"key\": \"1\",\n \"value\": 1\n },\n \"2\": {\n \"key\": \"2\",\n \"value\": 2\n },\n \"3\": {\n \"key\": \"3\",\n \"value\": 3\n }\n },\n \"allocations\": [\n {\n \"key\": \"1-for-1\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"number\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"1\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"1\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"2-for-123456789\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"number\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"123456789\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"2\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"3-for-not-2\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"number\",\n \"operator\": \"NOT_ONE_OF\",\n \"value\": [\n \"2\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"3\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"boolean-one-of-matches\": {\n \"key\": \"boolean-one-of-matches\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"1\": {\n \"key\": \"1\",\n \"value\": 1\n },\n \"2\": {\n \"key\": \"2\",\n \"value\": 2\n },\n \"3\": {\n \"key\": \"3\",\n \"value\": 3\n },\n \"4\": {\n \"key\": \"4\",\n \"value\": 4\n },\n \"5\": {\n \"key\": \"5\",\n \"value\": 5\n }\n },\n \"allocations\": [\n {\n \"key\": \"1-for-one-of\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"one_of_flag\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"1\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"2-for-matches\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"matches_flag\",\n \"operator\": \"MATCHES\",\n \"value\": \"true\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"2\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"3-for-not-one-of\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"not_one_of_flag\",\n \"operator\": \"NOT_ONE_OF\",\n \"value\": [\n \"false\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"3\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"4-for-not-matches\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"not_matches_flag\",\n \"operator\": \"NOT_MATCHES\",\n \"value\": \"false\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"4\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"5-for-matches-null\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"null_flag\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"null\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"5\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"empty_string_flag\": {\n \"key\": \"empty_string_flag\",\n \"enabled\": true,\n \"comment\": \"Testing the empty string as a variation value\",\n \"variationType\": \"STRING\",\n \"variations\": {\n \"empty_string\": {\n \"key\": \"empty_string\",\n \"value\": \"\"\n },\n \"non_empty\": {\n \"key\": \"non_empty\",\n \"value\": \"non_empty\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"allocation-empty\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"MATCHES\",\n \"value\": \"US\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"empty_string\",\n \"shards\": [\n {\n \"salt\": \"allocation-empty-shards\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"non_empty\",\n \"shards\": [\n {\n \"salt\": \"allocation-empty-shards\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"kill-switch\": {\n \"key\": \"kill-switch\",\n \"enabled\": true,\n \"variationType\": \"BOOLEAN\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": true\n },\n \"off\": {\n \"key\": \"off\",\n \"value\": false\n }\n },\n \"allocations\": [\n {\n \"key\": \"on-for-NA\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": [\n {\n \"salt\": \"some-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"on-for-age-50+\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"age\",\n \"operator\": \"GTE\",\n \"value\": 50\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": [\n {\n \"salt\": \"some-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"off-for-all\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"off\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"comparator-operator-test\": {\n \"key\": \"comparator-operator-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"small\": {\n \"key\": \"small\",\n \"value\": \"small\"\n },\n \"medium\": {\n \"key\": \"medium\",\n \"value\": \"medium\"\n },\n \"large\": {\n \"key\": \"large\",\n \"value\": \"large\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"small-size\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"LT\",\n \"value\": 10\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"small\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"medum-size\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"GTE\",\n \"value\": 10\n },\n {\n \"attribute\": \"size\",\n \"operator\": \"LTE\",\n \"value\": 20\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"medium\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"large-size\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"GT\",\n \"value\": 25\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"large\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"start-and-end-date-test\": {\n \"key\": \"start-and-end-date-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"old\": {\n \"key\": \"old\",\n \"value\": \"old\"\n },\n \"current\": {\n \"key\": \"current\",\n \"value\": \"current\"\n },\n \"new\": {\n \"key\": \"new\",\n \"value\": \"new\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"old-versions\",\n \"splits\": [\n {\n \"variationKey\": \"old\",\n \"shards\": []\n }\n ],\n \"endAt\": \"2002-10-31T09:00:00.594Z\",\n \"doLog\": true\n },\n {\n \"key\": \"future-versions\",\n \"splits\": [\n {\n \"variationKey\": \"new\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2052-10-31T09:00:00.594Z\",\n \"doLog\": true\n },\n {\n \"key\": \"current-versions\",\n \"splits\": [\n {\n \"variationKey\": \"current\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2022-10-31T09:00:00.594Z\",\n \"endAt\": \"2050-10-31T09:00:00.594Z\",\n \"doLog\": true\n }\n ]\n },\n \"null-operator-test\": {\n \"key\": \"null-operator-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"old\": {\n \"key\": \"old\",\n \"value\": \"old\"\n },\n \"new\": {\n \"key\": \"new\",\n \"value\": \"new\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"null-operator\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"IS_NULL\",\n \"value\": true\n }\n ]\n },\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"LT\",\n \"value\": 10\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"old\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"not-null-operator\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"IS_NULL\",\n \"value\": false\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"new\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"new-user-onboarding\": {\n \"key\": \"new-user-onboarding\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"control\": {\n \"key\": \"control\",\n \"value\": \"control\"\n },\n \"red\": {\n \"key\": \"red\",\n \"value\": \"red\"\n },\n \"blue\": {\n \"key\": \"blue\",\n \"value\": \"blue\"\n },\n \"green\": {\n \"key\": \"green\",\n \"value\": \"green\"\n },\n \"yellow\": {\n \"key\": \"yellow\",\n \"value\": \"yellow\"\n },\n \"purple\": {\n \"key\": \"purple\",\n \"value\": \"purple\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"id rule\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"id\",\n \"operator\": \"MATCHES\",\n \"value\": \"zach\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"purple\",\n \"shards\": []\n }\n ],\n \"doLog\": false\n },\n {\n \"key\": \"internal users\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"@mycompany.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"green\",\n \"shards\": []\n }\n ],\n \"doLog\": false\n },\n {\n \"key\": \"experiment\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"NOT_ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"control\",\n \"shards\": [\n {\n \"salt\": \"traffic-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 6000\n }\n ]\n },\n {\n \"salt\": \"split-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"red\",\n \"shards\": [\n {\n \"salt\": \"traffic-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 6000\n }\n ]\n },\n {\n \"salt\": \"split-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 8000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"yellow\",\n \"shards\": [\n {\n \"salt\": \"traffic-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 6000\n }\n ]\n },\n {\n \"salt\": \"split-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 8000,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"rollout\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"blue\",\n \"shards\": [\n {\n \"salt\": \"split-new-user-onboarding-rollout\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 8000\n }\n ]\n }\n ],\n \"extraLogging\": {\n \"allocationvalue_type\": \"rollout\",\n \"owner\": \"hippo\"\n }\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"integer-flag\": {\n \"key\": \"integer-flag\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"one\": {\n \"key\": \"one\",\n \"value\": 1\n },\n \"two\": {\n \"key\": \"two\",\n \"value\": 2\n },\n \"three\": {\n \"key\": \"three\",\n \"value\": 3\n }\n },\n \"allocations\": [\n {\n \"key\": \"targeted allocation\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n },\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \".*@example.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"three\",\n \"shards\": [\n {\n \"salt\": \"full-range-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"50/50 split\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"one\",\n \"shards\": [\n {\n \"salt\": \"split-numeric-flag-some-allocation\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"two\",\n \"shards\": [\n {\n \"salt\": \"split-numeric-flag-some-allocation\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"json-config-flag\": {\n \"key\": \"json-config-flag\",\n \"enabled\": true,\n \"variationType\": \"JSON\",\n \"variations\": {\n \"one\": {\n \"key\": \"one\",\n \"value\": {\n \"integer\": 1,\n \"string\": \"one\",\n \"float\": 1.0\n }\n },\n \"two\": {\n \"key\": \"two\",\n \"value\": {\n \"integer\": 2,\n \"string\": \"two\",\n \"float\": 2.0\n }\n },\n \"empty\": {\n \"key\": \"empty\",\n \"value\": {}\n }\n },\n \"allocations\": [\n {\n \"key\": \"Optionally Force Empty\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"Force Empty\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"empty\",\n \"shards\": [\n {\n \"salt\": \"full-range-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"50/50 split\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"one\",\n \"shards\": [\n {\n \"salt\": \"traffic-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n },\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"two\",\n \"shards\": [\n {\n \"salt\": \"traffic-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n },\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"special-characters\": {\n \"key\": \"special-characters\",\n \"enabled\": true,\n \"variationType\": \"JSON\",\n \"variations\": {\n \"de\": {\n \"key\": \"de\",\n \"value\": {\n \"a\": \"kümmert\",\n \"b\": \"schön\"\n }\n },\n \"ua\": {\n \"key\": \"ua\",\n \"value\": {\n \"a\": \"піклуватися\",\n \"b\": \"любов\"\n }\n },\n \"zh\": {\n \"key\": \"zh\",\n \"value\": {\n \"a\": \"照顾\",\n \"b\": \"漂亮\"\n }\n },\n \"emoji\": {\n \"key\": \"emoji\",\n \"value\": {\n \"a\": \"🤗\",\n \"b\": \"🌸\"\n }\n }\n },\n \"allocations\": [\n {\n \"key\": \"allocation-test\",\n \"splits\": [\n {\n \"variationKey\": \"de\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 2500\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"ua\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 2500,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"zh\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 7500\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"emoji\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 7500,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-default\",\n \"splits\": [\n {\n \"variationKey\": \"de\",\n \"shards\": []\n }\n ],\n \"doLog\": false\n }\n ]\n },\n \"string_flag_with_special_characters\": {\n \"key\": \"string_flag_with_special_characters\",\n \"enabled\": true,\n \"comment\": \"Testing the string with special characters and spaces\",\n \"variationType\": \"STRING\",\n \"variations\": {\n \"string_with_spaces\": {\n \"key\": \"string_with_spaces\",\n \"value\": \" a b c d e f \"\n },\n \"string_with_only_one_space\": {\n \"key\": \"string_with_only_one_space\",\n \"value\": \" \"\n },\n \"string_with_only_multiple_spaces\": {\n \"key\": \"string_with_only_multiple_spaces\",\n \"value\": \" \"\n },\n \"string_with_dots\": {\n \"key\": \"string_with_dots\",\n \"value\": \".a.b.c.d.e.f.\"\n },\n \"string_with_only_one_dot\": {\n \"key\": \"string_with_only_one_dot\",\n \"value\": \".\"\n },\n \"string_with_only_multiple_dots\": {\n \"key\": \"string_with_only_multiple_dots\",\n \"value\": \".......\"\n },\n \"string_with_comas\": {\n \"key\": \"string_with_comas\",\n \"value\": \",a,b,c,d,e,f,\"\n },\n \"string_with_only_one_coma\": {\n \"key\": \"string_with_only_one_coma\",\n \"value\": \",\"\n },\n \"string_with_only_multiple_comas\": {\n \"key\": \"string_with_only_multiple_comas\",\n \"value\": \",,,,,,,\"\n },\n \"string_with_colons\": {\n \"key\": \"string_with_colons\",\n \"value\": \":a:b:c:d:e:f:\"\n },\n \"string_with_only_one_colon\": {\n \"key\": \"string_with_only_one_colon\",\n \"value\": \":\"\n },\n \"string_with_only_multiple_colons\": {\n \"key\": \"string_with_only_multiple_colons\",\n \"value\": \":::::::\"\n },\n \"string_with_semicolons\": {\n \"key\": \"string_with_semicolons\",\n \"value\": \";a;b;c;d;e;f;\"\n },\n \"string_with_only_one_semicolon\": {\n \"key\": \"string_with_only_one_semicolon\",\n \"value\": \";\"\n },\n \"string_with_only_multiple_semicolons\": {\n \"key\": \"string_with_only_multiple_semicolons\",\n \"value\": \";;;;;;;\"\n },\n \"string_with_slashes\": {\n \"key\": \"string_with_slashes\",\n \"value\": \"/a/b/c/d/e/f/\"\n },\n \"string_with_only_one_slash\": {\n \"key\": \"string_with_only_one_slash\",\n \"value\": \"/\"\n },\n \"string_with_only_multiple_slashes\": {\n \"key\": \"string_with_only_multiple_slashes\",\n \"value\": \"///////\"\n },\n \"string_with_dashes\": {\n \"key\": \"string_with_dashes\",\n \"value\": \"-a-b-c-d-e-f-\"\n },\n \"string_with_only_one_dash\": {\n \"key\": \"string_with_only_one_dash\",\n \"value\": \"-\"\n },\n \"string_with_only_multiple_dashes\": {\n \"key\": \"string_with_only_multiple_dashes\",\n \"value\": \"-------\"\n },\n \"string_with_underscores\": {\n \"key\": \"string_with_underscores\",\n \"value\": \"_a_b_c_d_e_f_\"\n },\n \"string_with_only_one_underscore\": {\n \"key\": \"string_with_only_one_underscore\",\n \"value\": \"_\"\n },\n \"string_with_only_multiple_underscores\": {\n \"key\": \"string_with_only_multiple_underscores\",\n \"value\": \"_______\"\n },\n \"string_with_plus_signs\": {\n \"key\": \"string_with_plus_signs\",\n \"value\": \"+a+b+c+d+e+f+\"\n },\n \"string_with_only_one_plus_sign\": {\n \"key\": \"string_with_only_one_plus_sign\",\n \"value\": \"+\"\n },\n \"string_with_only_multiple_plus_signs\": {\n \"key\": \"string_with_only_multiple_plus_signs\",\n \"value\": \"+++++++\"\n },\n \"string_with_equal_signs\": {\n \"key\": \"string_with_equal_signs\",\n \"value\": \"=a=b=c=d=e=f=\"\n },\n \"string_with_only_one_equal_sign\": {\n \"key\": \"string_with_only_one_equal_sign\",\n \"value\": \"=\"\n },\n \"string_with_only_multiple_equal_signs\": {\n \"key\": \"string_with_only_multiple_equal_signs\",\n \"value\": \"=======\"\n },\n \"string_with_dollar_signs\": {\n \"key\": \"string_with_dollar_signs\",\n \"value\": \"$a$b$c$d$e$f$\"\n },\n \"string_with_only_one_dollar_sign\": {\n \"key\": \"string_with_only_one_dollar_sign\",\n \"value\": \"$\"\n },\n \"string_with_only_multiple_dollar_signs\": {\n \"key\": \"string_with_only_multiple_dollar_signs\",\n \"value\": \"$$$$$$$\"\n },\n \"string_with_at_signs\": {\n \"key\": \"string_with_at_signs\",\n \"value\": \"@a@b@c@d@e@f@\"\n },\n \"string_with_only_one_at_sign\": {\n \"key\": \"string_with_only_one_at_sign\",\n \"value\": \"@\"\n },\n \"string_with_only_multiple_at_signs\": {\n \"key\": \"string_with_only_multiple_at_signs\",\n \"value\": \"@@@@@@@\"\n },\n \"string_with_amp_signs\": {\n \"key\": \"string_with_amp_signs\",\n \"value\": \"&a&b&c&d&e&f&\"\n },\n \"string_with_only_one_amp_sign\": {\n \"key\": \"string_with_only_one_amp_sign\",\n \"value\": \"&\"\n },\n \"string_with_only_multiple_amp_signs\": {\n \"key\": \"string_with_only_multiple_amp_signs\",\n \"value\": \"&&&&&&&\"\n },\n \"string_with_hash_signs\": {\n \"key\": \"string_with_hash_signs\",\n \"value\": \"#a#b#c#d#e#f#\"\n },\n \"string_with_only_one_hash_sign\": {\n \"key\": \"string_with_only_one_hash_sign\",\n \"value\": \"#\"\n },\n \"string_with_only_multiple_hash_signs\": {\n \"key\": \"string_with_only_multiple_hash_signs\",\n \"value\": \"#######\"\n },\n \"string_with_percentage_signs\": {\n \"key\": \"string_with_percentage_signs\",\n \"value\": \"%a%b%c%d%e%f%\"\n },\n \"string_with_only_one_percentage_sign\": {\n \"key\": \"string_with_only_one_percentage_sign\",\n \"value\": \"%\"\n },\n \"string_with_only_multiple_percentage_signs\": {\n \"key\": \"string_with_only_multiple_percentage_signs\",\n \"value\": \"%%%%%%%\"\n },\n \"string_with_tilde_signs\": {\n \"key\": \"string_with_tilde_signs\",\n \"value\": \"~a~b~c~d~e~f~\"\n },\n \"string_with_only_one_tilde_sign\": {\n \"key\": \"string_with_only_one_tilde_sign\",\n \"value\": \"~\"\n },\n \"string_with_only_multiple_tilde_signs\": {\n \"key\": \"string_with_only_multiple_tilde_signs\",\n \"value\": \"~~~~~~~\"\n },\n \"string_with_asterix_signs\": {\n \"key\": \"string_with_asterix_signs\",\n \"value\": \"*a*b*c*d*e*f*\"\n },\n \"string_with_only_one_asterix_sign\": {\n \"key\": \"string_with_only_one_asterix_sign\",\n \"value\": \"*\"\n },\n \"string_with_only_multiple_asterix_signs\": {\n \"key\": \"string_with_only_multiple_asterix_signs\",\n \"value\": \"*******\"\n },\n \"string_with_single_quotes\": {\n \"key\": \"string_with_single_quotes\",\n \"value\": \"'a'b'c'd'e'f'\"\n },\n \"string_with_only_one_single_quote\": {\n \"key\": \"string_with_only_one_single_quote\",\n \"value\": \"'\"\n },\n \"string_with_only_multiple_single_quotes\": {\n \"key\": \"string_with_only_multiple_single_quotes\",\n \"value\": \"'''''''\"\n },\n \"string_with_question_marks\": {\n \"key\": \"string_with_question_marks\",\n \"value\": \"?a?b?c?d?e?f?\"\n },\n \"string_with_only_one_question_mark\": {\n \"key\": \"string_with_only_one_question_mark\",\n \"value\": \"?\"\n },\n \"string_with_only_multiple_question_marks\": {\n \"key\": \"string_with_only_multiple_question_marks\",\n \"value\": \"???????\"\n },\n \"string_with_exclamation_marks\": {\n \"key\": \"string_with_exclamation_marks\",\n \"value\": \"!a!b!c!d!e!f!\"\n },\n \"string_with_only_one_exclamation_mark\": {\n \"key\": \"string_with_only_one_exclamation_mark\",\n \"value\": \"!\"\n },\n \"string_with_only_multiple_exclamation_marks\": {\n \"key\": \"string_with_only_multiple_exclamation_marks\",\n \"value\": \"!!!!!!!\"\n },\n \"string_with_opening_parentheses\": {\n \"key\": \"string_with_opening_parentheses\",\n \"value\": \"(a(b(c(d(e(f(\"\n },\n \"string_with_only_one_opening_parenthese\": {\n \"key\": \"string_with_only_one_opening_parenthese\",\n \"value\": \"(\"\n },\n \"string_with_only_multiple_opening_parentheses\": {\n \"key\": \"string_with_only_multiple_opening_parentheses\",\n \"value\": \"(((((((\"\n },\n \"string_with_closing_parentheses\": {\n \"key\": \"string_with_closing_parentheses\",\n \"value\": \")a)b)c)d)e)f)\"\n },\n \"string_with_only_one_closing_parenthese\": {\n \"key\": \"string_with_only_one_closing_parenthese\",\n \"value\": \")\"\n },\n \"string_with_only_multiple_closing_parentheses\": {\n \"key\": \"string_with_only_multiple_closing_parentheses\",\n \"value\": \")))))))\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"allocation-test-string_with_spaces\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_spaces\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_spaces\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_space\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_space\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_space\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_spaces\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_spaces\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_spaces\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_dots\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_dots\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_dots\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_dot\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_dot\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_dot\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_dots\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_dots\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_dots\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_comas\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_comas\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_comas\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_coma\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_coma\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_coma\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_comas\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_comas\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_comas\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_colons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_colons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_colons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_colon\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_colon\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_colon\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_colons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_colons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_colons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_semicolons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_semicolons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_semicolons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_semicolon\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_semicolon\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_semicolon\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_semicolons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_semicolons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_semicolons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_slashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_slashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_slashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_slash\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_slash\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_slash\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_slashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_slashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_slashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_dashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_dashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_dashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_dash\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_dash\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_dash\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_dashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_dashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_dashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_underscores\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_underscores\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_underscores\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_underscore\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_underscore\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_underscore\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_underscores\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_underscores\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_underscores\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_plus_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_plus_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_plus_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_plus_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_plus_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_plus_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_plus_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_plus_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_plus_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_equal_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_equal_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_equal_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_equal_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_equal_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_equal_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_equal_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_equal_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_equal_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_dollar_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_dollar_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_dollar_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_dollar_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_dollar_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_dollar_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_dollar_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_dollar_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_dollar_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_at_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_at_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_at_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_at_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_at_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_at_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_at_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_at_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_at_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_amp_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_amp_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_amp_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_amp_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_amp_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_amp_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_amp_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_amp_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_amp_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_hash_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_hash_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_hash_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_hash_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_hash_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_hash_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_hash_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_hash_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_hash_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_percentage_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_percentage_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_percentage_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_percentage_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_percentage_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_percentage_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_percentage_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_percentage_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_percentage_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_tilde_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_tilde_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_tilde_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_tilde_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_tilde_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_tilde_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_tilde_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_tilde_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_tilde_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_asterix_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_asterix_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_asterix_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_asterix_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_asterix_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_asterix_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_asterix_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_asterix_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_asterix_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_single_quotes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_single_quotes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_single_quotes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_single_quote\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_single_quote\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_single_quote\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_single_quotes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_single_quotes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_single_quotes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_question_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_question_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_question_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_question_mark\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_question_mark\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_question_mark\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_question_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_question_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_question_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_exclamation_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_exclamation_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_exclamation_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_exclamation_mark\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_exclamation_mark\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_exclamation_mark\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_exclamation_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_exclamation_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_exclamation_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_opening_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_opening_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_opening_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_opening_parenthese\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_opening_parenthese\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_opening_parenthese\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_opening_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_opening_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_opening_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_closing_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_closing_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_closing_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_closing_parenthese\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_closing_parenthese\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_closing_parenthese\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_closing_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_closing_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_closing_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"boolean-false-assignment\": {\n \"key\": \"boolean-false-assignment\",\n \"enabled\": true,\n \"variationType\": \"BOOLEAN\",\n \"variations\": {\n \"false-variation\": {\n \"key\": \"false-variation\",\n \"value\": false\n },\n \"true-variation\": {\n \"key\": \"true-variation\",\n \"value\": true\n }\n },\n \"allocations\": [\n {\n \"key\": \"disable-feature\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"should_disable_feature\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"false-variation\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"enable-feature\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"should_disable_feature\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"false\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"true-variation\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ],\n \"totalShards\": 10000\n },\n \"empty-string-variation\": {\n \"key\": \"empty-string-variation\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"empty-content\": {\n \"key\": \"empty-content\",\n \"value\": \"\"\n },\n \"detailed-content\": {\n \"key\": \"detailed-content\",\n \"value\": \"detailed_content\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"minimal-content\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"content_type\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"minimal\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"empty-content\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"full-content\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"content_type\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"full\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"detailed-content\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ],\n \"totalShards\": 10000\n },\n \"falsy-value-assignments\": {\n \"key\": \"falsy-value-assignments\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"zero-limit\": {\n \"key\": \"zero-limit\",\n \"value\": 0\n },\n \"premium-limit\": {\n \"key\": \"premium-limit\",\n \"value\": 100\n }\n },\n \"allocations\": [\n {\n \"key\": \"free-tier-limit\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"plan_tier\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"free\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"zero-limit\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"premium-tier-limit\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"plan_tier\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"premium\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"premium-limit\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ],\n \"totalShards\": 10000\n },\n \"empty-targeting-key-flag\": {\n \"key\": \"empty-targeting-key-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on-value\"\n },\n \"off\": {\n \"key\": \"off\",\n \"value\": \"off-value\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"default-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"microsecond-date-test\": {\n \"key\": \"microsecond-date-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"expired\": {\n \"key\": \"expired\",\n \"value\": \"expired\"\n },\n \"active\": {\n \"key\": \"active\",\n \"value\": \"active\"\n },\n \"future\": {\n \"key\": \"future\",\n \"value\": \"future\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"expired-allocation\",\n \"splits\": [\n {\n \"variationKey\": \"expired\",\n \"shards\": []\n }\n ],\n \"endAt\": \"2002-10-31T09:00:00.594321Z\",\n \"doLog\": true\n },\n {\n \"key\": \"future-allocation\",\n \"splits\": [\n {\n \"variationKey\": \"future\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2052-10-31T09:00:00.123456Z\",\n \"doLog\": true\n },\n {\n \"key\": \"active-allocation\",\n \"splits\": [\n {\n \"variationKey\": \"active\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2022-10-31T09:00:00.235982Z\",\n \"endAt\": \"2050-10-31T09:00:00.987654Z\",\n \"doLog\": true\n }\n ]\n },\n \"unknown-fields-tolerance-flag\": {\n \"key\": \"unknown-fields-tolerance-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag deliberately contains unknown fields at every fixed-schema UFC level. SDKs must ignore those fields and evaluate the flag normally.\",\n \"unknownFlagField\": \"ignored\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on\",\n \"unknownVariationField\": \"ignored\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"unknown-fields-allocation\",\n \"unknownAllocationField\": \"ignored\",\n \"rules\": [\n {\n \"unknownRuleField\": \"ignored\",\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\"\n ],\n \"unknownConditionField\": \"ignored\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"unknownSplitField\": \"ignored\",\n \"shards\": [\n {\n \"salt\": \"unknown-fields-tolerance-flag\",\n \"totalShards\": 10000,\n \"unknownShardField\": \"ignored\",\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000,\n \"unknownRangeField\": \"ignored\"\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"operator-grease-flag\": {\n \"key\": \"operator-grease-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag deliberately uses an unknown condition operator. SDKs must ignore this flag rather than falling through to the catch-all allocation or rejecting the whole config.\",\n \"variations\": {\n \"trap\": {\n \"key\": \"trap\",\n \"value\": \"trap\"\n },\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"grease-allocation\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"NOT_A_REAL_OPERATOR\",\n \"value\": \"anything\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"trap\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"default-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"malformed-allocations-flag\": {\n \"key\": \"malformed-allocations-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag is intentionally UFC-schema-invalid: allocations must be an array, but this fixture uses a string to verify per-flag parse error isolation.\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on\"\n }\n },\n \"allocations\": \"this-is-not-a-list\"\n },\n \"missing-split-shards-flag\": {\n \"key\": \"missing-split-shards-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag is intentionally UFC-schema-invalid: split.shards is required even when the intended split is unsharded. SDKs must treat omitted shards differently from an explicit empty shards array.\",\n \"variations\": {\n \"trap\": {\n \"key\": \"trap\",\n \"value\": \"trap\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"malformed-split-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"trap\"\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"overflow-total-shards-flag\": {\n \"key\": \"overflow-total-shards-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag is intentionally UFC-schema-invalid: split.shards[].totalShards must fit in an unsigned 32-bit integer. SDKs must ignore this flag rather than overflowing or poisoning the whole config.\",\n \"variations\": {\n \"trap\": {\n \"key\": \"trap\",\n \"value\": \"trap\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"malformed-shard-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"trap\",\n \"shards\": [\n {\n \"salt\": \"overflow-total-shards-flag\",\n \"totalShards\": 4294967296,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 1\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"negative-shard-range-start-flag\": {\n \"key\": \"negative-shard-range-start-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag is intentionally UFC-schema-invalid: split.shards[].ranges[].start must be an unsigned integer. SDKs must ignore this flag rather than treating a negative range as matching traffic.\",\n \"variations\": {\n \"trap\": {\n \"key\": \"trap\",\n \"value\": \"trap\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"malformed-shard-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"trap\",\n \"shards\": [\n {\n \"salt\": \"negative-shard-range-start-flag\",\n \"totalShards\": 100,\n \"ranges\": [\n {\n \"start\": -1,\n \"end\": 1\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"valid-flag-after-invalid-config\": {\n \"key\": \"valid-flag-after-invalid-config\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This valid neighbor proves malformed or unsupported flags do not poison the rest of the remote config.\",\n \"variations\": {\n \"expected\": {\n \"key\": \"expected\",\n \"value\": \"expected\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"default-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"expected\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n }\n }\n}\n","etag":"ffe-system-test-data"}} \ No newline at end of file +{"version":2,"server":{"response":"{\n \"createdAt\": \"2024-04-17T19:40:53.716Z\",\n \"format\": \"SERVER\",\n \"environment\": {\n \"name\": \"Test\",\n \"unknownEnvironmentField\": \"ignored\"\n },\n \"unknownTopLevelField\": \"ignored\",\n \"unknownTopLevelNull\": null,\n \"flags\": {\n \"empty_flag\": {\n \"key\": \"empty_flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {},\n \"allocations\": []\n },\n \"disabled_flag\": {\n \"key\": \"disabled_flag\",\n \"enabled\": false,\n \"variationType\": \"INTEGER\",\n \"variations\": {},\n \"allocations\": []\n },\n \"no_allocations_flag\": {\n \"key\": \"no_allocations_flag\",\n \"enabled\": true,\n \"variationType\": \"JSON\",\n \"variations\": {\n \"control\": {\n \"key\": \"control\",\n \"value\": {\n \"variant\": \"control\"\n }\n },\n \"treatment\": {\n \"key\": \"treatment\",\n \"value\": {\n \"variant\": \"treatment\"\n }\n }\n },\n \"allocations\": []\n },\n \"numeric_flag\": {\n \"key\": \"numeric_flag\",\n \"enabled\": true,\n \"variationType\": \"NUMERIC\",\n \"variations\": {\n \"e\": {\n \"key\": \"e\",\n \"value\": 2.7182818\n },\n \"pi\": {\n \"key\": \"pi\",\n \"value\": 3.1415926\n }\n },\n \"allocations\": [\n {\n \"key\": \"rollout\",\n \"splits\": [\n {\n \"variationKey\": \"pi\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"regex-flag\": {\n \"key\": \"regex-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"partial-example\": {\n \"key\": \"partial-example\",\n \"value\": \"partial-example\"\n },\n \"test\": {\n \"key\": \"test\",\n \"value\": \"test\"\n },\n \"capturing-groups\": {\n \"key\": \"capturing-groups\",\n \"value\": \"capturing-groups\"\n },\n \"unicode-word-boundary\": {\n \"key\": \"unicode-word-boundary\",\n \"value\": \"unicode-word-boundary\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"capturing-groups\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"^([[:alnum:]._%+-]+)@capture\\\\.example$\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"capturing-groups\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"unicode-word-boundary\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"(?u)\\\\bmañana\\\\b\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"unicode-word-boundary\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"partial-example\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"@example\\\\.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"partial-example\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"test\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \".*@test\\\\.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"test\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"numeric-one-of\": {\n \"key\": \"numeric-one-of\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"1\": {\n \"key\": \"1\",\n \"value\": 1\n },\n \"2\": {\n \"key\": \"2\",\n \"value\": 2\n },\n \"3\": {\n \"key\": \"3\",\n \"value\": 3\n }\n },\n \"allocations\": [\n {\n \"key\": \"1-for-1\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"number\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"1\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"1\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"2-for-123456789\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"number\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"123456789\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"2\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"3-for-not-2\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"number\",\n \"operator\": \"NOT_ONE_OF\",\n \"value\": [\n \"2\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"3\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"boolean-one-of-matches\": {\n \"key\": \"boolean-one-of-matches\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"1\": {\n \"key\": \"1\",\n \"value\": 1\n },\n \"2\": {\n \"key\": \"2\",\n \"value\": 2\n },\n \"3\": {\n \"key\": \"3\",\n \"value\": 3\n },\n \"4\": {\n \"key\": \"4\",\n \"value\": 4\n },\n \"5\": {\n \"key\": \"5\",\n \"value\": 5\n }\n },\n \"allocations\": [\n {\n \"key\": \"1-for-one-of\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"one_of_flag\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"1\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"2-for-matches\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"matches_flag\",\n \"operator\": \"MATCHES\",\n \"value\": \"true\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"2\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"3-for-not-one-of\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"not_one_of_flag\",\n \"operator\": \"NOT_ONE_OF\",\n \"value\": [\n \"false\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"3\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"4-for-not-matches\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"not_matches_flag\",\n \"operator\": \"NOT_MATCHES\",\n \"value\": \"false\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"4\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"5-for-matches-null\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"null_flag\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"null\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"5\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"empty_string_flag\": {\n \"key\": \"empty_string_flag\",\n \"enabled\": true,\n \"comment\": \"Testing the empty string as a variation value\",\n \"variationType\": \"STRING\",\n \"variations\": {\n \"empty_string\": {\n \"key\": \"empty_string\",\n \"value\": \"\"\n },\n \"non_empty\": {\n \"key\": \"non_empty\",\n \"value\": \"non_empty\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"allocation-empty\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"MATCHES\",\n \"value\": \"US\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"empty_string\",\n \"shards\": [\n {\n \"salt\": \"allocation-empty-shards\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"non_empty\",\n \"shards\": [\n {\n \"salt\": \"allocation-empty-shards\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"kill-switch\": {\n \"key\": \"kill-switch\",\n \"enabled\": true,\n \"variationType\": \"BOOLEAN\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": true\n },\n \"off\": {\n \"key\": \"off\",\n \"value\": false\n }\n },\n \"allocations\": [\n {\n \"key\": \"on-for-NA\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": [\n {\n \"salt\": \"some-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"on-for-age-50+\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"age\",\n \"operator\": \"GTE\",\n \"value\": 50\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": [\n {\n \"salt\": \"some-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"off-for-all\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"off\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"comparator-operator-test\": {\n \"key\": \"comparator-operator-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"small\": {\n \"key\": \"small\",\n \"value\": \"small\"\n },\n \"medium\": {\n \"key\": \"medium\",\n \"value\": \"medium\"\n },\n \"large\": {\n \"key\": \"large\",\n \"value\": \"large\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"small-size\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"LT\",\n \"value\": 10\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"small\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"medum-size\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"GTE\",\n \"value\": 10\n },\n {\n \"attribute\": \"size\",\n \"operator\": \"LTE\",\n \"value\": 20\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"medium\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"large-size\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"GT\",\n \"value\": 25\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"large\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"start-and-end-date-test\": {\n \"key\": \"start-and-end-date-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"old\": {\n \"key\": \"old\",\n \"value\": \"old\"\n },\n \"current\": {\n \"key\": \"current\",\n \"value\": \"current\"\n },\n \"new\": {\n \"key\": \"new\",\n \"value\": \"new\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"old-versions\",\n \"splits\": [\n {\n \"variationKey\": \"old\",\n \"shards\": []\n }\n ],\n \"endAt\": \"2002-10-31T09:00:00.594Z\",\n \"doLog\": true\n },\n {\n \"key\": \"future-versions\",\n \"splits\": [\n {\n \"variationKey\": \"new\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2052-10-31T09:00:00.594Z\",\n \"doLog\": true\n },\n {\n \"key\": \"current-versions\",\n \"splits\": [\n {\n \"variationKey\": \"current\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2022-10-31T09:00:00.594Z\",\n \"endAt\": \"2050-10-31T09:00:00.594Z\",\n \"doLog\": true\n }\n ]\n },\n \"null-operator-test\": {\n \"key\": \"null-operator-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"old\": {\n \"key\": \"old\",\n \"value\": \"old\"\n },\n \"new\": {\n \"key\": \"new\",\n \"value\": \"new\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"null-operator\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"IS_NULL\",\n \"value\": true\n }\n ]\n },\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"LT\",\n \"value\": 10\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"old\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"not-null-operator\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"IS_NULL\",\n \"value\": false\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"new\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"new-user-onboarding\": {\n \"key\": \"new-user-onboarding\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"control\": {\n \"key\": \"control\",\n \"value\": \"control\"\n },\n \"red\": {\n \"key\": \"red\",\n \"value\": \"red\"\n },\n \"blue\": {\n \"key\": \"blue\",\n \"value\": \"blue\"\n },\n \"green\": {\n \"key\": \"green\",\n \"value\": \"green\"\n },\n \"yellow\": {\n \"key\": \"yellow\",\n \"value\": \"yellow\"\n },\n \"purple\": {\n \"key\": \"purple\",\n \"value\": \"purple\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"id rule\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"id\",\n \"operator\": \"MATCHES\",\n \"value\": \"zach\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"purple\",\n \"shards\": []\n }\n ],\n \"doLog\": false\n },\n {\n \"key\": \"internal users\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"@mycompany.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"green\",\n \"shards\": []\n }\n ],\n \"doLog\": false\n },\n {\n \"key\": \"experiment\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"NOT_ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"control\",\n \"shards\": [\n {\n \"salt\": \"traffic-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 6000\n }\n ]\n },\n {\n \"salt\": \"split-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"red\",\n \"shards\": [\n {\n \"salt\": \"traffic-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 6000\n }\n ]\n },\n {\n \"salt\": \"split-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 8000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"yellow\",\n \"shards\": [\n {\n \"salt\": \"traffic-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 6000\n }\n ]\n },\n {\n \"salt\": \"split-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 8000,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"rollout\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"blue\",\n \"shards\": [\n {\n \"salt\": \"split-new-user-onboarding-rollout\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 8000\n }\n ]\n }\n ],\n \"extraLogging\": {\n \"allocationvalue_type\": \"rollout\",\n \"owner\": \"hippo\"\n }\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"integer-flag\": {\n \"key\": \"integer-flag\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"one\": {\n \"key\": \"one\",\n \"value\": 1\n },\n \"two\": {\n \"key\": \"two\",\n \"value\": 2\n },\n \"three\": {\n \"key\": \"three\",\n \"value\": 3\n }\n },\n \"allocations\": [\n {\n \"key\": \"targeted allocation\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n },\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \".*@example.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"three\",\n \"shards\": [\n {\n \"salt\": \"full-range-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"50/50 split\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"one\",\n \"shards\": [\n {\n \"salt\": \"split-numeric-flag-some-allocation\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"two\",\n \"shards\": [\n {\n \"salt\": \"split-numeric-flag-some-allocation\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"json-config-flag\": {\n \"key\": \"json-config-flag\",\n \"enabled\": true,\n \"variationType\": \"JSON\",\n \"variations\": {\n \"one\": {\n \"key\": \"one\",\n \"value\": {\n \"integer\": 1,\n \"string\": \"one\",\n \"float\": 1.0\n }\n },\n \"two\": {\n \"key\": \"two\",\n \"value\": {\n \"integer\": 2,\n \"string\": \"two\",\n \"float\": 2.0\n }\n },\n \"empty\": {\n \"key\": \"empty\",\n \"value\": {}\n }\n },\n \"allocations\": [\n {\n \"key\": \"Optionally Force Empty\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"Force Empty\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"empty\",\n \"shards\": [\n {\n \"salt\": \"full-range-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"50/50 split\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"one\",\n \"shards\": [\n {\n \"salt\": \"traffic-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n },\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"two\",\n \"shards\": [\n {\n \"salt\": \"traffic-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n },\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"special-characters\": {\n \"key\": \"special-characters\",\n \"enabled\": true,\n \"variationType\": \"JSON\",\n \"variations\": {\n \"de\": {\n \"key\": \"de\",\n \"value\": {\n \"a\": \"kümmert\",\n \"b\": \"schön\"\n }\n },\n \"ua\": {\n \"key\": \"ua\",\n \"value\": {\n \"a\": \"піклуватися\",\n \"b\": \"любов\"\n }\n },\n \"zh\": {\n \"key\": \"zh\",\n \"value\": {\n \"a\": \"照顾\",\n \"b\": \"漂亮\"\n }\n },\n \"emoji\": {\n \"key\": \"emoji\",\n \"value\": {\n \"a\": \"🤗\",\n \"b\": \"🌸\"\n }\n }\n },\n \"allocations\": [\n {\n \"key\": \"allocation-test\",\n \"splits\": [\n {\n \"variationKey\": \"de\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 2500\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"ua\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 2500,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"zh\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 7500\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"emoji\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 7500,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-default\",\n \"splits\": [\n {\n \"variationKey\": \"de\",\n \"shards\": []\n }\n ],\n \"doLog\": false\n }\n ]\n },\n \"string_flag_with_special_characters\": {\n \"key\": \"string_flag_with_special_characters\",\n \"enabled\": true,\n \"comment\": \"Testing the string with special characters and spaces\",\n \"variationType\": \"STRING\",\n \"variations\": {\n \"string_with_spaces\": {\n \"key\": \"string_with_spaces\",\n \"value\": \" a b c d e f \"\n },\n \"string_with_only_one_space\": {\n \"key\": \"string_with_only_one_space\",\n \"value\": \" \"\n },\n \"string_with_only_multiple_spaces\": {\n \"key\": \"string_with_only_multiple_spaces\",\n \"value\": \" \"\n },\n \"string_with_dots\": {\n \"key\": \"string_with_dots\",\n \"value\": \".a.b.c.d.e.f.\"\n },\n \"string_with_only_one_dot\": {\n \"key\": \"string_with_only_one_dot\",\n \"value\": \".\"\n },\n \"string_with_only_multiple_dots\": {\n \"key\": \"string_with_only_multiple_dots\",\n \"value\": \".......\"\n },\n \"string_with_comas\": {\n \"key\": \"string_with_comas\",\n \"value\": \",a,b,c,d,e,f,\"\n },\n \"string_with_only_one_coma\": {\n \"key\": \"string_with_only_one_coma\",\n \"value\": \",\"\n },\n \"string_with_only_multiple_comas\": {\n \"key\": \"string_with_only_multiple_comas\",\n \"value\": \",,,,,,,\"\n },\n \"string_with_colons\": {\n \"key\": \"string_with_colons\",\n \"value\": \":a:b:c:d:e:f:\"\n },\n \"string_with_only_one_colon\": {\n \"key\": \"string_with_only_one_colon\",\n \"value\": \":\"\n },\n \"string_with_only_multiple_colons\": {\n \"key\": \"string_with_only_multiple_colons\",\n \"value\": \":::::::\"\n },\n \"string_with_semicolons\": {\n \"key\": \"string_with_semicolons\",\n \"value\": \";a;b;c;d;e;f;\"\n },\n \"string_with_only_one_semicolon\": {\n \"key\": \"string_with_only_one_semicolon\",\n \"value\": \";\"\n },\n \"string_with_only_multiple_semicolons\": {\n \"key\": \"string_with_only_multiple_semicolons\",\n \"value\": \";;;;;;;\"\n },\n \"string_with_slashes\": {\n \"key\": \"string_with_slashes\",\n \"value\": \"/a/b/c/d/e/f/\"\n },\n \"string_with_only_one_slash\": {\n \"key\": \"string_with_only_one_slash\",\n \"value\": \"/\"\n },\n \"string_with_only_multiple_slashes\": {\n \"key\": \"string_with_only_multiple_slashes\",\n \"value\": \"///////\"\n },\n \"string_with_dashes\": {\n \"key\": \"string_with_dashes\",\n \"value\": \"-a-b-c-d-e-f-\"\n },\n \"string_with_only_one_dash\": {\n \"key\": \"string_with_only_one_dash\",\n \"value\": \"-\"\n },\n \"string_with_only_multiple_dashes\": {\n \"key\": \"string_with_only_multiple_dashes\",\n \"value\": \"-------\"\n },\n \"string_with_underscores\": {\n \"key\": \"string_with_underscores\",\n \"value\": \"_a_b_c_d_e_f_\"\n },\n \"string_with_only_one_underscore\": {\n \"key\": \"string_with_only_one_underscore\",\n \"value\": \"_\"\n },\n \"string_with_only_multiple_underscores\": {\n \"key\": \"string_with_only_multiple_underscores\",\n \"value\": \"_______\"\n },\n \"string_with_plus_signs\": {\n \"key\": \"string_with_plus_signs\",\n \"value\": \"+a+b+c+d+e+f+\"\n },\n \"string_with_only_one_plus_sign\": {\n \"key\": \"string_with_only_one_plus_sign\",\n \"value\": \"+\"\n },\n \"string_with_only_multiple_plus_signs\": {\n \"key\": \"string_with_only_multiple_plus_signs\",\n \"value\": \"+++++++\"\n },\n \"string_with_equal_signs\": {\n \"key\": \"string_with_equal_signs\",\n \"value\": \"=a=b=c=d=e=f=\"\n },\n \"string_with_only_one_equal_sign\": {\n \"key\": \"string_with_only_one_equal_sign\",\n \"value\": \"=\"\n },\n \"string_with_only_multiple_equal_signs\": {\n \"key\": \"string_with_only_multiple_equal_signs\",\n \"value\": \"=======\"\n },\n \"string_with_dollar_signs\": {\n \"key\": \"string_with_dollar_signs\",\n \"value\": \"$a$b$c$d$e$f$\"\n },\n \"string_with_only_one_dollar_sign\": {\n \"key\": \"string_with_only_one_dollar_sign\",\n \"value\": \"$\"\n },\n \"string_with_only_multiple_dollar_signs\": {\n \"key\": \"string_with_only_multiple_dollar_signs\",\n \"value\": \"$$$$$$$\"\n },\n \"string_with_at_signs\": {\n \"key\": \"string_with_at_signs\",\n \"value\": \"@a@b@c@d@e@f@\"\n },\n \"string_with_only_one_at_sign\": {\n \"key\": \"string_with_only_one_at_sign\",\n \"value\": \"@\"\n },\n \"string_with_only_multiple_at_signs\": {\n \"key\": \"string_with_only_multiple_at_signs\",\n \"value\": \"@@@@@@@\"\n },\n \"string_with_amp_signs\": {\n \"key\": \"string_with_amp_signs\",\n \"value\": \"&a&b&c&d&e&f&\"\n },\n \"string_with_only_one_amp_sign\": {\n \"key\": \"string_with_only_one_amp_sign\",\n \"value\": \"&\"\n },\n \"string_with_only_multiple_amp_signs\": {\n \"key\": \"string_with_only_multiple_amp_signs\",\n \"value\": \"&&&&&&&\"\n },\n \"string_with_hash_signs\": {\n \"key\": \"string_with_hash_signs\",\n \"value\": \"#a#b#c#d#e#f#\"\n },\n \"string_with_only_one_hash_sign\": {\n \"key\": \"string_with_only_one_hash_sign\",\n \"value\": \"#\"\n },\n \"string_with_only_multiple_hash_signs\": {\n \"key\": \"string_with_only_multiple_hash_signs\",\n \"value\": \"#######\"\n },\n \"string_with_percentage_signs\": {\n \"key\": \"string_with_percentage_signs\",\n \"value\": \"%a%b%c%d%e%f%\"\n },\n \"string_with_only_one_percentage_sign\": {\n \"key\": \"string_with_only_one_percentage_sign\",\n \"value\": \"%\"\n },\n \"string_with_only_multiple_percentage_signs\": {\n \"key\": \"string_with_only_multiple_percentage_signs\",\n \"value\": \"%%%%%%%\"\n },\n \"string_with_tilde_signs\": {\n \"key\": \"string_with_tilde_signs\",\n \"value\": \"~a~b~c~d~e~f~\"\n },\n \"string_with_only_one_tilde_sign\": {\n \"key\": \"string_with_only_one_tilde_sign\",\n \"value\": \"~\"\n },\n \"string_with_only_multiple_tilde_signs\": {\n \"key\": \"string_with_only_multiple_tilde_signs\",\n \"value\": \"~~~~~~~\"\n },\n \"string_with_asterix_signs\": {\n \"key\": \"string_with_asterix_signs\",\n \"value\": \"*a*b*c*d*e*f*\"\n },\n \"string_with_only_one_asterix_sign\": {\n \"key\": \"string_with_only_one_asterix_sign\",\n \"value\": \"*\"\n },\n \"string_with_only_multiple_asterix_signs\": {\n \"key\": \"string_with_only_multiple_asterix_signs\",\n \"value\": \"*******\"\n },\n \"string_with_single_quotes\": {\n \"key\": \"string_with_single_quotes\",\n \"value\": \"'a'b'c'd'e'f'\"\n },\n \"string_with_only_one_single_quote\": {\n \"key\": \"string_with_only_one_single_quote\",\n \"value\": \"'\"\n },\n \"string_with_only_multiple_single_quotes\": {\n \"key\": \"string_with_only_multiple_single_quotes\",\n \"value\": \"'''''''\"\n },\n \"string_with_question_marks\": {\n \"key\": \"string_with_question_marks\",\n \"value\": \"?a?b?c?d?e?f?\"\n },\n \"string_with_only_one_question_mark\": {\n \"key\": \"string_with_only_one_question_mark\",\n \"value\": \"?\"\n },\n \"string_with_only_multiple_question_marks\": {\n \"key\": \"string_with_only_multiple_question_marks\",\n \"value\": \"???????\"\n },\n \"string_with_exclamation_marks\": {\n \"key\": \"string_with_exclamation_marks\",\n \"value\": \"!a!b!c!d!e!f!\"\n },\n \"string_with_only_one_exclamation_mark\": {\n \"key\": \"string_with_only_one_exclamation_mark\",\n \"value\": \"!\"\n },\n \"string_with_only_multiple_exclamation_marks\": {\n \"key\": \"string_with_only_multiple_exclamation_marks\",\n \"value\": \"!!!!!!!\"\n },\n \"string_with_opening_parentheses\": {\n \"key\": \"string_with_opening_parentheses\",\n \"value\": \"(a(b(c(d(e(f(\"\n },\n \"string_with_only_one_opening_parenthese\": {\n \"key\": \"string_with_only_one_opening_parenthese\",\n \"value\": \"(\"\n },\n \"string_with_only_multiple_opening_parentheses\": {\n \"key\": \"string_with_only_multiple_opening_parentheses\",\n \"value\": \"(((((((\"\n },\n \"string_with_closing_parentheses\": {\n \"key\": \"string_with_closing_parentheses\",\n \"value\": \")a)b)c)d)e)f)\"\n },\n \"string_with_only_one_closing_parenthese\": {\n \"key\": \"string_with_only_one_closing_parenthese\",\n \"value\": \")\"\n },\n \"string_with_only_multiple_closing_parentheses\": {\n \"key\": \"string_with_only_multiple_closing_parentheses\",\n \"value\": \")))))))\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"allocation-test-string_with_spaces\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_spaces\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_spaces\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_space\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_space\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_space\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_spaces\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_spaces\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_spaces\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_dots\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_dots\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_dots\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_dot\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_dot\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_dot\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_dots\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_dots\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_dots\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_comas\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_comas\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_comas\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_coma\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_coma\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_coma\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_comas\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_comas\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_comas\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_colons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_colons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_colons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_colon\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_colon\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_colon\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_colons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_colons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_colons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_semicolons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_semicolons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_semicolons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_semicolon\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_semicolon\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_semicolon\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_semicolons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_semicolons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_semicolons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_slashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_slashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_slashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_slash\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_slash\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_slash\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_slashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_slashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_slashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_dashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_dashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_dashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_dash\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_dash\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_dash\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_dashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_dashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_dashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_underscores\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_underscores\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_underscores\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_underscore\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_underscore\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_underscore\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_underscores\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_underscores\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_underscores\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_plus_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_plus_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_plus_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_plus_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_plus_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_plus_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_plus_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_plus_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_plus_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_equal_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_equal_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_equal_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_equal_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_equal_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_equal_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_equal_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_equal_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_equal_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_dollar_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_dollar_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_dollar_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_dollar_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_dollar_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_dollar_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_dollar_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_dollar_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_dollar_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_at_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_at_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_at_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_at_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_at_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_at_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_at_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_at_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_at_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_amp_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_amp_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_amp_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_amp_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_amp_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_amp_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_amp_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_amp_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_amp_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_hash_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_hash_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_hash_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_hash_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_hash_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_hash_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_hash_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_hash_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_hash_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_percentage_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_percentage_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_percentage_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_percentage_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_percentage_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_percentage_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_percentage_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_percentage_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_percentage_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_tilde_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_tilde_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_tilde_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_tilde_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_tilde_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_tilde_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_tilde_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_tilde_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_tilde_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_asterix_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_asterix_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_asterix_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_asterix_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_asterix_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_asterix_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_asterix_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_asterix_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_asterix_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_single_quotes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_single_quotes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_single_quotes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_single_quote\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_single_quote\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_single_quote\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_single_quotes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_single_quotes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_single_quotes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_question_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_question_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_question_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_question_mark\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_question_mark\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_question_mark\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_question_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_question_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_question_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_exclamation_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_exclamation_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_exclamation_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_exclamation_mark\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_exclamation_mark\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_exclamation_mark\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_exclamation_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_exclamation_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_exclamation_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_opening_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_opening_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_opening_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_opening_parenthese\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_opening_parenthese\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_opening_parenthese\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_opening_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_opening_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_opening_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_closing_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_closing_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_closing_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_closing_parenthese\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_closing_parenthese\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_closing_parenthese\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_closing_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_closing_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_closing_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"boolean-false-assignment\": {\n \"key\": \"boolean-false-assignment\",\n \"enabled\": true,\n \"variationType\": \"BOOLEAN\",\n \"variations\": {\n \"false-variation\": {\n \"key\": \"false-variation\",\n \"value\": false\n },\n \"true-variation\": {\n \"key\": \"true-variation\",\n \"value\": true\n }\n },\n \"allocations\": [\n {\n \"key\": \"disable-feature\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"should_disable_feature\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"false-variation\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"enable-feature\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"should_disable_feature\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"false\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"true-variation\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ],\n \"totalShards\": 10000\n },\n \"empty-string-variation\": {\n \"key\": \"empty-string-variation\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"empty-content\": {\n \"key\": \"empty-content\",\n \"value\": \"\"\n },\n \"detailed-content\": {\n \"key\": \"detailed-content\",\n \"value\": \"detailed_content\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"minimal-content\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"content_type\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"minimal\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"empty-content\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"full-content\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"content_type\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"full\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"detailed-content\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ],\n \"totalShards\": 10000\n },\n \"falsy-value-assignments\": {\n \"key\": \"falsy-value-assignments\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"zero-limit\": {\n \"key\": \"zero-limit\",\n \"value\": 0\n },\n \"premium-limit\": {\n \"key\": \"premium-limit\",\n \"value\": 100\n }\n },\n \"allocations\": [\n {\n \"key\": \"free-tier-limit\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"plan_tier\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"free\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"zero-limit\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"premium-tier-limit\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"plan_tier\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"premium\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"premium-limit\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ],\n \"totalShards\": 10000\n },\n \"empty-targeting-key-flag\": {\n \"key\": \"empty-targeting-key-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on-value\"\n },\n \"off\": {\n \"key\": \"off\",\n \"value\": \"off-value\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"default-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"microsecond-date-test\": {\n \"key\": \"microsecond-date-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"expired\": {\n \"key\": \"expired\",\n \"value\": \"expired\"\n },\n \"active\": {\n \"key\": \"active\",\n \"value\": \"active\"\n },\n \"future\": {\n \"key\": \"future\",\n \"value\": \"future\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"expired-allocation\",\n \"splits\": [\n {\n \"variationKey\": \"expired\",\n \"shards\": []\n }\n ],\n \"endAt\": \"2002-10-31T09:00:00.594321Z\",\n \"doLog\": true\n },\n {\n \"key\": \"future-allocation\",\n \"splits\": [\n {\n \"variationKey\": \"future\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2052-10-31T09:00:00.123456Z\",\n \"doLog\": true\n },\n {\n \"key\": \"active-allocation\",\n \"splits\": [\n {\n \"variationKey\": \"active\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2022-10-31T09:00:00.235982Z\",\n \"endAt\": \"2050-10-31T09:00:00.987654Z\",\n \"doLog\": true\n }\n ]\n },\n \"unknown-fields-tolerance-flag\": {\n \"key\": \"unknown-fields-tolerance-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag deliberately contains unknown fields at every fixed-schema UFC level. SDKs must ignore those fields and evaluate the flag normally.\",\n \"unknownFlagField\": \"ignored\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on\",\n \"unknownVariationField\": \"ignored\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"unknown-fields-allocation\",\n \"unknownAllocationField\": \"ignored\",\n \"rules\": [\n {\n \"unknownRuleField\": \"ignored\",\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\"\n ],\n \"unknownConditionField\": \"ignored\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"unknownSplitField\": \"ignored\",\n \"shards\": [\n {\n \"salt\": \"unknown-fields-tolerance-flag\",\n \"totalShards\": 10000,\n \"unknownShardField\": \"ignored\",\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000,\n \"unknownRangeField\": \"ignored\"\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"operator-grease-flag\": {\n \"key\": \"operator-grease-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag deliberately uses an unknown condition operator. SDKs must ignore this flag rather than falling through to the catch-all allocation or rejecting the whole config.\",\n \"variations\": {\n \"trap\": {\n \"key\": \"trap\",\n \"value\": \"trap\"\n },\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"grease-allocation\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"NOT_A_REAL_OPERATOR\",\n \"value\": \"anything\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"trap\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"default-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"malformed-allocations-flag\": {\n \"key\": \"malformed-allocations-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag is intentionally UFC-schema-invalid: allocations must be an array, but this fixture uses a string to verify per-flag parse error isolation.\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on\"\n }\n },\n \"allocations\": \"this-is-not-a-list\"\n },\n \"missing-split-shards-flag\": {\n \"key\": \"missing-split-shards-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag is intentionally UFC-schema-invalid: split.shards is required even when the intended split is unsharded. SDKs must treat omitted shards differently from an explicit empty shards array.\",\n \"variations\": {\n \"trap\": {\n \"key\": \"trap\",\n \"value\": \"trap\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"malformed-split-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"trap\"\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"valid-flag-after-invalid-config\": {\n \"key\": \"valid-flag-after-invalid-config\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This valid neighbor proves malformed or unsupported flags do not poison the rest of the remote config.\",\n \"variations\": {\n \"expected\": {\n \"key\": \"expected\",\n \"value\": \"expected\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"default-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"expected\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n }\n }\n}\n","etag":"ffe-system-test-data"}} \ No newline at end of file From 3799be91263456ac115e3380f1d1d2f6b2434585 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 18:30:57 -0400 Subject: [PATCH 08/18] Update native flags plan for parsed fixture models --- docs/native-ffe-react-native-poc-plan.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/native-ffe-react-native-poc-plan.md b/docs/native-ffe-react-native-poc-plan.md index 5eced4c45..5bfb6887a 100644 --- a/docs/native-ffe-react-native-poc-plan.md +++ b/docs/native-ffe-react-native-poc-plan.md @@ -36,7 +36,7 @@ Use the current JavaScript implementation and shared fixture repository as the r - Canonical UFC input: `ufc-config.json` - Canonical evaluation cases: `evaluation-cases/*.json` -For this RN-first milestone, copy the canonical JSON files into `packages/core/src/flags/__fixtures__/ffe-system-test-data/` using the same directory layout as the shared repo. This keeps tests off inline JSON and gives Kotlin, Swift, and JS the same fixture names and schemas. Treat these files as a temporary vendored snapshot; before extraction into the iOS/Android SDK repos, decide whether to replace the snapshot with a pinned `ffe-system-test-data` submodule. +For this RN-first milestone, pin the canonical fixture repository as a git submodule at `packages/core/src/flags/__fixtures__/ffe-system-test-data/` using the same directory layout as the shared repo. This keeps tests off inline JSON and gives Kotlin, Swift, and JS the same fixture names and schemas. The current branch pins `ffe-system-test-data` at `6c7f63b8f89b8d636d4686c7c2a3b9481c41e485`, the commit immediately before the bad regex/shard-bound fixture merge, so this RN repo is aligned with the intended shared corpus without copying fixture payloads. Current RN dependencies already bring in shipped native Flags SDKs: @@ -306,7 +306,7 @@ The POC can place these inside the RN repo, but the package/module boundary shou ## Minimal RFC Wire Format -Use a deliberately small versioned shape that mirrors the RFC wire fields without embedding ad hoc flag JSON in tests. The bridge and native tests should read `native-ffe/rules-configuration-wire.json`, generated from the vendored `ffe-system-test-data/ufc-config.json` bytes with `ffe-system-test-data` as the etag. RN-local JSON fixtures should only cover SDK-specific envelopes such as `native-ffe/evaluation-context-user-123.json` and `native-ffe/evaluation-side-effects/*.json`. +Use a deliberately small versioned shape that mirrors the RFC wire fields without embedding ad hoc flag JSON in tests. The bridge and native tests should read `native-ffe/rules-configuration-wire.json`, generated from the pinned submodule's `ffe-system-test-data/ufc-config.json` bytes with `ffe-system-test-data` as the etag. RN-local JSON fixtures should only cover SDK-specific envelopes such as `native-ffe/evaluation-context-user-123.json` and `native-ffe/evaluation-side-effects/*.json`. Rules evaluation must be native and fixture-backed, not a toy predicate engine. The first implementation can be limited to the UFC v1 behavior covered by `ffe-system-test-data`, but Kotlin and Swift should both implement the same evaluator surface: @@ -405,6 +405,8 @@ Tasks: - Implement `RulesFlagEvaluator` in Kotlin. - Implement `RulesFlagEvaluator` in Swift. - Load UFC v1 JSON into native typed structures. +- Parse and validate the UFC once during `configurationFromString`; evaluation must use the parsed native model and must not deserialize JSON on every flag evaluation. +- Preserve the original `ConfigurationWire` string for bridge round trips, persistence, and offline initialization. - Implement rule matching, allocation windows, type validation, MD5 sharding, variation selection, reasons, and error codes. - Include evaluation metadata needed by exposure logging. - Add a fixture runner that loads `ffe-system-test-data/ufc-config.json` and every `ffe-system-test-data/evaluation-cases/*.json`. @@ -570,7 +572,7 @@ dd-client-token: pub542a31cc0f5b23136420667ca212045a Correctness is fixture-driven first, then integration-driven: -- Load the vendored `packages/core/src/flags/__fixtures__/ffe-system-test-data` snapshot for this RN milestone; before SDK extraction, replace or validate it against a pinned `ffe-system-test-data` submodule. +- Load the pinned `packages/core/src/flags/__fixtures__/ffe-system-test-data` submodule for this RN milestone; keep the submodule SHA explicit in review notes so iOS, Android, and RN all validate against the same corpus. - Load `ufc-config.json` into Kotlin and Swift evaluator tests. - Iterate every `evaluation-cases/*.json` file on both platforms. - For every case, evaluate `flag`, `variationType`, `defaultValue`, `targetingKey`, and `attributes`. @@ -585,8 +587,9 @@ If a behavior is missing from `ffe-system-test-data`, add it there first when it ### 3. Reference Implementation Validation -Use `openfeature-js-client` Node as the research and parity reference: +Use the Go evaluator and `openfeature-js-client` Node implementation as research and parity references: +- Keep the native implementation close to `DataDog/dd-trace-go/openfeature/evaluator.go` for evaluation order, rule matching, sharding, and reason mapping where that implementation and the shared fixtures agree. - Port behavior from `evaluate`, `evaluateForSubject`, `matchesRule`, `matchesShard`, and `MD5Sharder`. - Keep an implementation note beside the native evaluator describing any intentional deviation from Node behavior. - For risky cases, run the same fixture through Node, Kotlin, and Swift and compare the normalized result tuple: `value`, `reason`, `errorCode`, `variant`, `allocationKey`, `doLog`, `splitSerialId`. @@ -652,7 +655,7 @@ Known local caveat: the current workstation reports an Xcode/CoreSimulator misma ## Recommended Next Step -The current branch has implemented the first native-first slice through configuration parsing, dynamic context, rules/precomputed evaluation, native fetch, native SDK data-store-backed persistence with app-private fallback, shared JSON fixture coverage on Kotlin and Swift, example-app wiring, and existing native Flags SDK side-effect reuse. The next implementation slice should finish the remaining extraction-oriented pieces: a review note that records the confirmed native reuse points above, broader fixture iteration across the full shared corpus instead of the current representative cases, and a later cleanup of RN-local fallbacks when this code moves downstream. +The current branch has implemented the first native-first slice through configuration parsing, parsed native UFC models, dynamic context, rules/precomputed evaluation, native fetch, native SDK data-store-backed persistence with app-private fallback, full shared-corpus fixture coverage on Kotlin and Swift, example-app wiring, and existing native Flags SDK side-effect reuse. The next implementation slice should finish the remaining extraction-oriented pieces: a review note that records the confirmed native reuse points above, broader example-app validation, and a later cleanup of RN-local fallbacks when this code moves downstream. The minimum demo sequence remains: From b47c11eba678916f2ca95b3b73c69c22a1d31814 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 18:38:32 -0400 Subject: [PATCH 09/18] Wire native flags example flow --- docs/native-ffe-react-native-poc-plan.md | 676 ----------------------- example-new-architecture/App.tsx | 222 ++++++-- 2 files changed, 182 insertions(+), 716 deletions(-) delete mode 100644 docs/native-ffe-react-native-poc-plan.md diff --git a/docs/native-ffe-react-native-poc-plan.md b/docs/native-ffe-react-native-poc-plan.md deleted file mode 100644 index 5bfb6887a..000000000 --- a/docs/native-ffe-react-native-poc-plan.md +++ /dev/null @@ -1,676 +0,0 @@ -# Native FF&E React Native POC Plan - -## Purpose - -Build a POC that demonstrates the React Native SDK can support Offline Initialization and Dynamic Context while keeping the durable implementation mostly native. The POC should make React Native a thin consumer of native iOS and Android primitives that can later be extracted into first-class iOS and Android SDK libraries. - -This is not a production implementation. It is an execution plan for proving the right seams: - -- Native owns portable configuration parsing, active provider state, context state, evaluation, fetch side effects, persistence, and evaluation side effects. -- React Native owns API ergonomics and bridge adaptation. -- The APIs are shaped so the native implementation could move into iOS/Android SDKs without rewriting the React Native contract. -- The POC must not require first landing, reviewing, releasing, and then consuming new iOS/Android SDK changes. Any new native code needed for the proof should be additive inside this RN repo and structured for later extraction. - -## Team Shareable Summary - -The proposal is to make Phase 1 a focused React Native push that can unblock the customer quickly, without gating delivery on new iOS and Android SDK releases. We will add the missing native capabilities as local Kotlin and Swift libraries inside the RN repo, with the same architecture we expect to extract into the iOS and Android SDKs later. React Native stays as the API/bridge layer; the new native libraries own configuration parsing, offline initialization, dynamic context, and UFC evaluation, while the existing stable native SDKs remain responsible for the customer-visible Datadog side effects: exposure emission, flag evaluation EVP emission, RUM feature-flag annotation, SDK telemetry, and any reusable persistence hooks. Phase 2 then becomes a lower-pressure extraction effort: move the new Kotlin/Swift libraries downstream into the iOS and Android SDKs, publish them, and delete the RN-local copies once RN can depend on the released native implementations. - -## Source RFCs Read - -- `/Users/leo.romanovsky/Downloads/[In Review] Portable Flag Configuration RFC.docx` -- `/Users/leo.romanovsky/Downloads/[In Review] Dynamic Context for Client SDKs RFC.docx` -- `/Users/leo.romanovsky/Downloads/[RFC] Offline Initialization for Feature Flagging.docx` - -## Research Inputs To Pin - -Use the current JavaScript implementation and shared fixture repository as the reference for native evaluator behavior: - -- `https://github.com/DataDog/openfeature-js-client` - - Node evaluator: `packages/node-server/src/configuration/evaluation.ts` - - Subject evaluation: `packages/node-server/src/configuration/evaluateForSubject.ts` - - UFC schema: `packages/node-server/src/configuration/ufc-v1.ts` - - Rule operators: `packages/node-server/src/rules/rules.ts` - - Sharding: `packages/node-server/src/shards/matchesShard.ts` and `packages/node-server/src/shards/sharders.ts` - - Configuration wire precedent: `packages/core/src/configuration/wire.ts` -- `https://github.com/DataDog/ffe-system-test-data` - - Canonical UFC input: `ufc-config.json` - - Canonical evaluation cases: `evaluation-cases/*.json` - -For this RN-first milestone, pin the canonical fixture repository as a git submodule at `packages/core/src/flags/__fixtures__/ffe-system-test-data/` using the same directory layout as the shared repo. This keeps tests off inline JSON and gives Kotlin, Swift, and JS the same fixture names and schemas. The current branch pins `ffe-system-test-data` at `6c7f63b8f89b8d636d4686c7c2a3b9481c41e485`, the commit immediately before the bad regex/shard-bound fixture merge, so this RN repo is aligned with the intended shared corpus without copying fixture payloads. - -Current RN dependencies already bring in shipped native Flags SDKs: - -- iOS `DatadogSDKReactNative.podspec` depends on `DatadogFlags 3.11.0`. -- Android `packages/core/android/build.gradle` depends on `com.datadoghq:dd-sdk-android-flags:3.10.0`. -- Existing RN flags bridge calls current native SDKs for precomputed assignment fetch, client state, and exposure/evaluation tracking: - - Android: `Flags.enable`, `FlagsClient`, `_FlagsInternalProxy`, `UnparsedFlag`, `FlagsClientState`. - - iOS: `Flags.enable`, `FlagsClientProtocol`, `FlagsClientInternal`, `FlagAssignment`, `FlagsEvaluationContext`. - -The preflight task is to prove which of these current native SDK pieces can be reused as-is and which missing pieces must be implemented as additive RN-local native libraries. - -## Zero Native-Release Dependency Strategy - -The biggest architectural constraint is delivery order. We need to avoid this blocked sequence: - -```text -change iOS SDK - -> review/release iOS SDK - -> change Android SDK - -> review/release Android SDK - -> update RN dependencies - -> start RN feature work -``` - -Instead, the POC should use this sequence: - -```text -inventory currently shipped iOS/Android SDK capabilities - -> call existing SDK APIs where they are usable today - -> add missing native libraries inside dd-sdk-reactnative - -> keep RN bridge thin over those libraries - -> validate with shared fixtures and example app - -> later extract the additive native libraries into iOS/Android SDK repos -``` - -Decision rule for each native capability: - -| Capability | Preferred source | Fallback if unavailable today | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | -| Precomputed assignment fetch | Existing `DatadogFlags` / `dd-sdk-android-flags` client fetch path | RN-local fetch adapter that mirrors current request shape through an interface. | -| Exposure/evaluation logging | Existing native `trackEvaluation` / internal tracking hooks already used by RN | RN-local `FlagsEvaluationSideEffects` fake plus adapter interface for later SDK integration. | -| RUM session correlation | Existing native Flags/RUM integration | Adapter interface that records required RUM context inputs but does not reimplement RUM in JS. | -| SDK storage path | Existing native SDK file/storage primitives if public enough | RN-local native store with the same interface and one last-good config policy. | -| HTTP transport and request construction | Existing native SDK HTTP/request builders if callable from RN package without unreleased changes | RN-local native transport with injectable fake server support; no JS fetch. | -| UFC rules evaluation | Existing native evaluator only if already shipped and callable | RN-local Kotlin and Swift evaluator implementations, fixture-backed, extracted later. | -| Portable config parse/serialize | Existing shipped parser if present | RN-local parser/serializer matching RFC wire contract. | -| Provider state machine / dynamic context | Existing state manager only if it can support rules config and context-only re-evaluation today | RN-local `NativeFfeCore` state machine that later moves into native SDKs. | - -Rules for additive RN-local libraries: - -- No React Native imports inside core evaluator, configuration, fetch, persistence, or side-effect types. -- Bridge code may only translate JS/RN values into native core inputs and serialize outputs back to JS. -- Native core code must depend on narrow ports for SDK-owned concerns: transport, storage, telemetry, RUM context, and exposure logging. -- Any use of Android `internal` packages, iOS SPI, or private-ish bridge helpers must be isolated in adapter classes and documented as a POC-only dependency. -- No dependency on unreleased Maven artifacts, unpublished Pods, local native SDK checkouts, or source changes in native SDK repos. -- Extraction should be a mechanical move: the core package/module should compile in unit tests without RN and with fake ports. - -## Existing Native SDK Reuse Targets - -Keep the hard dependency on existing iOS/Android SDKs focused on customer-visible Datadog side effects and SDK context. The evaluator and configuration state can be additive RN-local code; event emission and correlation should not be reimplemented in JS. - -Must call into existing native SDKs: - -- Exposure emission through the existing Flags pipelines. - - Android path: `_FlagsInternalProxy.trackFlagSnapshotEvaluation(...)` delegates to `DatadogFlagsClient.trackFlagSnapshotEvaluation`, which enters the same native `trackResolution` path as shipped Android flag resolution. - - iOS candidate: `FlagsClientInternal.sendFlagEvaluation(...)`, which calls the native exposure logger, evaluation logger, and RUM reporter. -- Flag evaluation emission in EVP through the existing native evaluator/event aggregation pipeline. - - Android path: `DatadogFlagsClient.trackResolution` calls `writeEvaluationEvent(...)` when `trackEvaluations` is enabled, reusing the shipped evaluation feature and writer pipeline. - - iOS path: `FlagsClient.trackEvaluation(...)` calls `evaluationLogger.logEvaluation(...)`, reusing the shipped evaluation aggregation pipeline. -- RUM feature-flag annotation / correlation. - - Android path: `DatadogFlagsClient.trackResolution` calls `RumEvaluationLogger.logEvaluation(...)` when RUM integration is enabled; `DefaultRumEvaluationLogger` sends a native `RumFlagEvaluationMessage`. - - iOS path: `rumFlagEvaluationReporter.sendFlagEvaluation(...)` underneath `FlagsClient.trackEvaluation(...)`, consumed by Datadog RUM's flag evaluation receiver. - -Validated reuse evidence for the RN-local native side-effect adapter: - -- Android shipped AAR bytecode confirms `trackFlagSnapshotEvaluation -> trackResolution -> writeExposureEvent`, `RumEvaluationLogger.logEvaluation`, and `writeEvaluationEvent`. -- iOS shipped Pod source confirms `sendFlagEvaluation -> trackEvaluation -> exposureLogger.logExposure`, `evaluationLogger.logEvaluation`, and `rumFlagEvaluationReporter.sendFlagEvaluation`. -- The RN-local evaluator should therefore call the existing native Flags tracking hook once per successful `doLog`/trackable evaluation and must not add a parallel direct RUM annotation call that could double-count. - -Also preserve through native SDKs, either directly or as inputs to side-effect adapters: - -- Tracking consent handling for emitted data. -- SDK core identity and configuration: site, client token, environment, service, version, source, and SDK instance. -- Native event timestamps and server time offset. -- Internal SDK logging/telemetry for parse, evaluation, fetch, and persistence failures. -- Existing RUM session context when annotation/correlation is enabled. - -Optional reuse, only if callable from currently released SDKs without source changes: - -- SDK-owned storage directories for persisted last-good configuration. -- SDK HTTP client/request builders, compression, retry, and endpoint/auth helpers for configuration fetch. -- Existing precomputed assignment request builders. - -If optional reuse is not callable today, implement it as an RN-local native port with the same shape expected from the future extracted SDK library. Do not block the RN POC on making those APIs public in the iOS/Android SDK repos first. - -Current storage finding: released iOS and Android Flags SDKs persist precomputed flag state on disk through native SDK data-store abstractions. iOS uses `FeatureScope.flagsDataStore` over Datadog `DataStore`, keyed by flags client name. Android uses `FlagsPersistenceManager` over `DataStoreHandler`, with a `flags-state-{instanceName}` key. The rules-based `ConfigurationWire` has no released save/load API yet, so the RN-local implementation should persist the downloaded rules configuration on native disk with equivalent semantics: versioned payload, per-client/slot keying, native-owned I/O, and explicit `loadConfiguration -> setConfiguration` activation. The current branch now prefers the existing native SDK feature data-store namespace when available: iOS writes through `CoreRegistry.default.scope(for: flags).dataStore`, and Android writes through the Flags `FeatureScope.dataStore` obtained from `FeatureSdkCore`. The app-private file store remains only as an RN-local fallback for uninitialized SDK/test scenarios. - -## RFC Requirements To Exercise - -| RFC area | Requirement | POC coverage target | -| ---------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| Portable configuration | `ConfigurationWire` is an opaque JSON string that can carry precomputed assignments and rules/UFC. | Native `configurationFromString` / `configurationToString` parse and serialize a versioned wire payload. | -| Configuration loading | `setConfiguration()` loads or replaces one complete active configuration. | Native provider state machine stores active configuration and emits/debugs ready, changed, and error states. | -| Offline init | Startup can load a supplied config before network is available. | RN passes a bundled/cached wire string to native, native evaluates without network. | -| Startup then refresh | SDK can start from supplied config and later replace it with fetched config. | Native fetch returns a config, RN or native caller then applies it through the same `setConfiguration` path. | -| Fetch helpers | `fetchRulesConfiguration()` / `fetchPrecomputedConfiguration()` return config and do not mutate provider state. | Native fetch API performs HTTP, ETag, headers, auth inputs, and returns wire/config without touching active state. | -| Conditional fetch | Existing `etag` becomes `If-None-Match`; `304` returns previous config. | Native fetch accepts previous wire/config, sends `If-None-Match`, and handles `304`. | -| Dynamic context | Rules-based config stores new context immediately with no network and no stale blackout. | Native `setEvaluationContext()` mutates context only; `evaluate()` re-evaluates against same active rules. | -| Precomputed safety | Precomputed config tied to one context must not serve another context. | Native evaluates precomputed only when stored context matches; mismatch returns default/error/debug state. | -| Evaluation output | Rules path must produce metadata compatible with logging. | Native result includes value, variant, reason, error code, allocation key, `doLog`, and `extraLogging`. | -| Logging side effects | RN should keep existing native logging consistency. | Native evaluation triggers the existing native Flags SDK tracking hook and reports attempted/tracked/skipped/failed counters in debug state. | -| Persistence | Mobile startup from last-known values is a target recipe. | Native `saveConfiguration()` / `loadConfiguration()` persist last-good wire on disk; caller still explicitly activates via `setConfiguration()`. | -| Failure behavior | Invalid wire, unsupported kind, refresh failure, stale serving are explicit states. | Native debug state reports invalid wire, unsupported kind, stale retained config, and last fetch error. | - -## Current Native Flag-Provider Coverage - -The current native flag-provider slice is designed to replace the earlier add-numbers/counter bridge exercise. - -Covered: - -- New RN bridge methods through old and new architecture. -- Native-owned mutable state across calls. -- JS-to-native JSON request and native-to-JS JSON response using RFC-shaped configuration, context, and evaluation result objects. -- `ConfigurationWire` parse/serialize round trips. -- `setConfiguration()` lifecycle and provider debug state. -- Dynamic context changes with no network request. -- Native UFC evaluation with canonical fixture coverage for `STATIC`, `SPLIT`, `TARGETING_MATCH`, and `ERROR` / `TARGETING_KEY_MISSING`. -- Evaluation metadata required for exposure logging, evaluation aggregation, and RUM: variant, allocation key, `doLog`, split serial id where present, configuration kind/etag, and `extraLogging`. -- Best-effort native side-effect adapters that convert successful native evaluation results into the shipped Flags SDK assignment shape: - - Android: `_FlagsInternalProxy.trackFlagSnapshotEvaluation(...)`. - - iOS: `FlagsClientInternal.sendFlagEvaluation(...)`. -- Provider debug state includes evaluation side-effect counters: attempted, tracked, skipped, failed, last status, and last error. - -Not covered: - -- Precomputed context mismatch protection. -- Native HTTP fetch side effects, auth, ETag, `304`, compression, request builders. -- Native persistence/offline boot. -- Production-grade side-effect dedup/reset policy for rules evaluation; the current adapter proves call-in feasibility and leaves dedup/cache semantics to the native SDK extraction. - -## Architecture Decision For The POC - -Use a native-first `NativeFfeCore` boundary on each platform. - -React Native should call into native with serialized inputs and receive serialized/debuggable outputs, but the durable state and side effects live behind a native API: - -```text -React Native JS - -> NativeDdSdk bridge methods - -> NativeFfeCore facade - -> configuration parser/serializer - -> provider state machine - -> evaluator interface - -> fetch transport - -> persistence store - -> logging side effects -``` - -Preferred native shape for the POC is closest to Dynamic Context RFC Option D: compose via a shared evaluator interface. That lets existing native `FlagsClient` logging patterns stay conceptually intact while a rules evaluator and precomputed evaluator plug into the same result type. - -## RFC-Shaped API Sketch - -Use names that are as close as possible to the RFC building blocks. The implementation can remain experimental while it lives in the RN repo, but the API shape should preview the eventual extracted iOS/Android SDK surface. - -### React Native Surface - -```ts -type NativeFfeConfigurationWire = string; - -type NativeFfeFetchOptions = { - kind: 'rules' | 'precomputed'; - endpoint: string; - clientToken?: string; - sdkKey?: string; - site?: string; - headers?: Record; - flagQueryParams?: Record; - evaluationContext?: Record; - previousConfigurationWire?: NativeFfeConfigurationWire; -}; - -type NativeFfeEvaluationContext = { - targetingKey?: string; - attributes?: Record; -}; - -type NativeFfeEvaluationResult = { - flagKey: string; - value: boolean | string | number | Record | null; - variant?: string; - reason: string; - errorCode?: string; - flagMetadata?: { - allocationKey?: string; - doLog?: boolean; - extraLogging?: Record; - configurationKind?: 'precomputed' | 'rules'; - configurationEtag?: string; - }; -}; - -type NativeFfeDebugState = { - status: 'not_ready' | 'ready' | 'stale' | 'error'; - activeConfigurationKind?: 'precomputed' | 'rules' | 'mixed'; - activeEtag?: string; - currentContext?: NativeFfeEvaluationContext; - configurationSetCount: number; - fetchCount: number; - evaluationCount: number; - lastEvent?: 'provider_ready' | 'configuration_changed' | 'provider_error'; - lastFetchRequest?: { - url: string; - method: string; - headers: Record; - }; - lastError?: string; -}; -``` - -Methods: - -```ts -DdSdkReactNative.configurationFromString(wire): Promise; -DdSdkReactNative.configurationToString(configuration): Promise; -DdSdkReactNative.fetchPrecomputedConfiguration(options): Promise; -DdSdkReactNative.fetchRulesConfiguration(options): Promise; -DdSdkReactNative.setConfiguration(configuration): Promise; -DdSdkReactNative.setEvaluationContext(context): Promise; -DdSdkReactNative.resolveBooleanEvaluation(flagKey, defaultValue): Promise; -DdSdkReactNative.resolveStringEvaluation(flagKey, defaultValue): Promise; -DdSdkReactNative.resolveNumberEvaluation(flagKey, defaultValue): Promise; -DdSdkReactNative.resolveObjectEvaluation(flagKey, defaultValue): Promise; -DdSdkReactNative.saveConfiguration(wire): Promise; -DdSdkReactNative.loadConfiguration(): Promise; -DdSdkReactNative.getProviderDebugState(): Promise; -``` - -### Native Library Boundary - -The bridge should delegate to platform-native classes that are not RN-specific beyond adapters: - -Android: - -- `NativeFfeCore` -- `FlagsConfigurationWireParser` -- `FlagsConfigurationStore` -- `FlagEvaluator` -- `PrecomputedFlagEvaluator` -- `RulesFlagEvaluator` -- `FlagsConfigurationFetcher` -- `FlagsConfigurationTransport` -- `FlagsEvaluationSideEffects` - -iOS: - -- `NativeFfeCore` -- `FlagsConfigurationWireParser` -- `FlagsConfigurationStore` -- `FlagEvaluator` -- `PrecomputedFlagEvaluator` -- `RulesFlagEvaluator` -- `FlagsConfigurationFetcher` -- `FlagsConfigurationTransport` -- `FlagsEvaluationSideEffects` - -The POC can place these inside the RN repo, but the package/module boundary should make later extraction into the iOS and Android SDKs straightforward. - -## Minimal RFC Wire Format - -Use a deliberately small versioned shape that mirrors the RFC wire fields without embedding ad hoc flag JSON in tests. The bridge and native tests should read `native-ffe/rules-configuration-wire.json`, generated from the pinned submodule's `ffe-system-test-data/ufc-config.json` bytes with `ffe-system-test-data` as the etag. RN-local JSON fixtures should only cover SDK-specific envelopes such as `native-ffe/evaluation-context-user-123.json` and `native-ffe/evaluation-side-effects/*.json`. - -Rules evaluation must be native and fixture-backed, not a toy predicate engine. The first implementation can be limited to the UFC v1 behavior covered by `ffe-system-test-data`, but Kotlin and Swift should both implement the same evaluator surface: - -- flag lookup by key -- enabled/disabled flags -- variation type validation for boolean, string, number, and object values -- allocation order -- allocation `startAt` / `endAt` windows -- rule conditions for `MATCHES`, `NOT_MATCHES`, `GTE`, `GT`, `LTE`, `LT`, `ONE_OF`, `NOT_ONE_OF`, and `IS_NULL` -- subject attribute construction matching Node behavior: `targetingKey` is exposed as `id`, and explicit context attributes may override `id` -- MD5 sharding using `${salt}-${targetingKey}`, the first 4 bytes of the MD5 hex output, modulo `totalShards`, and inclusive-start/exclusive-end ranges -- missing-targeting-key behavior only when sharding requires a subject key -- variant/value output -- OpenFeature-compatible reasons and error codes -- metadata including `variant`, `allocationKey`, `doLog`, split serial id, evaluation timestamp, and `extraLogging` where present - -Structure `RulesFlagEvaluator` behind an interface so the POC implementation can later be replaced by a production shared evaluator or an extracted iOS/Android SDK library, but the POC itself should prove the Kotlin and Swift implementations against shared fixtures. - -## Execution Phases - -### Phase 0: Establish The Native Flag Provider Bridge - -Goal: expose RFC-shaped flag configuration semantics while preserving compile proof. - -Tasks: - -- Replace counter-specific methods with API names close to the RFC surface: `configurationFromString`, `configurationToString`, `setConfiguration`, `setEvaluationContext`, typed `resolve*Evaluation`, and provider debug state. -- Keep JSON round trips, but use `ConfigurationWire`, context, and evaluation result objects. -- Move test payloads into canonical JSON fixtures; do not embed UFC config or evaluation cases inline in Kotlin, Swift, or JS tests. -- Add JS tests proving RN serializes request objects and parses native JSON responses. - -Acceptance: - -- RN call sequence demonstrates `set config -> set context -> evaluate -> set context -> evaluate`. -- No network is called during `setEvaluationContext()` in rules mode. - -### Phase 1: Native Core Boundary - -Goal: establish extractable native library structure. - -Tasks: - -- Add Android native classes under a package that does not depend on React Native except adapter methods. -- Add iOS native classes/types with the same separation. -- Add bridge adapter methods that only translate RN values to native inputs. -- Add `NativeFfeDebugState` for test visibility. - -Acceptance: - -- Most POC logic lives outside bridge method bodies. -- Unit tests can instantiate `NativeFfeCore` without React Native. - -### Phase 2: Portable Configuration Parse/Serialize - -Goal: exercise the RFC building blocks. - -Tasks: - -- Implement `configurationFromString(wire)` natively. -- Validate `version`. -- Parse precomputed and server/rules slots. -- Preserve `etag`, `fetchedAt`, and embedded context. -- Implement `configurationToString(configuration)`. -- Reject invalid JSON, unsupported version, and empty configuration. - -Acceptance: - -- Valid precomputed wire round trips. -- Valid rules wire round trips. -- Invalid wire produces provider error/debug state. -- Unsupported kind is explicit and predictable. - -### Phase 3: Provider State And Offline Init - -Goal: prove one loading path for startup, post-start, and refresh. - -Tasks: - -- Implement `setConfiguration(configuration)` in native. -- Track provider states: `not_ready`, `ready`, `stale`, `error`. -- Track events: first valid config is ready; replacement is configuration changed; invalid config is error. -- Allow evaluation immediately after loading a bundled/cached wire. - -Acceptance: - -- `configurationFromString -> setConfiguration -> evaluate` works without network. -- Replacing config changes `etag` and debug event. -- Invalid replacement does not silently corrupt previous usable state. - -### Phase 4: Kotlin And Swift UFC Evaluators - -Goal: prove native iOS and Android can own rules evaluation with cross-platform parity. - -Tasks: - -- Implement `RulesFlagEvaluator` in Kotlin. -- Implement `RulesFlagEvaluator` in Swift. -- Load UFC v1 JSON into native typed structures. -- Parse and validate the UFC once during `configurationFromString`; evaluation must use the parsed native model and must not deserialize JSON on every flag evaluation. -- Preserve the original `ConfigurationWire` string for bridge round trips, persistence, and offline initialization. -- Implement rule matching, allocation windows, type validation, MD5 sharding, variation selection, reasons, and error codes. -- Include evaluation metadata needed by exposure logging. -- Add a fixture runner that loads `ffe-system-test-data/ufc-config.json` and every `ffe-system-test-data/evaluation-cases/*.json`. -- Keep evaluator code outside RN bridge adapters. - -Acceptance: - -- Kotlin passes every shared evaluation case. -- Swift passes every shared evaluation case. -- Both platforms report the same value, reason, and error behavior for the same fixture input. -- Native tests include explicit metadata assertions for allocation key, variant, `doLog`, split serial id, timestamp, and `extraLogging` because the shared fixtures intentionally focus on value/reason. -- The implementation can be instantiated without React Native. - -### Phase 5: Dynamic Context - -Goal: model "dynamic under static" context behavior. - -Tasks: - -- Implement native `setEvaluationContext(context)`. -- In rules mode, store context and resolve immediately. -- In precomputed mode, require exact or well-defined context match. -- Add context mismatch behavior for precomputed config. -- Prove anonymous-to-authenticated and account-switch flows. - -Acceptance: - -- Rules config evaluates different values after context changes with no fetch. -- Precomputed config does not serve values for mismatched context. -- Debug state shows no fetch during rules-context changes. - -### Phase 6: Native Fetch Side Effects - -Goal: show native iOS/Android own networking infrastructure. - -Tasks: - -- Add native `fetchFlagsConfiguration(options)` returning wire/config only. -- Build request URL from endpoint/site, kind, flag query params, and precomputed context. -- Add auth/header handling inputs. -- Add `If-None-Match` from previous config etag. -- Handle `200` body, `304` previous config, and error. -- Keep fetch side-effect-free relative to active provider state. -- Use injectable transport in unit tests. -- Add one integration-style test using a local fake server if feasible. - -Acceptance: - -- Fetch increments fetch counter and records last request. -- Fetch does not change active configuration until `setConfiguration` is called. -- `304` returns the previous wire. -- Error leaves active provider untouched. - -### Phase 7: Persistence / Last-Known Startup - -Goal: prove mobile offline startup from native-owned storage. - -Tasks: - -- Add native save/load of wire string to the existing SDK feature data-store path when available. -- Keep policy minimal: one last-good config only. -- Load persisted wire before any network call. -- Refresh in background by explicit caller sequence: `load -> setConfiguration -> fetch -> setConfiguration`. - -Acceptance: - -- Cold-start path evaluates from persisted config. -- Refresh failure keeps prior config and marks stale/debug error. -- Storage is native-owned, not JS AsyncStorage; adapter tests prove the Datadog feature data-store path and fallback behavior. - -### Phase 8: Evaluation Side Effects - -Goal: preserve native logging architecture for RN. - -Tasks: - -- Add evaluation result metadata required by RFC: `variant`, `allocationKey`, `doLog`, `extraLogging`. -- Add native `NativeFfeEvaluationSideEffects` adapters beside the RN bridge, outside the pure evaluator core. -- Android adapter converts the evaluation result into `UnparsedFlag` and calls `_FlagsInternalProxy.trackFlagSnapshotEvaluation(...)`. -- iOS adapter converts the evaluation result into `FlagAssignment` and calls `FlagsClientInternal.sendFlagEvaluation(...)`. -- RN evaluation should not implement exposure batching in JS. -- Expose best-effort tracking counters in provider debug state so the POC can show whether native side effects were attempted, tracked, skipped, or failed. - -Acceptance: - -- Evaluating a successful `doLog=true` flag with a targeting key invokes the shipped native Flags side-effect hook. -- Error/default evaluations skip side effects. -- Missing native Flags initialization does not fail evaluation; it increments failed counters and logs through native SDK logging. -- RN only calls evaluate; native owns the side effect. - -### Phase 9: Example App Flow - -Goal: make the POC demonstrable. - -Tasks: - -- Add an example screen or simple script path in `example-new-architecture`. -- Show: - - load bundled rules wire - - set anonymous context - - evaluate - - switch to authenticated context - - evaluate without network - - fetch updated config natively - - set fetched config - - evaluate changed result - - save/load wire -- Include debug state output for request headers, etag, context, provider state, and evaluation metadata. - -Acceptance: - -- Demo visibly distinguishes context change from fetch. -- Demo shows native fetch side effect and later explicit state mutation. - -## Validation Plan - -Validation must answer three separate questions: whether the POC exercises the architectural goals, whether evaluator behavior is correct, and whether RN integration remains thin and stable. - -### 1. Goal Coverage Validation - -Maintain a traceability table in the PR or test output that maps every goal to concrete evidence: - -| Goal | Required proof | -| ----------------------------------- | ------------------------------------------------------------------------------------------------------ | -| Offline initialization | `configurationFromString -> setConfiguration -> evaluate` succeeds from a bundled or persisted wire. | -| Dynamic context | `setEvaluationContext` changes evaluation results under rules config without incrementing fetch count. | -| Native-owned fetch | Native fetch records request URL, headers, auth, ETag, query params, and status, but active config is unchanged. | -| Explicit config replacement | Fetched config changes behavior only after `setConfiguration`. | -| Conditional fetch | `If-None-Match` is sent and `304` returns prior wire without state mutation. | -| Native-owned persistence | Cold start loads native-stored last-good wire without JS storage, then explicitly activates it. | -| Native-owned evaluation side effect | `doLog=true` evaluation invokes the existing native Flags tracking hook, which reuses exposure, EVP, and RUM annotation paths. | -| RN remains adapter-only | Most logic is unit-tested through native core classes without React Native. | -| JSON bridge shape | JS serializes requests and parses native JSON responses for config, context, fetch, and evaluation. | - -The example app flow must visibly exercise this sequence: - -```text -load bundled rules wire - -> set anonymous context - -> evaluate - -> set authenticated context - -> evaluate again with no fetch - -> native fetch updated config - -> prove active state is unchanged - -> save fetched config to native disk - -> load fetched config from native disk - -> set loaded config - -> evaluate changed result - -> load wire on cold start without JS storage -``` - -Debug state is part of validation, not just demo UI. Tests should assert `configurationSetCount`, `configurationSaveCount`, `configurationLoadCount`, `fetchCount`, `evaluationCount`, active `etag`, current context, last provider event, last fetch request, and last storage operation. - -The example app fetch panel should use the staging Fastly route while this is being shared with the team: - -```text -GET https://dd.datad0g.com/api/v2/feature-flagging/config/rules-based?dd_env=staging -Fastly-Client: 1 -dd-client-token: pub542a31cc0f5b23136420667ca212045a -``` - -### 2. Correctness Validation - -Correctness is fixture-driven first, then integration-driven: - -- Load the pinned `packages/core/src/flags/__fixtures__/ffe-system-test-data` submodule for this RN milestone; keep the submodule SHA explicit in review notes so iOS, Android, and RN all validate against the same corpus. -- Load `ufc-config.json` into Kotlin and Swift evaluator tests. -- Iterate every `evaluation-cases/*.json` file on both platforms. -- For every case, evaluate `flag`, `variationType`, `defaultValue`, `targetingKey`, and `attributes`. -- Assert at minimum `result.value` and `result.reason`. -- Add native-only expectations for error codes where the shared case implies `FLAG_NOT_FOUND`, `TARGETING_KEY_MISSING`, `TYPE_MISMATCH`, `PROVIDER_NOT_READY`, or `GENERAL`. -- Add metadata tests derived from the UFC input for `variant`, `allocationKey`, `doLog`, split serial id, evaluation timestamp, and `extraLogging`. -- Add bridge tests proving JS can pass boolean, string, number, object, null/default values, nested attributes, and special-character strings through the native JSON boundary. -- Add parser tests for invalid JSON, unsupported wire version, malformed flags, unknown fields, missing split shards, invalid shard bounds, microsecond dates, null targeting keys, and empty targeting keys. -- Add state-machine tests for invalid replacement retaining the previous valid config, precomputed context mismatch, refresh failure, and `304` handling. - -If a behavior is missing from `ffe-system-test-data`, add it there first when it is cross-SDK evaluator behavior. Use RN-local tests only for RN bridge behavior, native side effects, persistence, or metadata that is intentionally SDK-specific. - -### 3. Reference Implementation Validation - -Use the Go evaluator and `openfeature-js-client` Node implementation as research and parity references: - -- Keep the native implementation close to `DataDog/dd-trace-go/openfeature/evaluator.go` for evaluation order, rule matching, sharding, and reason mapping where that implementation and the shared fixtures agree. -- Port behavior from `evaluate`, `evaluateForSubject`, `matchesRule`, `matchesShard`, and `MD5Sharder`. -- Keep an implementation note beside the native evaluator describing any intentional deviation from Node behavior. -- For risky cases, run the same fixture through Node, Kotlin, and Swift and compare the normalized result tuple: `value`, `reason`, `errorCode`, `variant`, `allocationKey`, `doLog`, `splitSerialId`. -- Treat parity with shared fixtures as the merge gate for the native evaluator portion of the POC. - -Required for each phase: - -- JS unit tests for RN API serialization and bridge forwarding. -- Android unit tests for native core. -- Android fixture tests for the Kotlin evaluator. -- iOS unit tests for native core. -- iOS fixture tests for the Swift evaluator. -- iOS side-effect adapter tests for the existing native Flags tracking request shape. -- Android new-architecture example build. -- iOS codegen generation and iOS build when local Xcode simulator/device support is available. - -Target commands: - -```bash -yarn --cwd packages/core test DdSdkReactNative.test.tsx --runInBand -yarn --cwd packages/core prepare -JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ANDROID_HOME=/opt/homebrew/share/android-commandlinetools ./gradlew testDebugUnitTest --tests "com.datadog.reactnative.NativeFfeCoreTest" --tests "com.datadog.reactnative.NativeFfeConfigurationFetcherTest" -yarn --cwd example-new-architecture tsc --noEmit -cd example-new-architecture/ios && GIT_CONFIG_GLOBAL=/dev/null bundle exec pod install -cd example-new-architecture/ios && xcodebuild -workspace DdSdkReactNativeExample.xcworkspace -scheme DatadogSDKReactNative -configuration Debug -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath build/DerivedData CODE_SIGNING_ALLOWED=NO build -cd example-new-architecture/ios && xcodebuild test -workspace DdSdkReactNativeExample.xcworkspace -scheme DatadogSDKReactNative-Unit-Tests -configuration Debug -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath build/DerivedData CODE_SIGNING_ALLOWED=NO -only-testing:DatadogSDKReactNative-Unit-Tests/NativeFfeCoreTests -only-testing:DatadogSDKReactNative-Unit-Tests/NativeFfeEvaluationSideEffectsTests -``` - -Known local caveat: the current workstation reports an Xcode/CoreSimulator mismatch warning for iOS simulator support. The RN package build and selected native FF&E iOS tests have been validated with the Mac Catalyst destinations above. - -## What This POC Should Prove - -- RN can surface the feature without owning the core logic. -- RN can get the important existing native SDK benefits today: exposure emission, flag evaluation EVP emission, and RUM feature-flag annotation/correlation. -- iOS and Android can own the future extractable core library boundaries. -- `ConfigurationWire` can be loaded, replaced, persisted, fetched, and evaluated through one native path. -- Kotlin and Swift can evaluate UFC rules with parity against the shared fixture corpus. -- Context changes are native state changes, not network side effects, when rules config is active. -- Fetch helpers are side-effect-free relative to provider state. -- Native networking, persistence, logging, and telemetry remain native concerns. -- Existing RN customers can eventually get the feature through a bridge over native SDK capabilities rather than a parallel JS SDK. - -## What This POC Should Not Attempt - -- Production-wide UFC evaluator behavior beyond the shared fixture corpus and explicit parity cases added for this POC. -- Production auth or endpoint finalization. -- Production polling/cache policy. -- Source merging or priority rules. -- Public API naming finalization. -- Replacing existing `DdFlags` behavior for customers. -- Moving code into iOS/Android SDK repos yet. - -## Open Decisions To Force During Review - -- Should native POC use Option D evaluator composition as the recommended iOS/Android path? -- What is the exact precomputed context mismatch behavior per platform: default value, provider not ready, or evaluation error? -- What credential can fetch client-appropriate rules from mobile clients, if any? -- Is rules-based client configuration a separate distribution channel? -- Where should RN expose building blocks: `DdSdkReactNative`, `DdFlags`, or a new `DdFlagsConfiguration` namespace? -- Should fetch and persistence be public RN APIs or internal to a higher-level provider? -- What minimum metadata is mandatory for native exposure/evaluation/RUM parity? -- What max wire size should mobile/RN treat as acceptable for startup? - -## Recommended Next Step - -The current branch has implemented the first native-first slice through configuration parsing, parsed native UFC models, dynamic context, rules/precomputed evaluation, native fetch, native SDK data-store-backed persistence with app-private fallback, full shared-corpus fixture coverage on Kotlin and Swift, example-app wiring, and existing native Flags SDK side-effect reuse. The next implementation slice should finish the remaining extraction-oriented pieces: a review note that records the confirmed native reuse points above, broader example-app validation, and a later cleanup of RN-local fallbacks when this code moves downstream. - -The minimum demo sequence remains: - -```text -configurationFromString(wire) - -> setConfiguration(configuration) - -> setEvaluationContext(anonymous) - -> evaluate(flag) - -> setEvaluationContext(authenticated) - -> evaluate(flag) - -> fetchRulesConfiguration({ dd_env: "staging" }) - -> confirm fetch did not mutate active configuration - -> saveConfiguration(fetchedConfiguration) - -> loadConfiguration() - -> confirm load did not mutate active configuration - -> setConfiguration(loadedConfiguration) - -> evaluate(flag) -``` diff --git a/example-new-architecture/App.tsx b/example-new-architecture/App.tsx index 15fd67b30..c9fccabab 100644 --- a/example-new-architecture/App.tsx +++ b/example-new-architecture/App.tsx @@ -12,6 +12,11 @@ import { DdFlags, PropagatorType, } from '@datadog/mobile-react-native'; +import type { + FlagEvaluationResult, + FlagsProviderDebugState, + NativeFlagsConfiguration, +} from '@datadog/mobile-react-native'; import {DatadogOpenFeatureProvider} from '@datadog/mobile-react-native-openfeature'; import { OpenFeature, @@ -41,6 +46,7 @@ import { } from 'react-native/Libraries/NewAppScreen'; // @ts-ignore import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; +import nativeFfeRulesConfigurationWire from '../packages/core/src/flags/__fixtures__/native-ffe/rules-configuration-wire.json'; const NATIVE_FFE_STAGING_RULES_ENDPOINT = 'https://dd.datad0g.com/api/v2/feature-flagging/config/rules-based'; @@ -53,12 +59,36 @@ const NATIVE_FFE_USER = { id: 'user-123', favoriteFruit: 'apple', }; -const NATIVE_FFE_EVALUATION_CONTEXT = { - targetingKey: NATIVE_FFE_USER.id, +const NATIVE_FFE_BUNDLED_RULES_WIRE = JSON.stringify( + nativeFfeRulesConfigurationWire, +); +const NATIVE_FFE_DEMO_FLAG = 'boolean-false-assignment'; +const NATIVE_FFE_ANONYMOUS_CONTEXT = { + targetingKey: 'anonymous-user', + attributes: { + should_disable_feature: true, + }, +}; +const NATIVE_FFE_AUTHENTICATED_CONTEXT = { + targetingKey: 'authenticated-user', attributes: { - favoriteFruit: NATIVE_FFE_USER.favoriteFruit, + should_disable_feature: false, }, }; +const NATIVE_FFE_STAGING_CONTEXT = { + targetingKey: 'test_subject4', + attributes: { + attr1: 'value1', + companyId: '1', + }, +}; +const NATIVE_FFE_STAGING_FLAGS = { + boolean: 'checkout.enabled', + string: 'checkout.copy', + integer: 'checkout.limit', + float: 'checkout.ratio', + object: 'checkout.config', +}; (async () => { const config = new CoreConfiguration( @@ -164,7 +194,7 @@ function App(): React.JSX.Element { details. - +
Edit App.tsx to change this @@ -186,30 +216,51 @@ function App(): React.JSX.Element { ); } -type NativeFfeFetchState = { +type NativeFfeFlowState = { status: 'idle' | 'loading' | 'ready' | 'error'; summary: string; details?: string; }; -function NativeFfeFetchPanel({ +function NativeFfeFlowPanel({ isDarkMode, }: { isDarkMode: boolean; }): React.JSX.Element { - const [fetchState, setFetchState] = React.useState({ + const [flowState, setFlowState] = React.useState({ status: 'idle', - summary: 'Native fetch has not run yet.', + summary: 'Native FF&E flow has not run yet.', }); - const loading = fetchState.status === 'loading'; + const loading = flowState.status === 'loading'; - const fetchNativeRulesConfiguration = React.useCallback(async () => { - setFetchState({ + const runNativeFfeFlow = React.useCallback(async () => { + setFlowState({ status: 'loading', - summary: 'Fetching staging rules configuration from native...', + summary: 'Running native FF&E flow...', }); try { + const bundledConfiguration = await DdSdkReactNative.configurationFromString( + NATIVE_FFE_BUNDLED_RULES_WIRE, + ); + const bundledState = await DdSdkReactNative.setConfiguration( + bundledConfiguration, + ); + await DdSdkReactNative.setEvaluationContext( + NATIVE_FFE_ANONYMOUS_CONTEXT, + ); + const anonymousResult = await DdSdkReactNative.resolveBooleanEvaluation( + NATIVE_FFE_DEMO_FLAG, + true, + ); + await DdSdkReactNative.setEvaluationContext( + NATIVE_FFE_AUTHENTICATED_CONTEXT, + ); + const authenticatedResult = await DdSdkReactNative.resolveBooleanEvaluation( + NATIVE_FFE_DEMO_FLAG, + true, + ); + const beforeFetchState = await DdSdkReactNative.getProviderDebugState(); const fetchedConfiguration = await DdSdkReactNative.fetchRulesConfiguration({ endpoint: NATIVE_FFE_STAGING_RULES_ENDPOINT, headers: { @@ -219,7 +270,9 @@ function NativeFfeFetchPanel({ flagQueryParams: { dd_env: 'staging', }, + previousConfigurationWire: NATIVE_FFE_BUNDLED_RULES_WIRE, }); + const afterFetchState = await DdSdkReactNative.getProviderDebugState(); const serializedWire = await DdSdkReactNative.configurationToString( fetchedConfiguration, ); @@ -233,48 +286,100 @@ function NativeFfeFetchPanel({ const loadedConfiguration = await DdSdkReactNative.loadConfiguration( NATIVE_FFE_STORAGE_OPTIONS, ); - const configurationState = await DdSdkReactNative.setConfiguration( + const afterLoadState = await DdSdkReactNative.getProviderDebugState(); + const loadedState = await DdSdkReactNative.setConfiguration( loadedConfiguration, ); - const contextState = await DdSdkReactNative.setEvaluationContext( - NATIVE_FFE_EVALUATION_CONTEXT, + await DdSdkReactNative.setEvaluationContext( + NATIVE_FFE_STAGING_CONTEXT, ); + const stagingResults = { + boolean: await DdSdkReactNative.resolveBooleanEvaluation( + NATIVE_FFE_STAGING_FLAGS.boolean, + false, + ), + string: await DdSdkReactNative.resolveStringEvaluation( + NATIVE_FFE_STAGING_FLAGS.string, + 'Fallback title', + ), + integer: await DdSdkReactNative.resolveNumberEvaluation( + NATIVE_FFE_STAGING_FLAGS.integer, + 0, + ), + float: await DdSdkReactNative.resolveNumberEvaluation( + NATIVE_FFE_STAGING_FLAGS.float, + 0, + ), + object: await DdSdkReactNative.resolveObjectEvaluation( + NATIVE_FFE_STAGING_FLAGS.object, + {}, + ), + }; const debugState = await DdSdkReactNative.getProviderDebugState(); - setFetchState({ + setFlowState({ status: 'ready', - summary: `Fetched, saved, loaded, and activated ${ - loadedConfiguration.kind - } configuration ${loadedConfiguration.etag ?? 'without etag'}.`, + summary: `Offline ${anonymousResult.value} -> ${ + authenticatedResult.value + }; fetch count ${beforeFetchState.fetchCount} -> ${ + afterFetchState.fetchCount + }; evaluated ${Object.keys(stagingResults).length} staging flags.`, details: JSON.stringify( { - fetchedConfiguration: { - kind: fetchedConfiguration.kind, - version: fetchedConfiguration.version, - etag: fetchedConfiguration.etag, + offlineInit: { + bundledConfiguration: summarizeConfiguration( + bundledConfiguration, + ), + setState: summarizeDebugState(bundledState), + anonymousResult: summarizeEvaluation(anonymousResult), + authenticatedResult: summarizeEvaluation(authenticatedResult), + fetchCountAfterContextChange: beforeFetchState.fetchCount, }, - serializedWireBytes: serializedWire.length, - saveState, - loadedConfiguration: { - kind: loadedConfiguration.kind, - version: loadedConfiguration.version, - etag: loadedConfiguration.etag, + nativeFetch: { + activeBeforeFetch: summarizeDebugState(beforeFetchState), + fetchedConfiguration: summarizeConfiguration( + fetchedConfiguration, + ), + activeAfterFetch: summarizeDebugState(afterFetchState), + activeStateUnchanged: + beforeFetchState.activeEtag === afterFetchState.activeEtag && + beforeFetchState.configurationSetCount === + afterFetchState.configurationSetCount, + serializedWireBytes: serializedWire.length, }, - configurationState, - contextState, - debugState, + nativePersistence: { + saveState: summarizeDebugState(saveState), + loadedConfiguration: summarizeConfiguration(loadedConfiguration), + stateAfterLoad: summarizeDebugState(afterLoadState), + loadDidNotActivate: + afterLoadState.activeEtag === afterFetchState.activeEtag && + afterLoadState.configurationSetCount === + afterFetchState.configurationSetCount, + }, + explicitActivation: { + setState: summarizeDebugState(loadedState), + stagingContext: NATIVE_FFE_STAGING_CONTEXT, + stagingResults: { + boolean: summarizeEvaluation(stagingResults.boolean), + string: summarizeEvaluation(stagingResults.string), + integer: summarizeEvaluation(stagingResults.integer), + float: summarizeEvaluation(stagingResults.float), + object: summarizeEvaluation(stagingResults.object), + }, + }, + finalDebugState: summarizeDebugState(debugState), }, null, 2, ), }); } catch (error) { - setFetchState({ + setFlowState({ status: 'error', summary: error instanceof Error ? error.message - : 'Native staging rules fetch failed.', + : 'Native FF&E flow failed.', }); } }, []); @@ -288,7 +393,7 @@ function NativeFfeFetchPanel({ color: isDarkMode ? Colors.white : Colors.black, }, ]}> - Native FFE fetch + Native FFE flow - {fetchState.summary} + {flowState.summary} [ styles.nativeFfeButton, loading && styles.nativeFfeButtonDisabled, pressed && !loading && styles.nativeFfeButtonPressed, ]}> - {loading ? 'Fetching...' : 'Fetch staging rules'} + {loading ? 'Running...' : 'Run native flow'} - {fetchState.details ? ( + {flowState.details ? ( - {fetchState.details} + {flowState.details} ) : null} ); } +function summarizeConfiguration(configuration: NativeFlagsConfiguration) { + return { + kind: configuration.kind, + version: configuration.version, + etag: configuration.etag, + }; +} + +function summarizeEvaluation(result: FlagEvaluationResult) { + return { + flagKey: result.flagKey, + value: result.value, + variant: result.variant, + reason: result.reason, + errorCode: result.errorCode, + flagMetadata: result.flagMetadata, + }; +} + +function summarizeDebugState(state: FlagsProviderDebugState) { + return { + status: state.status, + activeConfigurationKind: state.activeConfigurationKind, + activeEtag: state.activeEtag, + configurationSetCount: state.configurationSetCount, + configurationSaveCount: state.configurationSaveCount, + configurationLoadCount: state.configurationLoadCount, + fetchCount: state.fetchCount, + evaluationCount: state.evaluationCount, + lastEvent: state.lastEvent, + lastFetchRequest: state.lastFetchRequest, + lastStorage: state.lastStorage, + lastError: state.lastError, + evaluationSideEffects: state.evaluationSideEffects, + }; +} + type SectionProps = PropsWithChildren<{ title: string; }>; From 382d9bd8db907e80b6235e7feb9a1d66387f4720 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 18:45:22 -0400 Subject: [PATCH 10/18] Use dogfooding staging flags in example flow --- example-new-architecture/App.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/example-new-architecture/App.tsx b/example-new-architecture/App.tsx index c9fccabab..27d92193c 100644 --- a/example-new-architecture/App.tsx +++ b/example-new-architecture/App.tsx @@ -83,11 +83,11 @@ const NATIVE_FFE_STAGING_CONTEXT = { }, }; const NATIVE_FFE_STAGING_FLAGS = { - boolean: 'checkout.enabled', - string: 'checkout.copy', - integer: 'checkout.limit', - float: 'checkout.ratio', - object: 'checkout.config', + boolean: 'ffe-dogfooding-boolean-flag', + string: 'ffe-dogfooding-string-flag', + integer: 'ffe-dogfooding-integer-flag', + float: 'ffe-dogfooding-float-flag', + object: 'ffe-dogfooding-json-flag', }; (async () => { From 78f2ee0dbb4a2fb7f1fd56b84111a86124e664eb Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 19:17:17 -0400 Subject: [PATCH 11/18] Align native FFE fetch with rules API --- example-new-architecture/App.tsx | 73 ++++--------------- .../NativeFfeConfigurationFetcher.kt | 2 +- .../NativeFfeConfigurationFetcherTest.kt | 4 +- .../NativeFfeConfigurationFetcher.swift | 1 - .../core/ios/Tests/NativeFfeCoreTests.swift | 51 +++++++++++++ 5 files changed, 68 insertions(+), 63 deletions(-) diff --git a/example-new-architecture/App.tsx b/example-new-architecture/App.tsx index 27d92193c..7364ddf19 100644 --- a/example-new-architecture/App.tsx +++ b/example-new-architecture/App.tsx @@ -9,7 +9,6 @@ import { DdLogs, DdTrace, TrackingConsent, - DdFlags, PropagatorType, } from '@datadog/mobile-react-native'; import type { @@ -17,16 +16,9 @@ import type { FlagsProviderDebugState, NativeFlagsConfiguration, } from '@datadog/mobile-react-native'; -import {DatadogOpenFeatureProvider} from '@datadog/mobile-react-native-openfeature'; -import { - OpenFeature, - OpenFeatureProvider, - useObjectFlagDetails, -} from '@openfeature/react-sdk'; -import React, {Suspense} from 'react'; +import React from 'react'; import type {PropsWithChildren} from 'react'; import { - ActivityIndicator, Pressable, SafeAreaView, ScrollView, @@ -55,10 +47,6 @@ const NATIVE_FFE_STAGING_CLIENT_TOKEN = const NATIVE_FFE_STORAGE_OPTIONS = { slot: 'default', }; -const NATIVE_FFE_USER = { - id: 'user-123', - favoriteFruit: 'apple', -}; const NATIVE_FFE_BUNDLED_RULES_WIRE = JSON.stringify( nativeFfeRulesConfigurationWire, ); @@ -90,7 +78,7 @@ const NATIVE_FFE_STAGING_FLAGS = { object: 'ffe-dogfooding-json-flag', }; -(async () => { +const datadogInitialization = (async () => { const config = new CoreConfiguration( CLIENT_TOKEN, ENVIRONMENT, @@ -121,13 +109,6 @@ const NATIVE_FFE_STAGING_FLAGS = { // Initialize the Datadog SDK. await DdSdkReactNative.initialize(config); - // Enable Datadog Flags feature. - await DdFlags.enable(); - - // Set the provider with OpenFeature. - const provider = new DatadogOpenFeatureProvider(); - OpenFeature.setProvider(provider); - // Datadog SDK usage examples. await DdRum.startView('main', 'Main'); setTimeout(async () => { @@ -141,33 +122,7 @@ const NATIVE_FFE_STAGING_FLAGS = { await DdTrace.finishSpan(spanId); })(); -function AppWithProviders() { - React.useEffect(() => { - OpenFeature.setContext({ - targetingKey: NATIVE_FFE_USER.id, - favoriteFruit: NATIVE_FFE_USER.favoriteFruit, - }); - }, []); - - return ( - - - - }> - - - - - ); -} - function App(): React.JSX.Element { - const greetingFlag = useObjectFlagDetails('rn-sdk-test-json-flag', { - greeting: 'Default greeting', - }); - const isDarkMode = useColorScheme() === 'dark'; const backgroundStyle = { backgroundColor: isDarkMode ? Colors.darker : Colors.lighter, @@ -183,17 +138,6 @@ function App(): React.JSX.Element {
-
- The title of this section is based on the{' '} - {greetingFlag.flagKey} feature - flag.{'\n\n'} - If it's different from "Default greeting", then it is coming from - the feature flag evaluation.{'\n\n'} - Evaluation reason is {greetingFlag.reason}.{'\n\n'}Inspect greetingFlag in{' '} - App.tsx for more evaluation - details. -
-
@@ -240,6 +184,8 @@ function NativeFfeFlowPanel({ }); try { + await datadogInitialization; + const bundledConfiguration = await DdSdkReactNative.configurationFromString( NATIVE_FFE_BUNDLED_RULES_WIRE, ); @@ -384,6 +330,15 @@ function NativeFfeFlowPanel({ } }, []); + const autoRunStarted = React.useRef(false); + React.useEffect(() => { + if (autoRunStarted.current) { + return; + } + autoRunStarted.current = true; + void runNativeFfeFlow(); + }, [runNativeFfeFlow]); + return ( ("kind" to kind) + val queryParams = linkedMapOf() queryParams.putAll(flagQueryParams) if (kind == NATIVE_FFE_KIND_PRECOMPUTED && evaluationContext.isNotEmpty()) { queryParams["evaluationContext"] = evaluationContext.toNativeFfeJSONObject().toString() diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt index af35915d9..714420a81 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt @@ -48,8 +48,8 @@ internal class NativeFfeConfigurationFetcherTest { assertThat(request?.method).isEqualTo("GET") assertThat(request?.url) .startsWith("https://config.example.test/flags?existing=1&") - .contains("kind=rules") .contains("project=rn") + .doesNotContain("kind=rules") assertThat(request?.headers) .containsEntry("Accept", "application/json") .containsEntry("DD-Client-Token", "client-token") @@ -122,8 +122,8 @@ internal class NativeFfeConfigurationFetcherTest { assertThat(fetchedConfiguration.kind).isEqualTo("precomputed") assertThat(fetchedConfiguration.etag).isEqualTo("precomputed-v1") assertThat(fakeTransport.request?.url) - .contains("kind=precomputed") .contains("evaluationContext=") + .doesNotContain("kind=precomputed") } private class FakeTransport : NativeFfeConfigurationTransport { diff --git a/packages/core/ios/Sources/NativeFfeConfigurationFetcher.swift b/packages/core/ios/Sources/NativeFfeConfigurationFetcher.swift index cd45fce80..1edf27b38 100644 --- a/packages/core/ios/Sources/NativeFfeConfigurationFetcher.swift +++ b/packages/core/ios/Sources/NativeFfeConfigurationFetcher.swift @@ -201,7 +201,6 @@ private struct NativeFfeFetchOptions { } var queryItems = components.queryItems ?? [] - queryItems.append(URLQueryItem(name: "kind", value: kind)) for (key, value) in flagQueryParams where !(value is NSNull) { queryItems.append(URLQueryItem(name: key, value: try queryString(value))) } diff --git a/packages/core/ios/Tests/NativeFfeCoreTests.swift b/packages/core/ios/Tests/NativeFfeCoreTests.swift index 4ad305e2e..b63481061 100644 --- a/packages/core/ios/Tests/NativeFfeCoreTests.swift +++ b/packages/core/ios/Tests/NativeFfeCoreTests.swift @@ -91,6 +91,47 @@ final class NativeFfeCoreTests: XCTestCase { XCTAssertNotNil(dataStore.values["flags-configuration-default"]) } + func testFetchRulesConfigurationDoesNotSendInternalKindQueryParam() throws { + let testedCore = NativeFfeCore() + let transport = NativeFfeFakeTransport() + let fetcher = NativeFfeConfigurationFetcher(transport: transport) { + self.storedAtMs + } + transport.response = NativeFfeHTTPResponse( + statusCode: 200, + headers: ["ETag": "rules-v2"], + body: Self.canonicalUfcConfig + ) + + let fetchedConfiguration = try testedCore.fetchConfiguration( + kind: "rules", + options: [ + "endpoint": "https://config.example.test/flags?existing=1", + "headers": [ + "Fastly-Client": "1", + "dd-client-token": "client-token", + ], + "flagQueryParams": [ + "dd_env": "staging", + ], + "previousConfigurationWire": Self.flagsConfigurationWire, + ], + fetcher: fetcher + ) + + let request = try XCTUnwrap(transport.request) + XCTAssertEqual(fetchedConfiguration.kind, "rules") + XCTAssertEqual(fetchedConfiguration.etag, "rules-v2") + XCTAssertTrue(request.url.hasPrefix("https://config.example.test/flags?existing=1&")) + XCTAssertTrue(request.url.contains("dd_env=staging")) + XCTAssertFalse(request.url.contains("kind=rules")) + XCTAssertEqual(request.method, "GET") + XCTAssertEqual(request.headers["Accept"], "application/json") + XCTAssertEqual(request.headers["Fastly-Client"], "1") + XCTAssertEqual(request.headers["dd-client-token"], "client-token") + XCTAssertEqual(request.headers["If-None-Match"], "ffe-system-test-data") + } + func testReturnStaticReasonWithCanonicalNumericFlagCase() throws { let testedCore = try configuredCore() let evaluationCase = try Self.evaluationCase("test-case-numeric-flag.json") @@ -408,3 +449,13 @@ private final class NativeFfeFakeDataStore: DataStore { func flush() {} } + +private final class NativeFfeFakeTransport: NativeFfeConfigurationTransport { + var request: NativeFfeHTTPRequest? + var response: NativeFfeHTTPResponse! + + func execute(_ request: NativeFfeHTTPRequest) throws -> NativeFfeHTTPResponse { + self.request = request + return response + } +} From 70d44bd8eecc27b2ff230f4e65c5354d518f448b Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 19:38:10 -0400 Subject: [PATCH 12/18] Align native FFE fixtures and CI setup --- .github/workflows/codeql-analysis.yml | 2 + .github/workflows/publish.yml | 2 + .gitlab-ci.yml | 1 + example-new-architecture/.gitignore | 1 + example-new-architecture/App.tsx | 91 +++-- example-new-architecture/README.md | 18 + .../ddCredentials.example.js | 7 + .../offline-rules-configuration-wire.json | 0 packages/core/DatadogSDKReactNative.podspec | 2 +- .../NativeFfeConfigurationFetcherTest.kt | 5 +- .../datadog/reactnative/NativeFfeCoreTest.kt | 5 +- .../reactnative/NativeFfeJsonFixtures.kt | 15 + .../skipped-default-evaluation.json | 0 .../tracked-string-evaluation.json | 0 .../skipped-default-evaluation.json | 10 + .../tracked-string-evaluation.json | 22 ++ .../core/ios/Tests/NativeFfeCoreTests.swift | 2 +- .../ios/Tests/NativeFfeTestFixtures.swift | 39 +- packages/core/jest/mock.js | 348 ++++++++---------- .../src/__tests__/DdSdkReactNative.test.tsx | 16 +- .../evaluation-context-user-123.json | 6 - 21 files changed, 346 insertions(+), 246 deletions(-) create mode 100644 example-new-architecture/README.md create mode 100644 example-new-architecture/ddCredentials.example.js rename packages/core/src/flags/__fixtures__/native-ffe/rules-configuration-wire.json => example-new-architecture/fixtures/native-ffe/offline-rules-configuration-wire.json (100%) rename packages/core/{src/flags/__fixtures__ => android/src/test/resources}/native-ffe/evaluation-side-effects/skipped-default-evaluation.json (100%) rename packages/core/{src/flags/__fixtures__ => android/src/test/resources}/native-ffe/evaluation-side-effects/tracked-string-evaluation.json (100%) create mode 100644 packages/core/ios/Tests/Fixtures/native-ffe/evaluation-side-effects/skipped-default-evaluation.json create mode 100644 packages/core/ios/Tests/Fixtures/native-ffe/evaluation-side-effects/tracked-string-evaluation.json delete mode 100644 packages/core/src/flags/__fixtures__/native-ffe/evaluation-context-user-123.json diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8a07a0d59..835c0c5b7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: recursive # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 82b2bbb44..8ed65827d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,6 +12,8 @@ jobs: id-token: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: recursive - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.x' diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 36485cb44..ab2b20419 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,6 +5,7 @@ include: variables: GIT_DEPTH: 5 + GIT_SUBMODULE_STRATEGY: recursive DEVELOP_BRANCH: 'develop' ANDROID_SDK_VERSION: 'commandlinetools-mac-11076708_latest' EMULATOR_NAME: 'android_emulator' diff --git a/example-new-architecture/.gitignore b/example-new-architecture/.gitignore index 2d6734832..f787b9bd7 100644 --- a/example-new-architecture/.gitignore +++ b/example-new-architecture/.gitignore @@ -64,6 +64,7 @@ yarn-error.log # Secrets ddCredentials.* +!ddCredentials.example.js # Yarn ignored files .pnp.* diff --git a/example-new-architecture/App.tsx b/example-new-architecture/App.tsx index 7364ddf19..d1823a087 100644 --- a/example-new-architecture/App.tsx +++ b/example-new-architecture/App.tsx @@ -36,14 +36,17 @@ import { LearnMoreLinks, ReloadInstructions, } from 'react-native/Libraries/NewAppScreen'; -// @ts-ignore -import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; -import nativeFfeRulesConfigurationWire from '../packages/core/src/flags/__fixtures__/native-ffe/rules-configuration-wire.json'; +// @ts-ignore local ignored credentials file +import * as ddCredentials from './ddCredentials'; +import nativeFfeRulesConfigurationWire from './fixtures/native-ffe/offline-rules-configuration-wire.json'; +const APPLICATION_ID = ddCredentials.APPLICATION_ID; +const CLIENT_TOKEN = ddCredentials.CLIENT_TOKEN; +const ENVIRONMENT = ddCredentials.ENVIRONMENT; +const NATIVE_FFE_CLIENT_TOKEN = + ddCredentials.NATIVE_FFE_CLIENT_TOKEN ?? CLIENT_TOKEN; const NATIVE_FFE_STAGING_RULES_ENDPOINT = 'https://dd.datad0g.com/api/v2/feature-flagging/config/rules-based'; -const NATIVE_FFE_STAGING_CLIENT_TOKEN = - 'pub542a31cc0f5b23136420667ca212045a'; const NATIVE_FFE_STORAGE_OPTIONS = { slot: 'default', }; @@ -93,14 +96,19 @@ const datadogInitialization = (async () => { sessionSampleRate: 100, telemetrySampleRate: 100, nativeCrashReportEnabled: true, - firstPartyHosts: [{ - match: "example.com", - propagatorTypes: [PropagatorType.B3MULTI, PropagatorType.TRACECONTEXT] - }] + firstPartyHosts: [ + { + match: 'example.com', + propagatorTypes: [ + PropagatorType.B3MULTI, + PropagatorType.TRACECONTEXT, + ], + }, + ], }, logsConfiguration: {}, - traceConfiguration: {} - } + traceConfiguration: {}, + }, ); config.verbosity = SdkVerbosity.DEBUG; config.uploadFrequency = UploadFrequency.FREQUENT; @@ -134,10 +142,15 @@ function App(): React.JSX.Element { barStyle={isDarkMode ? 'light-content' : 'dark-content'} backgroundColor={backgroundStyle.backgroundColor} /> - +
- +
@@ -192,9 +205,7 @@ function NativeFfeFlowPanel({ const bundledState = await DdSdkReactNative.setConfiguration( bundledConfiguration, ); - await DdSdkReactNative.setEvaluationContext( - NATIVE_FFE_ANONYMOUS_CONTEXT, - ); + await DdSdkReactNative.setEvaluationContext(NATIVE_FFE_ANONYMOUS_CONTEXT); const anonymousResult = await DdSdkReactNative.resolveBooleanEvaluation( NATIVE_FFE_DEMO_FLAG, true, @@ -207,17 +218,19 @@ function NativeFfeFlowPanel({ true, ); const beforeFetchState = await DdSdkReactNative.getProviderDebugState(); - const fetchedConfiguration = await DdSdkReactNative.fetchRulesConfiguration({ - endpoint: NATIVE_FFE_STAGING_RULES_ENDPOINT, - headers: { - 'Fastly-Client': '1', - 'dd-client-token': NATIVE_FFE_STAGING_CLIENT_TOKEN, - }, - flagQueryParams: { - dd_env: 'staging', + const fetchedConfiguration = await DdSdkReactNative.fetchRulesConfiguration( + { + endpoint: NATIVE_FFE_STAGING_RULES_ENDPOINT, + headers: { + 'Fastly-Client': '1', + 'dd-client-token': NATIVE_FFE_CLIENT_TOKEN, + }, + flagQueryParams: { + dd_env: 'staging', + }, + previousConfigurationWire: NATIVE_FFE_BUNDLED_RULES_WIRE, }, - previousConfigurationWire: NATIVE_FFE_BUNDLED_RULES_WIRE, - }); + ); const afterFetchState = await DdSdkReactNative.getProviderDebugState(); const serializedWire = await DdSdkReactNative.configurationToString( fetchedConfiguration, @@ -236,9 +249,7 @@ function NativeFfeFlowPanel({ const loadedState = await DdSdkReactNative.setConfiguration( loadedConfiguration, ); - await DdSdkReactNative.setEvaluationContext( - NATIVE_FFE_STAGING_CONTEXT, - ); + await DdSdkReactNative.setEvaluationContext(NATIVE_FFE_STAGING_CONTEXT); const stagingResults = { boolean: await DdSdkReactNative.resolveBooleanEvaluation( NATIVE_FFE_STAGING_FLAGS.boolean, @@ -323,9 +334,7 @@ function NativeFfeFlowPanel({ setFlowState({ status: 'error', summary: - error instanceof Error - ? error.message - : 'Native FF&E flow failed.', + error instanceof Error ? error.message : 'Native FF&E flow failed.', }); } }, []); @@ -347,7 +356,8 @@ function NativeFfeFlowPanel({ { color: isDarkMode ? Colors.white : Colors.black, }, - ]}> + ]} + > Native FFE flow + ]} + > {flowState.summary} + ]} + > {loading ? 'Running...' : 'Run native flow'} @@ -381,7 +393,8 @@ function NativeFfeFlowPanel({ color: isDarkMode ? Colors.light : Colors.dark, backgroundColor: isDarkMode ? Colors.black : '#f3f4f6', }, - ]}> + ]} + > {flowState.details} ) : null} @@ -440,7 +453,8 @@ function Section({children, title}: SectionProps): React.JSX.Element { { color: isDarkMode ? Colors.white : Colors.black, }, - ]}> + ]} + > {title} + ]} + > {children} diff --git a/example-new-architecture/README.md b/example-new-architecture/README.md new file mode 100644 index 000000000..14385167c --- /dev/null +++ b/example-new-architecture/README.md @@ -0,0 +1,18 @@ +# New Architecture Example + +## Datadog Credentials + +Create the local credentials file before running the app: + +```sh +cp example-new-architecture/ddCredentials.example.js example-new-architecture/ddCredentials.js +``` + +`ddCredentials.js` is intentionally ignored by git. Edit it with: + +- `CLIENT_TOKEN`: Datadog public client token for SDK initialization. +- `APPLICATION_ID`: RUM application ID. The placeholder keeps the native FF&E demo runnable; use a real staging RUM application ID to validate RUM flag annotation. +- `ENVIRONMENT`: use `staging` for this demo. +- `NATIVE_FFE_CLIENT_TOKEN`: token used by the native dynamic rules fetcher against `https://dd.datad0g.com/api/v2/feature-flagging/config/rules-based?dd_env=staging`. + +The native FF&E demo also sends `Fastly-Client: 1` from `App.tsx` so it exercises the same staging rules-based request path used for this branch. diff --git a/example-new-architecture/ddCredentials.example.js b/example-new-architecture/ddCredentials.example.js new file mode 100644 index 000000000..81f587541 --- /dev/null +++ b/example-new-architecture/ddCredentials.example.js @@ -0,0 +1,7 @@ +export const APPLICATION_ID = '00000000-0000-0000-0000-000000000000'; +export const CLIENT_TOKEN = 'pub00000000000000000000000000000000'; +export const ENVIRONMENT = 'staging'; + +// Used by the native FF&E dynamic rules fetch demo. This can match CLIENT_TOKEN +// when that token is authorized for the staging rules-based configuration API. +export const NATIVE_FFE_CLIENT_TOKEN = CLIENT_TOKEN; diff --git a/packages/core/src/flags/__fixtures__/native-ffe/rules-configuration-wire.json b/example-new-architecture/fixtures/native-ffe/offline-rules-configuration-wire.json similarity index 100% rename from packages/core/src/flags/__fixtures__/native-ffe/rules-configuration-wire.json rename to example-new-architecture/fixtures/native-ffe/offline-rules-configuration-wire.json diff --git a/packages/core/DatadogSDKReactNative.podspec b/packages/core/DatadogSDKReactNative.podspec index 80ecfb303..a84c732b0 100644 --- a/packages/core/DatadogSDKReactNative.podspec +++ b/packages/core/DatadogSDKReactNative.podspec @@ -31,7 +31,7 @@ Pod::Spec.new do |s| s.test_spec 'Tests' do |test_spec| test_spec.source_files = 'ios/Tests/**/*.{swift,json}' - test_spec.resources = ['ios/Tests/Fixtures', 'src/flags/__fixtures__'] + test_spec.resources = ['ios/Tests/Fixtures', 'src/flags/__fixtures__/ffe-system-test-data'] test_spec.platforms = { :ios => "13.4", :tvos => "13.4" } end diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt index 714420a81..3a2f5aaad 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt @@ -140,10 +140,7 @@ internal class NativeFfeConfigurationFetcherTest { const val FETCHED_AT_MS = 1780000000000L val rulesConfigurationWire: String by lazy { - readNativeFfeFixture( - NativeFfeConfigurationFetcherTest::class.java, - "native-ffe/rules-configuration-wire.json" - ) + nativeFfeRulesConfigurationWire(canonicalUfcConfig) } val canonicalUfcConfig: String by lazy { diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt index 28c482abd..728100987 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt @@ -348,10 +348,7 @@ internal class NativeFfeCoreTest { const val STORED_AT_MS = 1780000000000L val flagsConfigurationWire: String by lazy { - readNativeFfeFixture( - NativeFfeCoreTest::class.java, - "native-ffe/rules-configuration-wire.json" - ) + nativeFfeRulesConfigurationWire(canonicalUfcConfig) } val canonicalUfcConfig: String by lazy { diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt index 210a51983..f88984e2e 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt @@ -17,6 +17,21 @@ internal fun readNativeFfeFixture(owner: Class<*>, relativePath: String): String ?: error("Missing FFE fixture: $relativePath") } +internal fun nativeFfeRulesConfigurationWire( + response: String, + etag: String = "ffe-system-test-data", +): String { + return JSONObject() + .put("version", 2) + .put( + "server", + JSONObject() + .put("response", response) + .put("etag", etag) + ) + .toString() +} + internal fun listNativeFfeFixtureFiles(owner: Class<*>, relativeDirectory: String): List { val resource = owner.classLoader ?.getResource(relativeDirectory) diff --git a/packages/core/src/flags/__fixtures__/native-ffe/evaluation-side-effects/skipped-default-evaluation.json b/packages/core/android/src/test/resources/native-ffe/evaluation-side-effects/skipped-default-evaluation.json similarity index 100% rename from packages/core/src/flags/__fixtures__/native-ffe/evaluation-side-effects/skipped-default-evaluation.json rename to packages/core/android/src/test/resources/native-ffe/evaluation-side-effects/skipped-default-evaluation.json diff --git a/packages/core/src/flags/__fixtures__/native-ffe/evaluation-side-effects/tracked-string-evaluation.json b/packages/core/android/src/test/resources/native-ffe/evaluation-side-effects/tracked-string-evaluation.json similarity index 100% rename from packages/core/src/flags/__fixtures__/native-ffe/evaluation-side-effects/tracked-string-evaluation.json rename to packages/core/android/src/test/resources/native-ffe/evaluation-side-effects/tracked-string-evaluation.json diff --git a/packages/core/ios/Tests/Fixtures/native-ffe/evaluation-side-effects/skipped-default-evaluation.json b/packages/core/ios/Tests/Fixtures/native-ffe/evaluation-side-effects/skipped-default-evaluation.json new file mode 100644 index 000000000..92476dc42 --- /dev/null +++ b/packages/core/ios/Tests/Fixtures/native-ffe/evaluation-side-effects/skipped-default-evaluation.json @@ -0,0 +1,10 @@ +{ + "result": { + "flagKey": "missing", + "value": "fallback", + "reason": "DEFAULT" + }, + "context": { + "targetingKey": "user-123" + } +} diff --git a/packages/core/ios/Tests/Fixtures/native-ffe/evaluation-side-effects/tracked-string-evaluation.json b/packages/core/ios/Tests/Fixtures/native-ffe/evaluation-side-effects/tracked-string-evaluation.json new file mode 100644 index 000000000..4ff621eba --- /dev/null +++ b/packages/core/ios/Tests/Fixtures/native-ffe/evaluation-side-effects/tracked-string-evaluation.json @@ -0,0 +1,22 @@ +{ + "result": { + "flagKey": "checkout.copy", + "value": "enabled", + "variant": "treatment", + "reason": "TARGETING_MATCH", + "flagMetadata": { + "allocationKey": "pro allocation", + "doLog": true, + "extraLogging": { + "owner": "feature-flags" + }, + "variationType": "string" + } + }, + "context": { + "targetingKey": "user-123", + "attributes": { + "plan": "pro" + } + } +} diff --git a/packages/core/ios/Tests/NativeFfeCoreTests.swift b/packages/core/ios/Tests/NativeFfeCoreTests.swift index b63481061..572b22c83 100644 --- a/packages/core/ios/Tests/NativeFfeCoreTests.swift +++ b/packages/core/ios/Tests/NativeFfeCoreTests.swift @@ -416,7 +416,7 @@ final class NativeFfeCoreTests: XCTestCase { } private static let flagsConfigurationWire: String = { - try! NativeFfeTestFixtures.readString("native-ffe/rules-configuration-wire.json") + try! NativeFfeTestFixtures.rulesConfigurationWire(response: canonicalUfcConfig) }() private static let canonicalUfcConfig: String = { diff --git a/packages/core/ios/Tests/NativeFfeTestFixtures.swift b/packages/core/ios/Tests/NativeFfeTestFixtures.swift index b01134a07..53039a9a2 100644 --- a/packages/core/ios/Tests/NativeFfeTestFixtures.swift +++ b/packages/core/ios/Tests/NativeFfeTestFixtures.swift @@ -8,6 +8,21 @@ import Foundation import XCTest enum NativeFfeTestFixtures { + static func rulesConfigurationWire( + response: String, + etag: String = "ffe-system-test-data" + ) throws -> String { + let wire: [String: Any] = [ + "version": 2, + "server": [ + "response": response, + "etag": etag, + ], + ] + let data = try JSONSerialization.data(withJSONObject: wire, options: [.sortedKeys]) + return try XCTUnwrap(String(data: data, encoding: .utf8)) + } + static func jsonObject(_ relativePath: String) throws -> [String: Any] { let fixture = try readString(relativePath) let data = try XCTUnwrap(fixture.data(using: .utf8)) @@ -35,6 +50,7 @@ enum NativeFfeTestFixtures { static func readString(_ relativePath: String) throws -> String { let bundle = Bundle(for: BundleToken.self) let candidates = [ + "Fixtures/\(relativePath)", "__fixtures__/\(relativePath)", relativePath, ] @@ -47,14 +63,22 @@ enum NativeFfeTestFixtures { } } - let localFixture = packageFixtureRoot() + for fixtureRoot in localFixtureRoots() { + let localFixture = fixtureRoot.appendingPathComponent(relativePath) + if FileManager.default.fileExists(atPath: localFixture.path) { + return try String(contentsOf: localFixture, encoding: .utf8) + } + } + + let fallbackFixture = localFixtureRoots().last! .appendingPathComponent(relativePath) - return try String(contentsOf: localFixture, encoding: .utf8) + return try String(contentsOf: fallbackFixture, encoding: .utf8) } private static func bundleDirectoryCandidates(_ relativeDirectory: String) -> [URL] { let bundle = Bundle(for: BundleToken.self) return [ + bundle.resourceURL?.appendingPathComponent("Fixtures").appendingPathComponent(relativeDirectory), bundle.resourceURL?.appendingPathComponent("__fixtures__").appendingPathComponent(relativeDirectory), bundle.resourceURL?.appendingPathComponent(relativeDirectory), ].compactMap { $0 } @@ -70,13 +94,20 @@ enum NativeFfeTestFixtures { .sorted() } - private static func packageFixtureRoot() -> URL { + private static func localFixtureRoots() -> [URL] { let sourceFile = URL(fileURLWithPath: #filePath) let packageRoot = sourceFile .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() - return packageRoot.appendingPathComponent("src/flags/__fixtures__") + return [ + packageRoot.appendingPathComponent("ios/Tests/Fixtures"), + packageRoot.appendingPathComponent("src/flags/__fixtures__"), + ] + } + + private static func packageFixtureRoot() -> URL { + localFixtureRoots().last! } private final class BundleToken {} diff --git a/packages/core/jest/mock.js b/packages/core/jest/mock.js index 060c343b3..ae431be04 100644 --- a/packages/core/jest/mock.js +++ b/packages/core/jest/mock.js @@ -43,8 +43,8 @@ const mockConfigurationFromString = wire => { parsed.server && parsed.precomputed ? 'mixed' : parsed.server - ? 'rules' - : 'precomputed'; + ? 'rules' + : 'precomputed'; return { __ddNativeFfeConfiguration: true, @@ -160,105 +160,93 @@ module.exports = { resolve(mockFetchConfiguration('precomputed', options)) ) ), - saveConfiguration: jest - .fn() - .mockImplementation( - (configuration, options) => - new Promise(resolve => - resolve({ - ...mockFlagsDebugState, - configurationSaveCount: 1, - lastStorage: { - operation: 'save', - status: 'stored', - key: `flags-configuration-${options.slot ?? 'default'}`, - wireBytes: (configuration.wire ?? '').length - } - }) - ) - ), - loadConfiguration: jest - .fn() - .mockImplementation( - () => - new Promise(resolve => - resolve( - mockConfigurationFromString( - JSON.stringify({ - version: 2, - server: { - response: '{}', - etag: 'stored' - } - }) - ) + saveConfiguration: jest.fn().mockImplementation( + (configuration, options) => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + configurationSaveCount: 1, + lastStorage: { + operation: 'save', + status: 'stored', + key: `flags-configuration-${ + options.slot ?? 'default' + }`, + wireBytes: (configuration.wire ?? '').length + } + }) + ) + ), + loadConfiguration: jest.fn().mockImplementation( + () => + new Promise(resolve => + resolve( + mockConfigurationFromString( + JSON.stringify({ + version: 2, + server: { + response: '{}', + etag: 'stored' + } + }) ) ) - ), + ) + ), setConfiguration: jest .fn() .mockImplementation( () => new Promise(resolve => resolve(mockFlagsDebugState)) ), - setEvaluationContext: jest - .fn() - .mockImplementation( - context => - new Promise(resolve => - resolve({ - ...mockFlagsDebugState, - currentContext: context - }) - ) - ), - resolveBooleanEvaluation: jest - .fn() - .mockImplementation( - (flagKey, defaultValue) => - new Promise(resolve => - resolve({ - flagKey, - value: defaultValue, - reason: 'DEFAULT' - }) - ) - ), - resolveStringEvaluation: jest - .fn() - .mockImplementation( - (flagKey, defaultValue) => - new Promise(resolve => - resolve({ - flagKey, - value: defaultValue, - reason: 'DEFAULT' - }) - ) - ), - resolveNumberEvaluation: jest - .fn() - .mockImplementation( - (flagKey, defaultValue) => - new Promise(resolve => - resolve({ - flagKey, - value: defaultValue, - reason: 'DEFAULT' - }) - ) - ), - resolveObjectEvaluation: jest - .fn() - .mockImplementation( - (flagKey, defaultValue) => - new Promise(resolve => - resolve({ - flagKey, - value: defaultValue, - reason: 'DEFAULT' - }) - ) - ), + setEvaluationContext: jest.fn().mockImplementation( + context => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + currentContext: context + }) + ) + ), + resolveBooleanEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveStringEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveNumberEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveObjectEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), getProviderDebugState: jest .fn() .mockImplementation( @@ -397,105 +385,93 @@ module.exports = { resolve(mockFetchConfiguration('precomputed', options)) ) ), - saveConfiguration: jest - .fn() - .mockImplementation( - (configuration, options) => - new Promise(resolve => - resolve({ - ...mockFlagsDebugState, - configurationSaveCount: 1, - lastStorage: { - operation: 'save', - status: 'stored', - key: `flags-configuration-${options.slot ?? 'default'}`, - wireBytes: (configuration.wire ?? '').length - } - }) - ) - ), - loadConfiguration: jest - .fn() - .mockImplementation( - () => - new Promise(resolve => - resolve( - mockConfigurationFromString( - JSON.stringify({ - version: 2, - server: { - response: '{}', - etag: 'stored' - } - }) - ) + saveConfiguration: jest.fn().mockImplementation( + (configuration, options) => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + configurationSaveCount: 1, + lastStorage: { + operation: 'save', + status: 'stored', + key: `flags-configuration-${ + options.slot ?? 'default' + }`, + wireBytes: (configuration.wire ?? '').length + } + }) + ) + ), + loadConfiguration: jest.fn().mockImplementation( + () => + new Promise(resolve => + resolve( + mockConfigurationFromString( + JSON.stringify({ + version: 2, + server: { + response: '{}', + etag: 'stored' + } + }) ) ) - ), + ) + ), setConfiguration: jest .fn() .mockImplementation( () => new Promise(resolve => resolve(mockFlagsDebugState)) ), - setEvaluationContext: jest - .fn() - .mockImplementation( - context => - new Promise(resolve => - resolve({ - ...mockFlagsDebugState, - currentContext: context - }) - ) - ), - resolveBooleanEvaluation: jest - .fn() - .mockImplementation( - (flagKey, defaultValue) => - new Promise(resolve => - resolve({ - flagKey, - value: defaultValue, - reason: 'DEFAULT' - }) - ) - ), - resolveStringEvaluation: jest - .fn() - .mockImplementation( - (flagKey, defaultValue) => - new Promise(resolve => - resolve({ - flagKey, - value: defaultValue, - reason: 'DEFAULT' - }) - ) - ), - resolveNumberEvaluation: jest - .fn() - .mockImplementation( - (flagKey, defaultValue) => - new Promise(resolve => - resolve({ - flagKey, - value: defaultValue, - reason: 'DEFAULT' - }) - ) - ), - resolveObjectEvaluation: jest - .fn() - .mockImplementation( - (flagKey, defaultValue) => - new Promise(resolve => - resolve({ - flagKey, - value: defaultValue, - reason: 'DEFAULT' - }) - ) - ), + setEvaluationContext: jest.fn().mockImplementation( + context => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + currentContext: context + }) + ) + ), + resolveBooleanEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveStringEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveNumberEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveObjectEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), getProviderDebugState: jest .fn() .mockImplementation( diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index eb603c4a3..ccaba2684 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -8,8 +8,6 @@ import { version as reactNativeVersion } from 'react-native/package.json'; import { NativeModules } from 'react-native'; import { DdSdkReactNative } from '../DdSdkReactNative'; -import flagsEvaluationContext from '../flags/__fixtures__/native-ffe/evaluation-context-user-123.json'; -import rulesConfigurationWire from '../flags/__fixtures__/native-ffe/rules-configuration-wire.json'; import type { DdSdkNativeConfiguration } from '../config/features/CoreConfigurationNative'; import { CoreConfiguration } from '../config/features/CoreConfiguration'; import { LogsConfiguration } from '../config/features/LogsConfiguration'; @@ -33,6 +31,20 @@ import { version as sdkVersion } from '../version'; jest.mock('../InternalLog'); +const flagsEvaluationContext = { + targetingKey: 'user-123', + attributes: { + plan: 'pro' + } +}; +const rulesConfigurationWire = { + version: 2, + server: { + response: '{}', + etag: 'ffe-system-test-data' + } +}; + jest.mock( '../rum/instrumentation/interactionTracking/DdRumUserInteractionTracking', () => { diff --git a/packages/core/src/flags/__fixtures__/native-ffe/evaluation-context-user-123.json b/packages/core/src/flags/__fixtures__/native-ffe/evaluation-context-user-123.json deleted file mode 100644 index f551c7bea..000000000 --- a/packages/core/src/flags/__fixtures__/native-ffe/evaluation-context-user-123.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "targetingKey": "user-123", - "attributes": { - "plan": "pro" - } -} From 35f8e3e1953433be7ea7ee188d405fa9ed7111b9 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 19:50:18 -0400 Subject: [PATCH 13/18] Fix Android native FFE detekt findings --- .../reactnative/DdSdkImplementation.kt | 40 +++++++++++++++++++ .../NativeFfeConfigurationFetcher.kt | 2 + .../NativeFfeConfigurationStore.kt | 1 + .../com/datadog/reactnative/NativeFfeCore.kt | 9 +++++ .../NativeFfeEvaluationSideEffects.kt | 2 + 5 files changed, 54 insertions(+) diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt index edcfb48d9..7fdd8daa4 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt @@ -289,18 +289,27 @@ class DdSdkImplementation( promise.resolve(null) } + /** + * Parses a serialized flags configuration wire payload into the bridge representation. + */ fun configurationFromString(wire: String, promise: Promise) { resolveFfePromise(promise) { nativeFfeCore.configurationFromString(wire).toMap().toWritableMap() } } + /** + * Serializes a bridge flags configuration back to its wire payload. + */ fun configurationToString(configuration: ReadableMap, promise: Promise) { resolveFfePromise(promise) { nativeFfeCore.configurationToString(configuration.toMap()) } } + /** + * Fetches a rules-based flags configuration and returns it as a bridge configuration. + */ fun fetchRulesConfiguration(options: ReadableMap, promise: Promise) { resolveFfePromise(promise) { nativeFfeCore.fetchConfiguration( @@ -311,6 +320,9 @@ class DdSdkImplementation( } } + /** + * Fetches a precomputed flags configuration and returns it as a bridge configuration. + */ fun fetchPrecomputedConfiguration(options: ReadableMap, promise: Promise) { resolveFfePromise(promise) { nativeFfeCore.fetchConfiguration( @@ -321,6 +333,9 @@ class DdSdkImplementation( } } + /** + * Persists a flags configuration in the requested native storage slot. + */ fun saveConfiguration(configuration: ReadableMap, options: ReadableMap, promise: Promise) { resolveFfePromise(promise) { nativeFfeCore.saveConfiguration( @@ -331,6 +346,9 @@ class DdSdkImplementation( } } + /** + * Loads a persisted flags configuration from the requested native storage slot. + */ fun loadConfiguration(options: ReadableMap, promise: Promise) { resolveFfePromise(promise) { nativeFfeCore.loadConfiguration( @@ -340,18 +358,27 @@ class DdSdkImplementation( } } + /** + * Activates a flags configuration for subsequent native evaluations. + */ fun setConfiguration(configuration: ReadableMap, promise: Promise) { resolveFfePromise(promise) { nativeFfeCore.setConfiguration(configuration.toMap()).toWritableMap() } } + /** + * Updates the evaluation context used by subsequent native flag evaluations. + */ fun setEvaluationContext(context: ReadableMap, promise: Promise) { resolveFfePromise(promise) { nativeFfeCore.setEvaluationContext(context.toMap()).toWritableMap() } } + /** + * Resolves a boolean flag evaluation through the active native configuration. + */ fun resolveBooleanEvaluation(flagKey: String, defaultValue: Boolean, promise: Promise) { resolveFfePromise(promise) { resolveNativeFfeEvaluation { @@ -360,6 +387,9 @@ class DdSdkImplementation( } } + /** + * Resolves a string flag evaluation through the active native configuration. + */ fun resolveStringEvaluation(flagKey: String, defaultValue: String, promise: Promise) { resolveFfePromise(promise) { resolveNativeFfeEvaluation { @@ -368,6 +398,9 @@ class DdSdkImplementation( } } + /** + * Resolves a numeric flag evaluation through the active native configuration. + */ fun resolveNumberEvaluation(flagKey: String, defaultValue: Double, promise: Promise) { resolveFfePromise(promise) { resolveNativeFfeEvaluation { @@ -376,6 +409,9 @@ class DdSdkImplementation( } } + /** + * Resolves a JSON/object flag evaluation through the active native configuration. + */ fun resolveObjectEvaluation(flagKey: String, defaultValue: ReadableMap, promise: Promise) { resolveFfePromise(promise) { resolveNativeFfeEvaluation { @@ -384,6 +420,9 @@ class DdSdkImplementation( } } + /** + * Returns native flags provider counters and last-operation state for the bridge. + */ fun getProviderDebugState(promise: Promise) { resolveFfePromise(promise) { ( @@ -424,6 +463,7 @@ class DdSdkImplementation( ) } + @Suppress("TooGenericExceptionCaught") private inline fun resolveFfePromise( promise: Promise, block: () -> Any? diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcher.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcher.kt index 838a48c06..9d89e112b 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcher.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcher.kt @@ -4,6 +4,8 @@ * Copyright 2016-Present Datadog, Inc. */ +@file:Suppress("LabeledExpression", "TooGenericExceptionCaught") + package com.datadog.reactnative import java.net.HttpURLConnection diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt index 263da444c..c242e3c84 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt @@ -207,6 +207,7 @@ private fun nativeFfeStoragePayload(key: String, wire: String, updatedAtMs: Long .toString() } +@Suppress("FunctionMaxLength") private fun nativeFfeStoredConfigurationFromPayload( expectedKey: String, payloadString: String, diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt index 68299f600..f1346a8fe 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt @@ -4,6 +4,15 @@ * Copyright 2016-Present Datadog, Inc. */ +@file:Suppress( + "ComplexCondition", + "CyclomaticComplexMethod", + "LabeledExpression", + "StringLiteralDuplication", + "TooGenericExceptionCaught", + "TooManyFunctions" +) + package com.datadog.reactnative import java.security.MessageDigest diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffects.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffects.kt index 1779b11a5..81ea4564c 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffects.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffects.kt @@ -4,6 +4,8 @@ * Copyright 2016-Present Datadog, Inc. */ +@file:Suppress("CyclomaticComplexMethod", "TooGenericExceptionCaught") + package com.datadog.reactnative import com.datadog.android.Datadog From 00dd579ea386f9dbb434884bc4471d4c12f2f92a Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 20:07:08 -0400 Subject: [PATCH 14/18] Fix Android native FFE build checks --- .../com/datadog/reactnative/NativeFfeCore.kt | 61 ++++++++++--- .../com/datadog/reactnative/DdSdkTest.kt | 6 ++ .../NativeFfeConfigurationFetcherTest.kt | 20 ++--- .../datadog/reactnative/NativeFfeCoreTest.kt | 90 +++++++++++++------ .../NativeFfeEvaluationSideEffectsTest.kt | 4 +- .../reactnative/NativeFfeJsonFixtures.kt | 2 +- 6 files changed, 129 insertions(+), 54 deletions(-) diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt index f1346a8fe..80c962e3a 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt @@ -16,7 +16,10 @@ package com.datadog.reactnative import java.security.MessageDigest -import java.time.Instant +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone import org.json.JSONArray import org.json.JSONObject @@ -300,10 +303,10 @@ internal class NativeFfeCore { } private fun NativeAllocation.isActive(): Boolean { - val now = Instant.now() + val nowMs = System.currentTimeMillis() return !hasInvalidDate && - (startAt == null || !now.isBefore(startAt)) && - (endAt == null || now.isBefore(endAt)) + (startAtMs == null || nowMs >= startAtMs) && + (endAtMs == null || nowMs < endAtMs) } private fun rulesMatch(rules: List, subjectAttributes: Map): Boolean { @@ -357,7 +360,7 @@ internal class NativeFfeCore { if (allocation.rules.isNotEmpty()) { return "TARGETING_MATCH" } - if (allocation.startAt != null || allocation.endAt != null) { + if (allocation.startAtMs != null || allocation.endAtMs != null) { return "DEFAULT" } return if (split.shards.isNotEmpty()) { @@ -477,8 +480,8 @@ internal class NativeFfeCore { val splits: List, val doLog: Boolean, val extraLogging: Map?, - val startAt: Instant?, - val endAt: Instant?, + val startAtMs: Long?, + val endAtMs: Long?, val hasInvalidDate: Boolean, ) @@ -549,16 +552,16 @@ internal class NativeFfeCore { private fun JSONObject.toNativeAllocation(): NativeAllocation { val startAt = optString("startAt").takeIf { it.isNotBlank() } val endAt = optString("endAt").takeIf { it.isNotBlank() } - val parsedStartAt = startAt?.toInstantOrNull() - val parsedEndAt = endAt?.toInstantOrNull() + val parsedStartAt = startAt?.toEpochMillisOrNull() + val parsedEndAt = endAt?.toEpochMillisOrNull() return NativeAllocation( key = optString("key").takeIf { it.isNotBlank() }, rules = optJSONArray("rules")?.toNativeRules() ?: emptyList(), splits = optJSONArray("splits")?.toNativeSplits() ?: emptyList(), doLog = optBoolean("doLog", false), extraLogging = optJSONObject("extraLogging")?.toMap(), - startAt = parsedStartAt, - endAt = parsedEndAt, + startAtMs = parsedStartAt, + endAtMs = parsedEndAt, hasInvalidDate = (startAt != null && parsedStartAt == null) || (endAt != null && parsedEndAt == null), ) } @@ -636,14 +639,42 @@ internal class NativeFfeCore { } } - private fun String.toInstantOrNull(): Instant? { + private fun String.toEpochMillisOrNull(): Long? { + val normalizedValue = normalizedIsoTimestamp() ?: return null return try { - Instant.parse(this) - } catch (_: Exception) { + isoDateFormatter().parse(normalizedValue)?.time + } catch (_: ParseException) { null } } + private fun String.normalizedIsoTimestamp(): String? { + if (!endsWith("Z")) { + return null + } + + val withoutZone = dropLast(1) + val fractionStart = withoutZone.indexOf('.') + if (fractionStart < 0) { + return "${withoutZone}.000Z" + } + + val timestampPrefix = withoutZone.substring(0, fractionStart) + val fraction = withoutZone.substring(fractionStart + 1) + if (fraction.isEmpty() || fraction.any { !it.isDigit() }) { + return null + } + + return "$timestampPrefix.${fraction.padEnd(ISO_MILLIS_LENGTH, '0').take(ISO_MILLIS_LENGTH)}Z" + } + + private fun isoDateFormatter(): SimpleDateFormat { + return SimpleDateFormat(ISO_DATE_FORMAT, Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + isLenient = false + } + } + private fun Any?.containsString(expected: String): Boolean { if (this is List<*>) { return any { it?.toString() == expected } @@ -724,6 +755,8 @@ internal class NativeFfeCore { const val EXPECTED_OBJECT = "object" const val BYTE_MASK = 0xffL const val MAX_UNSIGNED_INT = 4_294_967_295L + const val ISO_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + const val ISO_MILLIS_LENGTH = 3 val KNOWN_CONDITION_OPERATORS = setOf( "IS_NULL", "MATCHES", diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt index 1bd17f3f4..56e8251f8 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt @@ -60,6 +60,7 @@ import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.annotation.StringForgeryType import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension +import java.io.File import java.util.Locale import java.util.stream.Stream import kotlin.time.Duration.Companion.seconds @@ -70,6 +71,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource @@ -145,6 +147,9 @@ internal class DdSdkTest { @Mock lateinit var mockChoreographer: Choreographer + @TempDir + lateinit var tempDir: File + @BeforeEach fun `set up`() { val mockLooper = mock() @@ -160,6 +165,7 @@ internal class DdSdkTest { whenever(mockReactContext.applicationContext) doReturn mockContext whenever(mockContext.packageName) doReturn "packageName" + whenever(mockContext.filesDir) doReturn tempDir whenever( mockContext.packageManager.getPackageInfo( "packageName", diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt index 3a2f5aaad..682780d13 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt @@ -23,7 +23,7 @@ internal class NativeFfeConfigurationFetcherTest { fakeTransport.response = NativeFfeHttpResponse( statusCode = 200, headers = mapOf("ETag" to "rules-v2"), - body = canonicalUfcConfig, + body = canonicalUfcConfig ) // When @@ -36,9 +36,9 @@ internal class NativeFfeConfigurationFetcherTest { "site" to "datadoghq.com", "headers" to mapOf("X-Test" to "true"), "flagQueryParams" to mapOf("project" to "rn"), - "previousConfigurationWire" to rulesConfigurationWire, + "previousConfigurationWire" to rulesConfigurationWire ), - testedFetcher, + testedFetcher ) // Then @@ -74,7 +74,7 @@ internal class NativeFfeConfigurationFetcherTest { fakeTransport.response = NativeFfeHttpResponse( statusCode = 304, headers = emptyMap(), - body = "", + body = "" ) // When @@ -82,9 +82,9 @@ internal class NativeFfeConfigurationFetcherTest { "rules", mapOf( "endpoint" to "https://config.example.test/flags", - "previousConfigurationWire" to rulesConfigurationWire, + "previousConfigurationWire" to rulesConfigurationWire ), - testedFetcher, + testedFetcher ) // Then @@ -102,7 +102,7 @@ internal class NativeFfeConfigurationFetcherTest { fakeTransport.response = NativeFfeHttpResponse( statusCode = 200, headers = mapOf("etag" to "precomputed-v1"), - body = """{"flags":{}}""", + body = """{"flags":{}}""" ) // When @@ -112,10 +112,10 @@ internal class NativeFfeConfigurationFetcherTest { "endpoint" to "https://config.example.test/precomputed", "evaluationContext" to mapOf( "targetingKey" to "user-123", - "attributes" to mapOf("plan" to "pro"), - ), + "attributes" to mapOf("plan" to "pro") + ) ), - testedFetcher, + testedFetcher ) // Then diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt index 728100987..4b9eeb99f 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt @@ -49,11 +49,11 @@ internal class NativeFfeCoreTest { val saveState = testedCore.saveConfiguration( configuration.toMap(), mapOf("slot" to "default"), - store, + store ) val loadedConfiguration = testedCore.loadConfiguration( mapOf("slot" to "default"), - store, + store ) val activatedState = testedCore.setConfiguration(loadedConfiguration.toMap()) @@ -82,7 +82,7 @@ internal class NativeFfeCoreTest { val dataStore = FakeDataStoreHandler() val store = DatadogDataStoreNativeFfeConfigurationStore( dataStoreProvider = { dataStore }, - clockMs = { STORED_AT_MS }, + clockMs = { STORED_AT_MS } ) val configuration = testedCore.configurationFromString(flagsConfigurationWire) @@ -90,11 +90,11 @@ internal class NativeFfeCoreTest { val saveState = testedCore.saveConfiguration( configuration.toMap(), mapOf("slot" to "default"), - store, + store ) val loadedConfiguration = testedCore.loadConfiguration( mapOf("slot" to "default"), - store, + store ) // Then @@ -206,7 +206,7 @@ internal class NativeFfeCoreTest { testedCore.setEvaluationContext( mapOf( "targetingKey" to evaluationCase.targetingKey, - "attributes" to evaluationCase.attributes, + "attributes" to evaluationCase.attributes ) ) } @@ -215,29 +215,34 @@ internal class NativeFfeCoreTest { return when (evaluationCase.variationType) { "BOOLEAN" -> testedCore.resolveBooleanEvaluation( evaluationCase.flag, - evaluationCase.defaultValue as Boolean, + evaluationCase.defaultValue as Boolean ) "STRING" -> testedCore.resolveStringEvaluation( evaluationCase.flag, - evaluationCase.defaultValue as String, + evaluationCase.defaultValue as String ) "INTEGER", "NUMERIC" -> testedCore.resolveNumberEvaluation( evaluationCase.flag, - (evaluationCase.defaultValue as Number).toDouble(), + (evaluationCase.defaultValue as Number).toDouble() ) "JSON" -> { @Suppress("UNCHECKED_CAST") testedCore.resolveObjectEvaluation( evaluationCase.flag, - evaluationCase.defaultValue as Map, + evaluationCase.defaultValue as Map ) } - else -> error("Unsupported fixture variation type: ${evaluationCase.variationType}") + else -> error( + "Unsupported fixture variation type: ${evaluationCase.variationType}" + ) } } - private fun assertEvaluationResult(result: Map, evaluationCase: EvaluationCase) { + private fun assertEvaluationResult( + result: Map, + evaluationCase: EvaluationCase + ) { assertThat(result["flagKey"]).isEqualTo(evaluationCase.flag) assertThat(result["reason"]).isEqualTo(evaluationCase.expectedReason) assertJsonValue(result["value"], evaluationCase.expectedValue) @@ -246,25 +251,50 @@ internal class NativeFfeCoreTest { } } - private fun evaluationMismatch(result: Map, evaluationCase: EvaluationCase): String? { + private fun evaluationMismatch( + result: Map, + evaluationCase: EvaluationCase + ): String? { if (result["flagKey"] != evaluationCase.flag) { - return "${evaluationCase.source}: flagKey expected ${evaluationCase.flag}, got ${result["flagKey"]}" + return evaluationCase.mismatch( + "flagKey", + evaluationCase.flag, + result["flagKey"] + ) } if (result["reason"] != evaluationCase.expectedReason) { - return "${evaluationCase.source}: reason expected ${evaluationCase.expectedReason}, got ${result["reason"]}" + return evaluationCase.mismatch( + "reason", + evaluationCase.expectedReason, + result["reason"] + ) } if (!jsonValuesEqual(result["value"], evaluationCase.expectedValue)) { - return "${evaluationCase.source}: value expected ${evaluationCase.expectedValue}, got ${result["value"]}" + return evaluationCase.mismatch( + "value", + evaluationCase.expectedValue, + result["value"] + ) } - if (evaluationCase.expectedErrorCode != null && result["errorCode"] != evaluationCase.expectedErrorCode) { - return "${evaluationCase.source}: errorCode expected ${evaluationCase.expectedErrorCode}, got ${result["errorCode"]}" + if ( + evaluationCase.expectedErrorCode != null && + result["errorCode"] != evaluationCase.expectedErrorCode + ) { + return evaluationCase.mismatch( + "errorCode", + evaluationCase.expectedErrorCode, + result["errorCode"] + ) } return null } private fun assertJsonValue(actual: Any?, expected: Any?) { if (actual is Number && expected is Number) { - assertThat(actual.toDouble()).isCloseTo(expected.toDouble(), Offset.offset(NUMERIC_TOLERANCE)) + assertThat(actual.toDouble()).isCloseTo( + expected.toDouble(), + Offset.offset(NUMERIC_TOLERANCE) + ) } else { assertThat(jsonValuesEqual(actual, expected)) .withFailMessage("Expected <%s>, got <%s>", expected, actual) @@ -307,7 +337,7 @@ internal class NativeFfeCoreTest { private fun evaluationCase( fileName: String, caseIndex: Int, - caseJson: JSONObject, + caseJson: JSONObject ): EvaluationCase { val resultJson = caseJson.getJSONObject("result") return EvaluationCase( @@ -316,10 +346,12 @@ internal class NativeFfeCoreTest { variationType = caseJson.getString("variationType"), defaultValue = caseJson.get("defaultValue").toNativeFfeFixtureValue(), targetingKey = caseJson.optionalNativeFfeString("targetingKey"), - attributes = (caseJson.optJSONObject("attributes") ?: JSONObject()).toNativeFfeFixtureMap(), + attributes = ( + caseJson.optJSONObject("attributes") ?: JSONObject() + ).toNativeFfeFixtureMap(), expectedValue = resultJson.get("value").toNativeFfeFixtureValue(), expectedReason = resultJson.getString("reason"), - expectedErrorCode = resultJson.optionalNativeFfeString("errorCode"), + expectedErrorCode = resultJson.optionalNativeFfeString("errorCode") ) } @@ -340,8 +372,12 @@ internal class NativeFfeCoreTest { val attributes: Map, val expectedValue: Any?, val expectedReason: String, - val expectedErrorCode: String?, - ) + val expectedErrorCode: String? + ) { + fun mismatch(field: String, expected: Any?, actual: Any?): String { + return "$source: $field expected $expected, got $actual" + } + } private companion object { const val NUMERIC_TOLERANCE = 0.0000001 @@ -368,7 +404,7 @@ private class FakeDataStoreHandler : DataStoreHandler { data: T, version: Int, callback: DataStoreWriteCallback?, - serializer: Serializer, + serializer: Serializer ) { values[key] = version to (serializer.serialize(data) ?: "") callback?.onSuccess() @@ -378,7 +414,7 @@ private class FakeDataStoreHandler : DataStoreHandler { key: String, version: Int?, callback: DataStoreReadCallback, - deserializer: Deserializer, + deserializer: Deserializer ) { val stored = values[key] ?: return callback.onFailure() if (version != null && stored.first != version) { @@ -388,7 +424,7 @@ private class FakeDataStoreHandler : DataStoreHandler { callback.onSuccess( DataStoreContent( stored.first, - deserializer.deserialize(stored.second), + deserializer.deserialize(stored.second) ) ) } diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffectsTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffectsTest.kt index 6bb661c28..48bdad6a8 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffectsTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffectsTest.kt @@ -79,12 +79,12 @@ internal class NativeFfeEvaluationSideEffectsTest { return SideEffectFixture( result = fixture.getJSONObject("result").toNativeFfeFixtureMap(), - context = (fixture.optJSONObject("context") ?: JSONObject()).toNativeFfeFixtureMap(), + context = (fixture.optJSONObject("context") ?: JSONObject()).toNativeFfeFixtureMap() ) } private data class SideEffectFixture( val result: Map, - val context: Map, + val context: Map ) } diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt index f88984e2e..6687e0111 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt @@ -19,7 +19,7 @@ internal fun readNativeFfeFixture(owner: Class<*>, relativePath: String): String internal fun nativeFfeRulesConfigurationWire( response: String, - etag: String = "ffe-system-test-data", + etag: String = "ffe-system-test-data" ): String { return JSONObject() .put("version", 2) From 324334503c52a65538cfe50c0dcc8ca05cef367b Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 20:38:53 -0400 Subject: [PATCH 15/18] Add native FFE offline JS bridge smoke --- .gitlab-ci.yml | 1 + example-new-architecture/App.tsx | 218 +---------- example-new-architecture/README.md | 30 +- .../nativeFfeOfflineFixtureRunner.ts | 359 ++++++++++++++++++ .../native-ffe-offline-android-smoke.sh | 139 +++++++ .../com/datadog/reactnative/NativeFfeCore.kt | 28 +- .../datadog/reactnative/NativeFfeCoreTest.kt | 20 + packages/core/ios/Sources/NativeFfeCore.swift | 35 +- 8 files changed, 605 insertions(+), 225 deletions(-) create mode 100644 example-new-architecture/nativeFfeOfflineFixtureRunner.ts create mode 100755 example-new-architecture/scripts/native-ffe-offline-android-smoke.sh diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ab2b20419..66edaf4ab 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -93,6 +93,7 @@ test:native-android: - (cd packages/core/android && ./gradlew build -PDdSdkReactNative_minSdkVersion=24) - (cd packages/react-native-session-replay/android && ./gradlew build -PDdSdkReactNative_minSdkVersion=24 -PDatadogSDKReactNativeSessionReplay_minSdkVersion=24) - (cd packages/internal-testing-tools/android && ./gradlew build -PDdSdkReactNative_minSdkVersion=24 -PDatadogInternalTesting_minSdkVersion=24) + - ./example-new-architecture/scripts/native-ffe-offline-android-smoke.sh test:native-ios: tags: ['macos:sonoma', 'specific:true'] diff --git a/example-new-architecture/App.tsx b/example-new-architecture/App.tsx index d1823a087..07d0340f1 100644 --- a/example-new-architecture/App.tsx +++ b/example-new-architecture/App.tsx @@ -11,11 +11,6 @@ import { TrackingConsent, PropagatorType, } from '@datadog/mobile-react-native'; -import type { - FlagEvaluationResult, - FlagsProviderDebugState, - NativeFlagsConfiguration, -} from '@datadog/mobile-react-native'; import React from 'react'; import type {PropsWithChildren} from 'react'; import { @@ -36,50 +31,12 @@ import { LearnMoreLinks, ReloadInstructions, } from 'react-native/Libraries/NewAppScreen'; -// @ts-ignore local ignored credentials file -import * as ddCredentials from './ddCredentials'; -import nativeFfeRulesConfigurationWire from './fixtures/native-ffe/offline-rules-configuration-wire.json'; +import * as ddCredentials from './ddCredentials.example'; +import {runNativeFfeOfflineFixtureCorpus} from './nativeFfeOfflineFixtureRunner'; const APPLICATION_ID = ddCredentials.APPLICATION_ID; const CLIENT_TOKEN = ddCredentials.CLIENT_TOKEN; const ENVIRONMENT = ddCredentials.ENVIRONMENT; -const NATIVE_FFE_CLIENT_TOKEN = - ddCredentials.NATIVE_FFE_CLIENT_TOKEN ?? CLIENT_TOKEN; -const NATIVE_FFE_STAGING_RULES_ENDPOINT = - 'https://dd.datad0g.com/api/v2/feature-flagging/config/rules-based'; -const NATIVE_FFE_STORAGE_OPTIONS = { - slot: 'default', -}; -const NATIVE_FFE_BUNDLED_RULES_WIRE = JSON.stringify( - nativeFfeRulesConfigurationWire, -); -const NATIVE_FFE_DEMO_FLAG = 'boolean-false-assignment'; -const NATIVE_FFE_ANONYMOUS_CONTEXT = { - targetingKey: 'anonymous-user', - attributes: { - should_disable_feature: true, - }, -}; -const NATIVE_FFE_AUTHENTICATED_CONTEXT = { - targetingKey: 'authenticated-user', - attributes: { - should_disable_feature: false, - }, -}; -const NATIVE_FFE_STAGING_CONTEXT = { - targetingKey: 'test_subject4', - attributes: { - attr1: 'value1', - companyId: '1', - }, -}; -const NATIVE_FFE_STAGING_FLAGS = { - boolean: 'ffe-dogfooding-boolean-flag', - string: 'ffe-dogfooding-string-flag', - integer: 'ffe-dogfooding-integer-flag', - float: 'ffe-dogfooding-float-flag', - object: 'ffe-dogfooding-json-flag', -}; const datadogInitialization = (async () => { const config = new CoreConfiguration( @@ -193,142 +150,18 @@ function NativeFfeFlowPanel({ const runNativeFfeFlow = React.useCallback(async () => { setFlowState({ status: 'loading', - summary: 'Running native FF&E flow...', + summary: 'Running native FF&E offline fixture corpus...', }); try { await datadogInitialization; - const bundledConfiguration = await DdSdkReactNative.configurationFromString( - NATIVE_FFE_BUNDLED_RULES_WIRE, - ); - const bundledState = await DdSdkReactNative.setConfiguration( - bundledConfiguration, - ); - await DdSdkReactNative.setEvaluationContext(NATIVE_FFE_ANONYMOUS_CONTEXT); - const anonymousResult = await DdSdkReactNative.resolveBooleanEvaluation( - NATIVE_FFE_DEMO_FLAG, - true, - ); - await DdSdkReactNative.setEvaluationContext( - NATIVE_FFE_AUTHENTICATED_CONTEXT, - ); - const authenticatedResult = await DdSdkReactNative.resolveBooleanEvaluation( - NATIVE_FFE_DEMO_FLAG, - true, - ); - const beforeFetchState = await DdSdkReactNative.getProviderDebugState(); - const fetchedConfiguration = await DdSdkReactNative.fetchRulesConfiguration( - { - endpoint: NATIVE_FFE_STAGING_RULES_ENDPOINT, - headers: { - 'Fastly-Client': '1', - 'dd-client-token': NATIVE_FFE_CLIENT_TOKEN, - }, - flagQueryParams: { - dd_env: 'staging', - }, - previousConfigurationWire: NATIVE_FFE_BUNDLED_RULES_WIRE, - }, - ); - const afterFetchState = await DdSdkReactNative.getProviderDebugState(); - const serializedWire = await DdSdkReactNative.configurationToString( - fetchedConfiguration, - ); - const fetchedParsedConfiguration = await DdSdkReactNative.configurationFromString( - serializedWire, - ); - const saveState = await DdSdkReactNative.saveConfiguration( - fetchedParsedConfiguration, - NATIVE_FFE_STORAGE_OPTIONS, - ); - const loadedConfiguration = await DdSdkReactNative.loadConfiguration( - NATIVE_FFE_STORAGE_OPTIONS, - ); - const afterLoadState = await DdSdkReactNative.getProviderDebugState(); - const loadedState = await DdSdkReactNative.setConfiguration( - loadedConfiguration, - ); - await DdSdkReactNative.setEvaluationContext(NATIVE_FFE_STAGING_CONTEXT); - const stagingResults = { - boolean: await DdSdkReactNative.resolveBooleanEvaluation( - NATIVE_FFE_STAGING_FLAGS.boolean, - false, - ), - string: await DdSdkReactNative.resolveStringEvaluation( - NATIVE_FFE_STAGING_FLAGS.string, - 'Fallback title', - ), - integer: await DdSdkReactNative.resolveNumberEvaluation( - NATIVE_FFE_STAGING_FLAGS.integer, - 0, - ), - float: await DdSdkReactNative.resolveNumberEvaluation( - NATIVE_FFE_STAGING_FLAGS.float, - 0, - ), - object: await DdSdkReactNative.resolveObjectEvaluation( - NATIVE_FFE_STAGING_FLAGS.object, - {}, - ), - }; - const debugState = await DdSdkReactNative.getProviderDebugState(); + const report = await runNativeFfeOfflineFixtureCorpus(); setFlowState({ status: 'ready', - summary: `Offline ${anonymousResult.value} -> ${ - authenticatedResult.value - }; fetch count ${beforeFetchState.fetchCount} -> ${ - afterFetchState.fetchCount - }; evaluated ${Object.keys(stagingResults).length} staging flags.`, - details: JSON.stringify( - { - offlineInit: { - bundledConfiguration: summarizeConfiguration( - bundledConfiguration, - ), - setState: summarizeDebugState(bundledState), - anonymousResult: summarizeEvaluation(anonymousResult), - authenticatedResult: summarizeEvaluation(authenticatedResult), - fetchCountAfterContextChange: beforeFetchState.fetchCount, - }, - nativeFetch: { - activeBeforeFetch: summarizeDebugState(beforeFetchState), - fetchedConfiguration: summarizeConfiguration( - fetchedConfiguration, - ), - activeAfterFetch: summarizeDebugState(afterFetchState), - activeStateUnchanged: - beforeFetchState.activeEtag === afterFetchState.activeEtag && - beforeFetchState.configurationSetCount === - afterFetchState.configurationSetCount, - serializedWireBytes: serializedWire.length, - }, - nativePersistence: { - saveState: summarizeDebugState(saveState), - loadedConfiguration: summarizeConfiguration(loadedConfiguration), - stateAfterLoad: summarizeDebugState(afterLoadState), - loadDidNotActivate: - afterLoadState.activeEtag === afterFetchState.activeEtag && - afterLoadState.configurationSetCount === - afterFetchState.configurationSetCount, - }, - explicitActivation: { - setState: summarizeDebugState(loadedState), - stagingContext: NATIVE_FFE_STAGING_CONTEXT, - stagingResults: { - boolean: summarizeEvaluation(stagingResults.boolean), - string: summarizeEvaluation(stagingResults.string), - integer: summarizeEvaluation(stagingResults.integer), - float: summarizeEvaluation(stagingResults.float), - object: summarizeEvaluation(stagingResults.object), - }, - }, - finalDebugState: summarizeDebugState(debugState), - }, - null, - 2, - ), + summary: report.summary, + details: JSON.stringify(report.details, null, 2), }); } catch (error) { setFlowState({ @@ -381,7 +214,7 @@ function NativeFfeFlowPanel({ ]} > - {loading ? 'Running...' : 'Run native flow'} + {loading ? 'Running...' : 'Run offline fixture corpus'} {flowState.details ? ( @@ -402,43 +235,6 @@ function NativeFfeFlowPanel({ ); } -function summarizeConfiguration(configuration: NativeFlagsConfiguration) { - return { - kind: configuration.kind, - version: configuration.version, - etag: configuration.etag, - }; -} - -function summarizeEvaluation(result: FlagEvaluationResult) { - return { - flagKey: result.flagKey, - value: result.value, - variant: result.variant, - reason: result.reason, - errorCode: result.errorCode, - flagMetadata: result.flagMetadata, - }; -} - -function summarizeDebugState(state: FlagsProviderDebugState) { - return { - status: state.status, - activeConfigurationKind: state.activeConfigurationKind, - activeEtag: state.activeEtag, - configurationSetCount: state.configurationSetCount, - configurationSaveCount: state.configurationSaveCount, - configurationLoadCount: state.configurationLoadCount, - fetchCount: state.fetchCount, - evaluationCount: state.evaluationCount, - lastEvent: state.lastEvent, - lastFetchRequest: state.lastFetchRequest, - lastStorage: state.lastStorage, - lastError: state.lastError, - evaluationSideEffects: state.evaluationSideEffects, - }; -} - type SectionProps = PropsWithChildren<{ title: string; }>; diff --git a/example-new-architecture/README.md b/example-new-architecture/README.md index 14385167c..63f4c459e 100644 --- a/example-new-architecture/README.md +++ b/example-new-architecture/README.md @@ -2,17 +2,31 @@ ## Datadog Credentials -Create the local credentials file before running the app: +The example imports `ddCredentials.example.js`, which contains fake placeholder +values. That is enough for the offline native FF&E fixture demo and for CI, +because the demo passes a bundled rules configuration JSON file through the +React Native bridge and does not make a feature flag network request. -```sh -cp example-new-architecture/ddCredentials.example.js example-new-architecture/ddCredentials.js -``` - -`ddCredentials.js` is intentionally ignored by git. Edit it with: +To point the example at a real staging app for manual SDK/RUM validation, edit +the values in `ddCredentials.example.js` locally: - `CLIENT_TOKEN`: Datadog public client token for SDK initialization. - `APPLICATION_ID`: RUM application ID. The placeholder keeps the native FF&E demo runnable; use a real staging RUM application ID to validate RUM flag annotation. - `ENVIRONMENT`: use `staging` for this demo. -- `NATIVE_FFE_CLIENT_TOKEN`: token used by the native dynamic rules fetcher against `https://dd.datad0g.com/api/v2/feature-flagging/config/rules-based?dd_env=staging`. -The native FF&E demo also sends `Fastly-Client: 1` from `App.tsx` so it exercises the same staging rules-based request path used for this branch. +Do not commit real credentials. + +## Native FFE Offline Smoke + +CI runs the Android smoke test below to exercise the offline fixture corpus from +React Native JS through the native bridge: + +```sh +ANDROID_HOME="$ANDROID_HOME" ANDROID_SDK_ROOT="$ANDROID_SDK_ROOT" \ + ./example-new-architecture/scripts/native-ffe-offline-android-smoke.sh +``` + +The smoke test starts Metro if needed, installs the new-architecture Android +app, launches it on an emulator, and waits for `Native FFE offline fixture pass: +233 cases across 30 files`. It does not use credentials or make a feature flag +network request. diff --git a/example-new-architecture/nativeFfeOfflineFixtureRunner.ts b/example-new-architecture/nativeFfeOfflineFixtureRunner.ts new file mode 100644 index 000000000..3d7cc19d7 --- /dev/null +++ b/example-new-architecture/nativeFfeOfflineFixtureRunner.ts @@ -0,0 +1,359 @@ +import {DdSdkReactNative} from '@datadog/mobile-react-native'; +import type { + FlagEvaluationResult, + FlagValue, + FlagsEvaluationContext, + FlagsProviderDebugState, +} from '@datadog/mobile-react-native'; + +import ufcConfig from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/ufc-config.json'; +import testCaseBooleanFalseAssignment from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-false-assignment.json'; +import testCaseBooleanOneOfMatches from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-one-of-matches.json'; +import testCaseComparatorOperatorFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-comparator-operator-flag.json'; +import testCaseDisabledFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-disabled-flag.json'; +import testCaseEmptyFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-flag.json'; +import testCaseEmptyStringVariation from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-string-variation.json'; +import testCaseFalsyValueAssignments from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-falsy-value-assignments.json'; +import testCaseFlagWithEmptyString from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-flag-with-empty-string.json'; +import testCaseIntegerFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-integer-flag.json'; +import testCaseKillSwitchFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-kill-switch-flag.json'; +import testCaseMalformedFlagIsolation from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-malformed-flag-isolation.json'; +import testCaseMicrosecondDateFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-microsecond-date-flag.json'; +import testCaseMissingSplitShardsIsolation from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-missing-split-shards-isolation.json'; +import testCaseNewUserOnboardingFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-new-user-onboarding-flag.json'; +import testCaseNoAllocationsFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-no-allocations-flag.json'; +import testCaseNullOperatorFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-operator-flag.json'; +import testCaseNullTargetingKey from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-targeting-key.json'; +import testCaseNumericFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-flag.json'; +import testCaseNumericOneOfDefault from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of-default.json'; +import testCaseNumericOneOf from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of.json'; +import testCaseOf7EmptyTargetingKey from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-of-7-empty-targeting-key.json'; +import testCaseRegexFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-regex-flag.json'; +import testCaseStartAndEndDateFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-start-and-end-date-flag.json'; +import testCaseUnknownFieldsTolerance from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-fields-tolerance.json'; +import testCaseUnknownOperatorIsolation from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-operator-isolation.json'; +import testFlagThatDoesNotExist from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-flag-that-does-not-exist.json'; +import testJsonConfigFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-json-config-flag.json'; +import testNoAllocationsFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-no-allocations-flag.json'; +import testSpecialCharacters from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-special-characters.json'; +import testStringWithSpecialCharacters from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-string-with-special-characters.json'; + +type FixtureResult = { + reason: string; + value: FlagValue; + errorCode?: string; +}; + +type EvaluationCase = { + attributes?: Record; + defaultValue: FlagValue; + flag: string; + result: FixtureResult; + targetingKey?: string | null; + variationType: 'BOOLEAN' | 'STRING' | 'INTEGER' | 'NUMERIC' | 'JSON'; +}; + +type EvaluationCaseFixture = [string, EvaluationCase[]]; + +export type NativeFfeOfflineFixtureReport = { + summary: string; + details: { + caseCount: number; + fixtureCount: number; + configuration: { + etag?: string; + kind?: string; + version?: number; + }; + serializedWireBytes: number; + saveState: FlagsProviderDebugState; + setState: FlagsProviderDebugState; + finalState: FlagsProviderDebugState; + }; +}; + +export const NATIVE_FFE_OFFLINE_FIXTURE_SUCCESS_PREFIX = + 'Native FFE offline fixture pass'; + +export const NATIVE_FFE_SHARED_RULES_WIRE = JSON.stringify({ + version: 2, + server: { + response: JSON.stringify(ufcConfig), + etag: 'ffe-system-test-data', + }, +}); + +const NATIVE_FFE_FIXTURE_STORAGE_OPTIONS = { + slot: 'offline-fixture-corpus', +}; + +const NUMERIC_TOLERANCE = 0.0000001; + +const evaluationCaseFixtures: EvaluationCaseFixture[] = [ + [ + 'test-case-boolean-false-assignment.json', + testCaseBooleanFalseAssignment as EvaluationCase[], + ], + [ + 'test-case-boolean-one-of-matches.json', + testCaseBooleanOneOfMatches as EvaluationCase[], + ], + [ + 'test-case-comparator-operator-flag.json', + testCaseComparatorOperatorFlag as EvaluationCase[], + ], + ['test-case-disabled-flag.json', testCaseDisabledFlag as EvaluationCase[]], + ['test-case-empty-flag.json', testCaseEmptyFlag as EvaluationCase[]], + [ + 'test-case-empty-string-variation.json', + testCaseEmptyStringVariation as EvaluationCase[], + ], + [ + 'test-case-falsy-value-assignments.json', + testCaseFalsyValueAssignments as EvaluationCase[], + ], + [ + 'test-case-flag-with-empty-string.json', + testCaseFlagWithEmptyString as EvaluationCase[], + ], + ['test-case-integer-flag.json', testCaseIntegerFlag as EvaluationCase[]], + ['test-case-kill-switch-flag.json', testCaseKillSwitchFlag as EvaluationCase[]], + [ + 'test-case-malformed-flag-isolation.json', + testCaseMalformedFlagIsolation as EvaluationCase[], + ], + [ + 'test-case-microsecond-date-flag.json', + testCaseMicrosecondDateFlag as EvaluationCase[], + ], + [ + 'test-case-missing-split-shards-isolation.json', + testCaseMissingSplitShardsIsolation as EvaluationCase[], + ], + [ + 'test-case-new-user-onboarding-flag.json', + testCaseNewUserOnboardingFlag as EvaluationCase[], + ], + [ + 'test-case-no-allocations-flag.json', + testCaseNoAllocationsFlag as EvaluationCase[], + ], + [ + 'test-case-null-operator-flag.json', + testCaseNullOperatorFlag as EvaluationCase[], + ], + [ + 'test-case-null-targeting-key.json', + testCaseNullTargetingKey as EvaluationCase[], + ], + ['test-case-numeric-flag.json', testCaseNumericFlag as EvaluationCase[]], + [ + 'test-case-numeric-one-of-default.json', + testCaseNumericOneOfDefault as EvaluationCase[], + ], + ['test-case-numeric-one-of.json', testCaseNumericOneOf as EvaluationCase[]], + [ + 'test-case-of-7-empty-targeting-key.json', + testCaseOf7EmptyTargetingKey as EvaluationCase[], + ], + ['test-case-regex-flag.json', testCaseRegexFlag as EvaluationCase[]], + [ + 'test-case-start-and-end-date-flag.json', + testCaseStartAndEndDateFlag as EvaluationCase[], + ], + [ + 'test-case-unknown-fields-tolerance.json', + testCaseUnknownFieldsTolerance as EvaluationCase[], + ], + [ + 'test-case-unknown-operator-isolation.json', + testCaseUnknownOperatorIsolation as EvaluationCase[], + ], + ['test-flag-that-does-not-exist.json', testFlagThatDoesNotExist as EvaluationCase[]], + ['test-json-config-flag.json', testJsonConfigFlag as EvaluationCase[]], + ['test-no-allocations-flag.json', testNoAllocationsFlag as EvaluationCase[]], + ['test-special-characters.json', testSpecialCharacters as EvaluationCase[]], + [ + 'test-string-with-special-characters.json', + testStringWithSpecialCharacters as EvaluationCase[], + ], +]; + +export async function runNativeFfeOfflineFixtureCorpus(): Promise { + const startingState = await DdSdkReactNative.getProviderDebugState(); + const parsedConfiguration = await DdSdkReactNative.configurationFromString( + NATIVE_FFE_SHARED_RULES_WIRE, + ); + const serializedWire = await DdSdkReactNative.configurationToString( + parsedConfiguration, + ); + assertEqual( + serializedWire, + NATIVE_FFE_SHARED_RULES_WIRE, + 'configurationToString should preserve the original wire', + ); + + const reparsedConfiguration = await DdSdkReactNative.configurationFromString( + serializedWire, + ); + const saveState = await DdSdkReactNative.saveConfiguration( + reparsedConfiguration, + NATIVE_FFE_FIXTURE_STORAGE_OPTIONS, + ); + const loadedConfiguration = await DdSdkReactNative.loadConfiguration( + NATIVE_FFE_FIXTURE_STORAGE_OPTIONS, + ); + const setState = await DdSdkReactNative.setConfiguration(loadedConfiguration); + + let caseCount = 0; + for (const [fixtureName, cases] of evaluationCaseFixtures) { + for (let caseIndex = 0; caseIndex < cases.length; caseIndex += 1) { + const evaluationCase = cases[caseIndex]; + await evaluateCase(fixtureName, caseIndex, evaluationCase); + caseCount += 1; + } + } + + const finalState = await DdSdkReactNative.getProviderDebugState(); + assertEqual( + finalState.fetchCount, + startingState.fetchCount, + 'offline fixture runner should not perform network fetches', + ); + assertEqual( + finalState.evaluationCount - startingState.evaluationCount, + caseCount, + 'offline fixture runner should evaluate every shared case exactly once', + ); + + return { + summary: `${NATIVE_FFE_OFFLINE_FIXTURE_SUCCESS_PREFIX}: ${caseCount} cases across ${evaluationCaseFixtures.length} files.`, + details: { + caseCount, + fixtureCount: evaluationCaseFixtures.length, + configuration: { + etag: loadedConfiguration.etag, + kind: loadedConfiguration.kind, + version: loadedConfiguration.version, + }, + serializedWireBytes: serializedWire.length, + saveState, + setState, + finalState, + }, + }; +} + +async function evaluateCase( + fixtureName: string, + caseIndex: number, + evaluationCase: EvaluationCase, +): Promise { + await DdSdkReactNative.setEvaluationContext(evaluationContext(evaluationCase)); + const result = await resolveEvaluation(evaluationCase); + const source = `${fixtureName}[${caseIndex}]`; + + assertEqual(result.flagKey, evaluationCase.flag, `${source} flagKey`); + assertEqual(result.reason, evaluationCase.result.reason, `${source} reason`); + assertJsonValue(result.value, evaluationCase.result.value, `${source} value`); + if (evaluationCase.result.errorCode !== undefined) { + assertEqual( + result.errorCode, + evaluationCase.result.errorCode, + `${source} errorCode`, + ); + } +} + +function evaluationContext( + evaluationCase: EvaluationCase, +): FlagsEvaluationContext { + return { + ...(typeof evaluationCase.targetingKey === 'string' + ? {targetingKey: evaluationCase.targetingKey} + : {}), + attributes: evaluationCase.attributes ?? {}, + }; +} + +function resolveEvaluation( + evaluationCase: EvaluationCase, +): Promise { + switch (evaluationCase.variationType) { + case 'BOOLEAN': + return DdSdkReactNative.resolveBooleanEvaluation( + evaluationCase.flag, + evaluationCase.defaultValue as boolean, + ); + case 'STRING': + return DdSdkReactNative.resolveStringEvaluation( + evaluationCase.flag, + evaluationCase.defaultValue as string, + ); + case 'INTEGER': + case 'NUMERIC': + return DdSdkReactNative.resolveNumberEvaluation( + evaluationCase.flag, + evaluationCase.defaultValue as number, + ); + case 'JSON': + return DdSdkReactNative.resolveObjectEvaluation( + evaluationCase.flag, + evaluationCase.defaultValue as Record, + ); + } +} + +function assertJsonValue( + actual: unknown, + expected: unknown, + message: string, +): void { + if (!jsonValuesEqual(actual, expected)) { + throw new Error( + `${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} + +function assertEqual(actual: T, expected: T, message: string): void { + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } +} + +function jsonValuesEqual(actual: unknown, expected: unknown): boolean { + if (typeof actual === 'number' && typeof expected === 'number') { + return Math.abs(actual - expected) <= NUMERIC_TOLERANCE; + } + if (Array.isArray(actual) && Array.isArray(expected)) { + return arraysEqual(actual, expected); + } + if (isRecord(actual) && isRecord(expected)) { + return recordsEqual(actual, expected); + } + return Object.is(actual, expected); +} + +function arraysEqual(actual: unknown[], expected: unknown[]): boolean { + return ( + actual.length === expected.length && + actual.every((item, index) => jsonValuesEqual(item, expected[index])) + ); +} + +function recordsEqual( + actual: Record, + expected: Record, +): boolean { + const actualKeys = Object.keys(actual).sort(); + const expectedKeys = Object.keys(expected).sort(); + return ( + arraysEqual(actualKeys, expectedKeys) && + actualKeys.every(key => jsonValuesEqual(actual[key], expected[key])) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/example-new-architecture/scripts/native-ffe-offline-android-smoke.sh b/example-new-architecture/scripts/native-ffe-offline-android-smoke.sh new file mode 100755 index 000000000..6406ca036 --- /dev/null +++ b/example-new-architecture/scripts/native-ffe-offline-android-smoke.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +EXAMPLE_DIR="$ROOT_DIR/example-new-architecture" +ANDROID_DIR="$EXAMPLE_DIR/android" +LOG_DIR="${NATIVE_FFE_SMOKE_LOG_DIR:-$ROOT_DIR/build/native-ffe-smoke}" +SUCCESS_TEXT="Native FFE offline fixture pass: 233 cases across 30 files" +PACKAGE_NAME="com.ddsdkreactnativeexample" +ACTIVITY_NAME="$PACKAGE_NAME/.MainActivity" + +mkdir -p "$LOG_DIR" + +ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" +if [[ -z "$ANDROID_SDK_ROOT" ]]; then + echo "ANDROID_HOME or ANDROID_SDK_ROOT must be set." >&2 + exit 1 +fi + +ADB="$ANDROID_SDK_ROOT/platform-tools/adb" +EMULATOR="$ANDROID_SDK_ROOT/emulator/emulator" + +if [[ ! -x "$ADB" ]]; then + echo "adb not found at $ADB" >&2 + exit 1 +fi + +metro_pid="" +emulator_pid="" + +cleanup() { + if [[ -n "$metro_pid" ]]; then + kill "$metro_pid" >/dev/null 2>&1 || true + fi + if [[ -n "$emulator_pid" ]]; then + "$ADB" emu kill >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +has_connected_device() { + "$ADB" devices | awk 'NR > 1 && $2 == "device" { found = 1 } END { exit found ? 0 : 1 }' +} + +wait_for_boot() { + "$ADB" wait-for-device + for _ in $(seq 1 120); do + if [[ "$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; then + return 0 + fi + sleep 2 + done + echo "Timed out waiting for Android emulator boot." >&2 + exit 1 +} + +start_emulator_if_needed() { + if has_connected_device; then + return 0 + fi + + local avd_name="${NATIVE_FFE_ANDROID_AVD:-${EMULATOR_NAME:-android_emulator}}" + if [[ ! -x "$EMULATOR" ]]; then + echo "emulator not found at $EMULATOR" >&2 + exit 1 + fi + + "$EMULATOR" \ + -avd "$avd_name" \ + -no-window \ + -no-audio \ + -no-boot-anim \ + -gpu swiftshader_indirect \ + >"$LOG_DIR/emulator.log" 2>&1 & + emulator_pid="$!" + wait_for_boot +} + +start_metro_if_needed() { + if curl -fsS "http://localhost:8081/status" 2>/dev/null | grep -q "packager-status:running"; then + return 0 + fi + + ( + cd "$EXAMPLE_DIR" + yarn start --port 8081 + ) >"$LOG_DIR/metro.log" 2>&1 & + metro_pid="$!" + + for _ in $(seq 1 60); do + if curl -fsS "http://localhost:8081/status" 2>/dev/null | grep -q "packager-status:running"; then + return 0 + fi + sleep 2 + done + + echo "Timed out waiting for Metro on port 8081." >&2 + tail -100 "$LOG_DIR/metro.log" >&2 || true + exit 1 +} + +install_and_launch_app() { + "$ADB" reverse tcp:8081 tcp:8081 >/dev/null 2>&1 || true + ( + cd "$ANDROID_DIR" + ./gradlew --no-daemon :app:installDebug + ) + "$ADB" shell am force-stop "$PACKAGE_NAME" >/dev/null 2>&1 || true + "$ADB" shell am start -n "$ACTIVITY_NAME" >/dev/null +} + +wait_for_success_text() { + for _ in $(seq 1 120); do + if "$ADB" shell uiautomator dump /sdcard/native_ffe_window.xml >/dev/null 2>&1; then + "$ADB" exec-out cat /sdcard/native_ffe_window.xml >"$LOG_DIR/window.xml" || true + if grep -q "$SUCCESS_TEXT" "$LOG_DIR/window.xml"; then + echo "$SUCCESS_TEXT" + return 0 + fi + if grep -Eq "Native FF(&|&)E failed" "$LOG_DIR/window.xml"; then + echo "Native FFE offline fixture runner failed." >&2 + grep -o "Native FF[^\"]*" "$LOG_DIR/window.xml" >&2 || true + exit 1 + fi + fi + sleep 2 + done + + echo "Timed out waiting for '$SUCCESS_TEXT'." >&2 + "$ADB" logcat -d -t 300 ReactNativeJS '*:S' >"$LOG_DIR/react-native-js.log" || true + tail -100 "$LOG_DIR/react-native-js.log" >&2 || true + exit 1 +} + +start_emulator_if_needed +start_metro_if_needed +install_and_launch_app +wait_for_success_text diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt index 80c962e3a..cc7dd2066 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt @@ -331,8 +331,8 @@ internal class NativeFfeCore { } "MATCHES" -> regexMatches(condition.value.toString(), value?.toString()) "NOT_MATCHES" -> value?.toString()?.let { !regexMatches(condition.value.toString(), it) } ?: false - "ONE_OF" -> value?.toString()?.let { condition.value.containsString(it) } ?: false - "NOT_ONE_OF" -> value?.toString()?.let { !condition.value.containsString(it) } ?: false + "ONE_OF" -> condition.value.containsComparableValue(value) + "NOT_ONE_OF" -> value != null && !condition.value.containsComparableValue(value) "GTE" -> value.asDouble()?.let { it >= (condition.value.asDouble() ?: 0.0) } ?: false "GT" -> value.asDouble()?.let { it > (condition.value.asDouble() ?: 0.0) } ?: false "LTE" -> value.asDouble()?.let { it <= (condition.value.asDouble() ?: 0.0) } ?: false @@ -685,6 +685,30 @@ internal class NativeFfeCore { return false } + private fun Any?.containsComparableValue(value: Any?): Boolean { + return value.matchableStrings().any { containsString(it) } + } + + private fun Any?.matchableStrings(): Set { + if (this == null) { + return emptySet() + } + val strings = mutableSetOf(toString()) + if (this is Number) { + val doubleValue = toDouble() + if ( + !doubleValue.isNaN() && + !doubleValue.isInfinite() && + doubleValue % 1.0 == 0.0 && + doubleValue >= Long.MIN_VALUE.toDouble() && + doubleValue <= Long.MAX_VALUE.toDouble() + ) { + strings.add(doubleValue.toLong().toString()) + } + } + return strings + } + private fun JSONArray.containsString(expected: String): Boolean { for (index in 0 until length()) { if (opt(index)?.toString() == expected) { diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt index 4b9eeb99f..aa5b0a4f1 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt @@ -130,6 +130,26 @@ internal class NativeFfeCoreTest { .containsEntry("__dd_do_log", true) } + @Test + fun `M match integer one of W React Native bridge double`() { + // Given + val evaluationCase = evaluationCase("test-case-numeric-one-of.json") + setConfiguration() + testedCore.setEvaluationContext( + mapOf( + "targetingKey" to evaluationCase.targetingKey, + "attributes" to mapOf("number" to 1.0) + ) + ) + + // When + val result = testedCore.resolveNumberEvaluation(evaluationCase.flag, 0.0) + + // Then + assertJsonValue(result["value"], 1) + assertThat(result["reason"]).isEqualTo("TARGETING_MATCH") + } + @Test fun `M return split reason W canonical sharded flag case`() { // Given diff --git a/packages/core/ios/Sources/NativeFfeCore.swift b/packages/core/ios/Sources/NativeFfeCore.swift index 18527ee44..ce0e763bf 100644 --- a/packages/core/ios/Sources/NativeFfeCore.swift +++ b/packages/core/ios/Sources/NativeFfeCore.swift @@ -411,11 +411,12 @@ internal final class NativeFfeCore { } return !regexMatches(pattern: stringValue(condition.value), value: subjectValue) case "ONE_OF": - return stringValue(value).map { containsString(arrayValue(condition.value), expected: $0) } - ?? false + return containsComparableValue(arrayValue(condition.value), actual: value) case "NOT_ONE_OF": - return stringValue(value).map { !containsString(arrayValue(condition.value), expected: $0) } - ?? false + guard !isNull(value) else { + return false + } + return !containsComparableValue(arrayValue(condition.value), actual: value) case "GTE": return doubleValue(value).map { $0 >= (doubleValue(condition.value) ?? 0) } ?? false case "GT": @@ -672,6 +673,32 @@ internal final class NativeFfeCore { values.contains { stringValue($0) == expected } } + private func containsComparableValue(_ values: [Any], actual: Any?) -> Bool { + comparableStrings(actual).contains { containsString(values, expected: $0) } + } + + private func comparableStrings(_ value: Any?) -> [String] { + guard let value, !(value is NSNull) else { + return [] + } + + var strings = Set() + if let string = stringValue(value) { + strings.insert(string) + } + if let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() { + let doubleValue = number.doubleValue + if doubleValue.isFinite, + doubleValue.rounded(.towardZero) == doubleValue, + doubleValue >= Double(Int64.min), + doubleValue <= Double(Int64.max) { + strings.insert(String(Int64(doubleValue))) + } + } + return Array(strings) + } + private func bridgeValue(_ value: Any?) -> Any? { guard let value, !(value is NSNull) else { return nil From e5e49cc7c04efad661b2ef751e9838052efcc9aa Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 20:54:17 -0400 Subject: [PATCH 16/18] Retry Android native FFE smoke install --- .../native-ffe-offline-android-smoke.sh | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/example-new-architecture/scripts/native-ffe-offline-android-smoke.sh b/example-new-architecture/scripts/native-ffe-offline-android-smoke.sh index 6406ca036..69a1b7ae0 100755 --- a/example-new-architecture/scripts/native-ffe-offline-android-smoke.sh +++ b/example-new-architecture/scripts/native-ffe-offline-android-smoke.sh @@ -102,10 +102,20 @@ start_metro_if_needed() { install_and_launch_app() { "$ADB" reverse tcp:8081 tcp:8081 >/dev/null 2>&1 || true - ( - cd "$ANDROID_DIR" - ./gradlew --no-daemon :app:installDebug - ) + for attempt in 1 2 3; do + if ( + cd "$ANDROID_DIR" + ./gradlew --no-daemon :app:installDebug + ); then + break + fi + if [[ "$attempt" == "3" ]]; then + echo "Failed to install Android example after $attempt attempts." >&2 + exit 1 + fi + echo "Android example install failed; retrying attempt $((attempt + 1)) of 3." >&2 + sleep 20 + done "$ADB" shell am force-stop "$PACKAGE_NAME" >/dev/null 2>&1 || true "$ADB" shell am start -n "$ACTIVITY_NAME" >/dev/null } From dea1877c75d2edce9ee4dba46cf9b8c54f22b59f Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 21:14:21 -0400 Subject: [PATCH 17/18] Harden Android smoke dependency resolution --- example-new-architecture/android/build.gradle | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/example-new-architecture/android/build.gradle b/example-new-architecture/android/build.gradle index 9eb5eac03..f1b798643 100644 --- a/example-new-architecture/android/build.gradle +++ b/example-new-architecture/android/build.gradle @@ -1,3 +1,5 @@ +import org.gradle.api.artifacts.repositories.MavenArtifactRepository + // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { @@ -21,3 +23,16 @@ buildscript { } apply plugin: "com.facebook.react.rootproject" + +subprojects { + repositories.configureEach { repo -> + if ( + repo instanceof MavenArtifactRepository && + repo.url.toString().contains("oss.sonatype.org/content/repositories/snapshots") + ) { + repo.content { + excludeGroupByRegex("com\\.datadoghq(\\..*)?") + } + } + } +} From f423aa965f542b506ab0a323cc1cee6dd2f34754 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 30 Jun 2026 21:29:11 -0400 Subject: [PATCH 18/18] Restrict Android smoke snapshot repository --- example-new-architecture/android/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example-new-architecture/android/build.gradle b/example-new-architecture/android/build.gradle index f1b798643..94bf25930 100644 --- a/example-new-architecture/android/build.gradle +++ b/example-new-architecture/android/build.gradle @@ -30,8 +30,8 @@ subprojects { repo instanceof MavenArtifactRepository && repo.url.toString().contains("oss.sonatype.org/content/repositories/snapshots") ) { - repo.content { - excludeGroupByRegex("com\\.datadoghq(\\..*)?") + repo.mavenContent { + snapshotsOnly() } } }