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",
+]