Skip to content

Commit 15f2913

Browse files
leoromanovskycodex
andauthored
Stabilize FFE exposure system tests (#7168)
Co-authored-by: Codex GPT-5 <noreply@openai.com>
1 parent 14f65e4 commit 15f2913

1 file changed

Lines changed: 82 additions & 23 deletions

File tree

tests/ffe/test_exposures.py

Lines changed: 82 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,71 @@
1414

1515
RC_PRODUCT = "FFE_FLAGS"
1616
RC_PATH = f"datadog/2/{RC_PRODUCT}"
17+
EXPOSURES_PATH = "/api/v2/exposures"
18+
EXPOSURE_WAIT_TIMEOUT_SECONDS = 30
19+
20+
21+
def exposure_events_from_data(
22+
data: dict, flag_keys: set[str] | None = None, subject_id: str | None = None
23+
) -> list[dict]:
24+
"""Return exposure events from one agent payload matching the optional flag/subject filters."""
25+
if data.get("path") != EXPOSURES_PATH:
26+
return []
27+
28+
exposure_data = data.get("request", {}).get("content")
29+
if not isinstance(exposure_data, dict):
30+
return []
31+
32+
exposures = exposure_data.get("exposures")
33+
if not isinstance(exposures, list):
34+
return []
35+
36+
events = []
37+
for event in exposures:
38+
if not isinstance(event, dict):
39+
continue
40+
41+
flag = event.get("flag")
42+
subject = event.get("subject")
43+
event_flag_key = flag.get("key") if isinstance(flag, dict) else None
44+
event_subject_id = subject.get("id") if isinstance(subject, dict) else None
45+
46+
if flag_keys is not None and event_flag_key not in flag_keys:
47+
continue
48+
if subject_id is not None and event_subject_id != subject_id:
49+
continue
50+
events.append(event)
51+
return events
52+
53+
54+
def find_exposure_events(flag_key: str, subject_id: str | None = None) -> list[dict]:
55+
"""Find captured exposure events for a specific flag key and optionally a specific subject."""
56+
events = []
57+
for data in interfaces.agent.get_data(path_filters=EXPOSURES_PATH):
58+
events.extend(exposure_events_from_data(data, {flag_key}, subject_id))
59+
return events
60+
61+
62+
def wait_for_exposure_event(flag_keys: set[str], subject_id: str | None = None) -> None:
63+
"""Wait until the agent receives an exposure event for one of the given flags."""
64+
assert interfaces.agent.wait_for(
65+
lambda data: bool(exposure_events_from_data(data, flag_keys, subject_id)),
66+
timeout=EXPOSURE_WAIT_TIMEOUT_SECONDS,
67+
), f"Timed out waiting for exposure event for flags {sorted(flag_keys)} and subject {subject_id!r}"
68+
69+
70+
def wait_for_min_exposure_count(flag_key: str, expected: int, subject_id: str | None = None) -> int:
71+
"""Wait until enough matching exposure events are available, then return the current count."""
72+
count = count_exposure_events(flag_key, subject_id)
73+
74+
if count < expected:
75+
assert interfaces.agent.wait_for(
76+
lambda _: count_exposure_events(flag_key, subject_id) >= expected,
77+
timeout=EXPOSURE_WAIT_TIMEOUT_SECONDS,
78+
), f"Timed out waiting for exposure count >= {expected} for flag {flag_key} and subject {subject_id!r}"
79+
count = count_exposure_events(flag_key, subject_id)
80+
81+
return count
1782

1883

1984
# Simple UFC fixture for testing with doLog: true
@@ -71,12 +136,13 @@ def setup_ffe_exposure_event_generation(self):
71136
def test_ffe_exposure_event_generation(self):
72137
"""Test that FFE generates exposure events when flags are evaluated via weblog."""
73138
assert self.r.status_code == 200, f"Flag evaluation failed: {self.r.text}"
139+
wait_for_exposure_event({self.flag}, self.targeting_key)
74140

75141
# Search for our specific flag in all exposure events
76142
matching_event = None
77143
context_validated = False
78144

79-
for data in interfaces.agent.get_data(path_filters="/api/v2/exposures"):
145+
for data in interfaces.agent.get_data(path_filters=EXPOSURES_PATH):
80146
# validate data sent to /api/v2/exposures
81147

82148
exposure_data = data["request"]["content"]
@@ -216,11 +282,12 @@ def test_ffe_multiple_remote_config_files(self):
216282
"""Test that FFE correctly handles multiple remote config files with different flags."""
217283
assert self.r1.status_code == 200, f"First flag evaluation failed: {self.r1.text}"
218284
assert self.r2.status_code == 200, f"Second flag evaluation failed: {self.r2.text}"
285+
wait_for_exposure_event({self.flag_1, self.flag_2}, self.targeting_key)
219286

220287
# Collect all exposure events for our specific flags
221288
flags_found = set()
222289

223-
for data in interfaces.agent.get_data(path_filters="/api/v2/exposures"):
290+
for data in interfaces.agent.get_data(path_filters=EXPOSURES_PATH):
224291
exposure_data = data["request"]["content"]
225292
assert exposure_data is not None, "No exposure events were sent to agent"
226293

@@ -286,7 +353,7 @@ def test_ffe_empty_remote_config(self):
286353

287354
# When no remote config is set, FFE should still work but return default value
288355
# The exposure events should still be generated based on library configuration
289-
for data in interfaces.agent.get_data(path_filters="/api/v2/exposures"):
356+
for data in interfaces.agent.get_data(path_filters=EXPOSURES_PATH):
290357
exposure_data = data["request"]["content"]
291358
if exposure_data is not None:
292359
# Validate that context is still present
@@ -381,12 +448,13 @@ def test_ffe_malformed_remote_config_rejection(self):
381448
"""Test that FFE rejects malformed remote config and preserves the old valid configuration."""
382449
assert self.r1.status_code == 200, f"First flag evaluation failed: {self.r1.text}"
383450
assert self.r2.status_code == 200, f"Second flag evaluation failed: {self.r2.text}"
451+
wait_for_exposure_event({self.flag}, self.targeting_key)
384452

385453
# Verify that exposure events are still generated for both requests
386454
# and the flag configuration remained valid despite the malformed update
387455
events_found = []
388456

389-
for data in interfaces.agent.get_data(path_filters="/api/v2/exposures"):
457+
for data in interfaces.agent.get_data(path_filters=EXPOSURES_PATH):
390458
exposure_data = data["request"]["content"]
391459
assert exposure_data is not None, "No exposure events were sent to agent"
392460

@@ -430,21 +498,7 @@ def count_exposure_events(flag_key: str, subject_id: str | None = None) -> int:
430498
Number of matching exposure events found
431499
432500
"""
433-
count = 0
434-
for data in interfaces.agent.get_data(path_filters="/api/v2/exposures"):
435-
exposure_data = data["request"]["content"]
436-
if exposure_data is None:
437-
continue
438-
439-
exposures = exposure_data.get("exposures", [])
440-
for event in exposures:
441-
event_flag_key = event.get("flag", {}).get("key")
442-
event_subject_id = event.get("subject", {}).get("id")
443-
444-
if event_flag_key == flag_key:
445-
if subject_id is None or event_subject_id == subject_id:
446-
count += 1
447-
return count
501+
return len(find_exposure_events(flag_key, subject_id))
448502

449503

450504
@scenarios.feature_flagging_and_experimentation
@@ -488,7 +542,7 @@ def test_ffe_exposure_caching_same_subject(self):
488542
assert result["value"] == "value-a", f"Request {i + 1}: expected 'value-a', got '{result['value']}'"
489543

490544
# Count exposure events for this specific subject
491-
exposure_count = count_exposure_events(self.flag_key, self.targeting_key)
545+
exposure_count = wait_for_min_exposure_count(self.flag_key, 1, self.targeting_key)
492546

493547
# The exposure cache should deduplicate events - we expect exactly 1 exposure
494548
# for the same (subject, allocation, variant) tuple
@@ -538,6 +592,10 @@ def test_ffe_exposure_caching_different_subjects(self):
538592
result = json.loads(r.text)
539593
assert result["value"] == "value-a", f"Request {i + 1}: expected 'value-a', got '{result['value']}'"
540594

595+
# Wait for each subject to be observed before asserting exact totals.
596+
for subject in self.subjects:
597+
wait_for_min_exposure_count(self.flag_key, 1, subject)
598+
541599
# Count total exposure events for this flag
542600
total_exposure_count = count_exposure_events(self.flag_key)
543601

@@ -642,7 +700,7 @@ def test_ffe_exposure_caching_allocation_cycle(self):
642700
# - Exposure #1: default-allocation
643701
# - Exposure #2: different-allocation (allocation changed)
644702
# - Exposure #3: default-allocation (allocation changed back)
645-
exposure_count = count_exposure_events(self.flag_key, self.targeting_key)
703+
exposure_count = wait_for_min_exposure_count(self.flag_key, 3, self.targeting_key)
646704

647705
assert exposure_count == 3, (
648706
f"Expected exactly 3 exposure events for subject '{self.targeting_key}' "
@@ -737,7 +795,7 @@ def test_ffe_exposure_caching_variant_cycle(self):
737795
# - Exposure #1: variant-a
738796
# - Exposure #2: variant-b (variant changed)
739797
# - Exposure #3: variant-a (variant changed back)
740-
exposure_count = count_exposure_events(self.flag_key, self.targeting_key)
798+
exposure_count = wait_for_min_exposure_count(self.flag_key, 3, self.targeting_key)
741799

742800
assert exposure_count == 3, (
743801
f"Expected exactly 3 exposure events for subject '{self.targeting_key}' "
@@ -911,11 +969,12 @@ def test_ffe_exp_5_missing_targeting_key(self):
911969

912970
result = json.loads(self.response.text)
913971
assert result["value"] == "value-a", f"Expected 'value-a', got '{result['value']}'"
972+
wait_for_exposure_event({self.flag_key}, "")
914973

915974
# Search for exposure event with empty subject.id
916975
matching_event = None
917976
all_events_for_flag = [] # Collect all events for debugging
918-
for data in interfaces.agent.get_data(path_filters="/api/v2/exposures"):
977+
for data in interfaces.agent.get_data(path_filters=EXPOSURES_PATH):
919978
exposure_data = data["request"]["content"]
920979
if exposure_data is None:
921980
continue

0 commit comments

Comments
 (0)