diff --git a/manifests/cpp.yml b/manifests/cpp.yml index f47379886b7..3dbff5bfdb1 100644 --- a/manifests/cpp.yml +++ b/manifests/cpp.yml @@ -124,6 +124,7 @@ manifest: tests/parametric/test_extract_behavior.py::Test_ExtractBehavior_Restart_With_Extract_First::test_baggage: missing_feature (Automatic baggage propagation is not implemented) tests/parametric/test_ffe/test_configuration_sources.py: irrelevant (cpp is not in scope for v1 agentless server SDK configuration-source coverage) tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: missing_feature + tests/parametric/test_ffe/test_serial_id_metadata.py: missing_feature tests/parametric/test_ffe/test_span_enrichment.py: '>=2.1.1' # TODO: a lower version might be supported tests/parametric/test_ffe/test_span_enrichment.py::Test_Span_Enrichment_Child_Span_Propagation: missing_feature tests/parametric/test_ffe/test_span_enrichment.py::Test_Span_Enrichment_Default_Fallback: missing_feature diff --git a/manifests/dotnet.yml b/manifests/dotnet.yml index 1a06585da38..08e325cd8c2 100644 --- a/manifests/dotnet.yml +++ b/manifests/dotnet.yml @@ -965,6 +965,7 @@ manifest: tests/parametric/test_ffe/test_configuration_sources.py: missing_feature (FFL-2703 tracks .NET agentless configuration-source implementation; FFL-2731 tracks system-tests configuration-source contract) tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: '>=3.36.0' # Modified by easy win activation script tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation::test_ffe_flag_evaluation: missing_feature # Created by easy win activation script + tests/parametric/test_ffe/test_serial_id_metadata.py: v3.54.0 tests/parametric/test_ffe/test_span_enrichment.py: missing_feature tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_migrated_extract_valid: missing_feature (Need to remove b3=b3multi alias) tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_migrated_inject_valid: missing_feature (Need to remove b3=b3multi alias) diff --git a/manifests/golang.yml b/manifests/golang.yml index cdf890f8a9d..66f2acc104c 100644 --- a/manifests/golang.yml +++ b/manifests/golang.yml @@ -1631,6 +1631,7 @@ manifest: tests/parametric/test_extract_behavior.py::Test_ExtractBehavior_Restart_With_Extract_First: missing_feature (DD_TRACE_PROPAGATION_EXTRACT_FIRST=true is unsupported; dd-trace-go panics when extraction fails) tests/parametric/test_ffe/test_configuration_sources.py: v2.11.0-dev tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: v2.5.0-dev + tests/parametric/test_ffe/test_serial_id_metadata.py: missing_feature tests/parametric/test_ffe/test_span_enrichment.py: '>=2.10.0' # TODO: a lower version might be supported tests/parametric/test_ffe/test_span_enrichment.py::Test_Span_Enrichment_Child_Span_Propagation: missing_feature tests/parametric/test_ffe/test_span_enrichment.py::Test_Span_Enrichment_Default_Fallback: missing_feature diff --git a/manifests/java.yml b/manifests/java.yml index 3dfa97d6905..fd289b19138 100644 --- a/manifests/java.yml +++ b/manifests/java.yml @@ -3963,6 +3963,7 @@ manifest: : v1.65.0 tests/parametric/test_ffe/test_configuration_sources.py::Test_Feature_Flag_Configuration_Source_Warm_State_Preservation: v1.65.0 tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: v1.56.0 + tests/parametric/test_ffe/test_serial_id_metadata.py: missing_feature tests/parametric/test_ffe/test_span_enrichment.py: '>=1.64.2' # TODO: a lower version might be supported tests/parametric/test_ffe/test_span_enrichment.py::Test_Span_Enrichment_Child_Span_Propagation: missing_feature tests/parametric/test_ffe/test_span_enrichment.py::Test_Span_Enrichment_Default_Fallback: missing_feature diff --git a/manifests/nodejs.yml b/manifests/nodejs.yml index 9d1025d7d09..3476d912f50 100644 --- a/manifests/nodejs.yml +++ b/manifests/nodejs.yml @@ -2348,6 +2348,7 @@ manifest: tests/parametric/test_extract_behavior.py::Test_ExtractBehavior_Restart_With_Extract_First::test_baggage: incomplete_test_app (The parametric test app does not preserve baggage after extraction) tests/parametric/test_ffe/test_configuration_sources.py: *ref_6_8_0 tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: *ref_5_75_0 + tests/parametric/test_ffe/test_serial_id_metadata.py: missing_feature tests/parametric/test_ffe/test_span_enrichment.py: "missing_feature (dd-trace-js#8343)" tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_migrated_extract_invalid: # TODO: a lower version might be supported - declaration: missing_feature (Need to remove b3=b3multi alias) diff --git a/manifests/php.yml b/manifests/php.yml index 266887892a9..621695f0a7f 100644 --- a/manifests/php.yml +++ b/manifests/php.yml @@ -1476,6 +1476,7 @@ manifest: : missing_feature (FFL-2705 tracks PHP agentless configuration-source implementation; FFL-2731 tracks system-tests configuration-source contract) tests/parametric/test_ffe/test_configuration_sources.py::Test_Feature_Flag_Configuration_Source_Warm_State_Preservation: missing_feature (FFL-2705 tracks PHP agentless configuration-source implementation; FFL-2731 tracks system-tests configuration-source contract) tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: v1.21.0-dev + tests/parametric/test_ffe/test_serial_id_metadata.py: missing_feature tests/parametric/test_ffe/test_span_enrichment.py: '>=1.23.3' # TODO: a lower version might be supported tests/parametric/test_ffe/test_span_enrichment.py::Test_Span_Enrichment_Child_Span_Propagation: missing_feature tests/parametric/test_ffe/test_span_enrichment.py::Test_Span_Enrichment_Default_Fallback: missing_feature diff --git a/manifests/python.yml b/manifests/python.yml index cee16cef26a..405640a64fc 100644 --- a/manifests/python.yml +++ b/manifests/python.yml @@ -2005,6 +2005,7 @@ manifest: tests/parametric/test_extract_behavior.py::Test_ExtractBehavior_Restart_With_Extract_First: v2.20.0 tests/parametric/test_ffe/test_configuration_sources.py: v4.14.0-dev tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: v4.0.0 + tests/parametric/test_ffe/test_serial_id_metadata.py: missing_feature tests/parametric/test_ffe/test_span_enrichment.py: '>=4.9.0' # TODO: a lower version might be supported tests/parametric/test_ffe/test_span_enrichment.py::Test_Span_Enrichment_Child_Span_Propagation: missing_feature tests/parametric/test_ffe/test_span_enrichment.py::Test_Span_Enrichment_Default_Fallback: missing_feature diff --git a/manifests/ruby.yml b/manifests/ruby.yml index e5c9c253985..0e8575362dd 100644 --- a/manifests/ruby.yml +++ b/manifests/ruby.yml @@ -2419,6 +2419,7 @@ manifest: tests/parametric/test_extract_behavior.py::Test_ExtractBehavior_Restart_With_Extract_First: incomplete_test_app (The parametric test app does not emit restart span links or preserve baggage) tests/parametric/test_ffe/test_configuration_sources.py: missing_feature (FFL-2701 tracks Ruby agentless configuration-source implementation; FFL-2731 tracks system-tests configuration-source contract) tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: v2.23.0 + tests/parametric/test_ffe/test_serial_id_metadata.py: missing_feature tests/parametric/test_ffe/test_span_enrichment.py: missing_feature tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_extract_invalid: # Easy win for all weblogs and version 2.27.0 - declaration: missing_feature (Missing for 2.x) diff --git a/manifests/rust.yml b/manifests/rust.yml index 0a2d4fdca38..3cc8c2c7273 100644 --- a/manifests/rust.yml +++ b/manifests/rust.yml @@ -138,6 +138,7 @@ manifest: tests/parametric/test_extract_behavior.py: '>=0.4.1-dev' tests/parametric/test_ffe/test_configuration_sources.py: irrelevant (rust is not in scope for v1 agentless server SDK configuration-source coverage) tests/parametric/test_ffe/test_dynamic_evaluation.py::Test_Feature_Flag_Dynamic_Evaluation: missing_feature + tests/parametric/test_ffe/test_serial_id_metadata.py: missing_feature tests/parametric/test_ffe/test_span_enrichment.py: missing_feature tests/parametric/test_headers_b3.py: '>=0.2.1' # Modified by easy win activation script tests/parametric/test_headers_b3.py::Test_Headers_B3::test_headers_b3_extract_valid: missing_feature # Created by easy win activation script diff --git a/tests/parametric/test_ffe/serial-id-metadata-flags.json b/tests/parametric/test_ffe/serial-id-metadata-flags.json new file mode 100644 index 00000000000..35bf0ab4c74 --- /dev/null +++ b/tests/parametric/test_ffe/serial-id-metadata-flags.json @@ -0,0 +1,97 @@ +{ + "createdAt": "2024-01-01T00:00:00.000Z", + "format": "SERVER", + "environment": { "name": "Test" }, + "flags": { + "serial-id-basic-flag": { + "key": "serial-id-basic-flag", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "on": { "key": "on", "value": true }, + "off": { "key": "off", "value": false } + }, + "allocations": [ + { + "key": "on-allocation", + "rules": [], + "splits": [ + { "variationKey": "on", "shards": [], "serialId": 42 } + ], + "doLog": true + } + ] + }, + "serial-id-zero-flag": { + "key": "serial-id-zero-flag", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "on": { "key": "on", "value": true }, + "off": { "key": "off", "value": false } + }, + "allocations": [ + { + "key": "zero-allocation", + "rules": [], + "splits": [ + { "variationKey": "on", "shards": [], "serialId": 0 } + ], + "doLog": true + } + ] + }, + "serial-id-missing-flag": { + "key": "serial-id-missing-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "control": { "key": "control", "value": "control" }, + "treatment": { "key": "treatment", "value": "treatment" } + }, + "allocations": [ + { + "key": "no-serial-allocation", + "rules": [], + "splits": [ + { "variationKey": "control", "shards": [] } + ], + "doLog": true + } + ] + }, + "serial-id-targeting-flag": { + "key": "serial-id-targeting-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "vip": { "key": "vip", "value": "vip" }, + "standard": { "key": "standard", "value": "standard" } + }, + "allocations": [ + { + "key": "vip-allocation", + "rules": [ + { + "conditions": [ + { "attribute": "segment", "operator": "ONE_OF", "value": ["vip"] } + ] + } + ], + "splits": [ + { "variationKey": "vip", "shards": [], "serialId": 201 } + ], + "doLog": true + }, + { + "key": "default-allocation", + "rules": [], + "splits": [ + { "variationKey": "standard", "shards": [], "serialId": 200 } + ], + "doLog": true + } + ] + } + } +} diff --git a/tests/parametric/test_ffe/test_dynamic_evaluation.py b/tests/parametric/test_ffe/test_dynamic_evaluation.py index 7d48a1e0b7c..5ceea0560c9 100644 --- a/tests/parametric/test_ffe/test_dynamic_evaluation.py +++ b/tests/parametric/test_ffe/test_dynamic_evaluation.py @@ -41,7 +41,7 @@ def _get_test_case_files() -> list[str]: return [] # Exclude base fixtures that aren't test cases - excluded = {"flags-v1.json", "span-enrichment-flags.json"} + excluded = {"flags-v1.json", "serial-id-metadata-flags.json", "span-enrichment-flags.json"} return [f.name for f in test_data_dir.iterdir() if f.suffix == ".json" and f.name not in excluded] diff --git a/tests/parametric/test_ffe/test_serial_id_metadata.py b/tests/parametric/test_ffe/test_serial_id_metadata.py new file mode 100644 index 00000000000..c10feff5803 --- /dev/null +++ b/tests/parametric/test_ffe/test_serial_id_metadata.py @@ -0,0 +1,281 @@ +"""Test that UFC split serial IDs are threaded through OpenFeature flagMetadata. + +Every evaluated (allocation, variant) pair has a stable per-org serial ID +(`Split.serialId` in the UFC wire format). Downstream consumers -- APM span +enrichment, exposure caching, evaluation-metrics correlation -- need that +serial ID without re-deriving it from (flag_key, allocation_key, variant_key). + +The contract under test: when span enrichment is enabled, the SDK's OpenFeature +EvaluationDetails-equivalent object must expose the selected split's serial ID +via `flagMetadata` under the internal key `__dd_split_serial_id`, matching the +value present in the UFC config pushed over Remote Config. Only .NET represents +the serial ID as a decimal string, preserving Int64 IDs through OpenFeature's +metadata API; all other SDKs must return a numeric value. This is the field +`openfeature-js-client#269` introduced for the Node server SDK; this test +generalizes the contract to every parametric-tested SDK. + +This module tests: +1. flagMetadata carries the exact serialId of the selected split. +2. Zero is a valid serial ID and must not be treated as "missing" (falsy-check trap). +3. Evaluation details always expose flagMetadata; for a split with no serialId, the map must not + contain the serial ID key. +4. flagMetadata reflects whichever split targeting actually selected, not always the first one. +5. Repeated evaluations of the same context return the same serial ID. +""" + +import json +import time +from pathlib import Path +from typing import Any + +from utils import pytest + +from utils import context, features, scenarios +from utils.dd_constants import RemoteConfigApplyState +from utils.docker_fixtures import TestAgentAPI +from tests.parametric.conftest import APMLibrary + +RC_PRODUCT = "FFE_FLAGS" +RC_PATH = f"datadog/2/{RC_PRODUCT}" +FFE_READY_RETRY_ATTEMPTS = 10 +FFE_READY_RETRY_INTERVAL_SECONDS = 0.2 + +SERIAL_ID_METADATA_KEY = "__dd_split_serial_id" + +parametrize = pytest.mark.parametrize + + +def _expected_serial_id(serial_id: int) -> int | str: + """Only .NET uses strings to preserve Int64 IDs in OpenFeature metadata.""" + return str(serial_id) if context.library.name == "dotnet" else serial_id + + +def _load_serial_id_metadata_fixture() -> dict[str, Any]: + """Load the UFC fixture file for serial ID metadata tests.""" + fixture_path = Path(__file__).parent / "serial-id-metadata-flags.json" + + if not fixture_path.exists(): + pytest.skip(f"Fixture file not found: {fixture_path}") + + with fixture_path.open() as f: + return json.load(f) + + +UFC_SERIAL_ID_METADATA_DATA = _load_serial_id_metadata_fixture() + +DEFAULT_ENVVARS = { + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED": "true", + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED": "true", + "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE": "remote_config", + "DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS": "0.2", +} + + +def _set_and_wait_ffe_rc( + test_agent: TestAgentAPI, ufc_data: dict[str, Any], config_id: str | None = None +) -> dict[str, Any]: + """Set FFE Remote Config and wait for it to be acknowledged.""" + if not config_id: + config_id = str(hash(json.dumps(ufc_data, sort_keys=True))) + + test_agent.set_remote_config(path=f"{RC_PATH}/{config_id}/config", payload=ufc_data) + return test_agent.wait_for_rc_apply_state(RC_PRODUCT, state=RemoteConfigApplyState.ACKNOWLEDGED, clear=True) + + +def _is_ffe_waiting_for_rc(result: dict[str, Any]) -> bool: + provider_state = result.get("providerState") + return result.get("errorCode") == "PROVIDER_NOT_READY" or ( + isinstance(provider_state, dict) and provider_state.get("hasConfig") is False + ) + + +def _require_flag_metadata(result: dict[str, Any]) -> dict[str, Any]: + """Require evaluation details to expose flagMetadata, allowing a null map.""" + assert "flagMetadata" in result, f"FFE evaluation details did not emit flagMetadata; result={result}" + + flag_metadata = result["flagMetadata"] + assert flag_metadata is None or isinstance(flag_metadata, dict), ( + f"Expected flagMetadata to be a map or null, got {flag_metadata!r}" + ) + return flag_metadata or {} + + +def _ffe_evaluate_with_rc_retry( + test_library: APMLibrary, + *, + flag: str, + variation_type: str, + default_value: bool | str | float | dict[str, Any], + targeting_key: str, + attributes: dict[str, Any] | None = None, +) -> dict[str, Any]: + result = test_library.ffe_evaluate( + flag=flag, + variation_type=variation_type, + default_value=default_value, + targeting_key=targeting_key, + attributes=attributes, + ) + for _ in range(FFE_READY_RETRY_ATTEMPTS - 1): + if not _is_ffe_waiting_for_rc(result): + return result + time.sleep(FFE_READY_RETRY_INTERVAL_SECONDS) + result = test_library.ffe_evaluate( + flag=flag, + variation_type=variation_type, + default_value=default_value, + targeting_key=targeting_key, + attributes=attributes, + ) + + return result + + +@scenarios.parametric +@features.feature_flags_serial_id_metadata +class Test_FFE_Serial_Id_Metadata: + """Test that the selected split's serial ID is threaded through flagMetadata.""" + + @parametrize("library_env", [{**DEFAULT_ENVVARS}]) + def test_serial_id_present_in_flag_metadata(self, test_agent: TestAgentAPI, test_library: APMLibrary) -> None: + """The serial ID of the selected split must appear in flagMetadata.""" + _set_and_wait_ffe_rc(test_agent, UFC_SERIAL_ID_METADATA_DATA) + assert test_library.ffe_start(), "Failed to start FFE provider" + + result = _ffe_evaluate_with_rc_retry( + test_library, + flag="serial-id-basic-flag", + variation_type="BOOLEAN", + default_value=False, + targeting_key="user-1", + ) + assert not _is_ffe_waiting_for_rc(result), f"FFE provider did not load RC data; result={result}" + + flag_metadata = _require_flag_metadata(result) + expected_id = _expected_serial_id(42) + assert flag_metadata.get(SERIAL_ID_METADATA_KEY) == expected_id, ( + f"Expected flagMetadata['{SERIAL_ID_METADATA_KEY}'] == {expected_id!r} for the selected split, " + f"got flagMetadata={flag_metadata}" + ) + + @parametrize("library_env", [{**DEFAULT_ENVVARS}]) + def test_serial_id_zero_is_not_treated_as_missing(self, test_agent: TestAgentAPI, test_library: APMLibrary) -> None: + """Serial ID 0 is a real, valid ID and must survive falsy-value checks.""" + _set_and_wait_ffe_rc(test_agent, UFC_SERIAL_ID_METADATA_DATA) + assert test_library.ffe_start(), "Failed to start FFE provider" + + result = _ffe_evaluate_with_rc_retry( + test_library, + flag="serial-id-zero-flag", + variation_type="BOOLEAN", + default_value=False, + targeting_key="user-1", + ) + assert not _is_ffe_waiting_for_rc(result), f"FFE provider did not load RC data; result={result}" + + flag_metadata = _require_flag_metadata(result) + assert SERIAL_ID_METADATA_KEY in flag_metadata, ( + f"flagMetadata is missing '{SERIAL_ID_METADATA_KEY}' entirely; a 0 serial ID must not be " + f"dropped by a truthiness check. flagMetadata={flag_metadata}" + ) + expected_id = _expected_serial_id(0) + assert flag_metadata.get(SERIAL_ID_METADATA_KEY) == expected_id, ( + f"Expected flagMetadata['{SERIAL_ID_METADATA_KEY}'] == {expected_id!r}, got flagMetadata={flag_metadata}" + ) + + @parametrize("library_env", [{**DEFAULT_ENVVARS}]) + def test_serial_id_absent_when_split_has_no_serial_id( + self, test_agent: TestAgentAPI, test_library: APMLibrary + ) -> None: + """A split without a serialId must not have one fabricated for it.""" + _set_and_wait_ffe_rc(test_agent, UFC_SERIAL_ID_METADATA_DATA) + assert test_library.ffe_start(), "Failed to start FFE provider" + + result = _ffe_evaluate_with_rc_retry( + test_library, + flag="serial-id-missing-flag", + variation_type="STRING", + default_value="default", + targeting_key="user-1", + ) + assert not _is_ffe_waiting_for_rc(result), f"FFE provider did not load RC data; result={result}" + assert result.get("errorCode") in {None, ""}, f"FFE evaluation failed; result={result}" + assert result.get("reason") != "ERROR", f"FFE evaluation failed; result={result}" + assert result.get("value") == "control", f"Expected the configured control variation; result={result}" + + flag_metadata = _require_flag_metadata(result) + assert SERIAL_ID_METADATA_KEY not in flag_metadata, ( + f"Expected flagMetadata to omit '{SERIAL_ID_METADATA_KEY}' for a split without a serial ID, " + f"got flagMetadata={flag_metadata}" + ) + + @parametrize("library_env", [{**DEFAULT_ENVVARS}]) + def test_serial_id_matches_selected_split_under_targeting( + self, test_agent: TestAgentAPI, test_library: APMLibrary + ) -> None: + """FlagMetadata must reflect whichever split targeting actually selected.""" + _set_and_wait_ffe_rc(test_agent, UFC_SERIAL_ID_METADATA_DATA) + assert test_library.ffe_start(), "Failed to start FFE provider" + + vip_result = _ffe_evaluate_with_rc_retry( + test_library, + flag="serial-id-targeting-flag", + variation_type="STRING", + default_value="default", + targeting_key="user-vip", + attributes={"segment": "vip"}, + ) + standard_result = _ffe_evaluate_with_rc_retry( + test_library, + flag="serial-id-targeting-flag", + variation_type="STRING", + default_value="default", + targeting_key="user-standard", + attributes={"segment": "standard"}, + ) + + for label, result in (("vip", vip_result), ("standard", standard_result)): + assert not _is_ffe_waiting_for_rc(result), f"FFE provider did not load RC data ({label}); result={result}" + + vip_metadata = _require_flag_metadata(vip_result) + standard_metadata = _require_flag_metadata(standard_result) + + assert vip_result.get("value") == "vip" + assert vip_metadata.get(SERIAL_ID_METADATA_KEY) == _expected_serial_id(201), ( + f"VIP targeting should select the split with serialId 201, got flagMetadata={vip_metadata}" + ) + + assert standard_result.get("value") == "standard" + assert standard_metadata.get(SERIAL_ID_METADATA_KEY) == _expected_serial_id(200), ( + f"Non-VIP targeting should select the split with serialId 200, got flagMetadata={standard_metadata}" + ) + + @parametrize("library_env", [{**DEFAULT_ENVVARS}]) + def test_serial_id_stable_across_repeated_evaluations( + self, test_agent: TestAgentAPI, test_library: APMLibrary + ) -> None: + """The same context evaluated twice must yield the same serial ID.""" + _set_and_wait_ffe_rc(test_agent, UFC_SERIAL_ID_METADATA_DATA) + assert test_library.ffe_start(), "Failed to start FFE provider" + + first = _ffe_evaluate_with_rc_retry( + test_library, + flag="serial-id-basic-flag", + variation_type="BOOLEAN", + default_value=False, + targeting_key="user-repeat", + ) + second = _ffe_evaluate_with_rc_retry( + test_library, + flag="serial-id-basic-flag", + variation_type="BOOLEAN", + default_value=False, + targeting_key="user-repeat", + ) + + first_id = _require_flag_metadata(first).get(SERIAL_ID_METADATA_KEY) + second_id = _require_flag_metadata(second).get(SERIAL_ID_METADATA_KEY) + expected_id = _expected_serial_id(42) + assert first_id == second_id == expected_id, ( + f"Expected stable serial ID {expected_id!r} across repeated evaluations, got {first_id!r} then {second_id!r}" + ) diff --git a/utils/_features.py b/utils/_features.py index 0cd8b4acf13..38f5c3f8c6b 100644 --- a/utils/_features.py +++ b/utils/_features.py @@ -2729,6 +2729,14 @@ def feature_flags_event_enrichment(test_object): """ return _mark_test_object(test_object, feature_id=551, owner=_Owner.ffe) + @staticmethod + def feature_flags_serial_id_metadata(test_object): + """Feature Flags Serial ID Metadata (UFC split serialId threaded through flagMetadata) + + No feature-parity page yet; using NOT_REPORTED_ID until one is filed. + """ + return _mark_test_object(test_object, feature_id=NOT_REPORTED_ID, owner=_Owner.ffe) + @staticmethod def appsec_extended_data_collection(test_object): """AppSec supports extended data collection including headers and body diff --git a/utils/build/docker/dotnet/parametric/ApmTestApi.csproj b/utils/build/docker/dotnet/parametric/ApmTestApi.csproj index 9b1f4807601..b2fcbc99bf0 100644 --- a/utils/build/docker/dotnet/parametric/ApmTestApi.csproj +++ b/utils/build/docker/dotnet/parametric/ApmTestApi.csproj @@ -13,6 +13,7 @@ + diff --git a/utils/build/docker/dotnet/parametric/Endpoints/ApmTestApiFfe.cs b/utils/build/docker/dotnet/parametric/Endpoints/ApmTestApiFfe.cs new file mode 100644 index 00000000000..6836894540e --- /dev/null +++ b/utils/build/docker/dotnet/parametric/Endpoints/ApmTestApiFfe.cs @@ -0,0 +1,119 @@ +using Datadog.FeatureFlags.OpenFeature; +using OpenFeature; +using OpenFeature.Constant; +using OpenFeature.Model; +using System.Text.Json; + +namespace ApmTestApi.Endpoints; + +public static class ApmTestApiFfe +{ + private static FeatureClient? _client; + + public static void MapEndpoints(WebApplication app) + { + // Start configuration delivery before /ffe/start: tests wait for the RC + // acknowledgement before calling that endpoint. Do not block app startup. + var enabled = Environment.GetEnvironmentVariable("DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED"); + var initialization = enabled is "true" or "1" ? InitializeAsync() : Task.CompletedTask; + app.MapPost("/ffe/start", async () => + { + await initialization; + return _client is null ? Results.StatusCode(503) : Results.Ok(true); + }); + app.MapPost("/ffe/evaluate", EvaluateAsync); + } + + private static async Task InitializeAsync() + { + await Api.Instance.SetProviderAsync(new DatadogProvider()); + _client = Api.Instance.GetClient(); + } + + private static async Task EvaluateAsync(JsonElement request) + { + if (_client is null) + { + return Results.Json(new { errorCode = "PROVIDER_NOT_READY", flagMetadata = new { } }); + } + + var context = EvaluationContext.Builder(); + if (request.TryGetProperty("targetingKey", out var targetingKey) && targetingKey.ValueKind != JsonValueKind.Null) + { + context.SetTargetingKey(targetingKey.GetString()!); + } + if (request.TryGetProperty("attributes", out var attributes) && attributes.ValueKind == JsonValueKind.Object) + { + foreach (var property in attributes.EnumerateObject()) + { + context.Set(property.Name, ToValue(property.Value)); + } + } + + var evaluationContext = context.Build(); + var flag = request.GetProperty("flag").GetString()!; + var defaultValue = request.GetProperty("defaultValue"); + return request.GetProperty("variationType").GetString()?.ToUpperInvariant() switch + { + "BOOLEAN" => ToResponse(await _client.GetBooleanDetailsAsync(flag, defaultValue.GetBoolean(), evaluationContext)), + "STRING" => ToResponse(await _client.GetStringDetailsAsync(flag, defaultValue.GetString()!, evaluationContext)), + "INTEGER" => ToResponse(await _client.GetIntegerDetailsAsync(flag, defaultValue.GetInt32(), evaluationContext)), + "NUMERIC" => ToResponse(await _client.GetDoubleDetailsAsync(flag, defaultValue.GetDouble(), evaluationContext)), + "JSON" => ToResponse(await _client.GetObjectDetailsAsync(flag, ToValue(defaultValue), evaluationContext)), + _ => Results.BadRequest(new { error = "Unsupported variationType" }), + }; + } + + private static IResult ToResponse(FlagEvaluationDetails details) => Results.Json(new + { + value = details.Value is Value value ? ToPlainObject(value) : (object?)details.Value, + reason = details.Reason?.ToUpperInvariant(), + variant = details.Variant, + errorCode = details.ErrorType == ErrorType.None ? null : JsonNamingPolicy.SnakeCaseUpper.ConvertName(details.ErrorType.ToString()), + errorMessage = details.ErrorMessage, + flagMetadata = ReadMetadata(details.FlagMetadata), + }); + + private static Dictionary ReadMetadata(ImmutableMetadata? metadata) + { + var result = new Dictionary(); + if (metadata is null) + { + return result; + } + + // OpenFeature .NET exposes typed accessors, but no metadata enumeration. + // Forward the provider's known keys without coercing values or reading UFC. + foreach (var key in new[] { "__dd_allocation_key", "__dd_do_log", "__dd_split_serial_id" }) + { + object? value = metadata.GetString(key) ?? (object?)metadata.GetInt(key) + ?? metadata.GetDouble(key) ?? (object?)metadata.GetBool(key); + if (value is not null) + { + result[key] = value; + } + } + return result; + } + + private static Value ToValue(JsonElement value) => value.ValueKind switch + { + JsonValueKind.True => new Value(true), + JsonValueKind.False => new Value(false), + JsonValueKind.String => new Value(value.GetString()!), + JsonValueKind.Number => new Value(value.GetDouble()), + JsonValueKind.Array => new Value(value.EnumerateArray().Select(ToValue).ToList()), + JsonValueKind.Object => new Value(new Structure(value.EnumerateObject().ToDictionary(p => p.Name, p => ToValue(p.Value)))), + _ => new Value(), + }; + + private static object? ToPlainObject(Value value) + { + if (value.IsNull) return null; + if (value.IsBoolean) return value.AsBoolean; + if (value.IsString) return value.AsString; + if (value.IsNumber) return value.AsDouble; + if (value.IsList) return value.AsList!.Select(ToPlainObject).ToArray(); + return value.AsStructure!.AsDictionary().ToDictionary(p => p.Key, p => ToPlainObject(p.Value)); + } +} diff --git a/utils/build/docker/dotnet/parametric/Program.cs b/utils/build/docker/dotnet/parametric/Program.cs index ffc43036ed8..ee5bdadeae9 100644 --- a/utils/build/docker/dotnet/parametric/Program.cs +++ b/utils/build/docker/dotnet/parametric/Program.cs @@ -11,6 +11,7 @@ // Map endpoints ApmTestApi.Endpoints.ApmTestApi.MapApmTraceEndpoints(app, logger); ApmTestApi.Endpoints.ApmTestApiOtel.MapApmOtelEndpoints(app, otelLogger); +ApmTestApi.Endpoints.ApmTestApiFfe.MapEndpoints(app); if (!int.TryParse(Environment.GetEnvironmentVariable("APM_TEST_CLIENT_SERVER_PORT"), out var port)) { diff --git a/utils/pytest.py b/utils/pytest.py new file mode 100644 index 00000000000..cb74ce04b15 --- /dev/null +++ b/utils/pytest.py @@ -0,0 +1,62 @@ +"""Approved pytest API for system tests. + +Test modules should import this facade with ``from utils import pytest``. +The facade intentionally exposes only the pytest APIs used by this repository. +In particular, force-skip markers are available only through the semantic +decorators exported by :mod:`utils`. +""" + +import pytest as _pytest # noqa: PT013 - keep the underlying pytest module private + + +Config = _pytest.Config +FixtureRequest = _pytest.FixtureRequest +Item = _pytest.Item +Mark = _pytest.Mark +MarkDecorator = _pytest.MarkDecorator +MonkeyPatch = _pytest.MonkeyPatch +CaptureFixture = _pytest.CaptureFixture +Session = _pytest.Session +CallInfo = _pytest.CallInfo +TempPathFactory = _pytest.TempPathFactory + +approx = _pytest.approx +exit = _pytest.exit # noqa: A001 - preserve the public pytest API name +fail = _pytest.fail +fixture = _pytest.fixture +param = _pytest.param +raises = _pytest.raises +skip = _pytest.skip +hookimpl = _pytest.hookimpl + + +class _AllowedMarks: + """Pytest markers approved for direct use by system tests.""" + + features = _pytest.mark.features + parametrize = _pytest.mark.parametrize + scenario = _pytest.mark.scenario + usefixtures = _pytest.mark.usefixtures + + +mark = _AllowedMarks() + +__all__ = [ + "CallInfo", + "Config", + "FixtureRequest", + "Item", + "Mark", + "MarkDecorator", + "MonkeyPatch", + "Session", + "approx", + "exit", + "fail", + "fixture", + "hookimpl", + "mark", + "param", + "raises", + "skip", +]