diff --git a/ddtrace/internal/openfeature/_exposure.py b/ddtrace/internal/openfeature/_exposure.py index c5e4f46afa8..25efbc3e56b 100644 --- a/ddtrace/internal/openfeature/_exposure.py +++ b/ddtrace/internal/openfeature/_exposure.py @@ -3,8 +3,6 @@ """ import time -from typing import Any -from typing import Dict from typing import Optional from openfeature.evaluation_context import EvaluationContext @@ -31,7 +29,7 @@ def build_exposure_event( Args: flag_key: The feature flag key variant_key: The variant key returned by the evaluation - allocation_key: The allocation key (same as variant_key in basic cases) + allocation_key: The allocation key evaluation_context: The evaluation context with subject information """ # Validate required fields @@ -39,18 +37,15 @@ def build_exposure_event( logger.debug("Cannot build exposure event: flag_key is required") return None - if variant_key is None: - variant_key = "" + if not variant_key: + logger.debug("Cannot build exposure event: variant_key is required") + return None - # Build subject from evaluation context - subject = _build_subject(evaluation_context) - if not subject: - logger.debug("Cannot build exposure event: valid subject is required") + if not allocation_key: + logger.debug("Cannot build exposure event: allocation_key is required") return None - # Use variant_key as allocation_key if not explicitly provided - if allocation_key is None: - allocation_key = variant_key + evaluation_context = evaluation_context or EvaluationContext() # Build the exposure event exposure_event: ExposureEvent = { @@ -58,43 +53,10 @@ def build_exposure_event( "allocation": {"key": allocation_key}, "flag": {"key": flag_key}, "variant": {"key": variant_key}, - "subject": subject, + "subject": { + "id": evaluation_context.targeting_key or "", + "attributes": evaluation_context.attributes or {}, + }, } return exposure_event - - -def _build_subject(evaluation_context: Optional[EvaluationContext]) -> Optional[Dict[str, Any]]: - """ - Build subject object from OpenFeature EvaluationContext. - - The subject must have at minimum an 'id' field. - - Args: - evaluation_context: The OpenFeature evaluation context - - Returns: - Dictionary with subject information, or None if id cannot be determined - """ - if evaluation_context is None: - return None - - # Get targeting_key as the subject id - subject_id = evaluation_context.targeting_key - if not subject_id: - logger.debug("evaluation_context missing targeting_key for subject.id") - return None - - subject: Dict[str, Any] = {"id": subject_id} - - # Add optional subject type if available in attributes - attributes = evaluation_context.attributes or {} - if "subject_type" in attributes: - subject["type"] = str(attributes["subject_type"]) - - # Add remaining attributes (excluding subject_type which we already handled) - remaining_attrs = {k: v for k, v in attributes.items() if k != "subject_type"} - if remaining_attrs: - subject["attributes"] = remaining_attrs - - return subject diff --git a/ddtrace/internal/openfeature/_provider.py b/ddtrace/internal/openfeature/_provider.py index 053b780a5c7..17d4aa260f1 100644 --- a/ddtrace/internal/openfeature/_provider.py +++ b/ddtrace/internal/openfeature/_provider.py @@ -4,6 +4,9 @@ This module handles Feature Flag configuration rules from Remote Configuration and forwards the raw bytes to the native FFE processor. """ + +from collections import OrderedDict +from collections.abc import MutableMapping from importlib.metadata import version import typing @@ -40,9 +43,44 @@ T = typing.TypeVar("T", covariant=True) +K = typing.TypeVar("K") +V = typing.TypeVar("V") logger = get_logger(__name__) +class LRUCache(MutableMapping, typing.Generic[K, V]): + """LRU cache implementation using OrderedDict that implements the Mapping interface.""" + + def __init__(self, maxsize: int = 128): + self._cache: typing.OrderedDict[K, V] = OrderedDict() + self._maxsize = maxsize + + def __getitem__(self, key: K) -> V: + """Get value from cache, moving it to end (most recently used).""" + self._cache.move_to_end(key) + return self._cache[key] + + def __setitem__(self, key: K, value: V) -> None: + """Put value in cache, evicting least recently used if at capacity.""" + if key in self._cache: + self._cache.move_to_end(key) + self._cache[key] = value + if len(self._cache) > self._maxsize: + self._cache.popitem(last=False) # Remove least recently used (first item) + + def __delitem__(self, key: K) -> None: + """Delete key from cache.""" + del self._cache[key] + + def __iter__(self) -> typing.Iterator[K]: + """Iterate over cache keys.""" + return iter(self._cache) + + def __len__(self) -> int: + """Return number of items in cache.""" + return len(self._cache) + + class DataDogProvider(AbstractProvider): """ Datadog OpenFeature Provider. @@ -58,8 +96,11 @@ def __init__(self, *args: typing.Any, **kwargs: typing.Any): self._config_received = False # Cache for reported exposures to prevent duplicates - # Stores tuples of (flag_key, variant_key, allocation_key) - self._exposure_cache: typing.Set[typing.Tuple[str, typing.Optional[str], typing.Optional[str]]] = set() + # Stores mapping of (flag_key, subject_id) -> (allocation_key, variant_key) + # Using LRU cache with maxsize of 65536 to prevent unbounded memory growth + self._exposure_cache: LRUCache[ + typing.Tuple[str, str], typing.Tuple[typing.Optional[str], typing.Optional[str]] + ] = LRUCache(maxsize=65536) # Check if experimental flagging provider is enabled self._enabled = ffe_config.experimental_flagging_provider_enabled @@ -209,13 +250,8 @@ def _resolve_details( ) # No configuration available - return default + # Note: No exposure logging when configuration is missing if details is None: - self._report_exposure( - flag_key=flag_key, - variant_key=None, - allocation_key=None, - evaluation_context=evaluation_context, - ) return FlagResolutionDetails( value=default_value, reason=Reason.DEFAULT, @@ -229,12 +265,14 @@ def _resolve_details( # Flag not found - return default with DEFAULT reason if details.error_code == ffe.ErrorCode.FlagNotFound: - self._report_exposure( - flag_key=flag_key, - variant_key=None, - allocation_key=None, - evaluation_context=evaluation_context, - ) + # Only report exposure if do_log is explicitly True + if details.do_log: + self._report_exposure( + flag_key=flag_key, + variant_key=None, + allocation_key=None, + evaluation_context=evaluation_context, + ) return FlagResolutionDetails( value=default_value, reason=Reason.DEFAULT, @@ -252,13 +290,14 @@ def _resolve_details( # Map native ffe.Reason to OpenFeature Reason reason = self._map_reason_to_openfeature(details.reason) - # Report exposure event - self._report_exposure( - flag_key=flag_key, - variant_key=details.variant, - allocation_key=details.allocation_key, - evaluation_context=evaluation_context, - ) + # Report exposure event only if do_log flag is True + if details.do_log: + self._report_exposure( + flag_key=flag_key, + variant_key=details.variant, + allocation_key=details.allocation_key, + evaluation_context=evaluation_context, + ) # Check if variant is None/empty to determine if we should use default value. # For JSON flags, value can be null which is valid, so we check variant instead. @@ -297,27 +336,41 @@ def _report_exposure( Report a feature flag exposure event to the EVP proxy intake. Uses caching to prevent duplicate exposure events for the same - (flag_key, variant_key, allocation_key) combination. + (flag_key, subject_id, variant_key, allocation_key) combination. + + Note: This method should only be called when exposure logging is enabled. + Callers must check the do_log flag before invoking this method. + + Args: + flag_key: The feature flag key + variant_key: The variant key returned by evaluation + allocation_key: The allocation key + evaluation_context: The evaluation context with subject information """ try: - # Check cache to prevent duplicate exposure events - cache_key = (flag_key, variant_key, allocation_key) - if cache_key in self._exposure_cache: - logger.debug("Skipping duplicate exposure event for %s", cache_key) - return - exposure_event = build_exposure_event( flag_key=flag_key, variant_key=variant_key, allocation_key=allocation_key, evaluation_context=evaluation_context, ) + if not exposure_event: + return + + # Check cache to prevent duplicate exposure events + key = (flag_key, exposure_event["subject"]["id"]) + value = (allocation_key, variant_key) + + cached_value = self._exposure_cache.get(key, None) + if cached_value and cached_value == value: + logger.debug("Skipping duplicate exposure event for %s->%s", key, value) + return + + writer = get_exposure_writer() + writer.enqueue(exposure_event) - if exposure_event: - writer = get_exposure_writer() - writer.enqueue(exposure_event) - # Add to cache only after successful enqueue - self._exposure_cache.add(cache_key) + # Add to cache only after successful enqueue + self._exposure_cache[key] = value except Exception as e: logger.debug("Failed to report exposure event: %s", e, exc_info=True) diff --git a/releasenotes/notes/fix-ffe-exposure-logging-8fff59857ff54540.yaml b/releasenotes/notes/fix-ffe-exposure-logging-8fff59857ff54540.yaml new file mode 100644 index 00000000000..e67a84da8df --- /dev/null +++ b/releasenotes/notes/fix-ffe-exposure-logging-8fff59857ff54540.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + openfeature: Fix exposure event deduplication to use (flag_key, subject_id) as cache key + instead of (flag_key, variant_key, allocation_key). This ensures different users each + receive their own exposure event while still deduplicating repeated evaluations for the + same user. Also adds LRU eviction to prevent unbounded memory growth and respects the + do_log flag from flag metadata. diff --git a/tests/openfeature/test_exposure.py b/tests/openfeature/test_exposure.py index ff59a3450a3..94f19999f74 100644 --- a/tests/openfeature/test_exposure.py +++ b/tests/openfeature/test_exposure.py @@ -4,7 +4,6 @@ from openfeature.evaluation_context import EvaluationContext -from ddtrace.internal.openfeature._exposure import _build_subject from ddtrace.internal.openfeature._exposure import build_exposure_event @@ -29,11 +28,12 @@ def test_build_complete_exposure_event(self): assert event["variant"]["key"] == "variant-a" assert event["allocation"]["key"] == "allocation-1" assert event["subject"]["id"] == "user-123" + assert event["subject"]["attributes"] == {"tier": "premium", "email": "test@example.com"} assert "timestamp" in event assert isinstance(event["timestamp"], int) - def test_build_exposure_event_without_allocation_key(self): - """Test that variant_key is used as allocation_key if not provided.""" + def test_build_exposure_event_missing_allocation_key(self): + """Test that None is returned when allocation_key is not provided.""" context = EvaluationContext(targeting_key="user-456") event = build_exposure_event( @@ -43,9 +43,7 @@ def test_build_exposure_event_without_allocation_key(self): evaluation_context=context, ) - assert event is not None - assert event["allocation"]["key"] == "variant-b" - assert event["variant"]["key"] == "variant-b" + assert event is None def test_build_exposure_event_missing_flag_key(self): """Test that None is returned when flag_key is missing.""" @@ -71,12 +69,10 @@ def test_build_exposure_event_missing_variant_key(self): evaluation_context=context, ) - assert event is not None - assert event["variant"]["key"] == "" - assert event["allocation"]["key"] == "allocation-1" + assert event is None - def test_build_exposure_event_missing_subject(self): - """Test that None is returned when subject cannot be built.""" + def test_build_exposure_event_none_context(self): + """Test that exposure event is built with empty targeting_key when context is None.""" event = build_exposure_event( flag_key="test-flag", variant_key="variant-a", @@ -84,68 +80,36 @@ def test_build_exposure_event_missing_subject(self): evaluation_context=None, ) - assert event is None - - -class TestBuildSubject: - """Test subject building from evaluation context.""" - - def test_build_subject_with_targeting_key(self): - """Test building subject with targeting_key.""" - context = EvaluationContext(targeting_key="user-123") - - subject = _build_subject(context) - - assert subject is not None - assert subject["id"] == "user-123" - - def test_build_subject_with_attributes(self): - """Test building subject with attributes.""" - context = EvaluationContext(targeting_key="user-123", attributes={"tier": "premium", "region": "us-east"}) - - subject = _build_subject(context) - - assert subject is not None - assert subject["id"] == "user-123" - assert subject["attributes"]["tier"] == "premium" - assert subject["attributes"]["region"] == "us-east" - - def test_build_subject_with_type(self): - """Test building subject with explicit subject_type.""" - context = EvaluationContext( - targeting_key="org-456", attributes={"subject_type": "organization", "name": "ACME Corp"} - ) - - subject = _build_subject(context) - - assert subject is not None - assert subject["id"] == "org-456" - assert subject["type"] == "organization" - assert subject["attributes"]["name"] == "ACME Corp" - # subject_type should not appear in attributes - assert "subject_type" not in subject["attributes"] + assert event is not None + assert event["subject"]["id"] == "" + assert event["subject"]["attributes"] == {} - def test_build_subject_missing_targeting_key(self): - """Test that None is returned when targeting_key is missing.""" + def test_build_exposure_event_missing_targeting_key(self): + """Test that exposure event uses empty string when targeting_key is missing.""" context = EvaluationContext(targeting_key=None, attributes={"tier": "premium"}) - subject = _build_subject(context) - - assert subject is None - - def test_build_subject_none_context(self): - """Test that None is returned when context is None.""" - subject = _build_subject(None) + event = build_exposure_event( + flag_key="test-flag", + variant_key="variant-a", + allocation_key="allocation-1", + evaluation_context=context, + ) - assert subject is None + assert event is not None + assert event["subject"]["id"] == "" + assert event["subject"]["attributes"] == {"tier": "premium"} - def test_build_subject_empty_attributes(self): - """Test building subject with no attributes.""" + def test_build_exposure_event_empty_attributes(self): + """Test building exposure event with no attributes in context.""" context = EvaluationContext(targeting_key="user-789") - subject = _build_subject(context) + event = build_exposure_event( + flag_key="test-flag", + variant_key="variant-a", + allocation_key="allocation-1", + evaluation_context=context, + ) - assert subject is not None - assert subject["id"] == "user-789" - assert "attributes" not in subject - assert "type" not in subject + assert event is not None + assert event["subject"]["id"] == "user-789" + assert event["subject"]["attributes"] == {} diff --git a/tests/openfeature/test_provider_exposure.py b/tests/openfeature/test_provider_exposure.py index c9fdf0f2f40..919752050bd 100644 --- a/tests/openfeature/test_provider_exposure.py +++ b/tests/openfeature/test_provider_exposure.py @@ -102,7 +102,7 @@ def test_exposure_reported_on_successful_resolution(self, mock_get_writer, provi assert exposure_event["subject"]["id"] == "user-123" assert "timestamp" in exposure_event - @mock.patch("ddtrace.internal.openfeature.writer.get_exposure_writer") + @mock.patch("ddtrace.internal.openfeature._provider.get_exposure_writer") def test_no_exposure_on_flag_not_found(self, mock_get_writer, provider, evaluation_context): """Test that no exposure event is reported when flag is not found.""" mock_writer = mock.Mock() @@ -116,10 +116,10 @@ def test_no_exposure_on_flag_not_found(self, mock_get_writer, provider, evaluati # Verify default value returned assert result.value is False - # Verify no exposure event was reported + # Verify no exposure event was reported (variant_key and allocation_key are None) mock_writer.enqueue.assert_not_called() - @mock.patch("ddtrace.internal.openfeature.writer.get_exposure_writer") + @mock.patch("ddtrace.internal.openfeature._provider.get_exposure_writer") def test_no_exposure_on_disabled_flag(self, mock_get_writer, provider, evaluation_context): """Test that no exposure event is reported when flag is disabled.""" mock_writer = mock.Mock() @@ -136,7 +136,7 @@ def test_no_exposure_on_disabled_flag(self, mock_get_writer, provider, evaluatio # Verify no exposure event was reported mock_writer.enqueue.assert_not_called() - @mock.patch("ddtrace.internal.openfeature.writer.get_exposure_writer") + @mock.patch("ddtrace.internal.openfeature._provider.get_exposure_writer") def test_no_exposure_on_type_mismatch(self, mock_get_writer, provider, evaluation_context): """Test that no exposure event is reported on type mismatch error.""" mock_writer = mock.Mock() @@ -153,9 +153,9 @@ def test_no_exposure_on_type_mismatch(self, mock_get_writer, provider, evaluatio # Verify no exposure event was reported mock_writer.enqueue.assert_not_called() - @mock.patch("ddtrace.internal.openfeature.writer.get_exposure_writer") - def test_no_exposure_without_targeting_key(self, mock_get_writer, provider): - """Test that no exposure event is reported without targeting_key in context.""" + @mock.patch("ddtrace.internal.openfeature._provider.get_exposure_writer") + def test_exposure_with_empty_targeting_key(self, mock_get_writer, provider): + """Test that exposure event is reported even without targeting_key in context.""" mock_writer = mock.Mock() mock_get_writer.return_value = mock_writer @@ -170,8 +170,10 @@ def test_no_exposure_without_targeting_key(self, mock_get_writer, provider): # Verify flag resolved successfully assert result.value is True - # Verify no exposure event was reported (missing targeting_key) - mock_writer.enqueue.assert_not_called() + # Verify exposure event was reported with empty targeting_key + mock_writer.enqueue.assert_called_once() + exposure_event = mock_writer.enqueue.call_args[0][0] + assert exposure_event["subject"]["id"] == "" @mock.patch("ddtrace.internal.openfeature._provider.get_exposure_writer") def test_exposure_with_different_flag_types(self, mock_get_writer, provider, evaluation_context): @@ -238,7 +240,91 @@ def test_exposure_cache_cleared_on_clear_call(self, mock_get_writer, provider, e provider.resolve_boolean_details("clear-test-flag", False, evaluation_context) assert mock_writer.enqueue.call_count == 2 - @mock.patch("ddtrace.internal.openfeature.writer.get_exposure_writer") + @mock.patch("ddtrace.internal.openfeature._provider.get_exposure_writer") + def test_exposure_variant_allocation_cycling(self, mock_get_writer, provider, evaluation_context): + """Test that changing variant/allocation for same flag reports new exposures: A->B->B->A logs 3 events.""" + mock_writer = mock.Mock() + mock_get_writer.return_value = mock_writer + + # Create mock resolution details objects to control what the native resolver returns + from unittest.mock import patch + + from ddtrace.internal.native._native import ffe + + # Evaluation 1: variant=A, allocation=A + with patch("ddtrace.internal.openfeature._provider.resolve_flag") as mock_resolve: + mock_details_a = mock.Mock() + mock_details_a.value = True + mock_details_a.variant = "variant-a" + mock_details_a.allocation_key = "allocation-a" + mock_details_a.reason = ffe.Reason.Static + mock_details_a.error_code = None + mock_details_a.error_message = None + mock_details_a.do_log = True # Enable exposure logging + mock_resolve.return_value = mock_details_a + + result = provider.resolve_boolean_details("cycling-flag", False, evaluation_context) + assert result.value is True + assert mock_writer.enqueue.call_count == 1 + exposure_1 = mock_writer.enqueue.call_args[0][0] + assert exposure_1["variant"]["key"] == "variant-a" + assert exposure_1["allocation"]["key"] == "allocation-a" + + # Evaluation 2: variant=B, allocation=B (should report - different from A) + with patch("ddtrace.internal.openfeature._provider.resolve_flag") as mock_resolve: + mock_details_b = mock.Mock() + mock_details_b.value = True + mock_details_b.variant = "variant-b" + mock_details_b.allocation_key = "allocation-b" + mock_details_b.reason = ffe.Reason.Static + mock_details_b.error_code = None + mock_details_b.error_message = None + mock_details_b.do_log = True # Enable exposure logging + mock_resolve.return_value = mock_details_b + + result = provider.resolve_boolean_details("cycling-flag", False, evaluation_context) + assert result.value is True + assert mock_writer.enqueue.call_count == 2 + exposure_2 = mock_writer.enqueue.call_args[0][0] + assert exposure_2["variant"]["key"] == "variant-b" + assert exposure_2["allocation"]["key"] == "allocation-b" + + # Evaluation 3: variant=B, allocation=B (should NOT report - cached) + with patch("ddtrace.internal.openfeature._provider.resolve_flag") as mock_resolve: + mock_details_b2 = mock.Mock() + mock_details_b2.value = True + mock_details_b2.variant = "variant-b" + mock_details_b2.allocation_key = "allocation-b" + mock_details_b2.reason = ffe.Reason.Static + mock_details_b2.error_code = None + mock_details_b2.error_message = None + mock_details_b2.do_log = True # Enable exposure logging + mock_resolve.return_value = mock_details_b2 + + result = provider.resolve_boolean_details("cycling-flag", False, evaluation_context) + assert result.value is True + assert mock_writer.enqueue.call_count == 2 # Still 2, not 3 + + # Evaluation 4: variant=A, allocation=A (should report - different from B) + with patch("ddtrace.internal.openfeature._provider.resolve_flag") as mock_resolve: + mock_details_a2 = mock.Mock() + mock_details_a2.value = True + mock_details_a2.variant = "variant-a" + mock_details_a2.allocation_key = "allocation-a" + mock_details_a2.reason = ffe.Reason.Static + mock_details_a2.error_code = None + mock_details_a2.error_message = None + mock_details_a2.do_log = True # Enable exposure logging + mock_resolve.return_value = mock_details_a2 + + result = provider.resolve_boolean_details("cycling-flag", False, evaluation_context) + assert result.value is True + assert mock_writer.enqueue.call_count == 3 # Now 3 - cycled back to A + exposure_4 = mock_writer.enqueue.call_args[0][0] + assert exposure_4["variant"]["key"] == "variant-a" + assert exposure_4["allocation"]["key"] == "allocation-a" + + @mock.patch("ddtrace.internal.openfeature._provider.get_exposure_writer") def test_exposure_reporting_failure_does_not_affect_resolution(self, mock_get_writer, provider, evaluation_context): """Test that exposure reporting failure doesn't break flag resolution.""" # Make writer raise an exception @@ -255,6 +341,103 @@ def test_exposure_reporting_failure_does_not_affect_resolution(self, mock_get_wr # Verify flag still resolved successfully assert result.value is True + @mock.patch("ddtrace.internal.openfeature._provider.get_exposure_writer") + def test_no_exposure_when_do_log_is_false(self, mock_get_writer, provider, evaluation_context): + """Test that no exposure event is reported when do_log flag is False.""" + mock_writer = mock.Mock() + mock_get_writer.return_value = mock_writer + + # Mock resolve_flag to return details with do_log=False + from unittest.mock import patch + + from ddtrace.internal.native._native import ffe + + with patch("ddtrace.internal.openfeature._provider.resolve_flag") as mock_resolve: + mock_details = mock.Mock() + mock_details.value = True + mock_details.variant = "variant-a" + mock_details.allocation_key = "allocation-a" + mock_details.reason = ffe.Reason.Static + mock_details.error_code = None + mock_details.error_message = None + mock_details.do_log = False # Exposure logging disabled + mock_resolve.return_value = mock_details + + result = provider.resolve_boolean_details("no-log-flag", False, evaluation_context) + + # Verify flag resolved successfully + assert result.value is True + + # Verify NO exposure event was reported (do_log=False) + mock_writer.enqueue.assert_not_called() + + @mock.patch("ddtrace.internal.openfeature._provider.get_exposure_writer") + def test_exposure_reported_when_do_log_is_true(self, mock_get_writer, provider, evaluation_context): + """Test that exposure event is reported when do_log flag is True.""" + mock_writer = mock.Mock() + mock_get_writer.return_value = mock_writer + + # Mock resolve_flag to return details with do_log=True + from unittest.mock import patch + + from ddtrace.internal.native._native import ffe + + with patch("ddtrace.internal.openfeature._provider.resolve_flag") as mock_resolve: + mock_details = mock.Mock() + mock_details.value = True + mock_details.variant = "variant-a" + mock_details.allocation_key = "allocation-a" + mock_details.reason = ffe.Reason.Static + mock_details.error_code = None + mock_details.error_message = None + mock_details.do_log = True # Exposure logging enabled + mock_resolve.return_value = mock_details + + result = provider.resolve_boolean_details("log-flag", False, evaluation_context) + + # Verify flag resolved successfully + assert result.value is True + + # Verify exposure event WAS reported (do_log=True) + mock_writer.enqueue.assert_called_once() + exposure_event = mock_writer.enqueue.call_args[0][0] + assert exposure_event["flag"]["key"] == "log-flag" + assert exposure_event["variant"]["key"] == "variant-a" + assert exposure_event["allocation"]["key"] == "allocation-a" + + @mock.patch("ddtrace.internal.openfeature._provider.get_exposure_writer") + def test_different_subjects_log_separate_exposures(self, mock_get_writer, provider): + """Test that different subject IDs with same flag/variant/allocation log separate exposure events.""" + mock_writer = mock.Mock() + mock_get_writer.return_value = mock_writer + + config = create_config(create_boolean_flag("multi-subject-flag", enabled=True, default_value=True)) + process_ffe_configuration(config) + + # First evaluation with subject "user-1" + context1 = EvaluationContext(targeting_key="user-1", attributes={"tier": "premium"}) + result1 = provider.resolve_boolean_details("multi-subject-flag", False, context1) + assert result1.value is True + assert mock_writer.enqueue.call_count == 1 + + # Verify first exposure event + exposure_1 = mock_writer.enqueue.call_args_list[0][0][0] + assert exposure_1["flag"]["key"] == "multi-subject-flag" + assert exposure_1["subject"]["id"] == "user-1" + assert exposure_1["variant"]["key"] == "true" + + # Second evaluation with subject "user-2" (same flag, same variant, same allocation) + context2 = EvaluationContext(targeting_key="user-2", attributes={"tier": "premium"}) + result2 = provider.resolve_boolean_details("multi-subject-flag", False, context2) + assert result2.value is True + assert mock_writer.enqueue.call_count == 2 # Second event logged + + # Verify second exposure event + exposure_2 = mock_writer.enqueue.call_args_list[1][0][0] + assert exposure_2["flag"]["key"] == "multi-subject-flag" + assert exposure_2["subject"]["id"] == "user-2" + assert exposure_2["variant"]["key"] == "true" + class TestExposureConnectionErrors: """Test exposure reporting with various connection errors.""" @@ -356,22 +539,24 @@ def side_effect_fn(*args, **kwargs): assert result.value == "stable" @mock.patch("ddtrace.internal.openfeature._provider.get_exposure_writer") - def test_exposure_build_event_returns_none(self, mock_get_writer, provider): - """Test when build_exposure_event returns None (e.g., missing targeting_key).""" + def test_exposure_with_none_context(self, mock_get_writer, provider): + """Test that exposure event is reported with empty subject when context is None.""" mock_writer = mock.Mock() mock_get_writer.return_value = mock_writer config = create_config(create_boolean_flag("no-context-flag", enabled=True, default_value=True)) process_ffe_configuration(config) - # Resolve without evaluation context (no targeting_key) + # Resolve without evaluation context result = provider.resolve_boolean_details("no-context-flag", False, None) # Flag should still resolve assert result.value is True - # No exposure should be enqueued - mock_writer.enqueue.assert_not_called() + # Exposure should be enqueued with empty targeting_key + mock_writer.enqueue.assert_called_once() + exposure_event = mock_writer.enqueue.call_args[0][0] + assert exposure_event["subject"]["id"] == "" @mock.patch("ddtrace.internal.openfeature._provider.get_exposure_writer") def test_exposure_writer_generic_exception(self, mock_get_writer, provider, evaluation_context):