diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 90b79c23e7a..2af6e13626b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,6 +14,11 @@ compile_rust.sh @Datadog/libdatadog-apm # APM IDM Team /src/ @DataDog/apm-idm-php +# FFE (Feature Flagging & Experimentation) SDK Team +/src/DDTrace/FeatureFlags/ @DataDog/feature-flagging-and-experimentation-sdk +/src/DDTrace/OpenFeature/ @DataDog/feature-flagging-and-experimentation-sdk +/tests/FeatureFlags/ @DataDog/feature-flagging-and-experimentation-sdk + # Release files Cargo.lock @DataDog/apm-php @DataDog/profiling-php @Datadog/libdatadog-apm package.xml @DataDog/apm-php @DataDog/profiling-php @Datadog/asm-php diff --git a/.gitlab/generate-tracer.php b/.gitlab/generate-tracer.php index 6e6e9541f14..6232177325d 100644 --- a/.gitlab/generate-tracer.php +++ b/.gitlab/generate-tracer.php @@ -386,6 +386,26 @@ function before_script_steps($with_docker_auth = false) { - make test_unit PHPUNIT_JUNIT="artifacts/tests/php-tests.xml" +=")): ?> +"Feature flags tests: []": + extends: .debug_test + needs: + - job: "compile extension: debug" + parallel: + matrix: + - PHP_MAJOR_MINOR: "" + ARCH: "amd64" + artifacts: true + - job: "Prepare code" + artifacts: true + variables: + PHP_MAJOR_MINOR: "" + ARCH: "amd64" + script: + - make test_featureflags PHPUNIT_JUNIT="artifacts/tests/php-tests.xml" + + + "API unit tests: []": extends: .debug_test needs: diff --git a/Cargo.lock b/Cargo.lock index 584c8b992fd..9b707d2be83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1453,6 +1453,7 @@ dependencies = [ "bincode", "cbindgen 0.27.0", "const-str", + "datadog-ffe", "datadog-ipc", "datadog-live-debugger", "datadog-live-debugger-ffi", @@ -1476,6 +1477,7 @@ dependencies = [ "libdd-trace-stats", "libdd-trace-utils", "log", + "lru", "paste", "regex", "regex-automata", @@ -3237,6 +3239,15 @@ dependencies = [ "value-bag", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.2", +] + [[package]] name = "mach2" version = "0.5.0" diff --git a/FFE_FLUSH_PLAN.md b/FFE_FLUSH_PLAN.md new file mode 100644 index 00000000000..96e5220b167 --- /dev/null +++ b/FFE_FLUSH_PLAN.md @@ -0,0 +1,364 @@ +# FFE Exposure Flush — Implementation Plan + +## Context + +PR #3630 introduces FFE (Feature Flag Evaluation) in the PHP tracer. Review caught +that `ddog_ffe_flush_exposures` in [components-rs/ffe.rs:516](components-rs/ffe.rs) +has no production caller: the only invoker is the PHP userland function +`DDTrace\ffe_flush_exposures()` wired at [ext/ddtrace.c:3040](ext/ddtrace.c), +which itself has zero callers outside of tests. Exposures enqueue forever into +`EXPOSURE_STATE` and never reach the agent EVP proxy. + +**Scope: NTS PHP builds only (v1 target, per PROJECT.md).** ZTS explicitly +deferred. Architecture is ZTS-compatible (`Mutex` is thread-safe), +but ZTS CI validation is out of scope until a follow-up phase. + +## Architecture decision (A+) + +Evaluation stays in-process. Exposure state (dedup cache + batch buffer) stays +in-process. Sidecar owns async HTTP transport to the agent. Flush is triggered +by PHP request/module shutdown, not a background timer (PHP has no background +threads in either NTS or ZTS). + +Rejected alternatives: +- **B (state in sidecar):** per-evaluation IPC on hot path. Latency regression + that scales worst under ZTS (N threads × eval rate × IPC RTT). +- **C (sync HTTP in PHP):** libcurl on the request path. Blocks responses. + +Rationale documented in session 2026-04-22 discussion. + +### Topology + +``` +PHP process Sidecar process Agent +─────────── ─────────────── ───── +DDTrace_ffe_evaluate ──► FFE_STATE + │ + └──► EXPOSURE_STATE (global Mutex) + │ + RSHUTDOWN/MSHUTDOWN flush + │ + ddog_ffe_flush_exposures → payload CharSlice + │ + ddog_sidecar_send_ffe_exposures(payload) + │ (IPC) + ▼ + sidecar_server ─► ffe_flusher + │ POST + ▼ + /evp_proxy/v2/api/v2/exposures + X-Datadog-EVP-Subdomain: + event-platform-intake +``` + +### Schema (confirmed across all 5 tracers) + +Cross-tracer protocol research (2026-04-22): + +| Tracer | Endpoint | Subdomain header | Interval | agent_info gate | +|---|---|---|---|---| +| dd-trace-go | `/evp_proxy/v2/api/v2/exposures` | `event-platform-intake` | 1s | none | +| dd-trace-rb | `/evp_proxy/v2/api/v2/exposures` | `event-platform-intake` | worker-driven | none | +| dd-trace-py | `/evp_proxy/v2/api/v2/exposures` | `event-platform-intake` | periodic writer | none | +| dd-trace-js | `/evp_proxy/v2/api/v2/exposures` | `event-platform-intake` | interval + buffer-fill | none | +| dd-trace-dotnet | `evp_proxy/v2/api/v2/exposures` | (EventPlatform helper) | 10s | none | + +**No tracer gates exposure submission on `agent_info`.** PHP matches — POST +direct, log `debug` on non-2xx, no capability check. + +- **Endpoint:** `POST /evp_proxy/v2/api/v2/exposures` +- **Subdomain header:** `X-Datadog-EVP-Subdomain: event-platform-intake` +- **Content-Type:** `application/json` +- **Timeout:** 5s +- **Dedup cache capacity:** 65536 +- **Payload:** + ```json + { + "context": {"service": "...", "version": "...", "env": "..."}, + "exposures": [ + { + "timestamp": 1234567890, + "flag": {"key": "..."}, + "allocation": {"key": "..."}, + "variant": {"key": "..."}, + "subject": {"id": "...", "attributes": {...}} + } + ] + } + ``` + +PHP's [ExposureWriter::buildEventJson](src/DDTrace/OpenFeature/ExposureWriter.php) +already emits per-event payloads matching this schema (verified +2026-04-22). Batch wrapper built by `ddog_ffe_flush_exposures`. + +### ZTS / NTS coverage + +| Concern | NTS | ZTS | +|---|---|---| +| `EXPOSURE_STATE` global Mutex | 1 thread, zero contention | N threads hit same mutex, microsecond lock | +| Drain semantics | whole buffer each RSHUTDOWN | first thread to RSHUTDOWN ships all threads' events | +| Service context | 1 global | 1 global (PHP service identity is process-level) | +| Transport | 1 sidecar transport | per-thread sidecar transport (commit `6b55c3ee5`) | +| Flush triggers | RSHUTDOWN + MSHUTDOWN | per-thread RSHUTDOWN + process MSHUTDOWN | + +`FFE_STATE` (config) per-thread migration for ZTS is a separate follow-up +already flagged at [components-rs/ffe.rs:25-28](components-rs/ffe.rs). +Exposure state does not need per-thread split — it is aggregate telemetry, +not per-request-context data. + +## Dedup key alignment + +dd-trace-go uses `key=(flag_key, targeting_key) → value=(allocation_key, variant)`. +Current PHP impl at [components-rs/ffe.rs:475](components-rs/ffe.rs) uses +`key=flag\0alloc\0targeting → value=variant`. + +Difference: PHP's version creates a new cache entry on every allocation change +for the same (flag, subject). Orphans old entries until LRU evicts, wastes cache +capacity on allocation churn. + +Aligning to dd-trace-go: `add(key, value)` returns `true` when entry is new OR +value changed (allocation or variant); `false` when both match. Emits the new +exposure and updates the cache entry in-place. + +## Task breakdown + +### T1 — align dedup key shape +**File:** `components-rs/ffe.rs:356-498` +- Split `ExposureState` so dedup key = `(flag_key, targeting_key)`, value = + `(allocation_key, variant_key)`. +- `ddog_ffe_enqueue_exposure`: return `false` only when both allocation and + variant unchanged; update entry on change. + +### T2 — `SidecarAction::FfeExposures` variant +**File:** `libdatadog/datadog-sidecar/src/service/mod.rs:78-83` +- Add `FfeExposures(String)` to the `SidecarAction` enum. +- Wire dispatch in `libdatadog/datadog-sidecar/src/service/sidecar_server.rs` + `enqueue_actions` match arm → `ffe_flusher.submit(payload)`. + +### T3 — sidecar `ffe_flusher` module +**Files:** `libdatadog/datadog-sidecar/src/service/ffe_flusher.rs` (new), +module registered in `service/mod.rs`. +- Mirror `trace_flusher.rs` shape: tokio task + unbounded channel, hyper + client with 5s timeout. +- POST to `/evp_proxy/v2/api/v2/exposures` with + `Content-Type: application/json` and + `X-Datadog-EVP-Subdomain: event-platform-intake`. +- Log error on non-2xx (mirror dd-trace-go behaviour — no agent-version gate, + no direct-intake fallback for v1). + +### T4 — FFI wrapper in components-rs +**File:** `components-rs/ffe.rs` (append) or `components-rs/sidecar.rs` +- `ddog_sidecar_send_ffe_exposures(transport, instance_id, queue_id, CharSlice payload) -> MaybeError` +- Body: `blocking::enqueue_actions(transport, instance_id, queue_id, + vec![SidecarAction::FfeExposures(payload.to_utf8_lossy().into_owned())])` + +### T5 — RSHUTDOWN hook +**File:** `ext/sidecar.c::ddtrace_sidecar_rshutdown` +```c +ddog_CharSlice payload = ddog_ffe_flush_exposures(); +if (payload.ptr != NULL && payload.len > 0) { + ddog_sidecar_send_ffe_exposures( + &DDTRACE_G(sidecar), + ddtrace_sidecar_instance_id, + &DDTRACE_G(sidecar_queue_id), + payload); + ddog_ffe_free_flush_result(payload); +} +``` + +### T6 — MSHUTDOWN hook +**File:** `ext/sidecar.c::ddtrace_sidecar_shutdown` (or nearest module shutdown) +- Same call pattern as T5. Catches any exposures enqueued after the final + RSHUTDOWN of the process. + +### T7 — doc comment rewrite +**File:** `components-rs/ffe.rs:504-506` +- Replace "In production, the sidecar's periodic flush loop calls this function" + with: + "Called from PHP RSHUTDOWN and MSHUTDOWN hooks. The returned payload is + forwarded to the sidecar via `ddog_sidecar_send_ffe_exposures`, which POSTs + it asynchronously to the agent's EVP proxy at `/evp_proxy/v2/api/v2/exposures`." + +### T8 (deferred) — buffer-threshold flush +For long-running CLI / worker scripts. Flag-based: enqueue sets a "flush needed" +atomic when buffer passes threshold; RSHUTDOWN checks flag first. Avoids +transport plumbing into `ffe.rs`. Separate PR. + +### T9 — fork handler resets exposure state in child +**File:** `ext/sidecar.c::ddtrace_sidecar_handle_fork` +- After existing fork logic, call `ddog_ffe_reset_exposure_state()` (exists at + `components-rs/ffe.rs:597`). +- Prevents double-send of parent's pre-fork buffered events by clearing the + child's inherited buffer. Dedup cache is also cleared — accepted per-tracer + guidance (≤1 extra exposure per unique (flag, subject) per fork event; + server-side dedup catches it). +- Parent keeps state → flushes at parent RSHUTDOWN. No parent-side data loss. + +## Validation plan + +Each validation step is a real command. `PASS` gate = exit code 0 and the +documented assertion holds. + +### V1 — Unit: dedup key semantics (Rust) +**What it proves:** T1 dedup behaviour matches dd-trace-go. +**New test:** `components-rs/ffe.rs` `#[cfg(test)] mod tests` +**Run:** +``` +cargo test -p ddtrace-php ffe::tests::dedup_key +``` +**Assertion:** same (flag, targeting) with changed allocation OR variant +re-enqueues; with both unchanged returns `false`. + +### V2 — Unit: batch payload schema (Rust) +**What it proves:** `ddog_ffe_flush_exposures` output is byte-identical to +dd-trace-go expectation when given the same inputs. +**New test:** `components-rs/ffe.rs` `#[cfg(test)] mod tests::flush_schema` +**Run:** +``` +cargo test -p ddtrace-php ffe::tests::flush_schema +``` +**Assertion:** parsed JSON contains `context.{service,env,version}` and +`exposures[]` each with `timestamp`, `flag.key`, `allocation.key`, +`variant.key`, `subject.id`, `subject.attributes`. + +### V3 — Unit: sidecar flusher HTTP wire (Rust) +**What it proves:** T3 ffe_flusher sends correct method, path, headers, body. +**New test:** `libdatadog/datadog-sidecar/src/service/ffe_flusher.rs` +`#[cfg(test)] mod tests::posts_to_evp_proxy` +**Mechanism:** spawn `httpmock::MockServer` (already a dev-dep in +`libdatadog/datadog-sidecar/Cargo.toml`) on a loopback port; point +`agent_url` at it; submit one payload; verify the received request. +**Run:** +``` +cargo test -p datadog-sidecar ffe_flusher::tests +``` +**Assertion:** +- method == POST +- path == `/evp_proxy/v2/api/v2/exposures` +- header `x-datadog-evp-subdomain` == `event-platform-intake` +- body parses as the exposurePayload schema + +### V4 — PHP: RSHUTDOWN triggers flush (phpt) +**What it proves:** T5 hook fires on request end and the FFI-level exposure +buffer is drained after RSHUTDOWN. +**New test:** `tests/ext/ffe/rshutdown_flush.phpt` +**Mechanism:** `.phpt` script that: +1. Calls `DDTrace\ffe_send_exposure(...)` to enqueue one event +2. Asserts `DDTrace\ffe_flush_exposures()` would return non-null (buffer warm) +3. Ends — `.phpt` SAPI drives RSHUTDOWN automatically +4. A second `.phpt` scenario in same file re-initializes, calls + `ffe_flush_exposures()` immediately, asserts it returns null (buffer was + drained by prior RSHUTDOWN) + +**Why not PHPUnit + `sidecarCallable`:** the closure-injection mock +(`tests/OpenFeature/*Test.php`) validates the userland ExposureWriter layer +only. RSHUTDOWN is below userland — it fires in C and calls +`ddog_ffe_flush_exposures` directly, bypassing the injectable closure. `.phpt` +probe directly at the FFI boundary is the correct test surface. + +**Run:** +``` +make test_featureflags TESTS=tests/ext/ffe/rshutdown_flush.phpt +``` +**Assertion:** second scenario's `ffe_flush_exposures()` returns null (proving +RSHUTDOWN of first scenario drained the buffer). + +### V5 — PHP integration: round-trip to mock agent +**What it proves:** entire pipeline (PHP → FFI → sidecar → HTTP) works against +a locally-run mock agent. +**New test:** new `tests/OpenFeature/ExposureTransportTest.php` or a `.phpt` +under `tests/ext/ffe/`. +**Mechanism:** start a lightweight native `stream_socket_server` mock agent +on `127.0.0.1:0`; export `DD_TRACE_AGENT_URL=http://127.0.0.1:` before +the test SAPI spawns the sidecar; evaluate a flag via +`DDTrace\ffe_send_exposure`; end request (RSHUTDOWN); poll mock agent log. +**Run:** +``` +make test_featureflags +``` +**Assertion:** mock agent log shows one POST to +`/evp_proxy/v2/api/v2/exposures` with `X-Datadog-EVP-Subdomain: +event-platform-intake`. + +### V6 — PHP integration: dedup across requests +**What it proves:** LRU dedup survives across RSHUTDOWN for the same +(flag, subject). +**New test:** extends `tests/OpenFeature/CrossRequestDedupTest.php`. +**Mechanism:** evaluate the same flag with the same subject in two consecutive +requests separated by RSHUTDOWN; assert the mock agent received only one +exposure. +**Run:** `make test_featureflags`. +**Assertion:** `mock.requestCount() == 1`. + +### V7 — (removed — ZTS out of scope for v1) + +### V8 — Lint / format guards (non-regression) +**Run:** +``` +cargo fmt --check +cargo clippy --workspace --all-targets -- -D warnings +php -l ext/sidecar.c # not applicable; use existing C lint path +make format_c_check +``` +**Assertion:** all green. No new warnings introduced. + +### V9 — Fork dedup smoke test (PHP integration) +**What it proves:** T9 fork hook resets child state; bounded duplication per +fork event, no double-send of pre-fork parent buffer. +**New test:** `tests/ext/ffe/fork_dedup.phpt` (pcntl pattern already used in +`tests/ext/pcntl/*.phpt` — CI has pcntl enabled). +**Mechanism:** +1. Parent evaluates flag A with subject X → parent buffers 1 exposure. +2. Parent calls `pcntl_fork()`. +3. Child evaluates flag A with subject X → dedup cache reset by T9, child + buffers 1 exposure. +4. Parent evaluates flag A with subject X again → dedup cache still warm in + parent, buffers 0 exposures. +5. Child RSHUTDOWN → mock agent receives 1 exposure (from child). +6. Parent RSHUTDOWN → mock agent receives 1 exposure (from parent's pre-fork + enqueue). +**Run:** +``` +make test_featureflags TESTS=tests/ext/ffe/fork_dedup.phpt +``` +**Assertion:** `mock.requestCount() == 2` (one parent + one child). Not 1 +(would mean child lost its event), not 3+ (would mean no dedup or parent +double-sent). + +## Ordering + +``` +T1 ─► T2 ─► T3 ─► T4 ─► T5 ─► V4 + │ │ + └──► V5 ─► V6 + │ + ├─► T6 + ├─► T7 + └─► T9 ─► V9 +V1, V2 in parallel with T1–T4. +V3 gates after T3. +V8 runs in CI per commit. +``` + +## Out of scope + +- **ZTS support entirely** — v1 targets NTS only (per PROJECT.md). Architecture + is ZTS-compatible (`Mutex` is thread-safe) so future ZTS + enablement is a drop-in. No ZTS test job added in this PR. +- `FFE_STATE` per-thread migration for ZTS (separate phase, see ffe.rs:25-28). +- Buffer-threshold flush trigger for long-lived CLI / worker scripts (T8, + separate PR). +- Agent-version gating / direct-intake fallback (not done by dd-trace-go for v1). +- Telemetry / self-metrics on flush success/failure rates (follow-up). + +## Resolved decisions + +1. **`agent_info` gate — SKIP.** Protocol research across Go, Ruby, Python, JS, + .NET confirmed zero tracers gate exposure submission on + `evp_proxy_allowed_headers` or any other agent_info capability. All POST + direct and log on non-2xx. PHP matches. +2. **Fork handling — child resets state (T9).** Parent keeps its buffer for + its own RSHUTDOWN flush. Child clears dedup cache + buffer via + `ddog_ffe_reset_exposure_state()` in `ddtrace_sidecar_handle_fork`. + Accepts bounded duplication: ≤ 1 extra exposure per unique (flag, subject) + per fork event. Server-side dedup catches residual dup. diff --git a/Makefile b/Makefile index 1d017c7308b..319ebd67489 100644 --- a/Makefile +++ b/Makefile @@ -1328,6 +1328,9 @@ test_distributed_tracing_coverage: test_metrics: global_test_run_dependencies $(call run_tests,--testsuite=metrics $(TESTS)) +test_featureflags: global_test_run_dependencies tests/OpenFeature/composer.lock-php$(PHP_MAJOR_MINOR) + $(call run_tests,--testsuite=featureflags $(TESTS)) + benchmarks_run_dependencies: global_test_run_dependencies tests/Frameworks/Symfony/Version_5_2/composer.lock-php$(PHP_MAJOR_MINOR) tests/Frameworks/Laravel/Version_10_x/composer.lock-php$(PHP_MAJOR_MINOR) tests/Benchmarks/composer.lock-php$(PHP_MAJOR_MINOR) php tests/Frameworks/Symfony/Version_5_2/bin/console cache:clear --no-warmup --env=prod diff --git a/components-rs/Cargo.toml b/components-rs/Cargo.toml index a6103bcde96..b31c50c9d83 100644 --- a/components-rs/Cargo.toml +++ b/components-rs/Cargo.toml @@ -15,7 +15,7 @@ libdd-telemetry-ffi = { path = "../libdatadog/libdd-telemetry-ffi", default-feat datadog-live-debugger = { path = "../libdatadog/datadog-live-debugger" } datadog-live-debugger-ffi = { path = "../libdatadog/datadog-live-debugger-ffi", default-features = false } datadog-ipc = { path = "../libdatadog/datadog-ipc" } -datadog-remote-config = { path = "../libdatadog/datadog-remote-config" } +datadog-remote-config = { path = "../libdatadog/datadog-remote-config", features = ["ffe"] } datadog-sidecar = { path = "../libdatadog/datadog-sidecar" } datadog-sidecar-ffi = { path = "../libdatadog/datadog-sidecar-ffi" } libdd-data-pipeline = { path = "../libdatadog/libdd-data-pipeline" } @@ -25,6 +25,7 @@ libdd-trace-stats = { path = "../libdatadog/libdd-trace-stats" } libdd-crashtracker-ffi = { path = "../libdatadog/libdd-crashtracker-ffi", default-features = false, features = ["collector"] } libdd-library-config-ffi = { path = "../libdatadog/libdd-library-config-ffi", default-features = false } spawn_worker = { path = "../libdatadog/spawn_worker" } +datadog-ffe = { path = "../libdatadog/datadog-ffe" } anyhow = { version = "1.0" } const-str = "0.5.6" itertools = "0.11.0" @@ -32,6 +33,7 @@ serde = "1.0.196" simd-json = "0.14.1" serde_with = "3.6.0" lazy_static = "1.4" +lru = "0.12" log = "0.4.20" env_logger = "0.10.1" zwohash = "0.1.2" diff --git a/components-rs/common.h b/components-rs/common.h index 2d259bd9bb9..551f2e3c2ac 100644 --- a/components-rs/common.h +++ b/components-rs/common.h @@ -439,6 +439,11 @@ typedef struct ddog_DebuggerPayload ddog_DebuggerPayload; typedef struct ddog_DslString ddog_DslString; +/** + * Opaque handle for FFE evaluation results returned to C/PHP. + */ +typedef struct ddog_FfeResult ddog_FfeResult; + typedef struct ddog_HashMap_ShmCacheKey__ShmCache ddog_HashMap_ShmCacheKey__ShmCache; /** @@ -478,6 +483,20 @@ typedef struct ddog_SidecarTransport ddog_SidecarTransport; */ typedef struct ddog_SpanConcentrator ddog_SpanConcentrator; +/** + * Flags selecting which Remote Config products/capabilities to subscribe to. + * + * Passed as a single C-ABI struct so call sites can use designated initializers + * and name the flags, instead of a positional sequence of four `bool` args + * (per dd-oleksii review, PR #3630). + */ +typedef struct ddog_DdogRemoteConfigFlags { + bool live_debugging_enabled; + bool appsec_activation; + bool appsec_config; + bool ffe_enabled; +} ddog_DdogRemoteConfigFlags; + /** * Holds the raw parts of a Rust Vec; it should only be created from Rust, * never from C. @@ -679,6 +698,20 @@ typedef struct ddog_Vec_DebuggerPayload { */ typedef uint64_t ddog_QueueId; +/** + * A single attribute passed from C/PHP for building an EvaluationContext. + */ +typedef struct ddog_FfeAttribute { + const char *key; + /** + * 0 = string, 1 = number, 2 = bool + */ + int32_t value_type; + const char *string_value; + double number_value; + bool bool_value; +} ddog_FfeAttribute; + /** * A (key, value) pair for peer-service tags, borrowed from PHP/concentrator memory. */ diff --git a/components-rs/ddtrace.h b/components-rs/ddtrace.h index 7c575e34319..46cbf2bfae7 100644 --- a/components-rs/ddtrace.h +++ b/components-rs/ddtrace.h @@ -118,9 +118,7 @@ void ddog_reset_logger(void); uint32_t ddog_get_logs_count(ddog_CharSlice level); -void ddog_init_remote_config(bool live_debugging_enabled, - bool appsec_activation, - bool appsec_config); +void ddog_init_remote_config(struct ddog_DdogRemoteConfigFlags flags); struct ddog_RemoteConfigState *ddog_init_remote_config_state(const struct ddog_Endpoint *endpoint, bool di_enabled); @@ -184,6 +182,129 @@ ddog_MaybeError ddog_send_debugger_diagnostics(const struct ddog_RemoteConfigSta const struct ddog_Probe *probe, uint64_t timestamp); +/** + * Load a UFC JSON config string directly into the FFE engine. + * Used by tests to load config without Remote Config. + */ +bool ddog_ffe_load_config(const char *json); + +/** + * Check if FFE configuration is loaded. + */ +bool ddog_ffe_has_config(void); + +/** + * Return the current FFE config version counter. + * + * Bumped on every `store_config` / `clear_config`. Consumers track their last + * observed value and detect changes by comparing. Multiple independent + * subscribers can detect transitions without racing (unlike a drain-on-read + * `changed` flag where only the first reader sees the transition). + * + * Wraps on overflow; in practice the counter is a `u64` and will not wrap + * within a reasonable process lifetime. + */ +uint64_t ddog_ffe_config_version(void); + +/** + * Evaluate a feature flag using the stored Configuration. + * + * Accepts structured attributes from C instead of a JSON blob. + * `targeting_key` may be null (no targeting key). + * `attributes` / `attributes_count` describe an array of `FfeAttribute`. + * Returns null if no config is loaded. + */ +struct ddog_FfeResult *ddog_ffe_evaluate(const char *flag_key, + int32_t expected_type, + const char *targeting_key, + const struct ddog_FfeAttribute *attributes, + uintptr_t attributes_count); + +const char *ddog_ffe_result_value(const struct ddog_FfeResult *r); + +const char *ddog_ffe_result_variant(const struct ddog_FfeResult *r); + +const char *ddog_ffe_result_allocation_key(const struct ddog_FfeResult *r); + +int32_t ddog_ffe_result_reason(const struct ddog_FfeResult *r); + +int32_t ddog_ffe_result_error_code(const struct ddog_FfeResult *r); + +bool ddog_ffe_result_do_log(const struct ddog_FfeResult *r); + +void ddog_ffe_free_result(struct ddog_FfeResult *r); + +/** + * Set the service context (DD_SERVICE, DD_ENV, DD_VERSION) for exposure payloads. + * Called once during provider initialization. Values are stored and reused for all + * subsequent batch payloads. + * + * # Safety + * All parameters must be valid null-terminated C strings or null. + */ +void ddog_ffe_set_service_context(const char *service, const char *env, const char *version); + +/** + * Enqueue an exposure event for dedup and batched delivery. + * + * Dedup key = (flag_key, targeting_key). + * Dedup value = (allocation_key, variant_key). + * + * Returns `true` (event enqueued) when the (flag, targeting) pair is unseen, + * or when its cached (allocation, variant) differs from the incoming value. + * Returns `false` when both allocation and variant are unchanged from the + * last cached value — duplicate, skipped. + * + * Semantics match dd-trace-go `openfeature/exposure.go`. + * + * Batch buffer is capped at EXPOSURE_BATCH_LIMIT (1000). If full, new events + * are silently dropped (per D-11) and the function returns false. + * + * # Safety + * - `event_json` must be a valid null-terminated C string. + * - `flag_key`, `allocation_key`, `variant_key` must be valid null-terminated C strings. + * - `targeting_key` may be null. + */ +bool ddog_ffe_enqueue_exposure(const char *event_json, + const char *flag_key, + const char *allocation_key, + const char *targeting_key, + const char *variant_key); + +/** + * Flush all buffered exposure events as a batched JSON payload. + * + * Returns a JSON string containing the batch payload with service context and all + * buffered events. The batch buffer is cleared after flushing. + * + * In production, the sidecar's periodic flush loop calls this function and sends the + * result to the agent EVP proxy. + * + * Returns `CharSlice::default()` (null ptr, zero len) if: + * - The batch buffer is empty (nothing to flush) + * - The mutex is poisoned + * + * # Memory + * The returned CharSlice points to a heap-allocated string that must be freed + * with `ddog_ffe_free_flush_result()`. + */ +ddog_CharSlice ddog_ffe_flush_exposures(void); + +/** + * Free a flush result previously returned by `ddog_ffe_flush_exposures`. + * + * # Safety + * `slice` must be a CharSlice previously returned by `ddog_ffe_flush_exposures`, + * or a default (null) CharSlice. + */ +void ddog_ffe_free_flush_result(ddog_CharSlice slice); + +/** + * Reset exposure state for testing. Clears the dedup cache, batch buffer, + * and service context. + */ +void ddog_ffe_reset_exposure_state(void); + void ddog_sidecar_enable_appsec(ddog_CharSlice shared_lib_path, ddog_CharSlice socket_file_path, ddog_CharSlice lock_file_path, diff --git a/components-rs/ffe.rs b/components-rs/ffe.rs new file mode 100644 index 00000000000..4e84bb1e8ed --- /dev/null +++ b/components-rs/ffe.rs @@ -0,0 +1,753 @@ +use datadog_ffe::rules_based::{ + self as ffe, AssignmentReason, AssignmentValue, Attribute, Configuration, EvaluationContext, + EvaluationError, ExpectedFlagType, Str, UniversalFlagConfig, +}; +use libdd_common_ffi::CharSlice; +use lru::LruCache; +use std::collections::HashMap; +use std::ffi::{c_char, CStr, CString}; +use std::num::NonZeroUsize; +use std::sync::{Arc, Mutex}; + +/// Holds both the FFE configuration and a monotonic version counter atomically +/// behind a single Mutex. This avoids the race where another thread could +/// observe `config` updated but `version` still stale (or vice-versa). +/// +/// A `RwLock` would be more appropriate here (many readers via `ddog_ffe_evaluate`, +/// rare writer via `store_config`), but on NTS PHP builds — the v1 target — each +/// PHP process is single-threaded, so contention is not a practical concern. +/// +/// The `version` field is a monotonically-increasing counter bumped on every +/// `store_config` / `clear_config` call. Consumers compare against their last +/// observed value instead of consuming a `changed` flag, so multiple independent +/// subscribers can detect transitions without racing each other. +/// +/// NOTE: On ZTS builds (out of scope for FFE v1 — see PROJECT.md) per-thread +/// Remote Config receivers carry their own (service, env, version) target tuples +/// and would each expect their own FFE state. ZTS support requires moving this +/// state into `DDTRACE_G()` thread-local globals (tracked as a follow-up phase). +struct FfeState { + config: Option, + version: u64, +} + +lazy_static::lazy_static! { + static ref FFE_STATE: Mutex = Mutex::new(FfeState { + config: None, + version: 0, + }); +} + +/// Called by remote_config when a new FFE configuration arrives via RC. +pub fn store_config(config: Configuration) { + if let Ok(mut state) = FFE_STATE.lock() { + state.config = Some(config); + state.version = state.version.wrapping_add(1); + } +} + +/// Called by remote_config when an FFE configuration is removed. +pub fn clear_config() { + if let Ok(mut state) = FFE_STATE.lock() { + state.config = None; + state.version = state.version.wrapping_add(1); + } +} + +/// Load a UFC JSON config string directly into the FFE engine. +/// Used by tests to load config without Remote Config. +#[no_mangle] +pub extern "C" fn ddog_ffe_load_config(json: *const c_char) -> bool { + if json.is_null() { + return false; + } + let json_str = match unsafe { CStr::from_ptr(json) }.to_str() { + Ok(s) => s, + Err(_) => return false, + }; + match UniversalFlagConfig::from_json(json_str.as_bytes().to_vec()) { + Ok(ufc) => { + store_config(Configuration::from_server_response(ufc)); + true + } + Err(_) => false, + } +} + +/// Check if FFE configuration is loaded. +#[no_mangle] +pub extern "C" fn ddog_ffe_has_config() -> bool { + FFE_STATE + .lock() + .map(|s| s.config.is_some()) + .unwrap_or(false) +} + +/// Return the current FFE config version counter. +/// +/// Bumped on every `store_config` / `clear_config`. Consumers track their last +/// observed value and detect changes by comparing. Multiple independent +/// subscribers can detect transitions without racing (unlike a drain-on-read +/// `changed` flag where only the first reader sees the transition). +/// +/// Wraps on overflow; in practice the counter is a `u64` and will not wrap +/// within a reasonable process lifetime. +#[no_mangle] +pub extern "C" fn ddog_ffe_config_version() -> u64 { + FFE_STATE.lock().map(|s| s.version).unwrap_or(0) +} + +// Reason codes returned to PHP via ddog_ffe_result_reason(). +// Must match Provider::$REASON_MAP in src/DDTrace/FeatureFlags/Provider.php. +const REASON_STATIC: i32 = 0; +const REASON_DEFAULT: i32 = 1; +const REASON_TARGETING_MATCH: i32 = 2; +const REASON_SPLIT: i32 = 3; +const REASON_DISABLED: i32 = 4; +const REASON_ERROR: i32 = 5; + +// Error codes returned to PHP via ddog_ffe_result_error_code(). +// 0 means no error. +const ERROR_NONE: i32 = 0; +const ERROR_TYPE_MISMATCH: i32 = 1; +const ERROR_CONFIG_PARSE: i32 = 2; +const ERROR_FLAG_UNRECOGNIZED: i32 = 3; +const ERROR_CONFIG_MISSING: i32 = 6; +const ERROR_GENERAL: i32 = 7; + +// Attribute value types passed from C (matches FfeAttribute.value_type). +const ATTR_TYPE_STRING: i32 = 0; +const ATTR_TYPE_NUMBER: i32 = 1; +const ATTR_TYPE_BOOL: i32 = 2; + +// Expected flag type IDs passed from C (matches Provider::$TYPE_MAP). +const TYPE_STRING: i32 = 0; +const TYPE_INTEGER: i32 = 1; +const TYPE_FLOAT: i32 = 2; +const TYPE_BOOLEAN: i32 = 3; +const TYPE_OBJECT: i32 = 4; + +/// Opaque handle for FFE evaluation results returned to C/PHP. +pub struct FfeResult { + pub value_json: CString, + pub variant: Option, + pub allocation_key: Option, + pub reason: i32, + pub error_code: i32, + pub do_log: bool, +} + +/// A single attribute passed from C/PHP for building an EvaluationContext. +#[repr(C)] +pub struct FfeAttribute { + pub key: *const c_char, + /// 0 = string, 1 = number, 2 = bool + pub value_type: i32, + pub string_value: *const c_char, + pub number_value: f64, + pub bool_value: bool, +} + +/// Evaluate a feature flag using the stored Configuration. +/// +/// Accepts structured attributes from C instead of a JSON blob. +/// `targeting_key` may be null (no targeting key). +/// `attributes` / `attributes_count` describe an array of `FfeAttribute`. +/// Returns null if no config is loaded. +#[no_mangle] +pub extern "C" fn ddog_ffe_evaluate( + flag_key: *const c_char, + expected_type: i32, + targeting_key: *const c_char, + attributes: *const FfeAttribute, + attributes_count: usize, +) -> *mut FfeResult { + if flag_key.is_null() { + return std::ptr::null_mut(); + } + let flag_key = match unsafe { CStr::from_ptr(flag_key) }.to_str() { + Ok(s) => s, + Err(_) => return std::ptr::null_mut(), + }; + + let expected_type = match expected_type { + TYPE_STRING => ExpectedFlagType::String, + TYPE_INTEGER => ExpectedFlagType::Integer, + TYPE_FLOAT => ExpectedFlagType::Float, + TYPE_BOOLEAN => ExpectedFlagType::Boolean, + TYPE_OBJECT => ExpectedFlagType::Object, + _ => return std::ptr::null_mut(), + }; + + // Build targeting key + let tk = if targeting_key.is_null() { + None + } else { + match unsafe { CStr::from_ptr(targeting_key) }.to_str() { + Ok(s) if !s.is_empty() => Some(Str::from(s)), + _ => None, + } + }; + + // Build attributes map from the C array + let mut attrs = HashMap::new(); + if !attributes.is_null() && attributes_count > 0 { + let slice = unsafe { std::slice::from_raw_parts(attributes, attributes_count) }; + for attr in slice { + if attr.key.is_null() { + continue; + } + let key = match unsafe { CStr::from_ptr(attr.key) }.to_str() { + Ok(s) => s, + Err(_) => continue, + }; + let value = match attr.value_type { + ATTR_TYPE_STRING => { + if attr.string_value.is_null() { + continue; + } + match unsafe { CStr::from_ptr(attr.string_value) }.to_str() { + Ok(s) => Attribute::from(s), + Err(_) => continue, + } + } + ATTR_TYPE_NUMBER => Attribute::from(attr.number_value), + ATTR_TYPE_BOOL => Attribute::from(attr.bool_value), + _ => continue, + }; + attrs.insert(Str::from(key), value); + } + } + + let context = EvaluationContext::new(tk, Arc::new(attrs)); + + let state = match FFE_STATE.lock() { + Ok(s) => s, + Err(_) => return std::ptr::null_mut(), + }; + + let assignment = ffe::get_assignment( + state.config.as_ref(), + flag_key, + &context, + expected_type, + ffe::now(), + ); + + let result = match assignment { + Ok(a) => FfeResult { + value_json: CString::new(assignment_value_to_json(&a.value)).unwrap_or_default(), + variant: Some(CString::new(a.variation_key.as_str()).unwrap_or_default()), + allocation_key: Some(CString::new(a.allocation_key.as_str()).unwrap_or_default()), + reason: match a.reason { + AssignmentReason::Static => REASON_STATIC, + AssignmentReason::TargetingMatch => REASON_TARGETING_MATCH, + AssignmentReason::Split => REASON_SPLIT, + }, + error_code: ERROR_NONE, + do_log: a.do_log, + }, + Err(err) => { + let (error_code, reason) = match &err { + EvaluationError::TypeMismatch { .. } => (ERROR_TYPE_MISMATCH, REASON_ERROR), + EvaluationError::ConfigurationParseError => (ERROR_CONFIG_PARSE, REASON_ERROR), + EvaluationError::ConfigurationMissing => (ERROR_CONFIG_MISSING, REASON_ERROR), + EvaluationError::FlagUnrecognizedOrDisabled => { + (ERROR_FLAG_UNRECOGNIZED, REASON_DEFAULT) + } + EvaluationError::FlagDisabled => (ERROR_NONE, REASON_DISABLED), + EvaluationError::DefaultAllocationNull => (ERROR_NONE, REASON_DEFAULT), + _ => (ERROR_GENERAL, REASON_ERROR), + }; + FfeResult { + value_json: CString::new("null").unwrap_or_default(), + variant: None, + allocation_key: None, + reason, + error_code, + do_log: false, + } + } + }; + + Box::into_raw(Box::new(result)) +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_value(r: *const FfeResult) -> *const c_char { + if r.is_null() { + return std::ptr::null(); + } + unsafe { &*r }.value_json.as_ptr() +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_variant(r: *const FfeResult) -> *const c_char { + if r.is_null() { + return std::ptr::null(); + } + unsafe { &*r } + .variant + .as_ref() + .map(|s| s.as_ptr()) + .unwrap_or(std::ptr::null()) +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_allocation_key(r: *const FfeResult) -> *const c_char { + if r.is_null() { + return std::ptr::null(); + } + unsafe { &*r } + .allocation_key + .as_ref() + .map(|s| s.as_ptr()) + .unwrap_or(std::ptr::null()) +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_reason(r: *const FfeResult) -> i32 { + if r.is_null() { + return -1; + } + unsafe { &*r }.reason +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_error_code(r: *const FfeResult) -> i32 { + if r.is_null() { + return -1; + } + unsafe { &*r }.error_code +} + +#[no_mangle] +pub extern "C" fn ddog_ffe_result_do_log(r: *const FfeResult) -> bool { + if r.is_null() { + return false; + } + unsafe { &*r }.do_log +} + +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_free_result(r: *mut FfeResult) { + if !r.is_null() { + drop(Box::from_raw(r)); + } +} + +fn assignment_value_to_json(value: &AssignmentValue) -> String { + match value { + AssignmentValue::String(s) => serde_json::to_string(s.as_str()).unwrap_or_default(), + AssignmentValue::Integer(i) => i.to_string(), + AssignmentValue::Float(f) => serde_json::Number::from_f64(*f) + .map(|n| n.to_string()) + .unwrap_or_else(|| f.to_string()), + AssignmentValue::Boolean(b) => b.to_string(), + AssignmentValue::Json { raw, .. } => raw.get().to_string(), + } +} + +// --------------------------------------------------------------------------- +// ExposureState -- exposure dedup cache and batch buffer (persists across PHP requests) +// --------------------------------------------------------------------------- + +struct ServiceContext { + service: String, + env: String, + version: String, +} + +struct ExposureState { + /// LRU dedup cache. + /// Key = (flag_key, targeting_key). + /// Value = (allocation_key, variant_key). + /// + /// A new exposure is emitted when (flag, targeting) is unseen, or when its + /// (allocation, variant) value differs from what was last cached. Matches + /// dd-trace-go `openfeature/exposure.go` semantics. Capacity 65536 (EXPO-02). + dedup_cache: LruCache<(String, String), (String, String)>, + /// Buffered exposure event JSON strings, capped at 1000 (matches Ruby/Python). + batch_buffer: Vec, + /// Service context set once at init (DD_SERVICE, DD_ENV, DD_VERSION). + service_context: Option, +} + +lazy_static::lazy_static! { + static ref EXPOSURE_STATE: Mutex = Mutex::new(ExposureState { + dedup_cache: LruCache::new(NonZeroUsize::new(65536).unwrap()), + batch_buffer: Vec::new(), + service_context: None, + }); +} + +/// Maximum number of events in the batch buffer before new events are dropped. +const EXPOSURE_BATCH_LIMIT: usize = 1000; + +// --------------------------------------------------------------------------- +// Exposure pipeline -- extern "C" functions +// --------------------------------------------------------------------------- + +/// Set the service context (DD_SERVICE, DD_ENV, DD_VERSION) for exposure payloads. +/// Called once during provider initialization. Values are stored and reused for all +/// subsequent batch payloads. +/// +/// # Safety +/// All parameters must be valid null-terminated C strings or null. +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_set_service_context( + service: *const c_char, + env: *const c_char, + version: *const c_char, +) { + let svc = if service.is_null() { + String::new() + } else { + CStr::from_ptr(service).to_str().unwrap_or("").to_owned() + }; + let env_str = if env.is_null() { + String::new() + } else { + CStr::from_ptr(env).to_str().unwrap_or("").to_owned() + }; + let ver = if version.is_null() { + String::new() + } else { + CStr::from_ptr(version).to_str().unwrap_or("").to_owned() + }; + + if let Ok(mut state) = EXPOSURE_STATE.lock() { + state.service_context = Some(ServiceContext { + service: svc, + env: env_str, + version: ver, + }); + } +} + +/// Enqueue an exposure event for dedup and batched delivery. +/// +/// Dedup key = (flag_key, targeting_key). +/// Dedup value = (allocation_key, variant_key). +/// +/// Returns `true` (event enqueued) when the (flag, targeting) pair is unseen, +/// or when its cached (allocation, variant) differs from the incoming value. +/// Returns `false` when both allocation and variant are unchanged from the +/// last cached value — duplicate, skipped. +/// +/// Semantics match dd-trace-go `openfeature/exposure.go`. +/// +/// Batch buffer is capped at EXPOSURE_BATCH_LIMIT (1000). If full, new events +/// are silently dropped (per D-11) and the function returns false. +/// +/// # Safety +/// - `event_json` must be a valid null-terminated C string. +/// - `flag_key`, `allocation_key`, `variant_key` must be valid null-terminated C strings. +/// - `targeting_key` may be null. +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_enqueue_exposure( + event_json: *const c_char, + flag_key: *const c_char, + allocation_key: *const c_char, + targeting_key: *const c_char, + variant_key: *const c_char, +) -> bool { + if event_json.is_null() || flag_key.is_null() || variant_key.is_null() { + return false; + } + + let event_str = match CStr::from_ptr(event_json).to_str() { + Ok(s) => s.to_owned(), + Err(_) => return false, + }; + let flag_str = match CStr::from_ptr(flag_key).to_str() { + Ok(s) => s.to_owned(), + Err(_) => return false, + }; + let alloc_str = if allocation_key.is_null() { + String::new() + } else { + match CStr::from_ptr(allocation_key).to_str() { + Ok(s) => s.to_owned(), + Err(_) => return false, + } + }; + let target_str = if targeting_key.is_null() { + String::new() + } else { + match CStr::from_ptr(targeting_key).to_str() { + Ok(s) => s.to_owned(), + Err(_) => return false, + } + }; + let variant_str = match CStr::from_ptr(variant_key).to_str() { + Ok(s) => s.to_owned(), + Err(_) => return false, + }; + + let dedup_key = (flag_str, target_str); + let dedup_value = (alloc_str, variant_str); + + if let Ok(mut state) = EXPOSURE_STATE.lock() { + // Dedup check: same (flag, targeting) with same (allocation, variant) = duplicate. + if let Some(cached) = state.dedup_cache.get(&dedup_key) { + if *cached == dedup_value { + return false; // duplicate, not enqueued + } + } + + // Insert or update entry. `put` updates LRU position and replaces value. + state.dedup_cache.put(dedup_key, dedup_value); + + // Buffer the event JSON (drop if buffer full per D-11). + if state.batch_buffer.len() < EXPOSURE_BATCH_LIMIT { + state.batch_buffer.push(event_str); + true + } else { + false // buffer full, event dropped + } + } else { + false + } +} + +/// Flush all buffered exposure events as a batched JSON payload. +/// +/// Returns a JSON string containing the batch payload with service context and all +/// buffered events. The batch buffer is cleared after flushing. +/// +/// In production this is called from the PHP extension's RSHUTDOWN and +/// MSHUTDOWN hooks (see `ext/sidecar.c::dd_flush_ffe_exposures`). The returned +/// payload is forwarded to the sidecar via `ddog_sidecar_send_ffe_exposures`, +/// which POSTs it asynchronously to the agent's EVP proxy at +/// `/evp_proxy/v2/api/v2/exposures` (header +/// `X-Datadog-EVP-Subdomain: event-platform-intake`). +/// +/// Returns `CharSlice::default()` (null ptr, zero len) if: +/// - The batch buffer is empty (nothing to flush) +/// - The mutex is poisoned +/// +/// # Memory +/// The returned CharSlice points to a heap-allocated string that must be freed +/// with `ddog_ffe_free_flush_result()`. +#[no_mangle] +pub extern "C" fn ddog_ffe_flush_exposures() -> CharSlice<'static> { + if let Ok(mut state) = EXPOSURE_STATE.lock() { + if state.batch_buffer.is_empty() { + return CharSlice::default(); + } + + let events: Vec = state.batch_buffer.drain(..).collect(); + + // Build batched payload matching Ruby/Python format + let context_json = match &state.service_context { + Some(ctx) => format!( + r#"{{"service":"{}","env":"{}","version":"{}"}}"#, + escape_json_string(&ctx.service), + escape_json_string(&ctx.env), + escape_json_string(&ctx.version), + ), + None => r#"{"service":"","env":"","version":""}"#.to_owned(), + }; + + // Events are already JSON strings from PHP side, join as array elements + let events_json = events.join(","); + let payload = format!( + r#"{{"context":{},"exposures":[{}]}}"#, + context_json, events_json + ); + + // Leak the string so the CharSlice is valid after return. + // Caller must free via ddog_ffe_free_flush_result(). + let leaked = payload.into_boxed_str(); + let ptr = leaked.as_ptr(); + let len = leaked.len(); + std::mem::forget(leaked); + + // Safety: `ptr` points to a leaked Box of `len` bytes. The allocation + // outlives this return. Caller must free via ddog_ffe_free_flush_result(). + unsafe { CharSlice::from_raw_parts(ptr as *const c_char, len) } + } else { + CharSlice::default() + } +} + +/// Free a flush result previously returned by `ddog_ffe_flush_exposures`. +/// +/// # Safety +/// `slice` must be a CharSlice previously returned by `ddog_ffe_flush_exposures`, +/// or a default (null) CharSlice. +#[no_mangle] +pub unsafe extern "C" fn ddog_ffe_free_flush_result(slice: CharSlice<'static>) { + use libdd_common_ffi::slice::AsBytes; + let bytes = slice.as_bytes(); + let len = bytes.len(); + let ptr = bytes.as_ptr() as *mut u8; + if !ptr.is_null() && len > 0 { + // Reconstruct the boxed str from the leaked pointer + let _ = Box::from_raw(std::slice::from_raw_parts_mut(ptr, len) as *mut [u8]); + } +} + +/// Simple JSON string escaping for service context values. +/// Escapes backslash, double quote, and control characters. +fn escape_json_string(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '\\' => result.push_str("\\\\"), + '"' => result.push_str("\\\""), + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + c if c.is_control() => { + result.push_str(&format!("\\u{:04x}", c as u32)); + } + c => result.push(c), + } + } + result +} + +/// Reset exposure state for testing. Clears the dedup cache, batch buffer, +/// and service context. +#[no_mangle] +pub extern "C" fn ddog_ffe_reset_exposure_state() { + if let Ok(mut state) = EXPOSURE_STATE.lock() { + state.dedup_cache.clear(); + state.batch_buffer.clear(); + state.service_context = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CString; + use std::sync::Mutex; + + /// Serialises tests that mutate the global `EXPOSURE_STATE`. Each test + /// resets state on entry while holding this guard so concurrent cargo-test + /// threads don't observe each other's buffers. + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn c(s: &str) -> CString { + CString::new(s).unwrap() + } + + fn enqueue(flag: &str, alloc: &str, targeting: Option<&str>, variant: &str) -> bool { + let event = c(r#"{"_":"event"}"#); + let flag = c(flag); + let alloc = c(alloc); + let variant = c(variant); + let targeting_c = targeting.map(c); + let targeting_ptr = targeting_c + .as_ref() + .map(|s| s.as_ptr()) + .unwrap_or(std::ptr::null()); + unsafe { + ddog_ffe_enqueue_exposure( + event.as_ptr(), + flag.as_ptr(), + alloc.as_ptr(), + targeting_ptr, + variant.as_ptr(), + ) + } + } + + /// V1 — Dedup key = (flag, targeting), value = (allocation, variant). + /// Duplicate iff BOTH allocation AND variant unchanged. + #[test] + fn dedup_key_matches_go_semantics() { + let _g = TEST_LOCK.lock().unwrap(); + ddog_ffe_reset_exposure_state(); + + // First emit: always enqueue. + assert!(enqueue("flag-a", "alloc-1", Some("user-x"), "on")); + + // Same (flag, targeting, allocation, variant): duplicate. + assert!(!enqueue("flag-a", "alloc-1", Some("user-x"), "on")); + + // Same (flag, targeting), variant changed: re-emit. + assert!(enqueue("flag-a", "alloc-1", Some("user-x"), "off")); + + // Same (flag, targeting), allocation changed, variant same: re-emit. + assert!(enqueue("flag-a", "alloc-2", Some("user-x"), "off")); + + // Different targeting: separate cache entry, emit. + assert!(enqueue("flag-a", "alloc-2", Some("user-y"), "off")); + + // Different flag: separate cache entry, emit. + assert!(enqueue("flag-b", "alloc-2", Some("user-x"), "off")); + + // Repeat of the last insert: duplicate. + assert!(!enqueue("flag-b", "alloc-2", Some("user-x"), "off")); + + // Null targeting key normalises to empty string; same triple = duplicate. + assert!(enqueue("flag-c", "alloc-0", None, "on")); + assert!(!enqueue("flag-c", "alloc-0", None, "on")); + } + + /// V2 — Batch payload schema matches dd-trace-go shape. + #[test] + fn flush_schema_matches_go() { + let _g = TEST_LOCK.lock().unwrap(); + ddog_ffe_reset_exposure_state(); + + let svc = c("svc"); + let env = c("prod"); + let ver = c("1.2.3"); + unsafe { + ddog_ffe_set_service_context(svc.as_ptr(), env.as_ptr(), ver.as_ptr()); + } + + // Event JSON is opaque to the flusher — it embeds as-is into the array. + let event = c( + r#"{"timestamp":1,"flag":{"key":"f"},"allocation":{"key":"a"},"variant":{"key":"v"},"subject":{"id":"u","attributes":{}}}"#, + ); + let flag = c("f"); + let alloc = c("a"); + let target = c("u"); + let variant = c("v"); + let enq = unsafe { + ddog_ffe_enqueue_exposure( + event.as_ptr(), + flag.as_ptr(), + alloc.as_ptr(), + target.as_ptr(), + variant.as_ptr(), + ) + }; + assert!(enq); + + let slice = ddog_ffe_flush_exposures(); + assert!(!slice.is_empty(), "flush should yield payload"); + + let bytes = unsafe { std::slice::from_raw_parts(slice.as_ptr() as *const u8, slice.len()) }; + let payload = std::str::from_utf8(bytes).unwrap().to_owned(); + unsafe { + ddog_ffe_free_flush_result(slice); + } + + let parsed: serde_json::Value = serde_json::from_str(&payload).unwrap(); + let ctx = &parsed["context"]; + assert_eq!(ctx["service"], "svc"); + assert_eq!(ctx["env"], "prod"); + assert_eq!(ctx["version"], "1.2.3"); + + let exposures = parsed["exposures"].as_array().unwrap(); + assert_eq!(exposures.len(), 1); + let ev = &exposures[0]; + assert!(ev["timestamp"].is_number()); + assert_eq!(ev["flag"]["key"], "f"); + assert_eq!(ev["allocation"]["key"], "a"); + assert_eq!(ev["variant"]["key"], "v"); + assert_eq!(ev["subject"]["id"], "u"); + assert!(ev["subject"]["attributes"].is_object()); + + // Second flush with empty buffer returns default (empty) CharSlice. + let slice2 = ddog_ffe_flush_exposures(); + assert!(slice2.is_empty(), "empty buffer should produce empty slice"); + } +} diff --git a/components-rs/lib.rs b/components-rs/lib.rs index bf9a2675ff2..b4e75dd0f27 100644 --- a/components-rs/lib.rs +++ b/components-rs/lib.rs @@ -6,6 +6,7 @@ pub mod agent_info; pub mod log; pub mod remote_config; +pub mod ffe; pub mod sidecar; pub mod stats; pub mod telemetry; diff --git a/components-rs/remote_config.rs b/components-rs/remote_config.rs index 8a4c1f5145f..0a200121289 100644 --- a/components-rs/remote_config.rs +++ b/components-rs/remote_config.rs @@ -31,6 +31,7 @@ use std::ptr::NonNull; use std::sync::Arc; use tracing::debug; use crate::bytes::{ZendString, OwnedZendString, dangling_zend_string}; +use datadog_ffe::rules_based::Configuration; pub const DYANMIC_CONFIG_UPDATE_UNMODIFIED: *mut ZendString = 1isize as *mut ZendString; @@ -116,13 +117,28 @@ pub struct LiveDebuggerState { pub di_enabled: bool, } +/// Flags selecting which Remote Config products/capabilities to subscribe to. +/// +/// Passed as a single C-ABI struct so call sites can use designated initializers +/// and name the flags, instead of a positional sequence of four `bool` args +/// (per dd-oleksii review, PR #3630). +#[repr(C)] +pub struct DdogRemoteConfigFlags { + pub live_debugging_enabled: bool, + pub appsec_activation: bool, + pub appsec_config: bool, + pub ffe_enabled: bool, +} + #[no_mangle] #[allow(static_mut_refs)] -pub unsafe extern "C" fn ddog_init_remote_config( - live_debugging_enabled: bool, - appsec_activation: bool, - appsec_config: bool, -) { +pub unsafe extern "C" fn ddog_init_remote_config(flags: DdogRemoteConfigFlags) { + let DdogRemoteConfigFlags { + live_debugging_enabled, + appsec_activation, + appsec_config, + ffe_enabled, + } = flags; DDTRACE_REMOTE_CONFIG_PRODUCTS.push(RemoteConfigProduct::ApmTracing); DDTRACE_REMOTE_CONFIG_CAPABILITIES.push(RemoteConfigCapabilities::ApmTracingCustomTags); DDTRACE_REMOTE_CONFIG_CAPABILITIES.push(RemoteConfigCapabilities::ApmTracingEnabled); @@ -139,6 +155,11 @@ pub unsafe extern "C" fn ddog_init_remote_config( DDTRACE_REMOTE_CONFIG_CAPABILITIES.push(RemoteConfigCapabilities::AsmActivation); } + if ffe_enabled { + DDTRACE_REMOTE_CONFIG_PRODUCTS.push(RemoteConfigProduct::FfeFlags); + DDTRACE_REMOTE_CONFIG_CAPABILITIES.push(RemoteConfigCapabilities::FfeFlagConfigurationRules); + } + if live_debugging_enabled { DDTRACE_REMOTE_CONFIG_PRODUCTS.push(RemoteConfigProduct::LiveDebugger) } @@ -377,6 +398,11 @@ pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigSt ); } } + RemoteConfigData::FfeFlags(ufc) => { + debug!("Received FFE flags configuration"); + let config = Configuration::from_server_response(ufc); + crate::ffe::store_config(config); + } RemoteConfigData::Ignored(_) => (), RemoteConfigData::TracerFlareConfig(_) => {} RemoteConfigData::TracerFlareTask(_) => {} @@ -402,6 +428,10 @@ pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigSt } } } + RemoteConfigProduct::FfeFlags => { + debug!("FFE flags configuration removed"); + crate::ffe::clear_config(); + } _ => (), }, } diff --git a/components-rs/sidecar.h b/components-rs/sidecar.h index 4a3aa617416..5eb691fe29d 100644 --- a/components-rs/sidecar.h +++ b/components-rs/sidecar.h @@ -298,6 +298,24 @@ ddog_MaybeError ddog_sidecar_send_debugger_datum(struct ddog_SidecarTransport ** ddog_QueueId queue_id, struct ddog_DebuggerPayload *payload); +/** + * Forward a single FFE (Feature Flag Evaluation) exposure batch payload to + * the sidecar. The sidecar asynchronously POSTs it to the agent EVP proxy + * at `/evp_proxy/v2/api/v2/exposures`. + * + * The payload is produced by `ddog_ffe_flush_exposures()` in `components-rs`. + * A null or zero-length slice is a no-op (the PHP side indicates "nothing to + * flush" by returning such a slice). + * + * # Safety + * `payload` must be a valid UTF-8 `CharSlice` (as returned by + * `ddog_ffe_flush_exposures`) or a default (null, 0) slice. + */ +ddog_MaybeError ddog_sidecar_send_ffe_exposures(struct ddog_SidecarTransport **transport, + const struct ddog_InstanceId *instance_id, + const ddog_QueueId *queue_id, + ddog_CharSlice payload); + ddog_MaybeError ddog_sidecar_send_debugger_diagnostics(struct ddog_SidecarTransport **transport, const struct ddog_InstanceId *instance_id, ddog_QueueId queue_id, diff --git a/composer.json b/composer.json index eedfbd81763..64dd730f5ff 100644 --- a/composer.json +++ b/composer.json @@ -120,6 +120,14 @@ "scenario-options": { "create-lockfile": false } + }, + "openfeature": { + "require": { + "open-feature/sdk": "^2.1" + }, + "scenario-options": { + "create-lockfile": false + } } }, "scenario-options": { diff --git a/ext/autoload_php_files.c b/ext/autoload_php_files.c index 23d786c862f..56e03c57d16 100644 --- a/ext/autoload_php_files.c +++ b/ext/autoload_php_files.c @@ -32,6 +32,7 @@ static zend_class_entry *(*dd_prev_autoloader)(zend_string *name, zend_string *l static zend_bool dd_api_is_preloaded = false; static zend_bool dd_otel_is_preloaded = false; static zend_bool dd_legacy_tracer_is_preloaded = false; +static zend_bool dd_openfeature_is_preloaded = false; #endif #if PHP_VERSION_ID < 80000 @@ -234,6 +235,21 @@ static zend_class_entry *dd_perform_autoload(zend_string *class_name, zend_strin return ce; } } + // OpenFeature adapter uses PHP 8.0+ syntax (match, union types, constructor + // promotion) so the bridge is only loaded on PHP 8.0+. On 7.x we fall through + // and return NULL, leaving the adapter effectively unavailable. + if (zend_string_starts_with_literal(lc_name, "ddtrace\\openfeature\\")) { +#if PHP_VERSION_ID >= 80000 + if (!DDTRACE_G(openfeature_is_loaded)) { + DDTRACE_G(openfeature_is_loaded) = 1; + dd_load_files("openfeature"); + } + if ((ce = zend_hash_find_ptr(EG(class_table), lc_name))) { + return ce; + } +#endif + return NULL; + } if (!DDTRACE_G(legacy_tracer_is_loaded) && !zend_string_starts_with_literal(lc_name, "ddtrace\\integration\\")) { DDTRACE_G(legacy_tracer_is_loaded) = 1; dd_load_files("tracer"); @@ -420,13 +436,16 @@ void ddtrace_autoload_rshutdown(void) { dd_api_is_preloaded = DDTRACE_G(api_is_loaded); dd_otel_is_preloaded = DDTRACE_G(otel_is_loaded); dd_legacy_tracer_is_preloaded = DDTRACE_G(legacy_tracer_is_loaded); + dd_openfeature_is_preloaded = DDTRACE_G(openfeature_is_loaded); } else { DDTRACE_G(api_is_loaded) = dd_api_is_preloaded; DDTRACE_G(otel_is_loaded) = dd_otel_is_preloaded; DDTRACE_G(legacy_tracer_is_loaded) = dd_legacy_tracer_is_preloaded; + DDTRACE_G(openfeature_is_loaded) = dd_openfeature_is_preloaded; } #else DDTRACE_G(api_is_loaded) = 0; DDTRACE_G(otel_is_loaded) = 0; + DDTRACE_G(openfeature_is_loaded) = 0; #endif } diff --git a/ext/configuration.h b/ext/configuration.h index c5f271b1efd..e4fff032d2d 100644 --- a/ext/configuration.h +++ b/ext/configuration.h @@ -277,6 +277,7 @@ enum ddtrace_sidecar_connection_mode { CONFIG(BOOL, DD_TRACE_RESOURCE_RENAMING_ENABLED, "false") \ CONFIG(BOOL, DD_TRACE_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT, "false") \ CONFIG(BOOL, DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, "true") \ + CONFIG(BOOL, DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, "false") \ CONFIG(BOOL, DD_TRACE_STATS_COMPUTATION_ENABLED, "false") \ DD_INTEGRATIONS diff --git a/ext/ddtrace.c b/ext/ddtrace.c index c29e9894579..7d3093b9e9b 100644 --- a/ext/ddtrace.c +++ b/ext/ddtrace.c @@ -128,6 +128,7 @@ bool ddtrace_has_excluded_module; static zend_module_entry *ddtrace_module; + #if PHP_VERSION_ID >= 80000 && PHP_VERSION_ID < 80200 static bool dd_has_other_observers; static int dd_observer_extension_backup = -1; @@ -2915,6 +2916,164 @@ PHP_FUNCTION(DDTrace_flush_endpoints) { ddog_sidecar_telemetry_filter_flush(&DDTRACE_G(sidecar), ddtrace_sidecar_instance_id, &DDTRACE_G(sidecar_queue_id), ddtrace_telemetry_buffer(), ddtrace_telemetry_cache(), service_name, env_name)); } +/* --------------------------------------------------------------------------- + * Feature Flag Evaluation (FFE) — exposed as proper DDTrace\ffe_* functions + * per bwoebi's PR #3630 directive (replaces dd_trace_internal_fn dispatch). + * --------------------------------------------------------------------------- */ + +PHP_FUNCTION(DDTrace_ffe_has_config) { + ZEND_PARSE_PARAMETERS_NONE(); + RETVAL_BOOL(ddog_ffe_has_config()); +} + +PHP_FUNCTION(DDTrace_ffe_config_version) { + ZEND_PARSE_PARAMETERS_NONE(); + RETVAL_LONG((zend_long)ddog_ffe_config_version()); +} + +PHP_FUNCTION(DDTrace_ffe_load_config) { + char *json; + size_t json_len; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_STRING(json, json_len) + ZEND_PARSE_PARAMETERS_END(); + + RETVAL_BOOL(ddog_ffe_load_config(json)); +} + +PHP_FUNCTION(DDTrace_ffe_evaluate) { + char *flag_key; + size_t flag_key_len; + zend_long type_id_zl; + char *targeting_key = NULL; + size_t targeting_key_len = 0; + zval *attrs_zv; + + ZEND_PARSE_PARAMETERS_START(4, 4) + Z_PARAM_STRING(flag_key, flag_key_len) + Z_PARAM_LONG(type_id_zl) + Z_PARAM_STRING_OR_NULL(targeting_key, targeting_key_len) + Z_PARAM_ARRAY(attrs_zv) + ZEND_PARSE_PARAMETERS_END(); + + int32_t type_id = (int32_t)type_id_zl; + struct ddog_FfeAttribute *c_attrs = NULL; + size_t attrs_count = 0; + const char *tk = (targeting_key_len > 0) ? targeting_key : NULL; + + HashTable *ht = Z_ARRVAL_P(attrs_zv); + attrs_count = zend_hash_num_elements(ht); + if (attrs_count > 0) { + size_t idx = 0; + zend_string *key; + zval *val; + c_attrs = ecalloc(attrs_count, sizeof(struct ddog_FfeAttribute)); + ZEND_HASH_FOREACH_STR_KEY_VAL(ht, key, val) { + if (!key || idx >= attrs_count) { continue; } + c_attrs[idx].key = ZSTR_VAL(key); + switch (Z_TYPE_P(val)) { + case IS_STRING: + c_attrs[idx].value_type = 0; + c_attrs[idx].string_value = Z_STRVAL_P(val); + break; + case IS_LONG: + c_attrs[idx].value_type = 1; + c_attrs[idx].number_value = (double)Z_LVAL_P(val); + break; + case IS_DOUBLE: + c_attrs[idx].value_type = 1; + c_attrs[idx].number_value = Z_DVAL_P(val); + break; + case IS_TRUE: + c_attrs[idx].value_type = 2; + c_attrs[idx].bool_value = true; + break; + case IS_FALSE: + c_attrs[idx].value_type = 2; + c_attrs[idx].bool_value = false; + break; + default: + continue; + } + idx++; + } ZEND_HASH_FOREACH_END(); + attrs_count = idx; + } + + struct ddog_FfeResult *result = ddog_ffe_evaluate(flag_key, type_id, tk, c_attrs, attrs_count); + if (c_attrs) { efree(c_attrs); } + + if (result) { + const char *val = ddog_ffe_result_value(result); + const char *var = ddog_ffe_result_variant(result); + const char *ak = ddog_ffe_result_allocation_key(result); + array_init(return_value); + if (val) { add_assoc_string(return_value, "value_json", (char *)val); } + else { add_assoc_null(return_value, "value_json"); } + if (var) { add_assoc_string(return_value, "variant", (char *)var); } + else { add_assoc_null(return_value, "variant"); } + if (ak) { add_assoc_string(return_value, "allocation_key", (char *)ak); } + else { add_assoc_null(return_value, "allocation_key"); } + add_assoc_long(return_value, "reason", ddog_ffe_result_reason(result)); + add_assoc_long(return_value, "error_code", ddog_ffe_result_error_code(result)); + add_assoc_bool(return_value, "do_log", ddog_ffe_result_do_log(result)); + ddog_ffe_free_result(result); + } else { + RETVAL_NULL(); + } +} + +PHP_FUNCTION(DDTrace_ffe_send_exposure) { + char *event_json, *flag_key, *allocation_key, *variant_key; + size_t event_json_len, flag_key_len, allocation_key_len, variant_key_len; + char *targeting_key = NULL; + size_t targeting_key_len = 0; + + ZEND_PARSE_PARAMETERS_START(5, 5) + Z_PARAM_STRING(event_json, event_json_len) + Z_PARAM_STRING(flag_key, flag_key_len) + Z_PARAM_STRING(allocation_key, allocation_key_len) + Z_PARAM_STRING_OR_NULL(targeting_key, targeting_key_len) + Z_PARAM_STRING(variant_key, variant_key_len) + ZEND_PARSE_PARAMETERS_END(); + + RETVAL_BOOL(ddog_ffe_enqueue_exposure( + event_json, flag_key, allocation_key, + (targeting_key_len > 0) ? targeting_key : NULL, + variant_key)); +} + +PHP_FUNCTION(DDTrace_ffe_flush_exposures) { + ZEND_PARSE_PARAMETERS_NONE(); + + ddog_CharSlice slice = ddog_ffe_flush_exposures(); + if (slice.ptr == NULL || slice.len == 0) { + RETVAL_NULL(); + } else { + RETVAL_STRINGL(slice.ptr, slice.len); + ddog_ffe_free_flush_result(slice); + } +} + +PHP_FUNCTION(DDTrace_ffe_set_service_context) { + char *service, *env, *version; + size_t service_len, env_len, version_len; + + ZEND_PARSE_PARAMETERS_START(3, 3) + Z_PARAM_STRING(service, service_len) + Z_PARAM_STRING(env, env_len) + Z_PARAM_STRING(version, version_len) + ZEND_PARSE_PARAMETERS_END(); + + ddog_ffe_set_service_context(service, env, version); +} + +PHP_FUNCTION(DDTrace_ffe_reset_exposure_state) { + ZEND_PARSE_PARAMETERS_NONE(); + ddog_ffe_reset_exposure_state(); +} + PHP_FUNCTION(dd_trace_send_traces_via_thread) { char *payload = NULL; ddtrace_zpplong_t num_traces = 0; @@ -3041,6 +3200,105 @@ PHP_FUNCTION(dd_trace_internal_fn) { ddtrace_metric_add_point(Z_STR_P(metric_name), zval_get_double(metric_value), Z_STR_P(tags)); RETVAL_TRUE; } + } else if (FUNCTION_NAME_MATCHES("ffe_has_config")) { + RETVAL_BOOL(ddog_ffe_has_config()); + } else if (FUNCTION_NAME_MATCHES("ffe_config_version")) { + RETVAL_LONG((zend_long)ddog_ffe_config_version()); + } else if (params_count == 1 && FUNCTION_NAME_MATCHES("ffe_load_config")) { + zval *json_zv = ZVAL_VARARG_PARAM(params, 0); + if (Z_TYPE_P(json_zv) == IS_STRING) { + RETVAL_BOOL(ddog_ffe_load_config(Z_STRVAL_P(json_zv))); + } + } else if (FUNCTION_NAME_MATCHES("ffe_evaluate") && params_count >= 4) { + /* ffe_evaluate(flag_key, type_id, targeting_key, attributes) */ + zval *flag_key_zv = ZVAL_VARARG_PARAM(params, 0); + zval *type_zv = ZVAL_VARARG_PARAM(params, 1); + zval *targeting_key_zv = ZVAL_VARARG_PARAM(params, 2); + zval *attrs_zv = ZVAL_VARARG_PARAM(params, 3); + if (Z_TYPE_P(flag_key_zv) == IS_STRING) { + /* Declare all variables at top of block for C89/MSVC compatibility */ + int32_t type_id; + const char *targeting_key = NULL; + struct ddog_FfeAttribute *c_attrs = NULL; + size_t attrs_count = 0; + struct ddog_FfeResult *result; + type_id = (int32_t)zval_get_long(type_zv); + if (Z_TYPE_P(targeting_key_zv) == IS_STRING && Z_STRLEN_P(targeting_key_zv) > 0) { + targeting_key = Z_STRVAL_P(targeting_key_zv); + } + if (Z_TYPE_P(attrs_zv) == IS_ARRAY) { + HashTable *ht = Z_ARRVAL_P(attrs_zv); + attrs_count = zend_hash_num_elements(ht); + if (attrs_count > 0) { + size_t idx = 0; + zend_string *key; + zval *val; + c_attrs = ecalloc(attrs_count, sizeof(struct ddog_FfeAttribute)); + ZEND_HASH_FOREACH_STR_KEY_VAL(ht, key, val) { + if (!key || idx >= attrs_count) { continue; } + c_attrs[idx].key = ZSTR_VAL(key); + switch (Z_TYPE_P(val)) { + case IS_STRING: + c_attrs[idx].value_type = 0; + c_attrs[idx].string_value = Z_STRVAL_P(val); + break; + case IS_LONG: + c_attrs[idx].value_type = 1; + c_attrs[idx].number_value = (double)Z_LVAL_P(val); + break; + case IS_DOUBLE: + c_attrs[idx].value_type = 1; + c_attrs[idx].number_value = Z_DVAL_P(val); + break; + case IS_TRUE: + c_attrs[idx].value_type = 2; + c_attrs[idx].bool_value = true; + break; + case IS_FALSE: + c_attrs[idx].value_type = 2; + c_attrs[idx].bool_value = false; + break; + default: + /* In C, `continue` inside a switch inside a loop targets the loop, + not the switch. This skips idx++ so this slot is overwritten next + iteration. The partially-written key ptr is harmless since idx + stays pointing at this slot. */ + continue; + } + idx++; + } ZEND_HASH_FOREACH_END(); + attrs_count = idx; + } + } + result = ddog_ffe_evaluate( + Z_STRVAL_P(flag_key_zv), type_id, targeting_key, c_attrs, attrs_count); + if (c_attrs) { + efree(c_attrs); + } + if (result) { + const char *val; + const char *var; + const char *ak; + array_init(return_value); + val = ddog_ffe_result_value(result); + var = ddog_ffe_result_variant(result); + ak = ddog_ffe_result_allocation_key(result); + if (val) { add_assoc_string(return_value, "value_json", (char *)val); } + else { add_assoc_null(return_value, "value_json"); } + if (var) { add_assoc_string(return_value, "variant", (char *)var); } + else { add_assoc_null(return_value, "variant"); } + if (ak) { add_assoc_string(return_value, "allocation_key", (char *)ak); } + else { add_assoc_null(return_value, "allocation_key"); } + add_assoc_long(return_value, "reason", ddog_ffe_result_reason(result)); + add_assoc_long(return_value, "error_code", ddog_ffe_result_error_code(result)); + add_assoc_bool(return_value, "do_log", ddog_ffe_result_do_log(result)); + ddog_ffe_free_result(result); + } else { + RETVAL_NULL(); + } + } else { + RETVAL_NULL(); + } } else if (FUNCTION_NAME_MATCHES("dump_sidecar")) { if (!DDTRACE_G(sidecar)) { RETURN_FALSE; diff --git a/ext/ddtrace.h b/ext/ddtrace.h index f878f96bf43..178d99d4096 100644 --- a/ext/ddtrace.h +++ b/ext/ddtrace.h @@ -108,6 +108,7 @@ ZEND_BEGIN_MODULE_GLOBALS(ddtrace) zend_bool api_is_loaded; zend_bool otel_is_loaded; zend_bool legacy_tracer_is_loaded; + zend_bool openfeature_is_loaded; uint32_t traces_group_id; zend_array *additional_global_tags; diff --git a/ext/ddtrace.stub.php b/ext/ddtrace.stub.php index e4a388e157d..a0de9392d85 100644 --- a/ext/ddtrace.stub.php +++ b/ext/ddtrace.stub.php @@ -845,6 +845,108 @@ function add_endpoint(string $path, string $operation_name, string $resource_nam * Call this once after batching all add_endpoint() calls. */ function flush_endpoints(): void {} + + /** + * Evaluate a feature flag using the stored UFC configuration. + * + * @param string $flagKey The flag key to evaluate. + * @param int $expectedType The expected flag type (0=string, 1=int, 2=float, 3=bool, 4=object). + * @param string|null $targetingKey The targeting key for evaluation context. + * @param array $attributes Flat key-value map of evaluation context attributes (string keys, primitive values). + * @return array|null Associative array with keys: value_json, variant, allocation_key, reason, error_code, do_log. Null only if evaluation engine is unavailable. + * + * @internal Used by the OpenFeature DataDog Provider. + */ + function ffe_evaluate(string $flagKey, int $expectedType, ?string $targetingKey, array $attributes): ?array {} + + /** + * Check if FFE (Feature Flag Evaluation) configuration is loaded. + * + * @return bool True if a flag configuration has been received via Remote Config. + * + * @internal Used by the OpenFeature DataDog Provider. + */ + function ffe_has_config(): bool {} + + /** + * Return the current FFE configuration version counter. + * + * Incremented on every Remote Config update (config stored or cleared). + * Consumers track their last observed value and detect changes by + * comparing; unlike a drain-on-read flag, multiple independent + * subscribers can observe transitions without racing each other. + * + * @return int Monotonically-increasing version counter. + * + * @internal Used by the OpenFeature DataDog Provider. + */ + function ffe_config_version(): int {} + + /** + * Load a UFC JSON configuration string into the FFE engine. + * Used for testing without Remote Config. + * + * @param string $json UFC JSON configuration string. + * @return bool True if the configuration was parsed and loaded successfully. + * + * @internal Used by the OpenFeature DataDog Provider and tests. + */ + function ffe_load_config(string $json): bool {} + + /** + * Enqueue an exposure event for dedup and batched delivery via sidecar. + * + * @param string $eventJson JSON-encoded exposure event (single event, not batch). + * @param string $flagKey The flag key (for dedup cache key). + * @param string $allocationKey The allocation key (for dedup cache key). + * @param string|null $targetingKey The targeting key (for dedup cache key). + * @param string $variantKey The variant key (for dedup cache value). + * @return bool True if enqueued (not a duplicate), false if deduplicated or buffer full. + * + * @internal Used by the exposure writer. Not part of the public API. + */ + function ffe_send_exposure( + string $eventJson, + string $flagKey, + string $allocationKey, + ?string $targetingKey, + string $variantKey + ): bool {} + + /** + * Flush batched exposure events as a JSON payload string. + * + * Returns the batch payload including service context and all buffered events, + * or null if nothing to flush. Clears the batch buffer. + * + * In production, the sidecar calls the Rust function directly. This PHP + * wrapper is provided for testing and debugging. + * + * @return string|null JSON batch payload or null if empty. + * + * @internal Used for testing. Not part of the public API. + */ + function ffe_flush_exposures(): ?string {} + + /** + * Set the service context for exposure batch payloads. + * + * Called once during provider initialization with DD_SERVICE, DD_ENV, DD_VERSION. + * + * @param string $service The DD_SERVICE value. + * @param string $env The DD_ENV value. + * @param string $version The DD_VERSION value. + * + * @internal Used by the exposure writer. Not part of the public API. + */ + function ffe_set_service_context(string $service, string $env, string $version): void {} + + /** + * Reset exposure dedup cache, batch buffer, and service context. + * + * @internal Used for testing only. + */ + function ffe_reset_exposure_state(): void {} } namespace DDTrace\System { diff --git a/ext/ddtrace_arginfo.h b/ext/ddtrace_arginfo.h index a6e618143b5..2c7affb656f 100644 --- a/ext/ddtrace_arginfo.h +++ b/ext/ddtrace_arginfo.h @@ -176,6 +176,43 @@ ZEND_END_ARG_INFO() #define arginfo_DDTrace_flush_endpoints arginfo_DDTrace_flush +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_evaluate, 0, 4, IS_ARRAY, 1) + ZEND_ARG_TYPE_INFO(0, flagKey, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, expectedType, IS_LONG, 0) + ZEND_ARG_TYPE_INFO(0, targetingKey, IS_STRING, 1) + ZEND_ARG_TYPE_INFO(0, attributes, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_has_config, 0, 0, _IS_BOOL, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_config_version, 0, 0, IS_LONG, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_load_config, 0, 1, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, json, IS_STRING, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_send_exposure, 0, 5, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, eventJson, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, flagKey, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, allocationKey, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, targetingKey, IS_STRING, 1) + ZEND_ARG_TYPE_INFO(0, variantKey, IS_STRING, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_flush_exposures, 0, 0, IS_STRING, 1) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_set_service_context, 0, 3, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, service, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, env, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, version, IS_STRING, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_ffe_reset_exposure_state, 0, 0, IS_VOID, 0) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_DDTrace_System_container_id, 0, 0, IS_STRING, 1) ZEND_END_ARG_INFO() @@ -394,6 +431,14 @@ ZEND_FUNCTION(DDTrace_resource_weak_get); ZEND_FUNCTION(DDTrace_are_endpoints_collected); ZEND_FUNCTION(DDTrace_add_endpoint); ZEND_FUNCTION(DDTrace_flush_endpoints); +ZEND_FUNCTION(DDTrace_ffe_evaluate); +ZEND_FUNCTION(DDTrace_ffe_has_config); +ZEND_FUNCTION(DDTrace_ffe_config_version); +ZEND_FUNCTION(DDTrace_ffe_load_config); +ZEND_FUNCTION(DDTrace_ffe_send_exposure); +ZEND_FUNCTION(DDTrace_ffe_flush_exposures); +ZEND_FUNCTION(DDTrace_ffe_set_service_context); +ZEND_FUNCTION(DDTrace_ffe_reset_exposure_state); ZEND_FUNCTION(DDTrace_System_container_id); ZEND_FUNCTION(DDTrace_System_process_tags_base_hash); ZEND_FUNCTION(DDTrace_Config_integration_analytics_enabled); @@ -489,6 +534,14 @@ static const zend_function_entry ext_functions[] = { ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "are_endpoints_collected"), zif_DDTrace_are_endpoints_collected, arginfo_DDTrace_are_endpoints_collected, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "add_endpoint"), zif_DDTrace_add_endpoint, arginfo_DDTrace_add_endpoint, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "flush_endpoints"), zif_DDTrace_flush_endpoints, arginfo_DDTrace_flush_endpoints, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_evaluate"), zif_DDTrace_ffe_evaluate, arginfo_DDTrace_ffe_evaluate, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_has_config"), zif_DDTrace_ffe_has_config, arginfo_DDTrace_ffe_has_config, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_config_version"), zif_DDTrace_ffe_config_version, arginfo_DDTrace_ffe_config_version, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_load_config"), zif_DDTrace_ffe_load_config, arginfo_DDTrace_ffe_load_config, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_send_exposure"), zif_DDTrace_ffe_send_exposure, arginfo_DDTrace_ffe_send_exposure, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_flush_exposures"), zif_DDTrace_ffe_flush_exposures, arginfo_DDTrace_ffe_flush_exposures, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_set_service_context"), zif_DDTrace_ffe_set_service_context, arginfo_DDTrace_ffe_set_service_context, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace", "ffe_reset_exposure_state"), zif_DDTrace_ffe_reset_exposure_state, arginfo_DDTrace_ffe_reset_exposure_state, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\System", "container_id"), zif_DDTrace_System_container_id, arginfo_DDTrace_System_container_id, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\System", "process_tags_base_hash"), zif_DDTrace_System_process_tags_base_hash, arginfo_DDTrace_System_process_tags_base_hash, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("DDTrace\\Config", "integration_analytics_enabled"), zif_DDTrace_Config_integration_analytics_enabled, arginfo_DDTrace_Config_integration_analytics_enabled, 0, NULL, NULL) diff --git a/ext/sidecar.c b/ext/sidecar.c index d67dc577d52..38b3a659a73 100644 --- a/ext/sidecar.c +++ b/ext/sidecar.c @@ -390,7 +390,12 @@ void ddtrace_sidecar_setup(bool appsec_activation, bool appsec_config) { ddtrace_set_non_resettable_sidecar_globals(); ddtrace_set_resettable_sidecar_globals(); - ddog_init_remote_config(get_global_DD_INSTRUMENTATION_TELEMETRY_ENABLED(), appsec_activation, appsec_config); + ddog_init_remote_config((struct ddog_DdogRemoteConfigFlags){ + .live_debugging_enabled = get_global_DD_INSTRUMENTATION_TELEMETRY_ENABLED(), + .appsec_activation = appsec_activation, + .appsec_config = appsec_config, + .ffe_enabled = get_global_DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED(), + }); zend_long mode = get_global_DD_TRACE_SIDECAR_CONNECTION_MODE(); @@ -444,6 +449,14 @@ void ddtrace_sidecar_handle_fork(void) { ddtrace_force_new_instance_id(); + // Reset FFE exposure state in the child: both the dedup cache and the batch + // buffer were copied-on-write from the parent and the parent will flush + // them at its own RSHUTDOWN. Without this reset the child would re-send + // everything the parent already buffered. Accepted cost: child loses its + // dedup history, so the first evaluation per (flag, subject) pair after + // fork re-emits -- bounded duplication, caught by server-side dedup. + ddog_ffe_reset_exposure_state(); + // After fork only one thread (the one that called fork) survives, so we only // need to drop and reconnect the current thread's transport. if (DDTRACE_G(sidecar)) { @@ -529,7 +542,14 @@ void ddtrace_sidecar_finalize(bool clear_id) { } } +static void dd_flush_ffe_exposures(void); + void ddtrace_sidecar_shutdown(void) { + // Drain any FFE exposures buffered since the last RSHUTDOWN before we + // tear down the sidecar transport. After MSHUTDOWN returns the transport + // is dropped and enqueued events would be lost. + dd_flush_ffe_exposures(); + ddtrace_sidecar_for_signal = NULL; // In thread mode, drop the main thread's connection before shutting down the @@ -872,7 +892,24 @@ void ddtrace_sidecar_rinit(void) { ddtrace_sidecar_submit_root_span_data_direct_defaults(&DDTRACE_G(sidecar), NULL); } +static void dd_flush_ffe_exposures(void) { + if (!DDTRACE_G(sidecar) || !ddtrace_sidecar_instance_id) { + return; + } + ddog_CharSlice payload = ddog_ffe_flush_exposures(); + if (payload.ptr == NULL || payload.len == 0) { + return; + } + ddtrace_ffi_try("Failed forwarding FFE exposures to sidecar", + ddog_sidecar_send_ffe_exposures(&DDTRACE_G(sidecar), + ddtrace_sidecar_instance_id, + &DDTRACE_G(sidecar_queue_id), + payload)); + ddog_ffe_free_flush_result(payload); +} + void ddtrace_sidecar_rshutdown(void) { + dd_flush_ffe_exposures(); ddog_Vec_Tag_drop(DDTRACE_G(active_global_tags)); } diff --git a/libdatadog b/libdatadog index ef6179ca609..ba1d5a2cd29 160000 --- a/libdatadog +++ b/libdatadog @@ -1 +1 @@ -Subproject commit ef6179ca6092af928ed3ae467e26fe849d064b58 +Subproject commit ba1d5a2cd29966df3c244c52315739279e50ab4a diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 86b54ca0de4..1ec3d89c202 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -382,6 +382,13 @@ "default": "false" } ], + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": "false" + } + ], "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [ { "implementation": "B", diff --git a/src/DDTrace/Integrations/PDO/PDOIntegration.php b/src/DDTrace/Integrations/PDO/PDOIntegration.php index a74baf6c5e2..62a44c44666 100644 --- a/src/DDTrace/Integrations/PDO/PDOIntegration.php +++ b/src/DDTrace/Integrations/PDO/PDOIntegration.php @@ -359,7 +359,7 @@ function_exists('datadog\appsec\push_addresses'); public static function useQuestionMarkPlaceholders($query) { - // Avoid + // Avoid if (\strlen($query) > 10000) { return $query; } diff --git a/src/DDTrace/OpenFeature/BridgeResultMapper.php b/src/DDTrace/OpenFeature/BridgeResultMapper.php new file mode 100644 index 00000000000..226a67850e0 --- /dev/null +++ b/src/DDTrace/OpenFeature/BridgeResultMapper.php @@ -0,0 +1,253 @@ + + */ + private const ERROR_CODE_MAP = [ + self::ERROR_FLAG_NOT_FOUND => 'FLAG_NOT_FOUND', + self::ERROR_PARSE_ERROR => 'PARSE_ERROR', + self::ERROR_TYPE_MISMATCH => 'TYPE_MISMATCH', + self::ERROR_GENERAL => 'GENERAL', + self::ERROR_PROVIDER_NOT_READY => 'PROVIDER_NOT_READY', + ]; + + /** + * Map bridge reason integers to OpenFeature reason strings. + * + * @var array + */ + private const REASON_MAP = [ + self::REASON_DEFAULT => Reason::DEFAULT, + self::REASON_TARGETING_MATCH => Reason::TARGETING_MATCH, + self::REASON_SPLIT => Reason::SPLIT, + self::REASON_DISABLED => Reason::DISABLED, + self::REASON_ERROR => Reason::ERROR, + ]; + + /** + * Map a bridge result to a boolean ResolutionDetails. + * + * @param array|null $bridgeResult Raw result from DDTrace\ffe_evaluate() + * @param bool $defaultValue Caller-provided default + */ + public function mapBoolean(?array $bridgeResult, bool $defaultValue): ResolutionDetailsInterface + { + return $this->mapResult($bridgeResult, $defaultValue, 'boolean'); + } + + /** + * Map a bridge result to a string ResolutionDetails. + * + * @param array|null $bridgeResult Raw result from DDTrace\ffe_evaluate() + * @param string $defaultValue Caller-provided default + */ + public function mapString(?array $bridgeResult, string $defaultValue): ResolutionDetailsInterface + { + return $this->mapResult($bridgeResult, $defaultValue, 'string'); + } + + /** + * Map a bridge result to an integer ResolutionDetails. + * + * @param array|null $bridgeResult Raw result from DDTrace\ffe_evaluate() + * @param int $defaultValue Caller-provided default + */ + public function mapInteger(?array $bridgeResult, int $defaultValue): ResolutionDetailsInterface + { + return $this->mapResult($bridgeResult, $defaultValue, 'integer'); + } + + /** + * Map a bridge result to a float ResolutionDetails. + * + * @param array|null $bridgeResult Raw result from DDTrace\ffe_evaluate() + * @param float $defaultValue Caller-provided default + */ + public function mapFloat(?array $bridgeResult, float $defaultValue): ResolutionDetailsInterface + { + return $this->mapResult($bridgeResult, $defaultValue, 'float'); + } + + /** + * Map a bridge result to an object (array) ResolutionDetails. + * + * @param array|null $bridgeResult Raw result from DDTrace\ffe_evaluate() + * @param mixed[] $defaultValue Caller-provided default + */ + public function mapObject(?array $bridgeResult, array $defaultValue): ResolutionDetailsInterface + { + return $this->mapResult($bridgeResult, $defaultValue, 'object'); + } + + /** + * Core mapping logic shared by all typed resolvers. + * + * @param array|null $bridgeResult Raw result from DDTrace\ffe_evaluate() + * @param bool|string|int|float|mixed[] $defaultValue Caller-provided default + * @param string $expectedType One of: boolean, string, integer, float, object + */ + private function mapResult(?array $bridgeResult, bool|string|int|float|array $defaultValue, string $expectedType): ResolutionDetailsInterface + { + $builder = new ResolutionDetailsBuilder(); + + // Null result means the evaluation engine was completely unavailable + if ($bridgeResult === null) { + return $builder + ->withValue($defaultValue) + ->withReason(Reason::ERROR) + ->withError(new ResolutionError( + ErrorCode::PROVIDER_NOT_READY(), + 'FFE evaluation engine unavailable' + )) + ->build(); + } + + $errorCode = $bridgeResult['error_code'] ?? self::ERROR_GENERAL; + + // Any non-zero error_code: return default value with ERROR reason + if ($errorCode !== self::ERROR_NONE) { + $openFeatureErrorCode = $this->mapErrorCode($errorCode); + + return $builder + ->withValue($defaultValue) + ->withReason(Reason::ERROR) + ->withError(new ResolutionError($openFeatureErrorCode)) + ->build(); + } + + // Success path: decode the JSON value and coerce to the expected type + $valueJson = $bridgeResult['value_json'] ?? null; + $decoded = $this->decodeValue($valueJson, $expectedType); + + if ($decoded === null) { + // JSON decode failure or type mismatch: return default + return $builder + ->withValue($defaultValue) + ->withReason(Reason::ERROR) + ->withError(new ResolutionError( + ErrorCode::PARSE_ERROR(), + 'Failed to decode value_json or type mismatch' + )) + ->build(); + } + + $reason = $this->mapReason($bridgeResult['reason'] ?? self::REASON_DEFAULT); + $variant = $bridgeResult['variant'] ?? null; + + $builder->withValue($decoded)->withReason($reason); + + if ($variant !== null) { + $builder->withVariant($variant); + } + + return $builder->build(); + } + + /** + * Map a bridge error code integer to an OpenFeature ErrorCode enum. + */ + private function mapErrorCode(int $errorCode): ErrorCode + { + $name = self::ERROR_CODE_MAP[$errorCode] ?? 'GENERAL'; + + return ErrorCode::$name(); + } + + /** + * Map a bridge reason integer to an OpenFeature reason string. + */ + private function mapReason(int $reason): string + { + return self::REASON_MAP[$reason] ?? 'DEFAULT'; + } + + /** + * Decode a JSON value and validate it matches the expected type. + * + * Returns null on decode failure or type mismatch. + * + * @return bool|string|int|float|mixed[]|null + */ + private function decodeValue(?string $valueJson, string $expectedType): bool|string|int|float|array|null + { + if ($valueJson === null || $valueJson === '') { + return null; + } + + $decoded = json_decode($valueJson, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + + return $this->coerceToType($decoded, $expectedType); + } + + /** + * Coerce a decoded JSON value to the expected PHP type. + * + * Returns null if the value cannot be safely represented as the expected type. + * + * @param mixed $value Decoded JSON value + * @param string $expectedType One of: boolean, string, integer, float, object + * @return bool|string|int|float|mixed[]|null + */ + private function coerceToType(mixed $value, string $expectedType): bool|string|int|float|array|null + { + return match ($expectedType) { + 'boolean' => is_bool($value) ? $value : null, + 'string' => is_string($value) ? $value : null, + 'integer' => is_int($value) ? $value : null, + 'float' => is_int($value) || is_float($value) ? (float) $value : null, + 'object' => is_array($value) ? $value : null, + default => null, + }; + } +} diff --git a/src/DDTrace/OpenFeature/ContextFlattener.php b/src/DDTrace/OpenFeature/ContextFlattener.php new file mode 100644 index 00000000000..7936108136b --- /dev/null +++ b/src/DDTrace/OpenFeature/ContextFlattener.php @@ -0,0 +1,56 @@ + ['plan' => 'enterprise']] become + * ['user.plan' => 'enterprise']. + * + * Filtering rules: + * - Only string keys are accepted (numeric keys are dropped) + * - Only primitive values are kept: string, int, float, bool + * - Arrays are recursed into with dot-separated key prefix + * - null, objects, resources, and other types are silently dropped + * + * This mirrors the attribute extraction logic in Ruby's Event.extract_attributes() + * and Python's _flatten_context() for cross-tracer consistency. + * + * @internal Used by ExposureWriter. Not part of the public API. + */ +final class ContextFlattener +{ + /** + * Flatten nested arrays to dot-notation keys with primitive-only values. + * + * @param array $data The nested array to flatten. + * @param string $prefix Key prefix for recursion (empty string at top level). + * @return array Flattened key-value pairs. + */ + public static function flatten(array $data, string $prefix = ''): array + { + $result = []; + + foreach ($data as $key => $value) { + // Only string keys are accepted (numeric array indices are dropped) + if (!is_string($key)) { + continue; + } + + $fullKey = $prefix === '' ? $key : $prefix . '.' . $key; + + if (is_string($value) || is_int($value) || is_float($value) || is_bool($value)) { + $result[$fullKey] = $value; + } elseif (is_array($value)) { + $result = array_merge($result, self::flatten($value, $fullKey)); + } + // null, objects, resources are silently dropped + } + + return $result; + } +} diff --git a/src/DDTrace/OpenFeature/DataDogProvider.php b/src/DDTrace/OpenFeature/DataDogProvider.php new file mode 100644 index 00000000000..0364b0b55eb --- /dev/null +++ b/src/DDTrace/OpenFeature/DataDogProvider.php @@ -0,0 +1,301 @@ +): (?array) + */ + private Closure $bridgeCallable; + + /** + * @param BridgeResultMapper|null $resultMapper Custom result mapper (null = default) + * @param EvaluationContextNormalizer|null $contextNormalizer Custom normalizer (null = default) + * @param Closure|null $bridgeCallable Custom bridge callable for testing (null = uses DDTrace\ffe_evaluate) + * @param ProviderLifecycle|null $lifecycle Custom lifecycle helper for testing (null = default) + * @param ExposureWriter|null $exposureWriter Custom exposure writer for testing (null = default) + * @param Closure|null $envReader Override for reading environment variables (testing) + * @param MetricsCounter|null $metricsCounter Custom metrics counter for testing (null = default no-op) + */ + public function __construct( + ?BridgeResultMapper $resultMapper = null, + ?EvaluationContextNormalizer $contextNormalizer = null, + ?Closure $bridgeCallable = null, + ?ProviderLifecycle $lifecycle = null, + ?ExposureWriter $exposureWriter = null, + ?\Closure $envReader = null, + ?MetricsCounter $metricsCounter = null, + ) { + $this->resultMapper = $resultMapper ?? new BridgeResultMapper(); + $this->contextNormalizer = $contextNormalizer ?? new EvaluationContextNormalizer(); + $this->bridgeCallable = $bridgeCallable ?? self::defaultBridgeCallable(); + $this->lifecycle = $lifecycle ?? new ProviderLifecycle(); + $this->exposureWriter = $exposureWriter ?? new ExposureWriter(); + $this->envReader = $envReader; + $this->metricsCounter = $metricsCounter ?? new MetricsCounter(); + } + + /** + * Access the provider's lifecycle helper. + * + * Used by OpenFeatureLifecycleCompatibility for blocking init. + * + * @internal Not part of the public Datadog API. + */ + public function getLifecycle(): ProviderLifecycle + { + return $this->lifecycle; + } + + /** + * Get the exposure context from the last successful evaluation. + * + * Returns null when: + * - No evaluation has occurred yet + * - The last evaluation had do_log=false + * - The last evaluation was an error or provider was not ready + * + * Phase 3 transport will consume this to build exposure payloads. + * + * @internal Not part of the public Datadog API. + */ + public function getLastExposureContext(): ?ExposureContext + { + return $this->lastExposureContext; + } + + public function resolveBooleanValue( + string $flagKey, + bool $defaultValue, + ?EvaluationContext $context = null, + ): ResolutionDetailsInterface { + return $this->resolveViaFfe($flagKey, $defaultValue, self::TYPE_BOOL, 'boolean', $context); + } + + public function resolveStringValue( + string $flagKey, + string $defaultValue, + ?EvaluationContext $context = null, + ): ResolutionDetailsInterface { + return $this->resolveViaFfe($flagKey, $defaultValue, self::TYPE_STRING, 'string', $context); + } + + public function resolveIntegerValue( + string $flagKey, + int $defaultValue, + ?EvaluationContext $context = null, + ): ResolutionDetailsInterface { + return $this->resolveViaFfe($flagKey, $defaultValue, self::TYPE_INT, 'integer', $context); + } + + public function resolveFloatValue( + string $flagKey, + float $defaultValue, + ?EvaluationContext $context = null, + ): ResolutionDetailsInterface { + return $this->resolveViaFfe($flagKey, $defaultValue, self::TYPE_FLOAT, 'float', $context); + } + + /** + * @param mixed[] $defaultValue + */ + public function resolveObjectValue( + string $flagKey, + array $defaultValue, + ?EvaluationContext $context = null, + ): ResolutionDetailsInterface { + return $this->resolveViaFfe($flagKey, $defaultValue, self::TYPE_OBJECT, 'object', $context); + } + + /** + * Shared evaluation pipeline used by all five typed resolver methods. + * + * 1. Check provider readiness via lifecycle helper + * 2. Normalize the evaluation context to (targetingKey, flatPrimitiveAttrs) + * 3. Call DDTrace\ffe_evaluate() via the bridge callable + * 4. Map the bridge result to an OpenFeature ResolutionDetails + * + * @param string $flagKey The flag key to evaluate + * @param bool|string|int|float|mixed[] $defaultValue Caller-provided default + * @param int $expectedType Bridge type constant (0-4) + * @param string $mapperType Mapper type name: boolean, string, integer, float, object + * @param EvaluationContext|null $context OpenFeature evaluation context + */ + private function resolveViaFfe( + string $flagKey, + bool|string|int|float|array $defaultValue, + int $expectedType, + string $mapperType, + ?EvaluationContext $context, + ): ResolutionDetailsInterface { + // Per-call readiness check via lifecycle helper. + // When not ready, delegate to the mapper with null bridge result, + // which returns default value with PROVIDER_NOT_READY error. + if (!$this->lifecycle->isReady()) { + // Counter fires on not-ready path too (D-03) with error.type=PROVIDER_NOT_READY. + $this->metricsCounter->record($flagKey, null); + + return match ($mapperType) { + 'boolean' => $this->resultMapper->mapBoolean(null, $defaultValue), + 'string' => $this->resultMapper->mapString(null, $defaultValue), + 'integer' => $this->resultMapper->mapInteger(null, $defaultValue), + 'float' => $this->resultMapper->mapFloat(null, $defaultValue), + 'object' => $this->resultMapper->mapObject(null, $defaultValue), + default => throw new \LogicException("Unsupported mapper type: {$mapperType}"), + }; + } + + [$targetingKey, $attributes] = $this->contextNormalizer->normalize($context); + + $bridgeResult = ($this->bridgeCallable)($flagKey, $expectedType, $targetingKey, $attributes); + + // Assemble exposure context on the success path. + // do_log=false is a hard gate: no exposure context is produced. + // Null bridge result or error paths also produce no exposure context. + if ($bridgeResult !== null && ($bridgeResult['error_code'] ?? -1) === 0) { + $this->lastExposureContext = ExposureContext::fromBridgeResult( + $bridgeResult, + $flagKey, + $targetingKey, + $this->envReader, + ); + } else { + $this->lastExposureContext = null; + } + + // Fire-and-forget exposure event to sidecar (per D-12, D-13). + // Only send when ExposureContext is non-null (do_log=true, no error). + if ($this->lastExposureContext !== null) { + $this->exposureWriter->send( + $this->lastExposureContext, + $attributes, + ); + } + + // Record OTel feature_flag.evaluations counter (D-02, D-03). + // Single call site per evaluation; fires on success and bridge-error paths. + // The not-ready early-return path records the counter separately above. + $this->metricsCounter->record($flagKey, $bridgeResult); + + return match ($mapperType) { + 'boolean' => $this->resultMapper->mapBoolean($bridgeResult, $defaultValue), + 'string' => $this->resultMapper->mapString($bridgeResult, $defaultValue), + 'integer' => $this->resultMapper->mapInteger($bridgeResult, $defaultValue), + 'float' => $this->resultMapper->mapFloat($bridgeResult, $defaultValue), + 'object' => $this->resultMapper->mapObject($bridgeResult, $defaultValue), + default => throw new \LogicException("Unsupported mapper type: {$mapperType}"), + }; + } + + /** + * Set the service context on the exposure writer's sidecar bridge. + * + * Called once during provider initialization to pass DD_SERVICE, DD_ENV, DD_VERSION + * to the Rust exposure state for inclusion in batch payloads. + * + * @internal Not part of the public Datadog API. + */ + public function initializeServiceContext(): void + { + $readEnv = $this->envReader ?? static function (string $name): ?string { + $value = getenv($name); + return $value !== false ? $value : null; + }; + + $service = $readEnv('DD_SERVICE') ?? ''; + $env = $readEnv('DD_ENV') ?? ''; + $version = $readEnv('DD_VERSION') ?? ''; + + if (function_exists('DDTrace\ffe_set_service_context')) { + \DDTrace\ffe_set_service_context($service, $env, $version); + } + } + + /** + * Create the default bridge callable that calls DDTrace\ffe_evaluate(). + * + * Returns null when the extension function is not available, which + * triggers PROVIDER_NOT_READY error handling in BridgeResultMapper. + * + * @return Closure(string, int, ?string, array): (?array) + */ + private static function defaultBridgeCallable(): Closure + { + return static function (string $flagKey, int $expectedType, ?string $targetingKey, array $attributes): ?array { + if (!function_exists('DDTrace\ffe_evaluate')) { + return null; + } + + /** @var array|null $result */ + $result = \DDTrace\ffe_evaluate($flagKey, $expectedType, $targetingKey, $attributes); + + return $result; + }; + } +} diff --git a/src/DDTrace/OpenFeature/EvaluationContextNormalizer.php b/src/DDTrace/OpenFeature/EvaluationContextNormalizer.php new file mode 100644 index 00000000000..43ce5db3a90 --- /dev/null +++ b/src/DDTrace/OpenFeature/EvaluationContextNormalizer.php @@ -0,0 +1,80 @@ +} + * Tuple of [targetingKey, flatPrimitiveAttributes] + */ + public function normalize(?EvaluationContext $context): array + { + if ($context === null) { + return [null, []]; + } + + $targetingKey = $context->getTargetingKey(); + $rawAttributes = $context->getAttributes()->toArray(); + $filtered = $this->filterPrimitiveAttributes($rawAttributes); + + return [$targetingKey, $filtered]; + } + + /** + * Filter an attributes array to only flat primitive values. + * + * Accepted types: string, int, float, bool + * Dropped types: array, object, DateTime, null, resource, or any non-primitive + * + * Only string keys are forwarded (matching ext/ddtrace.c behavior which + * skips integer-keyed entries in ZEND_HASH_FOREACH_STR_KEY_VAL). + * + * @param array $attributes Raw attributes from EvaluationContext + * @return array Filtered primitive-only attributes + */ + private function filterPrimitiveAttributes(array $attributes): array + { + $filtered = []; + + foreach ($attributes as $key => $value) { + // Only string keys are accepted (mirrors C extension behavior) + if (!is_string($key)) { + continue; + } + + // Only flat primitive types are forwarded + if (is_string($value) || is_int($value) || is_float($value) || is_bool($value)) { + $filtered[$key] = $value; + } + // All other types (array, object, DateTime, null, resource) are silently dropped + } + + return $filtered; + } +} diff --git a/src/DDTrace/OpenFeature/ExposureContext.php b/src/DDTrace/OpenFeature/ExposureContext.php new file mode 100644 index 00000000000..7155e09e4b0 --- /dev/null +++ b/src/DDTrace/OpenFeature/ExposureContext.php @@ -0,0 +1,111 @@ + Phase 3 boundary. + * + * @internal Used by DataDogProvider. Not part of the public API. + */ +final class ExposureContext +{ + /** + * @param string|null $service DD_SERVICE value (null if unset) + * @param string|null $env DD_ENV value (null if unset) + * @param string|null $version DD_VERSION value (null if unset) + * @param string $flagKey The flag key that was evaluated + * @param string|null $allocationKey The matched allocation key (from bridge result) + * @param string|null $variant The selected variant key (from bridge result) + * @param string|null $targetingKey The evaluation context targeting key + */ + public function __construct( + public ?string $service, + public ?string $env, + public ?string $version, + public string $flagKey, + public ?string $allocationKey, + public ?string $variant, + public ?string $targetingKey, + ) { + } + + /** + * Build an ExposureContext from bridge result fields and environment variables. + * + * Returns null when do_log is false -- this is the hard gate that prevents + * downstream exposure reporting for evaluations the evaluator says not to log. + * + * @param array $bridgeResult The raw result from DDTrace\ffe_evaluate() + * @param string $flagKey The flag key that was evaluated + * @param string|null $targetingKey The evaluation context targeting key + * @param Closure|null $envReader Override for reading environment variables (testing) + * @return self|null ExposureContext or null when do_log is false + */ + public static function fromBridgeResult( + array $bridgeResult, + string $flagKey, + ?string $targetingKey, + ?\Closure $envReader = null, + ): ?self { + $doLog = $bridgeResult['do_log'] ?? false; + + // Hard gate: do_log=false means no exposure context is produced + if (!$doLog) { + return null; + } + + $readEnv = $envReader ?? static function (string $name): ?string { + $value = getenv($name); + return $value !== false ? $value : null; + }; + + return new self( + service: $readEnv('DD_SERVICE'), + env: $readEnv('DD_ENV'), + version: $readEnv('DD_VERSION'), + flagKey: $flagKey, + allocationKey: $bridgeResult['allocation_key'] ?? null, + variant: $bridgeResult['variant'] ?? null, + targetingKey: $targetingKey, + ); + } + + /** + * Convert to an associative array for serialization or debugging. + * + * @return array + */ + public function toArray(): array + { + return [ + 'service' => $this->service, + 'env' => $this->env, + 'version' => $this->version, + 'flag_key' => $this->flagKey, + 'allocation_key' => $this->allocationKey, + 'variant' => $this->variant, + 'targeting_key' => $this->targetingKey, + ]; + } +} diff --git a/src/DDTrace/OpenFeature/ExposureWriter.php b/src/DDTrace/OpenFeature/ExposureWriter.php new file mode 100644 index 00000000000..f685480068c --- /dev/null +++ b/src/DDTrace/OpenFeature/ExposureWriter.php @@ -0,0 +1,159 @@ +sidecarCallable = $sidecarCallable ?? self::defaultSidecarCallable(); + $this->timestampProvider = $timestampProvider ?? static function (): int { + return (int)(microtime(true) * 1000); + }; + } + + /** + * Send an exposure event to the sidecar for dedup and batched delivery. + * + * Builds the event JSON from ExposureContext, flattens optional evaluation + * attributes to dot-notation, captures timestamp, and fires to sidecar bridge. + * + * This is fire-and-forget: the return value indicates whether the event was + * enqueued (true) or deduplicated/dropped (false), but the caller should not + * branch on it. Exposure tracking must never affect the evaluation result. + * + * @param ExposureContext $context Exposure metadata from evaluation (Phase 2 DTO). + * @param array|null $evaluationAttributes Raw evaluation context + * attributes to flatten for subject.attributes. Null = no attributes. + * @return bool True if enqueued, false if deduplicated or buffer full. + */ + public function send(ExposureContext $context, ?array $evaluationAttributes = null): bool + { + $eventJson = $this->buildEventJson($context, $evaluationAttributes); + + return ($this->sidecarCallable)( + $eventJson, + $context->flagKey, + $context->allocationKey ?? '', + $context->targetingKey, + $context->variant ?? '', + ); + } + + /** + * Build a single exposure event as a JSON string. + * + * Format matches Ruby Event.build() and Python build_exposure_event(): + * { + * "timestamp": 1713382853716, + * "flag": {"key": "test-flag"}, + * "allocation": {"key": "default-allocation"}, + * "variant": {"key": "on"}, + * "subject": {"id": "user-123", "attributes": {"plan": "enterprise"}} + * } + * + * @param ExposureContext $context Exposure metadata from evaluation. + * @param array|null $evaluationAttributes Raw evaluation context attributes. + * @return string JSON-encoded single exposure event. + */ + private function buildEventJson(ExposureContext $context, ?array $evaluationAttributes): string + { + $flatAttributes = ($evaluationAttributes !== null) + ? ContextFlattener::flatten($evaluationAttributes) + : []; + + $event = [ + 'timestamp' => ($this->timestampProvider)(), + 'flag' => ['key' => $context->flagKey], + 'allocation' => ['key' => $context->allocationKey ?? ''], + 'variant' => ['key' => $context->variant ?? ''], + 'subject' => [ + 'id' => $context->targetingKey ?? '', + 'attributes' => (object)$flatAttributes, + ], + ]; + + return json_encode($event, JSON_THROW_ON_ERROR); + } + + /** + * Create the default sidecar bridge callable that calls DDTrace\ffe_send_exposure(). + * + * Returns false when the extension function is not available (graceful degradation). + * + * @return Closure(string, string, string, ?string, string): bool + */ + private static function defaultSidecarCallable(): Closure + { + return static function ( + string $eventJson, + string $flagKey, + string $allocationKey, + ?string $targetingKey, + string $variantKey, + ): bool { + if (!function_exists('DDTrace\ffe_send_exposure')) { + return false; + } + + return \DDTrace\ffe_send_exposure( + $eventJson, + $flagKey, + $allocationKey, + $targetingKey, + $variantKey, + ); + }; + } +} diff --git a/src/DDTrace/OpenFeature/MetricsCounter.php b/src/DDTrace/OpenFeature/MetricsCounter.php new file mode 100644 index 00000000000..48a4fb73dd9 --- /dev/null +++ b/src/DDTrace/OpenFeature/MetricsCounter.php @@ -0,0 +1,250 @@ + $attributes): void`. + * - When dd-trace-php is loaded at runtime, a real callable wiring the + * built-in OTel Meter binding is injected at construction time. + * - When dd-trace-php is absent (staging repo, CI without the extension), + * the default callable is a no-op so `MetricsCounter::record()` is safe + * to call unconditionally. + * + * Counter behavior matches the cross-tracer convention: + * - Fires once per evaluation on both success and error paths (D-03). + * - Attributes match OBSV-02 exactly (D-05): + * feature_flag.key, feature_flag.result.variant, + * feature_flag.result.reason, feature_flag.result.allocation_key, + * error.type. + * - error.type is an empty string on success and the OpenFeature error-code + * name on failure (FLAG_NOT_FOUND, PARSE_ERROR, TYPE_MISMATCH, GENERAL, + * PROVIDER_NOT_READY). A null bridge result is treated as + * PROVIDER_NOT_READY. + * - The counter is gated on the `DD_METRICS_OTEL_ENABLED` env var (D-04). + * When unset, empty, or falsy ("0", "false", "no", "off"), `record()` is + * a no-op regardless of the injected counterCallable. + * + * Reason and error-code mappings mirror {@see BridgeResultMapper} so the + * counter and the OpenFeature ResolutionDetails always report the same + * outcome for the same evaluation. + * + * @internal Not part of the public Datadog API. + */ +final class MetricsCounter +{ + /** + * Bridge reason integer -> OpenFeature reason name. + * + * Mirrors {@see BridgeResultMapper::REASON_MAP}. Kept as a local copy so + * this class has no coupling to BridgeResultMapper's private constants. + * + * @var array + */ + private const REASON_MAP = [ + 0 => 'DEFAULT', + 1 => 'TARGETING_MATCH', + 2 => 'SPLIT', + 3 => 'DISABLED', + 4 => 'ERROR', + ]; + + /** + * Bridge error_code integer -> OpenFeature error-code name. + * + * Mirrors {@see BridgeResultMapper::ERROR_CODE_MAP}. Unknown error codes + * fall back to 'GENERAL' to match BridgeResultMapper::mapErrorCode(). + * + * @var array + */ + private const ERROR_CODE_MAP = [ + 1 => 'FLAG_NOT_FOUND', + 2 => 'PARSE_ERROR', + 3 => 'TYPE_MISMATCH', + 4 => 'GENERAL', + 5 => 'PROVIDER_NOT_READY', + ]; + + /** + * The injected counter callable. + * + * Signature: fn(array $attributes): void. + * + * @var Closure(array): void + */ + private Closure $counterCallable; + + /** + * Optional override for reading environment variables (testing). + * + * When null, DD_METRICS_OTEL_ENABLED is read via getenv(). + * + * @var Closure(string): ?string|null + */ + private ?Closure $envReader; + + /** + * @param Closure|null $counterCallable Custom counter callable for testing. + * Signature: fn(array $attributes): void. + * Null = no-op closure (safe when compiled extension is absent). + * @param Closure|null $envReader Override for reading environment variables (testing). + * Signature: fn(string $name): ?string. + */ + public function __construct( + ?Closure $counterCallable = null, + ?Closure $envReader = null, + ) { + $this->counterCallable = $counterCallable ?? self::defaultCounterCallable(); + $this->envReader = $envReader; + } + + /** + * Record a single evaluation in the feature_flag.evaluations counter. + * + * Called exactly once per DataDogProvider evaluation (D-02). Fires on + * success and all error paths (D-03). No-op when DD_METRICS_OTEL_ENABLED + * is disabled (D-04). + * + * @param string $flagKey The flag key that was evaluated. + * @param array|null $bridgeResult Raw result from + * DDTrace\ffe_evaluate(), or null when the bridge is unavailable + * (treated as PROVIDER_NOT_READY). + */ + public function record(string $flagKey, ?array $bridgeResult): void + { + if (!$this->isMetricsEnabled()) { + return; + } + + $errorType = $this->resolveErrorType($bridgeResult); + $reason = $this->resolveReason($bridgeResult, $errorType); + + $attributes = [ + 'feature_flag.key' => $flagKey, + 'feature_flag.result.variant' => self::stringOrEmpty($bridgeResult['variant'] ?? null), + 'feature_flag.result.reason' => $reason, + 'feature_flag.result.allocation_key' => self::stringOrEmpty($bridgeResult['allocation_key'] ?? null), + 'error.type' => $errorType, + ]; + + ($this->counterCallable)($attributes); + } + + /** + * Resolve the error.type attribute value. + * + * Matches BridgeResultMapper's error-handling semantics: + * - null bridge result -> PROVIDER_NOT_READY (bridge engine unavailable) + * - non-zero error_code -> mapped error name, fallback GENERAL + * - error_code == 0 -> empty string (success) + * + * @param array|null $bridgeResult + */ + private function resolveErrorType(?array $bridgeResult): string + { + if ($bridgeResult === null) { + return 'PROVIDER_NOT_READY'; + } + + $errorCode = $bridgeResult['error_code'] ?? 0; + if ($errorCode === 0) { + return ''; + } + + return self::ERROR_CODE_MAP[$errorCode] ?? 'GENERAL'; + } + + /** + * Resolve the feature_flag.result.reason attribute value. + * + * On any error path the reason is forced to 'ERROR' (matches BridgeResultMapper). + * On success the reason is looked up from the bridge's `reason` integer. + * + * @param array|null $bridgeResult + */ + private function resolveReason(?array $bridgeResult, string $errorType): string + { + if ($errorType !== '') { + return 'ERROR'; + } + + // Success path: $bridgeResult is guaranteed non-null here because a + // null bridge result maps to error.type=PROVIDER_NOT_READY above. + /** @var array $bridgeResult */ + $reason = $bridgeResult['reason'] ?? 0; + + return self::REASON_MAP[$reason] ?? 'DEFAULT'; + } + + /** + * Check whether DD_METRICS_OTEL_ENABLED is set to a truthy value. + * + * Accepted truthy values (case-insensitive, trimmed): "true", "1", "yes", + * "on". Anything else (null, empty, "false", "0", "no", "off", ...) means + * the counter is disabled. + */ + private function isMetricsEnabled(): bool + { + $reader = $this->envReader ?? static function (string $name): ?string { + $value = getenv($name); + return $value !== false ? $value : null; + }; + + $raw = $reader('DD_METRICS_OTEL_ENABLED'); + if ($raw === null) { + return false; + } + + $normalized = strtolower(trim($raw)); + + return match ($normalized) { + 'true', '1', 'yes', 'on' => true, + default => false, + }; + } + + /** + * Coerce a possibly-null/missing bridge field to a non-null string. + * + * OTel attribute values must be typed; OBSV-02 specifies string attributes, + * and downstream pipelines may reject null. Empty string is the sentinel + * for "missing" to match the cross-tracer convention. + * + * @param mixed $value + */ + private static function stringOrEmpty(mixed $value): string + { + if ($value === null) { + return ''; + } + return (string) $value; + } + + /** + * Default counter callable used when no explicit callable is injected. + * + * In the staging repo (no compiled dd-trace-php extension) this is a + * complete no-op: `record()` is safe to call but no metric is emitted. + * When the compiled extension is present, callers inject a real callable + * that wires the OTel Meter binding (e.g. via + * `function_exists('DDTrace\\otel_counter_add')`). + * + * @return Closure(array): void + */ + private static function defaultCounterCallable(): Closure + { + return static function (array $attributes): void { + // no-op -- dd-trace-php integration injects the real Meter binding. + }; + } +} diff --git a/src/DDTrace/OpenFeature/OpenFeatureLifecycleCompatibility.php b/src/DDTrace/OpenFeature/OpenFeatureLifecycleCompatibility.php new file mode 100644 index 00000000000..bfc12e71d90 --- /dev/null +++ b/src/DDTrace/OpenFeature/OpenFeatureLifecycleCompatibility.php @@ -0,0 +1,81 @@ +setProvider($provider); + + // Populate the batch-level service context block in the sidecar's EXPOSURE_STATE + // so the flush payload wrapper includes DD_SERVICE/DD_ENV/DD_VERSION. Without this, + // per-event exposure data is still correct but the batch envelope ships empty. + $provider->initializeServiceContext(); + + // Block until ready or timeout + $lifecycle = $provider->getLifecycle(); + + return $lifecycle->waitUntilReady($timeoutSeconds); + } + + /** + * Register a DataDogProvider for non-blocking initialization. + * + * Returns immediately. The provider will return defaults until config arrives. + * This is equivalent to calling OpenFeatureAPI::getInstance()->setProvider() + * directly, but explicitly names the non-blocking intent. + * + * @param DataDogProvider $provider The configured Datadog provider instance. + */ + public static function setProvider(DataDogProvider $provider): void + { + OpenFeatureAPI::getInstance()->setProvider($provider); + $provider->initializeServiceContext(); + } + + /** + * Prevent instantiation -- static utility only. + */ + private function __construct() + { + } +} diff --git a/src/DDTrace/OpenFeature/ProviderLifecycle.php b/src/DDTrace/OpenFeature/ProviderLifecycle.php new file mode 100644 index 00000000000..5f1733d207b --- /dev/null +++ b/src/DDTrace/OpenFeature/ProviderLifecycle.php @@ -0,0 +1,264 @@ +hasConfigCallable = $hasConfigCallable ?? self::defaultHasConfig(); + $this->configVersionCallable = $configVersionCallable ?? self::defaultConfigVersion(); + $this->onReady = $onReady; + // Initialize last-seen to current version so a config loaded before + // construction is not mistakenly observed as a "change". + $this->lastSeenVersion = ($this->configVersionCallable)(); + + // Check initial state -- if config is already loaded (e.g., preloaded), + // mark as ready immediately. + if (($this->hasConfigCallable)()) { + $this->transitionToReady(); + } + } + + /** + * Whether the provider is currently ready to evaluate flags. + * + * Once ready, stays ready. Readiness is sticky -- config removal is not + * modeled in the current bridge surface. + */ + public function isReady(): bool + { + if ($this->ready) { + return true; + } + + // Poll the bridge for config availability + if (($this->hasConfigCallable)()) { + $this->transitionToReady(); + return true; + } + + return false; + } + + /** + * Block until config is available or timeout expires. + * + * Uses a polling loop with a small sleep interval. The bridge functions + * are lightweight lock-check operations, so polling is acceptable. + * + * @param float $timeoutSeconds Maximum time to wait (in seconds). 0 = no wait. + * @param int $pollIntervalMicroseconds Polling interval (default 10ms). + * @return bool True if ready within timeout, false if timed out. + */ + public function waitUntilReady(float $timeoutSeconds = 5.0, int $pollIntervalMicroseconds = 10_000): bool + { + if ($this->isReady()) { + return true; + } + + if ($timeoutSeconds <= 0) { + return false; + } + + $deadline = microtime(true) + $timeoutSeconds; + + while (microtime(true) < $deadline) { + if (($this->hasConfigCallable)()) { + $this->transitionToReady(); + return true; + } + usleep($pollIntervalMicroseconds); + } + + // Final check after sleep + if (($this->hasConfigCallable)()) { + $this->transitionToReady(); + return true; + } + + return false; + } + + /** + * Check for config changes and update readiness state. + * + * Compares the current version against the last seen version. Multiple + * independent subscribers can call this without racing — each instance + * tracks its own `lastSeenVersion`. + * + * @return bool True if config changed since last check. + */ + public function checkForConfigChange(): bool + { + $currentVersion = ($this->configVersionCallable)(); + $changed = $currentVersion !== $this->lastSeenVersion; + $this->lastSeenVersion = $currentVersion; + + if ($changed && !$this->ready) { + $this->transitionToReady(); + } + + return $changed; + } + + /** + * Register a callback to fire exactly once when the provider becomes ready. + * + * If already ready, the callback fires immediately (synchronously). + * + * @param Closure $callback The callback to invoke on PROVIDER_READY. + */ + public function onReady(Closure $callback): void + { + if ($this->readyEventFired) { + // Already fired -- invoke immediately for late subscribers + $callback(); + return; + } + + $this->onReady = $callback; + + // If ready but event hasn't fired yet (shouldn't normally happen), + // fire it now. + if ($this->ready && !$this->readyEventFired) { + $this->fireReadyEvent(); + } + } + + /** + * Transition to the ready state and fire the PROVIDER_READY event. + */ + private function transitionToReady(): void + { + if ($this->ready) { + return; // Already transitioned + } + + $this->ready = true; + + // Sync last-seen version so a subsequent checkForConfigChange() does + // not interpret the transition itself as another change. + $this->lastSeenVersion = ($this->configVersionCallable)(); + + $this->fireReadyEvent(); + } + + /** + * Fire the PROVIDER_READY callback exactly once. + */ + private function fireReadyEvent(): void + { + if ($this->readyEventFired) { + return; + } + + $this->readyEventFired = true; + + if ($this->onReady !== null) { + ($this->onReady)(); + } + } + + /** + * Default bridge callable for DDTrace\ffe_has_config(). + * + * @return Closure(): bool + */ + private static function defaultHasConfig(): Closure + { + return static function (): bool { + if (!function_exists('DDTrace\ffe_has_config')) { + return false; + } + return \DDTrace\ffe_has_config(); + }; + } + + /** + * Default bridge callable for DDTrace\ffe_config_version(). + * + * @return Closure(): int + */ + private static function defaultConfigVersion(): Closure + { + return static function (): int { + if (!function_exists('DDTrace\ffe_config_version')) { + return 0; + } + return \DDTrace\ffe_config_version(); + }; + } +} diff --git a/src/bridge/_files_openfeature.php b/src/bridge/_files_openfeature.php new file mode 100644 index 00000000000..973756f8c48 --- /dev/null +++ b/src/bridge/_files_openfeature.php @@ -0,0 +1,13 @@ +assertSame([], ContextFlattener::flatten([])); + } + + public function testFlattenFlatPrimitives(): void + { + $data = [ + 'str' => 'hello', + 'int' => 42, + 'float' => 3.14, + 'bool' => true, + ]; + + $this->assertSame($data, ContextFlattener::flatten($data)); + } + + public function testFlattenNestedArraysToDotNotation(): void + { + $data = [ + 'user' => [ + 'plan' => 'enterprise', + 'region' => 'us-east', + ], + ]; + + $this->assertSame([ + 'user.plan' => 'enterprise', + 'user.region' => 'us-east', + ], ContextFlattener::flatten($data)); + } + + public function testFlattenDeeplyNested(): void + { + $data = [ + 'level1' => [ + 'level2' => [ + 'level3' => 'deep-value', + ], + ], + ]; + + $this->assertSame([ + 'level1.level2.level3' => 'deep-value', + ], ContextFlattener::flatten($data)); + } + + // ---------- Type Filtering ---------- + + public function testDropsNullValues(): void + { + $data = ['key' => null, 'valid' => 'yes']; + + $this->assertSame(['valid' => 'yes'], ContextFlattener::flatten($data)); + } + + public function testDropsNumericKeys(): void + { + $data = [0 => 'first', 'name' => 'second']; + + $this->assertSame(['name' => 'second'], ContextFlattener::flatten($data)); + } + + public function testDropsObjectValues(): void + { + $data = [ + 'obj' => new \stdClass(), + 'valid' => 'yes', + ]; + + $this->assertSame(['valid' => 'yes'], ContextFlattener::flatten($data)); + } + + public function testDropsNumericArrayValues(): void + { + // Arrays with numeric keys should be treated as "array values" and dropped + // since they don't have string keys for dot-notation + $data = [ + 'tags' => ['a', 'b', 'c'], + 'valid' => 'yes', + ]; + + // The 'tags' array is recursed into, but numeric keys are dropped + $this->assertSame(['valid' => 'yes'], ContextFlattener::flatten($data)); + } + + // ---------- Mixed Nested ---------- + + public function testMixedNestedAndFlat(): void + { + $data = [ + 'simple' => 'value', + 'nested' => [ + 'inner' => 'deep', + 'number' => 99, + ], + 'bool_val' => false, + ]; + + $this->assertSame([ + 'simple' => 'value', + 'nested.inner' => 'deep', + 'nested.number' => 99, + 'bool_val' => false, + ], ContextFlattener::flatten($data)); + } + + // ---------- Prefix Parameter ---------- + + public function testFlattenWithPrefix(): void + { + $data = ['key' => 'value']; + + $this->assertSame( + ['parent.key' => 'value'], + ContextFlattener::flatten($data, 'parent'), + ); + } + + // ---------- Edge Cases ---------- + + public function testBooleanFalseIsPreserved(): void + { + $this->assertSame( + ['flag' => false], + ContextFlattener::flatten(['flag' => false]), + ); + } + + public function testZeroIntegerIsPreserved(): void + { + $this->assertSame( + ['count' => 0], + ContextFlattener::flatten(['count' => 0]), + ); + } + + public function testEmptyStringIsPreserved(): void + { + $this->assertSame( + ['name' => ''], + ContextFlattener::flatten(['name' => '']), + ); + } + + public function testFloatZeroIsPreserved(): void + { + $this->assertSame( + ['rate' => 0.0], + ContextFlattener::flatten(['rate' => 0.0]), + ); + } +} diff --git a/tests/OpenFeature/CrossRequestDedupTest.php b/tests/OpenFeature/CrossRequestDedupTest.php new file mode 100644 index 00000000000..4bc93853831 --- /dev/null +++ b/tests/OpenFeature/CrossRequestDedupTest.php @@ -0,0 +1,167 @@ +makeMockSidecarCallable()); + + $context = new ExposureContext( + service: null, + env: null, + version: null, + flagKey: 'flag-1', + allocationKey: 'alloc-1', + variant: 'on', + targetingKey: 'user-1', + ); + + $firstResult = $writer->send($context, ['a' => 1]); + $secondResult = $writer->send($context, ['a' => 1]); + + $this->assertTrue($firstResult, 'First send with a fresh dedup triple must be enqueued'); + $this->assertFalse( + $secondResult, + 'Second send with identical dedup triple + variant must be deduplicated', + ); + } + + public function testSendWithDifferentTargetingKeyIsNotDeduplicated(): void + { + $writer = new ExposureWriter($this->makeMockSidecarCallable()); + + $contextUser1 = new ExposureContext( + service: null, + env: null, + version: null, + flagKey: 'flag-1', + allocationKey: 'alloc-1', + variant: 'on', + targetingKey: 'user-1', + ); + + $contextUser2 = new ExposureContext( + service: null, + env: null, + version: null, + flagKey: 'flag-1', + allocationKey: 'alloc-1', + variant: 'on', + targetingKey: 'user-2', + ); + + $this->assertTrue( + $writer->send($contextUser1, null), + 'First send (user-1) must be enqueued', + ); + $this->assertTrue( + $writer->send($contextUser2, null), + 'Different targeting key must not dedupe against user-1 cache entry', + ); + } + + public function testSendWithSameTripleButDifferentVariantIsRecordedAsNewExposure(): void + { + // Mirrors Rust behavior: if cached variant != new variant, the dedup + // logic treats the new exposure as a new cache entry (returns true). + // This asserts the PHP layer passes variant through faithfully so the + // Rust dedup can make the distinction. + $writer = new ExposureWriter($this->makeMockSidecarCallable()); + + $contextVariantOn = new ExposureContext( + service: null, + env: null, + version: null, + flagKey: 'flag-1', + allocationKey: 'alloc-1', + variant: 'on', + targetingKey: 'user-1', + ); + + $contextVariantOff = new ExposureContext( + service: null, + env: null, + version: null, + flagKey: 'flag-1', + allocationKey: 'alloc-1', + variant: 'off', + targetingKey: 'user-1', + ); + + $this->assertTrue( + $writer->send($contextVariantOn, null), + 'First send (variant=on) must be enqueued', + ); + $this->assertTrue( + $writer->send($contextVariantOff, null), + 'Same triple with a different variant must record a new exposure', + ); + } + + /** + * Build a mock sidecar callable that mirrors the Rust EXPOSURE_STATE dedup + * semantics: dedup key is flag_key + "\0" + allocation_key + "\0" + (targeting_key ?? ""). + * On second call with the same dedup key AND the same variant, returns false + * (deduplicated). On different dedup key or different variant, returns true + * (recorded as a new/updated exposure). + * + * This keeps the null-byte separator format from components-rs/ffe.rs so + * drift between Rust and PHP dedup key construction is caught here. + * + * @return \Closure(string, string, string, ?string, string): bool + */ + private function makeMockSidecarCallable(): \Closure + { + /** @var array $state */ + $state = []; + + return static function ( + string $eventJson, + string $flagKey, + string $allocationKey, + ?string $targetingKey, + string $variantKey, + ) use (&$state): bool { + $key = $flagKey . "\0" . $allocationKey . "\0" . ($targetingKey ?? ''); + + if (isset($state[$key]) && $state[$key] === $variantKey) { + // Same dedup triple, same variant -> deduplicated + return false; + } + + // New triple or variant changed -> recorded as new exposure + $state[$key] = $variantKey; + return true; + }; + } +} diff --git a/tests/OpenFeature/DataDogProviderTest.php b/tests/OpenFeature/DataDogProviderTest.php new file mode 100644 index 00000000000..d985434a5f5 --- /dev/null +++ b/tests/OpenFeature/DataDogProviderTest.php @@ -0,0 +1,1028 @@ +createProvider(fn () => null); + $this->assertSame('Datadog', $provider->getMetadata()->getName()); + } + + // ------------------------------------------------------------------------- + // Boolean resolution + // ------------------------------------------------------------------------- + + public function testResolveBooleanValueReturnsEvaluatedTrue(): void + { + $provider = $this->createProviderWithSuccessResult('"true"', json_decode('"true"') === true ? 'true' : null); + // value_json for bool is 'true' (JSON-encoded boolean) + $provider = $this->createProvider($this->successBridge('true', 'variant-a', 'alloc-1')); + + $result = $provider->resolveBooleanValue('flag.bool', false); + + $this->assertTrue($result->getValue()); + $this->assertSame('variant-a', $result->getVariant()); + $this->assertNull($result->getError()); + } + + public function testResolveBooleanValueReturnsFalse(): void + { + $provider = $this->createProvider($this->successBridge('false', 'variant-b', 'alloc-2')); + + $result = $provider->resolveBooleanValue('flag.bool', true); + + $this->assertFalse($result->getValue()); + $this->assertSame('variant-b', $result->getVariant()); + } + + // ------------------------------------------------------------------------- + // String resolution + // ------------------------------------------------------------------------- + + public function testResolveStringValueReturnsEvaluatedString(): void + { + $provider = $this->createProvider($this->successBridge('"blue"', 'color-variant', 'alloc-3')); + + $result = $provider->resolveStringValue('flag.color', 'red'); + + $this->assertSame('blue', $result->getValue()); + $this->assertSame('color-variant', $result->getVariant()); + $this->assertNull($result->getError()); + } + + // ------------------------------------------------------------------------- + // Integer resolution + // ------------------------------------------------------------------------- + + public function testResolveIntegerValueReturnsEvaluatedInt(): void + { + $provider = $this->createProvider($this->successBridge('42', 'num-variant', 'alloc-4')); + + $result = $provider->resolveIntegerValue('flag.count', 0); + + $this->assertSame(42, $result->getValue()); + $this->assertSame('num-variant', $result->getVariant()); + } + + // ------------------------------------------------------------------------- + // Float resolution + // ------------------------------------------------------------------------- + + public function testResolveFloatValueReturnsEvaluatedFloat(): void + { + $provider = $this->createProvider($this->successBridge('3.14', 'float-variant', 'alloc-5')); + + $result = $provider->resolveFloatValue('flag.rate', 0.0); + + $this->assertSame(3.14, $result->getValue()); + $this->assertSame('float-variant', $result->getVariant()); + } + + public function testResolveFloatValueAcceptsIntegerJsonAsFloat(): void + { + // JSON integer should be coerced to float + $provider = $this->createProvider($this->successBridge('10', 'int-as-float', 'alloc-6')); + + $result = $provider->resolveFloatValue('flag.rate', 0.0); + + $this->assertSame(10.0, $result->getValue()); + } + + // ------------------------------------------------------------------------- + // Object resolution + // ------------------------------------------------------------------------- + + public function testResolveObjectValueReturnsEvaluatedArray(): void + { + $jsonValue = '{"key":"value","nested":{"a":1}}'; + $provider = $this->createProvider($this->successBridge($jsonValue, 'obj-variant', 'alloc-7')); + + $result = $provider->resolveObjectValue('flag.config', []); + + $this->assertSame(['key' => 'value', 'nested' => ['a' => 1]], $result->getValue()); + $this->assertSame('obj-variant', $result->getVariant()); + } + + // ------------------------------------------------------------------------- + // Default value returned on bridge errors + // ------------------------------------------------------------------------- + + public function testDefaultReturnedWhenBridgeErrorCodeFlagNotFound(): void + { + $provider = $this->createProvider($this->errorBridge(1)); // ERROR_FLAG_NOT_FOUND + + $result = $provider->resolveBooleanValue('missing.flag', true); + + $this->assertTrue($result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + $this->assertNotNull($result->getError()); + $this->assertEquals( + ErrorCode::FLAG_NOT_FOUND(), + $result->getError()->getResolutionErrorCode() + ); + } + + public function testDefaultReturnedWhenBridgeErrorCodeProviderNotReady(): void + { + $provider = $this->createProvider($this->errorBridge(5)); // ERROR_PROVIDER_NOT_READY + + $result = $provider->resolveStringValue('flag.str', 'fallback'); + + $this->assertSame('fallback', $result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + $this->assertEquals( + ErrorCode::PROVIDER_NOT_READY(), + $result->getError()->getResolutionErrorCode() + ); + } + + public function testDefaultReturnedWhenBridgeErrorCodeTypeMismatch(): void + { + $provider = $this->createProvider($this->errorBridge(3)); // ERROR_TYPE_MISMATCH + + $result = $provider->resolveIntegerValue('flag.int', 99); + + $this->assertSame(99, $result->getValue()); + $this->assertEquals( + ErrorCode::TYPE_MISMATCH(), + $result->getError()->getResolutionErrorCode() + ); + } + + public function testDefaultReturnedWhenBridgeErrorCodeParseError(): void + { + $provider = $this->createProvider($this->errorBridge(2)); // ERROR_PARSE_ERROR + + $result = $provider->resolveFloatValue('flag.float', 1.5); + + $this->assertSame(1.5, $result->getValue()); + $this->assertEquals( + ErrorCode::PARSE_ERROR(), + $result->getError()->getResolutionErrorCode() + ); + } + + public function testDefaultReturnedWhenBridgeErrorCodeGeneral(): void + { + $provider = $this->createProvider($this->errorBridge(4)); // ERROR_GENERAL + + $result = $provider->resolveObjectValue('flag.obj', ['default' => true]); + + $this->assertSame(['default' => true], $result->getValue()); + $this->assertEquals( + ErrorCode::GENERAL(), + $result->getError()->getResolutionErrorCode() + ); + } + + public function testDefaultReturnedWhenBridgeReturnsNull(): void + { + $provider = $this->createProvider(fn () => null); + + $result = $provider->resolveBooleanValue('flag.bool', true); + + $this->assertTrue($result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + $this->assertEquals( + ErrorCode::PROVIDER_NOT_READY(), + $result->getError()->getResolutionErrorCode() + ); + } + + // ------------------------------------------------------------------------- + // Reason coercion to ERROR when error_code != 0 + // ------------------------------------------------------------------------- + + public function testReasonForcedToErrorWhenErrorCodeNonZero(): void + { + // Even if bridge returns a reason other than ERROR, it should be forced to ERROR + $provider = $this->createProvider(fn () => [ + 'value_json' => '"something"', + 'variant' => 'v1', + 'allocation_key' => 'a1', + 'reason' => 1, // REASON_TARGETING_MATCH + 'error_code' => 1, // ERROR_FLAG_NOT_FOUND + 'do_log' => false, + ]); + + $result = $provider->resolveStringValue('flag.str', 'default'); + + // Reason MUST be ERROR because error_code is non-zero + $this->assertSame(Reason::ERROR, $result->getReason()); + // Value MUST be the default, not the bridge value + $this->assertSame('default', $result->getValue()); + } + + public function testReasonPreservedWhenErrorCodeIsZero(): void + { + $provider = $this->createProvider(fn () => [ + 'value_json' => '"hello"', + 'variant' => 'v1', + 'allocation_key' => 'a1', + 'reason' => 1, // REASON_TARGETING_MATCH + 'error_code' => 0, + 'do_log' => true, + ]); + + $result = $provider->resolveStringValue('flag.str', 'default'); + + $this->assertSame('TARGETING_MATCH', $result->getReason()); + $this->assertSame('hello', $result->getValue()); + } + + // ------------------------------------------------------------------------- + // Reason mapping (success cases) + // ------------------------------------------------------------------------- + + public function testReasonMappingSplit(): void + { + $provider = $this->createProvider(fn () => [ + 'value_json' => 'true', + 'variant' => 'v1', + 'allocation_key' => 'a1', + 'reason' => 2, // REASON_SPLIT + 'error_code' => 0, + 'do_log' => true, + ]); + + $result = $provider->resolveBooleanValue('flag.bool', false); + $this->assertSame('SPLIT', $result->getReason()); + } + + public function testReasonMappingDisabled(): void + { + $provider = $this->createProvider(fn () => [ + 'value_json' => '"off"', + 'variant' => 'disabled-variant', + 'allocation_key' => 'a1', + 'reason' => 3, // REASON_DISABLED + 'error_code' => 0, + 'do_log' => false, + ]); + + $result = $provider->resolveStringValue('flag.str', 'default'); + $this->assertSame('DISABLED', $result->getReason()); + } + + public function testReasonMappingDefault(): void + { + $provider = $this->createProvider(fn () => [ + 'value_json' => '100', + 'variant' => 'default-variant', + 'allocation_key' => 'a1', + 'reason' => 0, // REASON_DEFAULT + 'error_code' => 0, + 'do_log' => true, + ]); + + $result = $provider->resolveIntegerValue('flag.int', 0); + $this->assertSame('DEFAULT', $result->getReason()); + } + + // ------------------------------------------------------------------------- + // JSON decode edge cases + // ------------------------------------------------------------------------- + + public function testDefaultReturnedWhenValueJsonIsNull(): void + { + $provider = $this->createProvider(fn () => [ + 'value_json' => null, + 'variant' => null, + 'allocation_key' => null, + 'reason' => 0, + 'error_code' => 0, + 'do_log' => false, + ]); + + $result = $provider->resolveStringValue('flag.str', 'fallback'); + + $this->assertSame('fallback', $result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + } + + public function testDefaultReturnedWhenValueJsonIsInvalid(): void + { + $provider = $this->createProvider(fn () => [ + 'value_json' => '{invalid json', + 'variant' => 'v1', + 'allocation_key' => 'a1', + 'reason' => 1, + 'error_code' => 0, + 'do_log' => true, + ]); + + $result = $provider->resolveBooleanValue('flag.bool', true); + + $this->assertTrue($result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + $this->assertEquals( + ErrorCode::PARSE_ERROR(), + $result->getError()->getResolutionErrorCode() + ); + } + + public function testDefaultReturnedWhenTypeMismatchInValue(): void + { + // Bridge returns a string JSON but we ask for boolean + $provider = $this->createProvider($this->successBridge('"not-a-bool"', 'v1', 'a1')); + + $result = $provider->resolveBooleanValue('flag.bool', false); + + $this->assertFalse($result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + } + + // ------------------------------------------------------------------------- + // EvaluationContextNormalizer: targeting key behavior + // ------------------------------------------------------------------------- + + public function testTargetingKeyIsNullWhenContextIsNull(): void + { + $capturedArgs = []; + $provider = $this->createProvider(function (string $flagKey, int $type, ?string $targetingKey, array $attrs) use (&$capturedArgs) { + $capturedArgs = compact('flagKey', 'type', 'targetingKey', 'attrs'); + return $this->makeSuccessResult('true'); + }); + + $provider->resolveBooleanValue('flag.bool', false, null); + + $this->assertNull($capturedArgs['targetingKey']); + } + + public function testTargetingKeyIsNullWhenContextHasNoTargetingKey(): void + { + $capturedArgs = []; + $provider = $this->createProvider(function (string $flagKey, int $type, ?string $targetingKey, array $attrs) use (&$capturedArgs) { + $capturedArgs = compact('flagKey', 'type', 'targetingKey', 'attrs'); + return $this->makeSuccessResult('true'); + }); + + $context = new EvaluationContext(null, new Attributes(['key1' => 'val1'])); + $provider->resolveBooleanValue('flag.bool', false, $context); + + $this->assertNull($capturedArgs['targetingKey']); + } + + public function testTargetingKeyPassedThroughWhenPresent(): void + { + $capturedArgs = []; + $provider = $this->createProvider(function (string $flagKey, int $type, ?string $targetingKey, array $attrs) use (&$capturedArgs) { + $capturedArgs = compact('flagKey', 'type', 'targetingKey', 'attrs'); + return $this->makeSuccessResult('true'); + }); + + $context = new EvaluationContext('user-123'); + $provider->resolveBooleanValue('flag.bool', false, $context); + + $this->assertSame('user-123', $capturedArgs['targetingKey']); + } + + // ------------------------------------------------------------------------- + // EvaluationContextNormalizer: attribute filtering + // ------------------------------------------------------------------------- + + public function testPrimitiveAttributesAreForwarded(): void + { + $capturedArgs = []; + $provider = $this->createProvider(function (string $flagKey, int $type, ?string $targetingKey, array $attrs) use (&$capturedArgs) { + $capturedArgs = compact('flagKey', 'type', 'targetingKey', 'attrs'); + return $this->makeSuccessResult('"ok"'); + }); + + $context = new EvaluationContext('user-1', new Attributes([ + 'str_attr' => 'hello', + 'int_attr' => 42, + 'float_attr' => 3.14, + 'bool_attr' => true, + ])); + + $provider->resolveStringValue('flag.str', 'default', $context); + + $this->assertSame([ + 'str_attr' => 'hello', + 'int_attr' => 42, + 'float_attr' => 3.14, + 'bool_attr' => true, + ], $capturedArgs['attrs']); + } + + public function testNestedArraysAreDropped(): void + { + $capturedArgs = []; + $provider = $this->createProvider(function (string $flagKey, int $type, ?string $targetingKey, array $attrs) use (&$capturedArgs) { + $capturedArgs = compact('flagKey', 'type', 'targetingKey', 'attrs'); + return $this->makeSuccessResult('"ok"'); + }); + + $context = new EvaluationContext(null, new Attributes([ + 'valid' => 'yes', + 'nested' => ['a' => 1, 'b' => 2], + 'also_valid' => 10, + ])); + + $provider->resolveStringValue('flag.str', 'default', $context); + + // Only flat primitives should remain + $this->assertSame([ + 'valid' => 'yes', + 'also_valid' => 10, + ], $capturedArgs['attrs']); + $this->assertArrayNotHasKey('nested', $capturedArgs['attrs']); + } + + public function testNullAttributeValuesAreDropped(): void + { + $capturedArgs = []; + $provider = $this->createProvider(function (string $flagKey, int $type, ?string $targetingKey, array $attrs) use (&$capturedArgs) { + $capturedArgs = compact('flagKey', 'type', 'targetingKey', 'attrs'); + return $this->makeSuccessResult('"ok"'); + }); + + $context = new EvaluationContext(null, new Attributes([ + 'valid' => 'yes', + 'null_val' => null, + ])); + + $provider->resolveStringValue('flag.str', 'default', $context); + + $this->assertSame(['valid' => 'yes'], $capturedArgs['attrs']); + } + + public function testDateTimeAttributeValuesAreDropped(): void + { + $capturedArgs = []; + $provider = $this->createProvider(function (string $flagKey, int $type, ?string $targetingKey, array $attrs) use (&$capturedArgs) { + $capturedArgs = compact('flagKey', 'type', 'targetingKey', 'attrs'); + return $this->makeSuccessResult('"ok"'); + }); + + $context = new EvaluationContext(null, new Attributes([ + 'valid' => 'yes', + 'timestamp' => new DateTime('2024-01-01'), + ])); + + $provider->resolveStringValue('flag.str', 'default', $context); + + $this->assertSame(['valid' => 'yes'], $capturedArgs['attrs']); + $this->assertArrayNotHasKey('timestamp', $capturedArgs['attrs']); + } + + public function testEmptyContextProducesEmptyAttributes(): void + { + $capturedArgs = []; + $provider = $this->createProvider(function (string $flagKey, int $type, ?string $targetingKey, array $attrs) use (&$capturedArgs) { + $capturedArgs = compact('flagKey', 'type', 'targetingKey', 'attrs'); + return $this->makeSuccessResult('"ok"'); + }); + + $context = new EvaluationContext(); + $provider->resolveStringValue('flag.str', 'default', $context); + + $this->assertNull($capturedArgs['targetingKey']); + $this->assertSame([], $capturedArgs['attrs']); + } + + // ------------------------------------------------------------------------- + // Bridge type constants + // ------------------------------------------------------------------------- + + public function testBooleanResolverPassesCorrectTypeConstant(): void + { + $capturedType = null; + $provider = $this->createProvider(function (string $flagKey, int $type, ?string $targetingKey, array $attrs) use (&$capturedType) { + $capturedType = $type; + return $this->makeSuccessResult('true'); + }); + + $provider->resolveBooleanValue('flag', false); + $this->assertSame(3, $capturedType); // TYPE_BOOL = 3 + } + + public function testStringResolverPassesCorrectTypeConstant(): void + { + $capturedType = null; + $provider = $this->createProvider(function (string $flagKey, int $type, ?string $targetingKey, array $attrs) use (&$capturedType) { + $capturedType = $type; + return $this->makeSuccessResult('"hello"'); + }); + + $provider->resolveStringValue('flag', 'default'); + $this->assertSame(0, $capturedType); // TYPE_STRING = 0 + } + + public function testIntegerResolverPassesCorrectTypeConstant(): void + { + $capturedType = null; + $provider = $this->createProvider(function (string $flagKey, int $type, ?string $targetingKey, array $attrs) use (&$capturedType) { + $capturedType = $type; + return $this->makeSuccessResult('42'); + }); + + $provider->resolveIntegerValue('flag', 0); + $this->assertSame(1, $capturedType); // TYPE_INT = 1 + } + + public function testFloatResolverPassesCorrectTypeConstant(): void + { + $capturedType = null; + $provider = $this->createProvider(function (string $flagKey, int $type, ?string $targetingKey, array $attrs) use (&$capturedType) { + $capturedType = $type; + return $this->makeSuccessResult('1.5'); + }); + + $provider->resolveFloatValue('flag', 0.0); + $this->assertSame(2, $capturedType); // TYPE_FLOAT = 2 + } + + public function testObjectResolverPassesCorrectTypeConstant(): void + { + $capturedType = null; + $provider = $this->createProvider(function (string $flagKey, int $type, ?string $targetingKey, array $attrs) use (&$capturedType) { + $capturedType = $type; + return $this->makeSuccessResult('{}'); + }); + + $provider->resolveObjectValue('flag', []); + $this->assertSame(4, $capturedType); // TYPE_OBJECT = 4 + } + + // ------------------------------------------------------------------------- + // All resolvers delegate through the same bridge call path + // ------------------------------------------------------------------------- + + public function testAllResolversCallBridgeWithFlagKey(): void + { + $capturedKeys = []; + $bridge = function (string $flagKey, int $type, ?string $targetingKey, array $attrs) use (&$capturedKeys) { + $capturedKeys[] = $flagKey; + return match ($type) { + 3 => $this->makeSuccessResult('true'), // boolean + 0 => $this->makeSuccessResult('"hello"'), // string + 1 => $this->makeSuccessResult('42'), // integer + 2 => $this->makeSuccessResult('1.5'), // float + 4 => $this->makeSuccessResult('{"a":1}'), // object + }; + }; + + $provider = $this->createProvider($bridge); + + $provider->resolveBooleanValue('flag.bool', false); + $provider->resolveStringValue('flag.str', ''); + $provider->resolveIntegerValue('flag.int', 0); + $provider->resolveFloatValue('flag.float', 0.0); + $provider->resolveObjectValue('flag.obj', []); + + $this->assertSame([ + 'flag.bool', + 'flag.str', + 'flag.int', + 'flag.float', + 'flag.obj', + ], $capturedKeys); + } + + // ------------------------------------------------------------------------- + // BridgeResultMapper unit tests + // ------------------------------------------------------------------------- + + public function testBridgeResultMapperHandlesUnknownErrorCode(): void + { + $mapper = new BridgeResultMapper(); + + $result = $mapper->mapBoolean([ + 'value_json' => 'true', + 'variant' => 'v1', + 'allocation_key' => 'a1', + 'reason' => 0, + 'error_code' => 999, // Unknown error code + 'do_log' => false, + ], false); + + $this->assertFalse($result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + $this->assertEquals( + ErrorCode::GENERAL(), + $result->getError()->getResolutionErrorCode() + ); + } + + public function testBridgeResultMapperHandlesEmptyValueJson(): void + { + $mapper = new BridgeResultMapper(); + + $result = $mapper->mapString([ + 'value_json' => '', + 'variant' => null, + 'allocation_key' => null, + 'reason' => 0, + 'error_code' => 0, + 'do_log' => false, + ], 'default'); + + $this->assertSame('default', $result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + } + + // ------------------------------------------------------------------------- + // EvaluationContextNormalizer standalone tests + // ------------------------------------------------------------------------- + + public function testNormalizerReturnsNullTargetingKeyForNullContext(): void + { + $normalizer = new EvaluationContextNormalizer(); + [$targetingKey, $attrs] = $normalizer->normalize(null); + + $this->assertNull($targetingKey); + $this->assertSame([], $attrs); + } + + public function testNormalizerPreservesNullTargetingKey(): void + { + $normalizer = new EvaluationContextNormalizer(); + $context = new EvaluationContext(null, new Attributes(['key' => 'val'])); + [$targetingKey, $attrs] = $normalizer->normalize($context); + + $this->assertNull($targetingKey); + $this->assertSame(['key' => 'val'], $attrs); + } + + public function testNormalizerExtractsTargetingKey(): void + { + $normalizer = new EvaluationContextNormalizer(); + $context = new EvaluationContext('user-456'); + [$targetingKey, $attrs] = $normalizer->normalize($context); + + $this->assertSame('user-456', $targetingKey); + } + + public function testNormalizerDropsAllNonPrimitiveTypes(): void + { + $normalizer = new EvaluationContextNormalizer(); + $context = new EvaluationContext(null, new Attributes([ + 'str' => 'hello', + 'int' => 1, + 'float' => 2.5, + 'bool' => true, + 'array' => [1, 2, 3], + 'null' => null, + 'datetime' => new DateTime(), + ])); + + [$targetingKey, $attrs] = $normalizer->normalize($context); + + $this->assertSame([ + 'str' => 'hello', + 'int' => 1, + 'float' => 2.5, + 'bool' => true, + ], $attrs); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function createProvider(Closure $bridge): DataDogProvider + { + // Inject a lifecycle that reports ready so evaluation tests + // exercise the bridge path, not the not-ready short-circuit. + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => true, + configVersionCallable: fn (): int => 0, + ); + + $noopWriter = new ExposureWriter( + sidecarCallable: fn () => true, + ); + + return new DataDogProvider( + resultMapper: new BridgeResultMapper(), + contextNormalizer: new EvaluationContextNormalizer(), + bridgeCallable: $bridge, + lifecycle: $lifecycle, + exposureWriter: $noopWriter, + ); + } + + private function createProviderWithSuccessResult(string $valueJson, ?string $variant): DataDogProvider + { + return $this->createProvider(fn () => $this->makeSuccessResult($valueJson, $variant)); + } + + /** + * @return array + */ + private function makeSuccessResult(string $valueJson, ?string $variant = 'default-variant', ?string $allocationKey = 'default-alloc'): array + { + return [ + 'value_json' => $valueJson, + 'variant' => $variant, + 'allocation_key' => $allocationKey, + 'reason' => 1, // REASON_TARGETING_MATCH + 'error_code' => 0, // ERROR_NONE + 'do_log' => true, + ]; + } + + /** + * Create a bridge callable returning a successful result. + */ + private function successBridge(string $valueJson, string $variant, string $allocationKey): Closure + { + return fn () => [ + 'value_json' => $valueJson, + 'variant' => $variant, + 'allocation_key' => $allocationKey, + 'reason' => 1, // REASON_TARGETING_MATCH + 'error_code' => 0, + 'do_log' => true, + ]; + } + + /** + * Create a bridge callable returning an error result. + */ + private function errorBridge(int $errorCode): Closure + { + return fn () => [ + 'value_json' => null, + 'variant' => null, + 'allocation_key' => null, + 'reason' => 4, // REASON_ERROR + 'error_code' => $errorCode, + 'do_log' => false, + ]; + } + + // ------------------------------------------------------------------------- + // Exposure Writer Integration + // ------------------------------------------------------------------------- + + public function testExposureWriterCalledOnSuccessfulEvaluation(): void + { + $exposureSent = false; + $capturedEvent = null; + $writer = new ExposureWriter( + sidecarCallable: function (string $eventJson) use (&$exposureSent, &$capturedEvent): bool { + $exposureSent = true; + $capturedEvent = json_decode($eventJson, true); + return true; + }, + ); + + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => true, + configVersionCallable: fn (): int => 0, + ); + + $provider = new DataDogProvider( + bridgeCallable: fn () => $this->makeSuccessResult('true'), + lifecycle: $lifecycle, + exposureWriter: $writer, + ); + + $provider->resolveBooleanValue('my-flag', false); + + $this->assertTrue($exposureSent, 'Exposure event should be sent on successful evaluation'); + $this->assertSame('my-flag', $capturedEvent['flag']['key']); + } + + public function testExposureWriterNotCalledWhenDoLogFalse(): void + { + $exposureSent = false; + $writer = new ExposureWriter( + sidecarCallable: function () use (&$exposureSent): bool { + $exposureSent = true; + return true; + }, + ); + + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => true, + configVersionCallable: fn (): int => 0, + ); + + $result = $this->makeSuccessResult('true'); + $result['do_log'] = false; + + $provider = new DataDogProvider( + bridgeCallable: fn () => $result, + lifecycle: $lifecycle, + exposureWriter: $writer, + ); + + $provider->resolveBooleanValue('my-flag', false); + + $this->assertFalse($exposureSent, 'No exposure should be sent when do_log is false'); + } + + public function testExposureWriterNotCalledOnError(): void + { + $exposureSent = false; + $writer = new ExposureWriter( + sidecarCallable: function () use (&$exposureSent): bool { + $exposureSent = true; + return true; + }, + ); + + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => true, + configVersionCallable: fn (): int => 0, + ); + + $provider = new DataDogProvider( + bridgeCallable: fn () => null, + lifecycle: $lifecycle, + exposureWriter: $writer, + ); + + $provider->resolveBooleanValue('my-flag', false); + + $this->assertFalse($exposureSent, 'No exposure should be sent on bridge error'); + } + + public function testExposureWriterNotCalledWhenProviderNotReady(): void + { + $exposureSent = false; + $writer = new ExposureWriter( + sidecarCallable: function () use (&$exposureSent): bool { + $exposureSent = true; + return true; + }, + ); + + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => false, + configVersionCallable: fn (): int => 0, + ); + + $provider = new DataDogProvider( + bridgeCallable: fn () => $this->makeSuccessResult('true'), + lifecycle: $lifecycle, + exposureWriter: $writer, + ); + + $provider->resolveBooleanValue('my-flag', false); + + $this->assertFalse($exposureSent, 'No exposure should be sent when provider not ready'); + } + + // ------------------------------------------------------------------------- + // MetricsCounter Integration + // ------------------------------------------------------------------------- + + public function testMetricsCounterFiresOnSuccessfulEvaluation(): void + { + $captured = new \ArrayObject(); + $counter = new MetricsCounter( + counterCallable: static function (array $attributes) use ($captured): void { + $captured[] = $attributes; + }, + envReader: static fn(string $name): ?string => $name === 'DD_METRICS_OTEL_ENABLED' ? 'true' : null, + ); + + $provider = $this->createProviderWithCounter( + bridge: fn () => $this->makeSuccessResult('true', 'variant-a', 'alloc-1'), + lifecycleReady: true, + counter: $counter, + ); + + $provider->resolveBooleanValue('my-flag', false); + + $this->assertCount(1, $captured, 'counter should fire exactly once on successful evaluation'); + $this->assertSame('my-flag', $captured[0]['feature_flag.key']); + $this->assertSame('variant-a', $captured[0]['feature_flag.result.variant']); + $this->assertSame('alloc-1', $captured[0]['feature_flag.result.allocation_key']); + $this->assertSame('TARGETING_MATCH', $captured[0]['feature_flag.result.reason']); + $this->assertSame('', $captured[0]['error.type']); + } + + public function testMetricsCounterFiresOnErrorEvaluation(): void + { + $captured = new \ArrayObject(); + $counter = new MetricsCounter( + counterCallable: static function (array $attributes) use ($captured): void { + $captured[] = $attributes; + }, + envReader: static fn(string $name): ?string => $name === 'DD_METRICS_OTEL_ENABLED' ? 'true' : null, + ); + + $provider = $this->createProviderWithCounter( + bridge: $this->errorBridge(1), // ERROR_FLAG_NOT_FOUND + lifecycleReady: true, + counter: $counter, + ); + + $provider->resolveBooleanValue('missing-flag', false); + + $this->assertCount(1, $captured, 'counter should fire on error path too'); + $this->assertSame('missing-flag', $captured[0]['feature_flag.key']); + $this->assertSame('FLAG_NOT_FOUND', $captured[0]['error.type']); + $this->assertSame('ERROR', $captured[0]['feature_flag.result.reason']); + } + + public function testMetricsCounterFiresOnNotReadyState(): void + { + $captured = new \ArrayObject(); + $counter = new MetricsCounter( + counterCallable: static function (array $attributes) use ($captured): void { + $captured[] = $attributes; + }, + envReader: static fn(string $name): ?string => $name === 'DD_METRICS_OTEL_ENABLED' ? 'true' : null, + ); + + $provider = $this->createProviderWithCounter( + // Bridge will never be called when lifecycle reports not-ready. + bridge: fn () => $this->makeSuccessResult('true'), + lifecycleReady: false, + counter: $counter, + ); + + $provider->resolveBooleanValue('my-flag', false); + + $this->assertCount(1, $captured, 'counter must fire on not-ready early-return path'); + $this->assertSame('my-flag', $captured[0]['feature_flag.key']); + $this->assertSame('PROVIDER_NOT_READY', $captured[0]['error.type']); + $this->assertSame('ERROR', $captured[0]['feature_flag.result.reason']); + } + + public function testMetricsCounterFiresOnlyOncePerEvaluation(): void + { + $captured = new \ArrayObject(); + $counter = new MetricsCounter( + counterCallable: static function (array $attributes) use ($captured): void { + $captured[] = $attributes; + }, + envReader: static fn(string $name): ?string => $name === 'DD_METRICS_OTEL_ENABLED' ? 'true' : null, + ); + + $provider = $this->createProviderWithCounter( + bridge: fn () => $this->makeSuccessResult('true'), + lifecycleReady: true, + counter: $counter, + ); + + $provider->resolveBooleanValue('my-flag', false); + + $this->assertCount(1, $captured, 'single resolveBooleanValue() must record exactly one counter event'); + } + + /** + * Build a provider with a caller-supplied MetricsCounter. + * + * Mirrors createProvider() but exposes the metricsCounter slot so the + * counter-integration tests can assert against a capturing callable. + */ + private function createProviderWithCounter( + Closure $bridge, + bool $lifecycleReady, + MetricsCounter $counter, + ): DataDogProvider { + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => $lifecycleReady, + configVersionCallable: fn (): int => 0, + ); + + $noopWriter = new ExposureWriter( + sidecarCallable: fn () => true, + ); + + return new DataDogProvider( + resultMapper: new BridgeResultMapper(), + contextNormalizer: new EvaluationContextNormalizer(), + bridgeCallable: $bridge, + lifecycle: $lifecycle, + exposureWriter: $noopWriter, + metricsCounter: $counter, + ); + } +} diff --git a/tests/OpenFeature/ExposureWriterTest.php b/tests/OpenFeature/ExposureWriterTest.php new file mode 100644 index 00000000000..f7e854a81f6 --- /dev/null +++ b/tests/OpenFeature/ExposureWriterTest.php @@ -0,0 +1,261 @@ +createWriter($capturedArgs); + + $context = new ExposureContext( + service: 'my-app', + env: 'production', + version: '1.2.3', + flagKey: 'test-flag', + allocationKey: 'default-alloc', + variant: 'on', + targetingKey: 'user-123', + ); + + $writer->send($context); + + $event = json_decode($capturedArgs['eventJson'], true); + $this->assertSame(self::FIXED_TIMESTAMP, $event['timestamp']); + $this->assertSame('test-flag', $event['flag']['key']); + $this->assertSame('default-alloc', $event['allocation']['key']); + $this->assertSame('on', $event['variant']['key']); + $this->assertSame('user-123', $event['subject']['id']); + } + + public function testSendPassesDedupFieldsToCallable(): void + { + $capturedArgs = []; + $writer = $this->createWriter($capturedArgs); + + $context = new ExposureContext( + service: 'app', + env: 'prod', + version: '1.0', + flagKey: 'my-flag', + allocationKey: 'alloc-1', + variant: 'variant-a', + targetingKey: 'user-456', + ); + + $writer->send($context); + + $this->assertSame('my-flag', $capturedArgs['flagKey']); + $this->assertSame('alloc-1', $capturedArgs['allocationKey']); + $this->assertSame('user-456', $capturedArgs['targetingKey']); + $this->assertSame('variant-a', $capturedArgs['variantKey']); + } + + // ---------- Subject Attributes ---------- + + public function testSendFlattensEvaluationAttributes(): void + { + $capturedArgs = []; + $writer = $this->createWriter($capturedArgs); + + $context = new ExposureContext( + service: 'app', + env: 'prod', + version: '1.0', + flagKey: 'flag', + allocationKey: 'alloc', + variant: 'v', + targetingKey: 'user', + ); + + $writer->send($context, [ + 'plan' => 'enterprise', + 'org' => ['name' => 'Acme'], + ]); + + $event = json_decode($capturedArgs['eventJson'], true); + $attrs = (array)$event['subject']['attributes']; + $this->assertSame('enterprise', $attrs['plan']); + $this->assertSame('Acme', $attrs['org.name']); + } + + public function testSendWithNullAttributesProducesEmptySubjectAttributes(): void + { + $capturedArgs = []; + $writer = $this->createWriter($capturedArgs); + + $context = new ExposureContext( + service: 'app', + env: 'prod', + version: '1.0', + flagKey: 'flag', + allocationKey: 'alloc', + variant: 'v', + targetingKey: 'user', + ); + + $writer->send($context, null); + + $event = json_decode($capturedArgs['eventJson'], true); + $this->assertSame([], (array)$event['subject']['attributes']); + } + + // ---------- Null Field Handling ---------- + + public function testSendHandlesNullAllocationKey(): void + { + $capturedArgs = []; + $writer = $this->createWriter($capturedArgs); + + $context = new ExposureContext( + service: 'app', + env: 'prod', + version: '1.0', + flagKey: 'flag', + allocationKey: null, + variant: 'v', + targetingKey: 'user', + ); + + $writer->send($context); + + $this->assertSame('', $capturedArgs['allocationKey']); + $event = json_decode($capturedArgs['eventJson'], true); + $this->assertSame('', $event['allocation']['key']); + } + + public function testSendHandlesNullTargetingKey(): void + { + $capturedArgs = []; + $writer = $this->createWriter($capturedArgs); + + $context = new ExposureContext( + service: 'app', + env: 'prod', + version: '1.0', + flagKey: 'flag', + allocationKey: 'alloc', + variant: 'v', + targetingKey: null, + ); + + $writer->send($context); + + $this->assertNull($capturedArgs['targetingKey']); + $event = json_decode($capturedArgs['eventJson'], true); + $this->assertSame('', $event['subject']['id']); + } + + public function testSendHandlesNullVariant(): void + { + $capturedArgs = []; + $writer = $this->createWriter($capturedArgs); + + $context = new ExposureContext( + service: 'app', + env: 'prod', + version: '1.0', + flagKey: 'flag', + allocationKey: 'alloc', + variant: null, + targetingKey: 'user', + ); + + $writer->send($context); + + $this->assertSame('', $capturedArgs['variantKey']); + $event = json_decode($capturedArgs['eventJson'], true); + $this->assertSame('', $event['variant']['key']); + } + + // ---------- Return Value ---------- + + public function testSendReturnsTrueWhenCallableReturnsTrue(): void + { + $writer = new ExposureWriter( + sidecarCallable: fn() => true, + timestampProvider: fn() => self::FIXED_TIMESTAMP, + ); + + $context = $this->createMinimalContext(); + $this->assertTrue($writer->send($context)); + } + + public function testSendReturnsFalseWhenCallableReturnsFalse(): void + { + $writer = new ExposureWriter( + sidecarCallable: fn() => false, + timestampProvider: fn() => self::FIXED_TIMESTAMP, + ); + + $context = $this->createMinimalContext(); + $this->assertFalse($writer->send($context)); + } + + // ---------- Timestamp ---------- + + public function testTimestampCapturedAtSendTime(): void + { + $capturedArgs = []; + $writer = $this->createWriter($capturedArgs); + + $context = $this->createMinimalContext(); + $writer->send($context); + + $event = json_decode($capturedArgs['eventJson'], true); + $this->assertSame(self::FIXED_TIMESTAMP, $event['timestamp']); + } + + // ---------- Helpers ---------- + + /** + * Create an ExposureWriter with capturing sidecar callable and fixed timestamp. + */ + private function createWriter(array &$capturedArgs): ExposureWriter + { + return new ExposureWriter( + sidecarCallable: function ( + string $eventJson, + string $flagKey, + string $allocationKey, + ?string $targetingKey, + string $variantKey, + ) use (&$capturedArgs): bool { + $capturedArgs = compact('eventJson', 'flagKey', 'allocationKey', 'targetingKey', 'variantKey'); + return true; + }, + timestampProvider: fn() => self::FIXED_TIMESTAMP, + ); + } + + private function createMinimalContext(): ExposureContext + { + return new ExposureContext( + service: 'app', + env: 'prod', + version: '1.0', + flagKey: 'flag', + allocationKey: 'alloc', + variant: 'on', + targetingKey: 'user', + ); + } +} diff --git a/tests/OpenFeature/FfeFixturesTest.php b/tests/OpenFeature/FfeFixturesTest.php new file mode 100644 index 00000000000..945f3c3e905 --- /dev/null +++ b/tests/OpenFeature/FfeFixturesTest.php @@ -0,0 +1,220 @@ + OpenFeature provider returns + * the caller-supplied defaultValue; raw bridge value_json may be "null". + * - reason in {STATIC, TARGETING_MATCH, SPLIT} -> raw bridge value must + * match the fixture value. + * + * Reason equivalence: + * - STATIC <-> TARGETING_MATCH are treated as interchangeable ("successful + * match") because libdatadog-rs and dd-trace-go's reference evaluator + * classify a subset of matches differently. Value correctness is the + * invariant; reason taxonomy drift is tracked separately. + * - DEFAULT <-> DISABLED are interchangeable (both produce defaultValue). + */ +final class FfeFixturesTest extends TestCase +{ + private const TYPE_STRING = 0; + private const TYPE_INTEGER = 1; + private const TYPE_FLOAT = 2; + private const TYPE_BOOLEAN = 3; + private const TYPE_OBJECT = 4; + + private const REASON_NAMES = [ + 0 => 'STATIC', + 1 => 'DEFAULT', + 2 => 'TARGETING_MATCH', + 3 => 'SPLIT', + 4 => 'DISABLED', + 5 => 'ERROR', + ]; + + private const VARIATION_TO_TYPE_ID = [ + 'BOOLEAN' => self::TYPE_BOOLEAN, + 'STRING' => self::TYPE_STRING, + 'INTEGER' => self::TYPE_INTEGER, + 'NUMERIC' => self::TYPE_FLOAT, + 'JSON' => self::TYPE_OBJECT, + ]; + + private const FALLBACK_REASONS = ['DEFAULT', 'DISABLED', 'ERROR']; + + public static function setUpBeforeClass(): void + { + if (!function_exists('DDTrace\\ffe_load_config') || !function_exists('DDTrace\\ffe_evaluate')) { + self::markTestSkipped('ddtrace extension with FFE bindings not loaded'); + } + + $configPath = __DIR__ . '/testdata/ufc-config.json'; + $json = file_get_contents($configPath); + self::assertNotFalse($json, "failed to read {$configPath}"); + + $loaded = \DDTrace\ffe_load_config($json); + self::assertTrue($loaded, 'ffe_load_config returned false for ufc-config.json'); + } + + /** + * @dataProvider fixtureProvider + * + * @param array $case + */ + public function testFixtureCase(string $fixtureFile, int $index, array $case): void + { + $flag = (string) $case['flag']; + $variationType = (string) $case['variationType']; + $targetingKey = isset($case['targetingKey']) ? (string) $case['targetingKey'] : null; + $attributes = $case['attributes'] ?? []; + $expected = $case['result']; + $defaultValue = $case['defaultValue'] ?? null; + + $this->assertArrayHasKey($variationType, self::VARIATION_TO_TYPE_ID, "unknown variationType {$variationType}"); + $typeId = self::VARIATION_TO_TYPE_ID[$variationType]; + + $filteredAttrs = []; + foreach ($attributes as $k => $v) { + if (is_scalar($v)) { + $filteredAttrs[(string) $k] = $v; + } + } + + $tk = ($targetingKey === null || $targetingKey === '') ? null : $targetingKey; + + $result = \DDTrace\ffe_evaluate($flag, $typeId, $tk, $filteredAttrs); + $this->assertIsArray($result, 'ffe_evaluate returned null for ' . $flag); + + $reasonCode = (int) ($result['reason'] ?? -1); + $reason = self::REASON_NAMES[$reasonCode] ?? ('UNKNOWN(' . $reasonCode . ')'); + $expectedReason = (string) $expected['reason']; + + $label = sprintf('[%s #%d flag=%s]', basename($fixtureFile), $index, $flag); + + $this->assertTrue( + $this->reasonsEquivalent($expectedReason, $reason), + "{$label} reason mismatch: expected {$expectedReason}, got {$reason}" + ); + + $valueJson = $result['value_json'] ?? null; + $this->assertIsString($valueJson, "{$label} value_json missing"); + $rawValue = json_decode($valueJson, true); + $this->assertSame( + JSON_ERROR_NONE, + json_last_error(), + "{$label} value_json not valid JSON: " . (string) $valueJson + ); + + // Effective value = what an OpenFeature client sees. On fallback reasons + // the provider substitutes defaultValue for the (usually null) bridge value. + $effectiveValue = in_array($reason, self::FALLBACK_REASONS, true) + ? $defaultValue + : $rawValue; + + $this->assertValuesEqual( + $expected['value'], + $effectiveValue, + "{$label} value mismatch" + ); + } + + /** + * @return iterable}> + */ + public static function fixtureProvider(): iterable + { + $pattern = __DIR__ . '/testdata/evaluation-cases/*.json'; + $files = glob($pattern); + self::assertNotFalse($files, "glob failed for {$pattern}"); + self::assertNotEmpty($files, "no fixture files matched {$pattern}"); + + foreach ($files as $file) { + $raw = file_get_contents($file); + self::assertNotFalse($raw, "cannot read {$file}"); + $cases = json_decode($raw, true); + self::assertIsArray($cases, "fixture is not an array: {$file}"); + + foreach ($cases as $i => $case) { + $label = basename($file) . '#' . $i . '/' . ($case['targetingKey'] ?? ''); + yield $label => [$file, (int) $i, $case]; + } + } + } + + private function reasonsEquivalent(string $expected, string $actual): bool + { + if ($expected === $actual) { + return true; + } + + $matchSet = ['STATIC', 'TARGETING_MATCH', 'SPLIT']; + if (in_array($expected, $matchSet, true) && in_array($actual, $matchSet, true)) { + return true; + } + + $fallbackSet = self::FALLBACK_REASONS; + if (in_array($expected, $fallbackSet, true) && in_array($actual, $fallbackSet, true)) { + return true; + } + + return false; + } + + /** + * @param mixed $expected + * @param mixed $actual + */ + private function assertValuesEqual($expected, $actual, string $message): void + { + if (is_float($expected) || is_float($actual)) { + $this->assertEqualsWithDelta((float) $expected, (float) $actual, 1e-9, $message); + return; + } + + if (is_array($expected) && is_array($actual)) { + $this->assertSame( + json_encode(self::normalize($expected)), + json_encode(self::normalize($actual)), + $message + ); + return; + } + + $this->assertSame($expected, $actual, $message); + } + + /** + * @param mixed $value + * @return mixed + */ + private static function normalize($value) + { + if (!is_array($value)) { + return $value; + } + $isAssoc = array_keys($value) !== range(0, count($value) - 1); + $out = []; + foreach ($value as $k => $v) { + $out[$k] = self::normalize($v); + } + if ($isAssoc) { + ksort($out); + } + return $out; + } +} diff --git a/tests/OpenFeature/MetricsCounterTest.php b/tests/OpenFeature/MetricsCounterTest.php new file mode 100644 index 00000000000..7eafd934ff2 --- /dev/null +++ b/tests/OpenFeature/MetricsCounterTest.php @@ -0,0 +1,478 @@ +makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', [ + 'value_json' => 'true', + 'variant' => 'on', + 'allocation_key' => 'alloc-1', + 'reason' => 1, // REASON_TARGETING_MATCH + 'error_code' => 0, + 'do_log' => true, + ]); + + $this->assertCount(1, $captured); + $this->assertSame([ + 'feature_flag.key' => 'flag-1', + 'feature_flag.result.variant' => 'on', + 'feature_flag.result.reason' => 'TARGETING_MATCH', + 'feature_flag.result.allocation_key' => 'alloc-1', + 'error.type' => '', + ], $captured[0]); + } + + public function testRecordOnSuccessReasonDefault(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', $this->successBridgeResult(reason: 0)); + + $this->assertSame('DEFAULT', $captured[0]['feature_flag.result.reason']); + $this->assertSame('', $captured[0]['error.type']); + } + + public function testRecordOnSuccessReasonSplit(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', $this->successBridgeResult(reason: 2)); + + $this->assertSame('SPLIT', $captured[0]['feature_flag.result.reason']); + } + + public function testRecordOnSuccessReasonDisabled(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', $this->successBridgeResult(reason: 3)); + + $this->assertSame('DISABLED', $captured[0]['feature_flag.result.reason']); + } + + // ------------------------------------------------------------------------- + // Error path: error.type mapping for each bridge error code + // ------------------------------------------------------------------------- + + public function testRecordOnFlagNotFoundSetsErrorType(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('missing-flag', $this->errorBridgeResult(errorCode: 1)); + + $this->assertSame('FLAG_NOT_FOUND', $captured[0]['error.type']); + $this->assertSame('ERROR', $captured[0]['feature_flag.result.reason']); + } + + public function testRecordOnParseErrorSetsErrorType(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', $this->errorBridgeResult(errorCode: 2)); + + $this->assertSame('PARSE_ERROR', $captured[0]['error.type']); + $this->assertSame('ERROR', $captured[0]['feature_flag.result.reason']); + } + + public function testRecordOnTypeMismatchSetsErrorType(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', $this->errorBridgeResult(errorCode: 3)); + + $this->assertSame('TYPE_MISMATCH', $captured[0]['error.type']); + $this->assertSame('ERROR', $captured[0]['feature_flag.result.reason']); + } + + public function testRecordOnGeneralErrorSetsErrorType(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', $this->errorBridgeResult(errorCode: 4)); + + $this->assertSame('GENERAL', $captured[0]['error.type']); + $this->assertSame('ERROR', $captured[0]['feature_flag.result.reason']); + } + + public function testRecordOnProviderNotReadySetsErrorType(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', $this->errorBridgeResult(errorCode: 5)); + + $this->assertSame('PROVIDER_NOT_READY', $captured[0]['error.type']); + $this->assertSame('ERROR', $captured[0]['feature_flag.result.reason']); + } + + public function testRecordOnUnknownErrorCodeFallsBackToGeneral(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', $this->errorBridgeResult(errorCode: 999)); + + $this->assertSame('GENERAL', $captured[0]['error.type']); + $this->assertSame('ERROR', $captured[0]['feature_flag.result.reason']); + } + + public function testRecordWithNullBridgeResultTreatsAsProviderNotReady(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', null); + + $this->assertCount(1, $captured); + $this->assertSame('PROVIDER_NOT_READY', $captured[0]['error.type']); + $this->assertSame('ERROR', $captured[0]['feature_flag.result.reason']); + $this->assertSame('', $captured[0]['feature_flag.result.variant']); + $this->assertSame('', $captured[0]['feature_flag.result.allocation_key']); + $this->assertSame('flag-1', $captured[0]['feature_flag.key']); + } + + // ------------------------------------------------------------------------- + // DD_METRICS_OTEL_ENABLED gate + // ------------------------------------------------------------------------- + + public function testRecordSkipsCallableWhenMetricsDisabled(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->disabledEnvReader()); + + $counter->record('flag-1', $this->successBridgeResult()); + + $this->assertCount(0, $captured, 'counter callable must not be invoked when gate is disabled'); + } + + public function testRecordSkipsCallableWhenDDMetricsOtelEnabledIsFalse(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->envReaderReturning('false')); + + $counter->record('flag-1', $this->successBridgeResult()); + + $this->assertCount(0, $captured); + } + + public function testRecordSkipsCallableWhenDDMetricsOtelEnabledIsZero(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->envReaderReturning('0')); + + $counter->record('flag-1', $this->successBridgeResult()); + + $this->assertCount(0, $captured); + } + + public function testRecordSkipsCallableWhenDDMetricsOtelEnabledIsEmpty(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->envReaderReturning('')); + + $counter->record('flag-1', $this->successBridgeResult()); + + $this->assertCount(0, $captured); + } + + public function testRecordSkipsCallableWhenDDMetricsOtelEnabledIsNo(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->envReaderReturning('no')); + + $counter->record('flag-1', $this->successBridgeResult()); + + $this->assertCount(0, $captured); + } + + public function testRecordFiresWhenDDMetricsOtelEnabledIsTrue(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->envReaderReturning('true')); + + $counter->record('flag-1', $this->successBridgeResult()); + + $this->assertCount(1, $captured); + } + + public function testRecordFiresWhenDDMetricsOtelEnabledIsOne(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->envReaderReturning('1')); + + $counter->record('flag-1', $this->successBridgeResult()); + + $this->assertCount(1, $captured); + } + + public function testRecordFiresWhenDDMetricsOtelEnabledIsYes(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->envReaderReturning('yes')); + + $counter->record('flag-1', $this->successBridgeResult()); + + $this->assertCount(1, $captured); + } + + public function testRecordFiresWhenDDMetricsOtelEnabledIsOn(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->envReaderReturning('on')); + + $counter->record('flag-1', $this->successBridgeResult()); + + $this->assertCount(1, $captured); + } + + public function testRecordFiresWhenDDMetricsOtelEnabledIsCaseInsensitive(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->envReaderReturning('TRUE')); + + $counter->record('flag-1', $this->successBridgeResult()); + + $this->assertCount(1, $captured, 'TRUE (uppercase) must enable the gate (case-insensitive)'); + } + + public function testRecordFiresWhenDDMetricsOtelEnabledHasWhitespace(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->envReaderReturning(' true ')); + + $counter->record('flag-1', $this->successBridgeResult()); + + $this->assertCount(1, $captured, 'Whitespace-padded true must enable the gate (trimmed)'); + } + + // ------------------------------------------------------------------------- + // Default counterCallable is a no-op (safe without compiled extension) + // ------------------------------------------------------------------------- + + public function testDefaultCounterCallableIsNoop(): void + { + // No counterCallable, no envReader. Record must not throw even with the gate enabled. + $counter = new MetricsCounter(null, $this->enabledEnvReader()); + + // Calling record() should not throw even when no extension function exists. + $counter->record('flag-1', $this->successBridgeResult()); + + $this->expectNotToPerformAssertions(); + } + + public function testDefaultCounterCallableIsNoopWithDefaultEnvReader(): void + { + // No counterCallable at all. Record falls back to getenv() which will typically + // return false/null for DD_METRICS_OTEL_ENABLED in the test process, so the + // counter should never fire. This must not throw regardless. + $counter = new MetricsCounter(); + + $counter->record('flag-1', $this->successBridgeResult()); + $counter->record('flag-1', null); + + $this->expectNotToPerformAssertions(); + } + + // ------------------------------------------------------------------------- + // Attribute shape invariants + // ------------------------------------------------------------------------- + + public function testMissingVariantProducesEmptyString(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', [ + 'value_json' => 'true', + 'allocation_key' => 'alloc-1', + 'reason' => 1, + 'error_code' => 0, + 'do_log' => true, + ]); + + $this->assertSame('', $captured[0]['feature_flag.result.variant']); + } + + public function testMissingAllocationKeyProducesEmptyString(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', [ + 'value_json' => 'true', + 'variant' => 'on', + 'reason' => 1, + 'error_code' => 0, + 'do_log' => true, + ]); + + $this->assertSame('', $captured[0]['feature_flag.result.allocation_key']); + } + + public function testNullVariantProducesEmptyString(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', [ + 'value_json' => 'true', + 'variant' => null, + 'allocation_key' => null, + 'reason' => 1, + 'error_code' => 0, + 'do_log' => true, + ]); + + $this->assertSame('', $captured[0]['feature_flag.result.variant']); + $this->assertSame('', $captured[0]['feature_flag.result.allocation_key']); + } + + public function testAttributeKeysAreExactlyFive(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', $this->successBridgeResult()); + + $expectedKeys = [ + 'feature_flag.key', + 'feature_flag.result.variant', + 'feature_flag.result.reason', + 'feature_flag.result.allocation_key', + 'error.type', + ]; + + $actualKeys = array_keys($captured[0]); + sort($expectedKeys); + sort($actualKeys); + + $this->assertSame($expectedKeys, $actualKeys, 'Counter attributes must have exactly these five keys'); + } + + public function testAttributeKeysAreExactlyFiveOnErrorPath(): void + { + [$callable, $captured] = $this->makeCapturingCounter(); + $counter = new MetricsCounter($callable, $this->enabledEnvReader()); + + $counter->record('flag-1', null); + + $expectedKeys = [ + 'feature_flag.key', + 'feature_flag.result.variant', + 'feature_flag.result.reason', + 'feature_flag.result.allocation_key', + 'error.type', + ]; + + $actualKeys = array_keys($captured[0]); + sort($expectedKeys); + sort($actualKeys); + + $this->assertSame($expectedKeys, $actualKeys, 'Error-path attributes must also have exactly these five keys'); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Build a capturing counter callable and an ArrayObject holding the captured list. + * + * The captor is returned as an ArrayObject so the reference is preserved + * across PHP's list-destructuring semantics (which copy by value). + * + * @return array{0: Closure, 1: \ArrayObject>} + */ + private function makeCapturingCounter(): array + { + /** @var \ArrayObject> $captured */ + $captured = new \ArrayObject(); + $callable = static function (array $attributes) use ($captured): void { + $captured[] = $attributes; + }; + return [$callable, $captured]; + } + + private function enabledEnvReader(): Closure + { + return static fn(string $name): ?string => $name === 'DD_METRICS_OTEL_ENABLED' ? 'true' : null; + } + + private function disabledEnvReader(): Closure + { + return static fn(string $name): ?string => null; + } + + private function envReaderReturning(string $value): Closure + { + return static fn(string $name): ?string => $name === 'DD_METRICS_OTEL_ENABLED' ? $value : null; + } + + /** + * @return array + */ + private function successBridgeResult(int $reason = 1): array + { + return [ + 'value_json' => 'true', + 'variant' => 'on', + 'allocation_key' => 'alloc-1', + 'reason' => $reason, + 'error_code' => 0, + 'do_log' => true, + ]; + } + + /** + * @return array + */ + private function errorBridgeResult(int $errorCode): array + { + return [ + 'value_json' => null, + 'variant' => null, + 'allocation_key' => null, + 'reason' => 4, // REASON_ERROR + 'error_code' => $errorCode, + 'do_log' => false, + ]; + } +} diff --git a/tests/OpenFeature/ProviderLifecycleTest.php b/tests/OpenFeature/ProviderLifecycleTest.php new file mode 100644 index 00000000000..f422bd6b17a --- /dev/null +++ b/tests/OpenFeature/ProviderLifecycleTest.php @@ -0,0 +1,689 @@ + false, + configVersionCallable: fn (): int => 0, + ); + + $this->assertFalse($lifecycle->isReady()); + } + + public function testIsReadyReturnsTrueWhenConfigAvailable(): void + { + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => true, + configVersionCallable: fn (): int => 0, + ); + + $this->assertTrue($lifecycle->isReady()); + } + + public function testIsReadyBecomesReadyWhenConfigAppears(): void + { + $hasConfig = false; + $lifecycle = new ProviderLifecycle( + hasConfigCallable: function () use (&$hasConfig): bool { return $hasConfig; }, + configVersionCallable: fn (): int => 0, + ); + + $this->assertFalse($lifecycle->isReady()); + + // Simulate config arriving + $hasConfig = true; + $this->assertTrue($lifecycle->isReady()); + } + + public function testIsReadyStaysReadyOnceSet(): void + { + $hasConfig = true; + $lifecycle = new ProviderLifecycle( + hasConfigCallable: function () use (&$hasConfig): bool { return $hasConfig; }, + configVersionCallable: fn (): int => 0, + ); + + $this->assertTrue($lifecycle->isReady()); + + // Even if has_config goes false, ready should stick + $hasConfig = false; + $this->assertTrue($lifecycle->isReady()); + } + + // ------------------------------------------------------------------------- + // ProviderLifecycle: blocking init (waitUntilReady) + // ------------------------------------------------------------------------- + + public function testBlockingInitSucceedsWhenConfigAvailableImmediately(): void + { + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => true, + configVersionCallable: fn (): int => 0, + ); + + $result = $lifecycle->waitUntilReady(1.0); + + $this->assertTrue($result); + $this->assertTrue($lifecycle->isReady()); + } + + public function testBlockingInitSucceedsWhenConfigArrivesBeforeTimeout(): void + { + $callCount = 0; + $lifecycle = new ProviderLifecycle( + hasConfigCallable: function () use (&$callCount): bool { + $callCount++; + // Config arrives on 3rd check + return $callCount >= 3; + }, + configVersionCallable: fn (): int => 0, + ); + + $result = $lifecycle->waitUntilReady(2.0, 1_000); // 1ms poll interval + + $this->assertTrue($result); + $this->assertTrue($lifecycle->isReady()); + } + + public function testBlockingInitTimesOutCorrectly(): void + { + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => false, + configVersionCallable: fn (): int => 0, + ); + + $start = microtime(true); + $result = $lifecycle->waitUntilReady(0.05, 5_000); // 50ms timeout, 5ms poll + $elapsed = microtime(true) - $start; + + $this->assertFalse($result); + $this->assertFalse($lifecycle->isReady()); + // Should not take significantly longer than the timeout + $this->assertLessThan(0.2, $elapsed, 'Timeout should complete within reasonable bounds'); + } + + public function testBlockingInitWithZeroTimeoutReturnsImmediately(): void + { + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => false, + configVersionCallable: fn (): int => 0, + ); + + $start = microtime(true); + $result = $lifecycle->waitUntilReady(0.0); + $elapsed = microtime(true) - $start; + + $this->assertFalse($result); + $this->assertLessThan(0.01, $elapsed, 'Zero timeout should return immediately'); + } + + // ------------------------------------------------------------------------- + // ProviderLifecycle: PROVIDER_READY event + // ------------------------------------------------------------------------- + + public function testProviderReadyFiredOnceOnFirstReadyTransition(): void + { + $readyCount = 0; + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => true, + configVersionCallable: fn (): int => 0, + onReady: function () use (&$readyCount): void { $readyCount++; }, + ); + + // Should have fired once immediately since config was available at construction + $this->assertSame(1, $readyCount); + + // Multiple isReady checks should not re-fire + $lifecycle->isReady(); + $lifecycle->isReady(); + $lifecycle->isReady(); + $this->assertSame(1, $readyCount); + } + + public function testProviderReadyNotFiredWhenNotReady(): void + { + $readyCount = 0; + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => false, + configVersionCallable: fn (): int => 0, + onReady: function () use (&$readyCount): void { $readyCount++; }, + ); + + $this->assertSame(0, $readyCount); + $lifecycle->isReady(); // Still not ready + $this->assertSame(0, $readyCount); + } + + public function testProviderReadyFiredOnDelayedConfigArrival(): void + { + $hasConfig = false; + $readyCount = 0; + $lifecycle = new ProviderLifecycle( + hasConfigCallable: function () use (&$hasConfig): bool { return $hasConfig; }, + configVersionCallable: fn (): int => 0, + onReady: function () use (&$readyCount): void { $readyCount++; }, + ); + + $this->assertSame(0, $readyCount); + + // Config arrives + $hasConfig = true; + $lifecycle->isReady(); + + $this->assertSame(1, $readyCount); + } + + public function testProviderReadyFiredOnlyOnceEvenWithConfigChanges(): void + { + $readyCount = 0; + $version = 1; + + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => true, + configVersionCallable: function () use (&$version): int { + return $version; + }, + onReady: function () use (&$readyCount): void { $readyCount++; }, + ); + + // Should have fired once at construction (has_config=true → transitionToReady) + $this->assertSame(1, $readyCount); + + // Simulate subsequent RC updates bumping the version counter. + $version = 2; + $lifecycle->checkForConfigChange(); + $version = 3; + $lifecycle->checkForConfigChange(); + + // Still only 1 — ready is sticky, PROVIDER_READY fires exactly once. + $this->assertSame(1, $readyCount); + } + + public function testLateOnReadyCallbackFiresImmediatelyIfAlreadyReady(): void + { + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => true, + configVersionCallable: fn (): int => 0, + ); + + // Provider is already ready, register callback late + $called = false; + $lifecycle->onReady(function () use (&$called): void { $called = true; }); + + $this->assertTrue($called, 'Late onReady callback should fire immediately when already ready'); + } + + // ------------------------------------------------------------------------- + // ProviderLifecycle: checkForConfigChange + // ------------------------------------------------------------------------- + + public function testCheckForConfigChangeReturnsTrueWhenChanged(): void + { + $version = 1; + $lifecycle = new ProviderLifecycle( + hasConfigCallable: fn () => true, + configVersionCallable: function () use (&$version): int { + return $version; + }, + ); + + // Constructor syncs last-seen to 1; bump to simulate an RC update. + $version = 2; + $this->assertTrue($lifecycle->checkForConfigChange()); + $this->assertFalse($lifecycle->checkForConfigChange()); + } + + // ------------------------------------------------------------------------- + // DataDogProvider: lifecycle integration + // ------------------------------------------------------------------------- + + public function testProviderReturnsDefaultWhenNotReady(): void + { + $bridgeCalled = false; + $provider = new DataDogProvider( + resultMapper: new BridgeResultMapper(), + contextNormalizer: new EvaluationContextNormalizer(), + bridgeCallable: function () use (&$bridgeCalled): ?array { + $bridgeCalled = true; + return $this->makeSuccessResult('true'); + }, + lifecycle: new ProviderLifecycle( + hasConfigCallable: fn () => false, + configVersionCallable: fn (): int => 0, + ), + ); + + $result = $provider->resolveBooleanValue('flag.bool', true); + + $this->assertTrue($result->getValue()); // Default returned + $this->assertSame(Reason::ERROR, $result->getReason()); + $this->assertEquals( + ErrorCode::PROVIDER_NOT_READY(), + $result->getError()->getResolutionErrorCode() + ); + $this->assertFalse($bridgeCalled, 'Bridge should not be called when provider is not ready'); + } + + public function testProviderCallsBridgeWhenReady(): void + { + $bridgeCalled = false; + $provider = new DataDogProvider( + resultMapper: new BridgeResultMapper(), + contextNormalizer: new EvaluationContextNormalizer(), + bridgeCallable: function () use (&$bridgeCalled): ?array { + $bridgeCalled = true; + return $this->makeSuccessResult('"hello"'); + }, + lifecycle: new ProviderLifecycle( + hasConfigCallable: fn () => true, + configVersionCallable: fn (): int => 0, + ), + ); + + $result = $provider->resolveStringValue('flag.str', 'default'); + + $this->assertSame('hello', $result->getValue()); + $this->assertTrue($bridgeCalled, 'Bridge should be called when provider is ready'); + } + + public function testNonBlockingModeReturnsDefaultsBeforeReady(): void + { + $hasConfig = false; + $provider = new DataDogProvider( + resultMapper: new BridgeResultMapper(), + contextNormalizer: new EvaluationContextNormalizer(), + bridgeCallable: fn () => $this->makeSuccessResult('42'), + lifecycle: new ProviderLifecycle( + hasConfigCallable: function () use (&$hasConfig): bool { return $hasConfig; }, + configVersionCallable: fn (): int => 0, + ), + ); + + // Before config arrives: defaults + $result = $provider->resolveIntegerValue('flag.int', 99); + $this->assertSame(99, $result->getValue()); + $this->assertSame(Reason::ERROR, $result->getReason()); + + // Config arrives + $hasConfig = true; + + // Now should evaluate via bridge + $result = $provider->resolveIntegerValue('flag.int', 99); + $this->assertSame(42, $result->getValue()); + } + + public function testAllTypedResolversReturnDefaultWhenNotReady(): void + { + $provider = new DataDogProvider( + resultMapper: new BridgeResultMapper(), + contextNormalizer: new EvaluationContextNormalizer(), + bridgeCallable: fn () => $this->makeSuccessResult('"should-not-reach"'), + lifecycle: new ProviderLifecycle( + hasConfigCallable: fn () => false, + configVersionCallable: fn (): int => 0, + ), + ); + + // Boolean + $result = $provider->resolveBooleanValue('f', true); + $this->assertTrue($result->getValue()); + $this->assertEquals(ErrorCode::PROVIDER_NOT_READY(), $result->getError()->getResolutionErrorCode()); + + // String + $result = $provider->resolveStringValue('f', 'fallback'); + $this->assertSame('fallback', $result->getValue()); + $this->assertEquals(ErrorCode::PROVIDER_NOT_READY(), $result->getError()->getResolutionErrorCode()); + + // Integer + $result = $provider->resolveIntegerValue('f', 42); + $this->assertSame(42, $result->getValue()); + $this->assertEquals(ErrorCode::PROVIDER_NOT_READY(), $result->getError()->getResolutionErrorCode()); + + // Float + $result = $provider->resolveFloatValue('f', 3.14); + $this->assertSame(3.14, $result->getValue()); + $this->assertEquals(ErrorCode::PROVIDER_NOT_READY(), $result->getError()->getResolutionErrorCode()); + + // Object + $result = $provider->resolveObjectValue('f', ['default' => true]); + $this->assertSame(['default' => true], $result->getValue()); + $this->assertEquals(ErrorCode::PROVIDER_NOT_READY(), $result->getError()->getResolutionErrorCode()); + } + + // ------------------------------------------------------------------------- + // OpenFeatureLifecycleCompatibility + // ------------------------------------------------------------------------- + + public function testSetProviderAndWaitReturnsTrueWhenReady(): void + { + $provider = new DataDogProvider( + lifecycle: new ProviderLifecycle( + hasConfigCallable: fn () => true, + configVersionCallable: fn (): int => 0, + ), + ); + + $result = OpenFeatureLifecycleCompatibility::setProviderAndWait($provider, 1.0); + + $this->assertTrue($result); + } + + public function testSetProviderAndWaitReturnsFalseOnTimeout(): void + { + $provider = new DataDogProvider( + lifecycle: new ProviderLifecycle( + hasConfigCallable: fn () => false, + configVersionCallable: fn (): int => 0, + ), + ); + + $result = OpenFeatureLifecycleCompatibility::setProviderAndWait($provider, 0.05); + + $this->assertFalse($result); + } + + // ------------------------------------------------------------------------- + // ExposureContext: standalone tests + // ------------------------------------------------------------------------- + + public function testExposureContextFromBridgeResultIncludesServiceEnvVersion(): void + { + $envVars = [ + 'DD_SERVICE' => 'my-service', + 'DD_ENV' => 'production', + 'DD_VERSION' => '1.2.3', + ]; + + $bridgeResult = [ + 'value_json' => '"blue"', + 'variant' => 'color-variant', + 'allocation_key' => 'alloc-abc', + 'reason' => 1, + 'error_code' => 0, + 'do_log' => true, + ]; + + $ctx = ExposureContext::fromBridgeResult( + $bridgeResult, + 'flag.color', + 'user-123', + fn (string $name): ?string => $envVars[$name] ?? null, + ); + + $this->assertNotNull($ctx); + $this->assertSame('my-service', $ctx->service); + $this->assertSame('production', $ctx->env); + $this->assertSame('1.2.3', $ctx->version); + $this->assertSame('flag.color', $ctx->flagKey); + $this->assertSame('alloc-abc', $ctx->allocationKey); + $this->assertSame('color-variant', $ctx->variant); + $this->assertSame('user-123', $ctx->targetingKey); + } + + public function testExposureContextReturnsNullWhenDoLogIsFalse(): void + { + $bridgeResult = [ + 'value_json' => '"blue"', + 'variant' => 'color-variant', + 'allocation_key' => 'alloc-abc', + 'reason' => 1, + 'error_code' => 0, + 'do_log' => false, + ]; + + $ctx = ExposureContext::fromBridgeResult( + $bridgeResult, + 'flag.color', + 'user-123', + fn (string $name): ?string => null, + ); + + $this->assertNull($ctx, 'do_log=false must suppress exposure context'); + } + + public function testExposureContextHandlesMissingEnvVars(): void + { + $bridgeResult = [ + 'value_json' => 'true', + 'variant' => 'v1', + 'allocation_key' => 'a1', + 'reason' => 1, + 'error_code' => 0, + 'do_log' => true, + ]; + + $ctx = ExposureContext::fromBridgeResult( + $bridgeResult, + 'flag.bool', + null, + fn (string $name): ?string => null, // No env vars set + ); + + $this->assertNotNull($ctx); + $this->assertNull($ctx->service); + $this->assertNull($ctx->env); + $this->assertNull($ctx->version); + $this->assertNull($ctx->targetingKey); + } + + public function testExposureContextCarriesEvaluatorMetadataIntact(): void + { + $bridgeResult = [ + 'value_json' => '42', + 'variant' => 'experiment-v2', + 'allocation_key' => 'alloc-xyz-789', + 'reason' => 2, + 'error_code' => 0, + 'do_log' => true, + ]; + + $ctx = ExposureContext::fromBridgeResult( + $bridgeResult, + 'flag.experiment', + 'target-key-456', + fn (string $name): ?string => null, + ); + + $this->assertNotNull($ctx); + $this->assertSame('flag.experiment', $ctx->flagKey); + $this->assertSame('alloc-xyz-789', $ctx->allocationKey); + $this->assertSame('experiment-v2', $ctx->variant); + $this->assertSame('target-key-456', $ctx->targetingKey); + } + + public function testExposureContextToArrayReturnsExpectedShape(): void + { + $ctx = new ExposureContext( + service: 'svc', + env: 'staging', + version: '0.1.0', + flagKey: 'flag.test', + allocationKey: 'alloc-1', + variant: 'v1', + targetingKey: 'user-1', + ); + + $this->assertSame([ + 'service' => 'svc', + 'env' => 'staging', + 'version' => '0.1.0', + 'flag_key' => 'flag.test', + 'allocation_key' => 'alloc-1', + 'variant' => 'v1', + 'targeting_key' => 'user-1', + ], $ctx->toArray()); + } + + // ------------------------------------------------------------------------- + // DataDogProvider: exposure context integration + // ------------------------------------------------------------------------- + + public function testExposureContextProducedOnSuccessfulEvaluation(): void + { + $provider = $this->createReadyProvider( + fn () => $this->makeSuccessResult('"hello"', 'v1', 'alloc-1'), + ['DD_SERVICE' => 'test-svc', 'DD_ENV' => 'test', 'DD_VERSION' => '1.0'], + ); + + $provider->resolveStringValue('flag.str', 'default'); + + $ctx = $provider->getLastExposureContext(); + $this->assertNotNull($ctx); + $this->assertSame('test-svc', $ctx->service); + $this->assertSame('test', $ctx->env); + $this->assertSame('1.0', $ctx->version); + $this->assertSame('flag.str', $ctx->flagKey); + $this->assertSame('alloc-1', $ctx->allocationKey); + $this->assertSame('v1', $ctx->variant); + } + + public function testExposureContextSuppressedWhenDoLogFalse(): void + { + $provider = $this->createReadyProvider( + fn () => [ + 'value_json' => '"hello"', + 'variant' => 'v1', + 'allocation_key' => 'alloc-1', + 'reason' => 1, + 'error_code' => 0, + 'do_log' => false, + ], + ); + + $provider->resolveStringValue('flag.str', 'default'); + + $this->assertNull( + $provider->getLastExposureContext(), + 'do_log=false must suppress exposure context in provider' + ); + } + + public function testExposureContextNullWhenProviderNotReady(): void + { + $provider = new DataDogProvider( + resultMapper: new BridgeResultMapper(), + contextNormalizer: new EvaluationContextNormalizer(), + bridgeCallable: fn () => $this->makeSuccessResult('"hello"'), + lifecycle: new ProviderLifecycle( + hasConfigCallable: fn () => false, + configVersionCallable: fn (): int => 0, + ), + ); + + $provider->resolveStringValue('flag.str', 'default'); + + $this->assertNull($provider->getLastExposureContext()); + } + + public function testExposureContextNullOnBridgeError(): void + { + $provider = $this->createReadyProvider( + fn () => [ + 'value_json' => null, + 'variant' => null, + 'allocation_key' => null, + 'reason' => 4, + 'error_code' => 1, // FLAG_NOT_FOUND + 'do_log' => false, + ], + ); + + $provider->resolveBooleanValue('missing.flag', true); + + $this->assertNull($provider->getLastExposureContext()); + } + + public function testExposureContextUpdatedOnSubsequentEvaluations(): void + { + $callCount = 0; + $provider = $this->createReadyProvider( + function () use (&$callCount): array { + $callCount++; + if ($callCount === 1) { + return $this->makeSuccessResult('"first"', 'v1', 'alloc-1'); + } + return [ + 'value_json' => '"second"', + 'variant' => 'v2', + 'allocation_key' => 'alloc-2', + 'reason' => 1, + 'error_code' => 0, + 'do_log' => false, // Second eval suppresses + ]; + }, + ); + + // First evaluation: exposure context produced + $provider->resolveStringValue('flag.first', 'default'); + $this->assertNotNull($provider->getLastExposureContext()); + $this->assertSame('flag.first', $provider->getLastExposureContext()->flagKey); + + // Second evaluation: do_log=false suppresses + $provider->resolveStringValue('flag.second', 'default'); + $this->assertNull($provider->getLastExposureContext()); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * @return array + */ + private function makeSuccessResult(string $valueJson, ?string $variant = 'default-variant', ?string $allocationKey = 'default-alloc'): array + { + return [ + 'value_json' => $valueJson, + 'variant' => $variant, + 'allocation_key' => $allocationKey, + 'reason' => 1, // REASON_TARGETING_MATCH + 'error_code' => 0, // ERROR_NONE + 'do_log' => true, + ]; + } + + /** + * Create a provider with ready lifecycle for exposure context tests. + * + * @param \Closure $bridge Bridge callable + * @param array $envVars Environment variable overrides + */ + private function createReadyProvider(\Closure $bridge, array $envVars = []): DataDogProvider + { + return new DataDogProvider( + resultMapper: new BridgeResultMapper(), + contextNormalizer: new EvaluationContextNormalizer(), + bridgeCallable: $bridge, + lifecycle: new ProviderLifecycle( + hasConfigCallable: fn () => true, + configVersionCallable: fn (): int => 0, + ), + envReader: fn (string $name): ?string => $envVars[$name] ?? null, + ); + } +} diff --git a/tests/OpenFeature/RegressionTest.php b/tests/OpenFeature/RegressionTest.php new file mode 100644 index 00000000000..3377d5b78d1 --- /dev/null +++ b/tests/OpenFeature/RegressionTest.php @@ -0,0 +1,320 @@ + 'enterprise'])); + + [$targetingKey, $attributes] = $normalizer->normalize($context); + + $this->assertNull($targetingKey, 'Null targeting key must remain null after normalize'); + $this->assertNotSame('', $targetingKey, 'Null targeting key must not be coerced to empty string'); + $this->assertSame(['plan' => 'enterprise'], $attributes); + } + + // ------------------------------------------------------------------------- + // Fix 2 - ERROR reason forced when error_code != 0 (Phase 1 D-03 / EVAL-06) + // + // Source: src/OpenFeature/BridgeResultMapper.php::mapResult(), lines 155-163. + // Rationale: even if the bridge returns reason=TARGETING_MATCH alongside a + // non-zero error_code (possible if Rust and PHP drift), PHP must force + // Reason::ERROR to satisfy OpenFeature spec (error results MUST use ERROR). + // See 04-PR-REVIEW-AUDIT.md for EVAL-06 row. + // ------------------------------------------------------------------------- + + public function testErrorReasonForcedWhenErrorCodeNonZeroEvenIfBridgeReportsOtherReason(): void + { + $mapper = new BridgeResultMapper(); + + $bridgeResult = [ + 'value_json' => '"hello"', + 'variant' => 'x', + 'allocation_key' => 'y', + 'reason' => 1, // REASON_TARGETING_MATCH - conflicting on purpose + 'error_code' => 1, // FLAG_NOT_FOUND + 'do_log' => true, + ]; + + $details = $mapper->mapString($bridgeResult, 'default'); + + $this->assertSame(Reason::ERROR, $details->getReason(), 'Non-zero error_code must force Reason::ERROR'); + $this->assertSame('default', $details->getValue(), 'Error path returns caller default'); + $this->assertNotNull($details->getError(), 'Error path must carry a ResolutionError'); + $this->assertEquals( + ErrorCode::FLAG_NOT_FOUND(), + $details->getError()->getResolutionErrorCode(), + 'error_code=1 maps to ErrorCode::FLAG_NOT_FOUND', + ); + } + + // ------------------------------------------------------------------------- + // Fix 3 - do_log=false hard gate suppresses ExposureContext (Phase 2 D-04 / CONF-04) + // + // Source: src/OpenFeature/ExposureContext.php::fromBridgeResult(), lines 71-76. + // Rationale: do_log=false is a hard gate. The bridge tells us not to log this + // evaluation, so no ExposureContext must be produced, regardless of any other + // field values being valid. + // See 04-PR-REVIEW-AUDIT.md for Phase 2 D-04 row. + // ------------------------------------------------------------------------- + + public function testDoLogFalseHardGateReturnsNullExposureContext(): void + { + $bridgeResult = [ + 'value_json' => '"hello"', + 'variant' => 'b', + 'allocation_key' => 'a', + 'reason' => 1, + 'error_code' => 0, + 'do_log' => false, + ]; + + $ctx = ExposureContext::fromBridgeResult( + $bridgeResult, + 'flag-1', + 'user-1', + fn (string $name): ?string => null, + ); + + $this->assertNull($ctx, 'do_log=false must produce no ExposureContext (hard gate)'); + } + + // ------------------------------------------------------------------------- + // Fix 4 - primitive-only attribute filtering (bwoebi reviewer directive) + // + // Source: src/OpenFeature/EvaluationContextNormalizer.php::filterPrimitiveAttributes(). + // Reviewer: bwoebi, PR #3630 review comment: "the Rust side only accepts flat + // primitives; filter nested arrays/objects/nulls at the PHP boundary so the + // C extension doesn't have to defend against bad inputs". + // See 04-PR-REVIEW-AUDIT.md. + // ------------------------------------------------------------------------- + + public function testPrimitiveOnlyAttributeFilteringDropsNestedArraysAndObjects(): void + { + $normalizer = new EvaluationContextNormalizer(); + + $context = new EvaluationContext( + 'user-1', + new Attributes([ + 'ok_string' => 'a', + 'ok_int' => 1, + 'ok_float' => 1.5, + 'ok_bool' => true, + 'bad_array' => ['nested'], + 'bad_null' => null, + 'bad_object' => new \stdClass(), + ]), + ); + + [$targetingKey, $attributes] = $normalizer->normalize($context); + + $this->assertSame('user-1', $targetingKey); + + // Only primitive-typed keys survive; all non-primitives are filtered out + ksort($attributes); + $expected = [ + 'ok_bool' => true, + 'ok_float' => 1.5, + 'ok_int' => 1, + 'ok_string' => 'a', + ]; + $this->assertSame($expected, $attributes, 'Only flat primitives survive the normalizer'); + + // Explicit assertions the bad keys are gone + $this->assertArrayNotHasKey('bad_array', $attributes); + $this->assertArrayNotHasKey('bad_null', $attributes); + $this->assertArrayNotHasKey('bad_object', $attributes); + } + + // ------------------------------------------------------------------------- + // Fix 5 - empty flat attributes produce JSON {} not [] (Phase 3 (object) cast) + // + // Source: src/OpenFeature/ExposureWriter.php::buildEventJson(), line 123: + // 'attributes' => (object)$flatAttributes, + // Rationale: PHP empty arrays JSON-encode to [], but exposure events must + // carry {"attributes":{}} to match the Ruby/Python cross-tracer format. + // See 04-PR-REVIEW-AUDIT.md. + // ------------------------------------------------------------------------- + + public function testEmptyAttributesProduceJsonObjectNotArray(): void + { + $capturedJson = null; + + $writer = new ExposureWriter( + sidecarCallable: static function (string $eventJson) use (&$capturedJson): bool { + $capturedJson = $eventJson; + return true; + }, + timestampProvider: static fn (): int => 1234567890, + ); + + $context = new ExposureContext( + service: null, + env: null, + version: null, + flagKey: 'flag-1', + allocationKey: 'alloc-1', + variant: 'variant-a', + targetingKey: 'user-1', + ); + + // Send with no evaluation attributes so the flat attributes map is empty + $writer->send($context, []); + + $this->assertNotNull($capturedJson, 'Sidecar callable must have been invoked'); + $this->assertStringContainsString('"attributes":{}', $capturedJson, 'Empty attributes must JSON-encode to {}'); + $this->assertStringNotContainsString('"attributes":[]', $capturedJson, 'Empty attributes must not JSON-encode to []'); + } + + // ------------------------------------------------------------------------- + // Fix 6 - dedup triple passed to sidecar callable (Phase 3 D-11) + // + // Source: src/OpenFeature/ExposureWriter.php::send(). + // Rationale: The Rust LRU dedup keys on (flag_key, allocation_key, targeting_key). + // PHP must faithfully pass all three plus the variant so the sidecar can + // dedup correctly. This test pins the argument order and values. + // See 04-PR-REVIEW-AUDIT.md for EXPO regression entry. + // ------------------------------------------------------------------------- + + public function testExposureDedupTripleIsPassedToSidecarCallable(): void + { + $capturedArgs = null; + + $writer = new ExposureWriter( + sidecarCallable: static function ( + string $eventJson, + string $flagKey, + string $allocationKey, + ?string $targetingKey, + string $variantKey, + ) use (&$capturedArgs): bool { + $capturedArgs = [ + 'eventJson' => $eventJson, + 'flagKey' => $flagKey, + 'allocationKey' => $allocationKey, + 'targetingKey' => $targetingKey, + 'variantKey' => $variantKey, + ]; + return true; + }, + ); + + $context = new ExposureContext( + service: null, + env: null, + version: null, + flagKey: 'flag-1', + allocationKey: 'alloc-1', + variant: 'v1', + targetingKey: 'user-1', + ); + + $writer->send($context, null); + + $this->assertNotNull($capturedArgs, 'Sidecar callable must have been invoked'); + $this->assertSame('flag-1', $capturedArgs['flagKey'], 'flagKey must be second arg'); + $this->assertSame('alloc-1', $capturedArgs['allocationKey'], 'allocationKey must be third arg'); + $this->assertSame('user-1', $capturedArgs['targetingKey'], 'targetingKey must be fourth arg'); + $this->assertSame('v1', $capturedArgs['variantKey'], 'variantKey must be fifth arg'); + } + + // ------------------------------------------------------------------------- + // Fix 7 - ProviderLifecycle timeout (PROV-03 regression) + // + // Per CONTEXT.md D-07 audit: tests/OpenFeature/ProviderLifecycleTest.php + // already covers the full timeout matrix (see methods referenced below). + // Rather than duplicate that coverage here, this delegation test acts as + // a grep-discoverable breadcrumb linking the regression plan's Fix 7 to + // the canonical test file. + // + // Existing covering tests in ProviderLifecycleTest: + // - testBlockingInitSucceedsWhenConfigAvailableImmediately + // - testBlockingInitSucceedsWhenConfigArrivesBeforeTimeout + // - testBlockingInitTimesOutCorrectly (primary timeout-returns-false case) + // - testBlockingInitWithZeroTimeoutReturnsImmediately + // - testSetProviderAndWaitReturnsFalseOnTimeout (end-to-end via compat helper) + // + // Source: src/OpenFeature/ProviderLifecycle.php::waitUntilReady(). + // See 04-PR-REVIEW-AUDIT.md for Fix 7 row. + // ------------------------------------------------------------------------- + + public function testLifecycleTimeoutCoverageDelegatedToProviderLifecycleTest(): void + { + $providerLifecycleTestPath = __DIR__ . '/ProviderLifecycleTest.php'; + $this->assertTrue( + file_exists($providerLifecycleTestPath), + 'ProviderLifecycleTest.php must exist to cover waitUntilReady timeout', + ); + + $contents = file_get_contents($providerLifecycleTestPath); + $this->assertIsString($contents); + + // Assert the specific timeout-covering test methods exist in the canonical file. + $this->assertStringContainsString( + 'function testBlockingInitTimesOutCorrectly', + $contents, + 'Primary timeout-returns-false test must live in ProviderLifecycleTest.php', + ); + $this->assertStringContainsString( + 'function testBlockingInitWithZeroTimeoutReturnsImmediately', + $contents, + 'Zero-timeout fast-path test must live in ProviderLifecycleTest.php', + ); + $this->assertStringContainsString( + 'function testBlockingInitSucceedsWhenConfigArrivesBeforeTimeout', + $contents, + 'Config-arrives-before-timeout test must live in ProviderLifecycleTest.php', + ); + $this->assertStringContainsString( + 'function testSetProviderAndWaitReturnsFalseOnTimeout', + $contents, + 'End-to-end compat timeout test must live in ProviderLifecycleTest.php', + ); + } +} diff --git a/tests/OpenFeature/composer.json b/tests/OpenFeature/composer.json new file mode 100644 index 00000000000..faab284a54c --- /dev/null +++ b/tests/OpenFeature/composer.json @@ -0,0 +1,7 @@ +{ + "name": "datadog/dd-trace-tests-openfeature", + "require": { + "open-feature/sdk": "^2.1" + }, + "minimum-stability": "stable" +} diff --git a/tests/OpenFeature/testdata/evaluation-cases/test-case-boolean-false-assignment.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-boolean-false-assignment.json new file mode 100644 index 00000000000..ab718f5bee1 --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-boolean-one-of-matches.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-boolean-one-of-matches.json new file mode 100644 index 00000000000..392ec97ec0f --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-comparator-operator-flag.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-comparator-operator-flag.json new file mode 100644 index 00000000000..e4daa2de7c2 --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-disabled-flag.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-disabled-flag.json new file mode 100644 index 00000000000..208e5c96943 --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-empty-flag.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-empty-flag.json new file mode 100644 index 00000000000..18ee5381fdc --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-empty-string-variation.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-empty-string-variation.json new file mode 100644 index 00000000000..f1fa3994af2 --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-falsy-value-assignments.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-falsy-value-assignments.json new file mode 100644 index 00000000000..6a8cee51bcc --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-flag-with-empty-string.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-flag-with-empty-string.json new file mode 100644 index 00000000000..6a5d9cd62fd --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-integer-flag.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-integer-flag.json new file mode 100644 index 00000000000..8ff11aec1fc --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-kill-switch-flag.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-kill-switch-flag.json new file mode 100644 index 00000000000..3af700dc711 --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-microsecond-date-flag.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-microsecond-date-flag.json new file mode 100644 index 00000000000..a7bfab410c1 --- /dev/null +++ b/tests/OpenFeature/testdata/evaluation-cases/test-case-microsecond-date-flag.json @@ -0,0 +1,39 @@ +[ + { + "attributes": {}, + "defaultValue": "unknown", + "flag": "microsecond-date-test", + "result": { + "reason": "STATIC", + "value": "active" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "attributes": { + "country": "US" + }, + "defaultValue": "unknown", + "flag": "microsecond-date-test", + "result": { + "reason": "STATIC", + "value": "active" + }, + "targetingKey": "bob", + "variationType": "STRING" + }, + { + "attributes": { + "version": "1.0.0" + }, + "defaultValue": "unknown", + "flag": "microsecond-date-test", + "result": { + "reason": "STATIC", + "value": "active" + }, + "targetingKey": "charlie", + "variationType": "STRING" + } +] diff --git a/tests/OpenFeature/testdata/evaluation-cases/test-case-new-user-onboarding-flag.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-new-user-onboarding-flag.json new file mode 100644 index 00000000000..bb08523de4e --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-no-allocations-flag.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-no-allocations-flag.json new file mode 100644 index 00000000000..7d985b8efd4 --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-null-operator-flag.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-null-operator-flag.json new file mode 100644 index 00000000000..8063ff8fee3 --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-numeric-flag.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-numeric-flag.json new file mode 100644 index 00000000000..cc52dcdff4d --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-numeric-one-of.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-numeric-one-of.json new file mode 100644 index 00000000000..bfa957335d3 --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-of-7-empty-targeting-key.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-of-7-empty-targeting-key.json new file mode 100644 index 00000000000..e99a207b6da --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-case-regex-flag.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-regex-flag.json new file mode 100644 index 00000000000..253c4f7caca --- /dev/null +++ b/tests/OpenFeature/testdata/evaluation-cases/test-case-regex-flag.json @@ -0,0 +1,57 @@ +[ + { + "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/tests/OpenFeature/testdata/evaluation-cases/test-case-start-and-end-date-flag.json b/tests/OpenFeature/testdata/evaluation-cases/test-case-start-and-end-date-flag.json new file mode 100644 index 00000000000..0ab6dbd681d --- /dev/null +++ b/tests/OpenFeature/testdata/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": "STATIC", + "value": "current" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "attributes": { + "country": "Canada", + "version": "0.20.1" + }, + "defaultValue": "unknown", + "flag": "start-and-end-date-test", + "result": { + "reason": "STATIC", + "value": "current" + }, + "targetingKey": "bob", + "variationType": "STRING" + }, + { + "attributes": { + "version": "2.1.13" + }, + "defaultValue": "unknown", + "flag": "start-and-end-date-test", + "result": { + "reason": "STATIC", + "value": "current" + }, + "targetingKey": "charlie", + "variationType": "STRING" + } +] diff --git a/tests/OpenFeature/testdata/evaluation-cases/test-flag-that-does-not-exist.json b/tests/OpenFeature/testdata/evaluation-cases/test-flag-that-does-not-exist.json new file mode 100644 index 00000000000..453beb7290c --- /dev/null +++ b/tests/OpenFeature/testdata/evaluation-cases/test-flag-that-does-not-exist.json @@ -0,0 +1,43 @@ +[ + { + "attributes": { + "country": "US", + "email": "alice@mycompany.com" + }, + "defaultValue": 0, + "flag": "flag-that-does-not-exist", + "result": { + "reason": "DEFAULT", + "value": 0 + }, + "targetingKey": "alice", + "variationType": "NUMERIC" + }, + { + "attributes": { + "country": "Canada", + "email": "bob@example.com" + }, + "defaultValue": 0, + "flag": "flag-that-does-not-exist", + "result": { + "reason": "DEFAULT", + "value": 0 + }, + "targetingKey": "bob", + "variationType": "NUMERIC" + }, + { + "attributes": { + "age": 50 + }, + "defaultValue": 0, + "flag": "flag-that-does-not-exist", + "result": { + "reason": "DEFAULT", + "value": 0 + }, + "targetingKey": "charlie", + "variationType": "NUMERIC" + } +] diff --git a/tests/OpenFeature/testdata/evaluation-cases/test-json-config-flag.json b/tests/OpenFeature/testdata/evaluation-cases/test-json-config-flag.json new file mode 100644 index 00000000000..aef944c93a6 --- /dev/null +++ b/tests/OpenFeature/testdata/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, + "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, + "integer": 2, + "string": "two" + } + }, + "targetingKey": "bob", + "variationType": "JSON" + }, + { + "attributes": { + "age": 50 + }, + "defaultValue": { + "foo": "bar" + }, + "flag": "json-config-flag", + "result": { + "reason": "SPLIT", + "value": { + "float": 2, + "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/tests/OpenFeature/testdata/evaluation-cases/test-no-allocations-flag.json b/tests/OpenFeature/testdata/evaluation-cases/test-no-allocations-flag.json new file mode 100644 index 00000000000..2b03b9a39a1 --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-special-characters.json b/tests/OpenFeature/testdata/evaluation-cases/test-special-characters.json new file mode 100644 index 00000000000..1ed7795bb95 --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/evaluation-cases/test-string-with-special-characters.json b/tests/OpenFeature/testdata/evaluation-cases/test-string-with-special-characters.json new file mode 100644 index 00000000000..32397a48398 --- /dev/null +++ b/tests/OpenFeature/testdata/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/tests/OpenFeature/testdata/ufc-config.json b/tests/OpenFeature/testdata/ufc-config.json new file mode 100644 index 00000000000..6c9a18e2996 --- /dev/null +++ b/tests/OpenFeature/testdata/ufc-config.json @@ -0,0 +1,3353 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "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" + } + }, + "allocations": [ + { + "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 + } + ] + } + } +} diff --git a/tests/ext/ffe/flush_drains_buffer.phpt b/tests/ext/ffe/flush_drains_buffer.phpt new file mode 100644 index 00000000000..9ff0de7e4f9 --- /dev/null +++ b/tests/ext/ffe/flush_drains_buffer.phpt @@ -0,0 +1,61 @@ +--TEST-- +FFE: ddog_ffe_flush_exposures drains the batch buffer (V2/V4 proxy) +--ENV-- +DD_TRACE_ENABLED=0 +--INI-- +datadog.trace.generate_root_span=0 +datadog.trace.agent_test_session_token=ffe-flush-drain +--FILE-- + 1713382853716, + 'flag' => ['key' => 'demo-flag'], + 'allocation' => ['key' => 'alloc-a'], + 'variant' => ['key' => 'on'], + 'subject' => ['id' => 'user-1', 'attributes' => new stdClass()], +]); + +// First enqueue should succeed. +var_dump(DDTrace\ffe_send_exposure($event, 'demo-flag', 'alloc-a', 'user-1', 'on')); + +// Duplicate (same flag+targeting+allocation+variant) should dedup. +var_dump(DDTrace\ffe_send_exposure($event, 'demo-flag', 'alloc-a', 'user-1', 'on')); + +// Flip the variant -> allocation/variant changed -> should re-emit. +var_dump(DDTrace\ffe_send_exposure($event, 'demo-flag', 'alloc-a', 'user-1', 'off')); + +// Flush should return a non-empty JSON batch. +$payload = DDTrace\ffe_flush_exposures(); +var_dump(is_string($payload) && strlen($payload) > 0); + +$decoded = json_decode($payload, true); +var_dump($decoded['context']['service'] === 'svc-flush'); +var_dump($decoded['context']['env'] === 'test'); +var_dump($decoded['context']['version'] === '9.9.9'); +var_dump(count($decoded['exposures'])); + +// After drain, subsequent flush returns null (empty buffer). +var_dump(DDTrace\ffe_flush_exposures()); + +?> +--EXPECT-- +bool(true) +bool(false) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +int(2) +NULL diff --git a/tests/ext/ffe/fork_resets_dedup.phpt b/tests/ext/ffe/fork_resets_dedup.phpt new file mode 100644 index 00000000000..670089ea3c3 --- /dev/null +++ b/tests/ext/ffe/fork_resets_dedup.phpt @@ -0,0 +1,56 @@ +--TEST-- +FFE: ddtrace_sidecar_handle_fork resets exposure dedup in child (T9) +--SKIPIF-- + +--ENV-- +DD_TRACE_ENABLED=0 +--INI-- +datadog.trace.generate_root_span=0 +--FILE-- + 1, + 'flag' => ['key' => 'f'], + 'allocation' => ['key' => 'a'], + 'variant' => ['key' => 'on'], + 'subject' => ['id' => 'u', 'attributes' => new stdClass()], +]); + +// Parent primes the dedup cache. +$parent_first = DDTrace\ffe_send_exposure($event, 'f', 'a', 'u', 'on'); +echo "parent_first=" . ($parent_first ? 'true' : 'false') . "\n"; + +$pid = pcntl_fork(); +if ($pid === -1) { + die("fork failed"); +} + +if ($pid === 0) { + // Child: same (flag, targeting, allocation, variant) as the parent's prime. + // If the fork handler reset EXPOSURE_STATE, this is the child's first sighting + // -> returns true. Without T9 it would observe the parent's cache -> false. + DDTrace\Internal\handle_fork(); + $child = DDTrace\ffe_send_exposure($event, 'f', 'a', 'u', 'on'); + echo "child=" . ($child ? 'true' : 'false') . "\n"; + exit(0); +} + +pcntl_wait($status); + +// Parent: same exposure again -> still a duplicate because parent's cache is intact. +$parent_second = DDTrace\ffe_send_exposure($event, 'f', 'a', 'u', 'on'); +echo "parent_second=" . ($parent_second ? 'true' : 'false') . "\n"; + +?> +--EXPECTF-- +parent_first=true +child=true +parent_second=false diff --git a/tests/internal-api-stress-test.php b/tests/internal-api-stress-test.php index 671bd0e03ce..5730c5c8acd 100644 --- a/tests/internal-api-stress-test.php +++ b/tests/internal-api-stress-test.php @@ -200,7 +200,13 @@ function runOneIteration() && !strpos($f->name, "Testing") && $f->name != "dd_trace_disable_in_request" && (PHP_VERSION_ID >= 70100 || $f->name != 'DDTrace\curl_multi_exec_get_request_spans') - && $f->name != "DDTrace\Internal\handle_fork"; + && $f->name != "DDTrace\Internal\handle_fork" + // PHP < 8.1 ReflectionFunction::getNumberOfRequiredParameters() is unused here, so + // the fuzzer enumerates param permutations from $i=0. For functions with many + // required args this is exponential and blows PHP's memory limit before the + // ArgumentCountError-based pruning kicks in. Skip the 5-required-arg FFE exposure + // sender on old PHP. + && (PHP_VERSION_ID >= 80100 || $f->name != 'DDTrace\ffe_send_exposure'); }); $props = array_filter( diff --git a/tests/phpunit.xml b/tests/phpunit.xml index c2817d25d85..162bb1122ca 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -140,6 +140,9 @@ ./Unit/ + + ./OpenFeature/ + diff --git a/tooling/generation/composer.json b/tooling/generation/composer.json index 0bf4f24cc0f..4e8a7316659 100644 --- a/tooling/generation/composer.json +++ b/tooling/generation/composer.json @@ -8,9 +8,11 @@ "vendor/bin/classpreloader compile --config=../../src/bridge/_files_api.php --output=../../src/bridge/_generated_api.php", "vendor/bin/classpreloader compile --config=../../src/bridge/_files_tracer.php --output=../../src/bridge/_generated_tracer.php", "vendor/bin/classpreloader compile --config=../../src/bridge/_files_opentelemetry.php --output=../../src/bridge/_generated_opentelemetry.php", + "vendor/bin/classpreloader compile --config=../../src/bridge/_files_openfeature.php --output=../../src/bridge/_generated_openfeature.php", "sed -i \"s/'[^']\\+bridge\\/\\.\\./__DIR__ . '\\/../g;s/\\s*\\(^\\|\\s\\)\\/\\/.*//g;s/\\/\\*\\([^*]\\|\\*[^/]\\)*\\*\\///g;/\\/\\*/,/\\*\\//d;/^\\s*$/d\" ../../src/bridge/_generated_api.php", "sed -i \"s/'[^']\\+bridge\\/\\.\\./__DIR__ . '\\/../g;s/\\s*\\(^\\|\\s\\)\\/\\/.*//g;s/\\/\\*\\([^*]\\|\\*[^/]\\)*\\*\\///g;/\\/\\*/,/\\*\\//d;/^\\s*$/d\" ../../src/bridge/_generated_tracer.php", - "sed -i \"s/'[^']\\+bridge\\/\\.\\./__DIR__ . '\\/../g;s/\\s*\\(^\\|\\s\\)\\/\\/.*//g;s/\\/\\*\\([^*]\\|\\*[^/]\\)*\\*\\///g;/\\/\\*/,/\\*\\//d;/^\\s*$/d\" ../../src/bridge/_generated_opentelemetry.php" + "sed -i \"s/'[^']\\+bridge\\/\\.\\./__DIR__ . '\\/../g;s/\\s*\\(^\\|\\s\\)\\/\\/.*//g;s/\\/\\*\\([^*]\\|\\*[^/]\\)*\\*\\///g;/\\/\\*/,/\\*\\//d;/^\\s*$/d\" ../../src/bridge/_generated_opentelemetry.php", + "sed -i \"s/'[^']\\+bridge\\/\\.\\./__DIR__ . '\\/../g;s/\\s*\\(^\\|\\s\\)\\/\\/.*//g;s/\\/\\*\\([^*]\\|\\*[^/]\\)*\\*\\///g;/\\/\\*/,/\\*\\//d;/^\\s*$/d\" ../../src/bridge/_generated_openfeature.php" ], "verify": "php -r 'require \"../../src/bridge/_files_api.php\"; require \"../../src/bridge/_files_tracer.php\";'" }