Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d1ad980
FFL-2783: add L3 tests for observeFullEvaluationData PII protection
vjfridge Jul 16, 2026
4320460
Merge branch 'main' into vickie/FFL-2783-observeFullEvaluationData-te…
vjfridge Jul 16, 2026
a9cf55c
FFL-2783: point Go manifest rows at FFL-2784 (SDK implementation ticket)
vjfridge Jul 16, 2026
a210c39
FFL-2783: address codex feedback on PII tests
vjfridge Jul 16, 2026
3508a33
FFL-2783: add @not_yet_implemented decorator alias and use consistent…
vjfridge Jul 17, 2026
63b0c15
FFL-2783: fix ruff formatting issues
vjfridge Jul 17, 2026
38b327e
Merge branch 'main' into vickie/FFL-2783-observeFullEvaluationData-te…
vjfridge Jul 22, 2026
c42c827
FFL-2783: move observeFullEvaluationData to top-level UFC field
vjfridge Jul 22, 2026
64bc013
FFL-2783: fix codegen-leaked wording in PII vector comment
vjfridge Jul 22, 2026
c4a88d0
FFL-2783: document PHP sidecar flaky caveat on Burst/HighCardinality/…
vjfridge Jul 22, 2026
e9a5752
Merge branch 'main' into vickie/FFL-2783-observeFullEvaluationData-te…
vjfridge Jul 23, 2026
dd6a5ee
Remove @not_yet_implemented force-skip from FFE EVP tests
vjfridge Jul 27, 2026
46ebc5a
Merge branch 'main' into vickie/FFL-2783-observeFullEvaluationData-te…
vjfridge Jul 27, 2026
221e146
Use @scenario_crash on FFE EVP tests to protect PHP weblog
vjfridge Jul 27, 2026
d86e55d
Scope @scenario_crash to the 3 aggregation classes only
vjfridge Jul 27, 2026
fbac217
Pin PHP EVP crash-prone classes to bug (FFL-2676) in manifest
vjfridge Jul 27, 2026
08669d5
Fix alphabetical order in manifests/php.yml
vjfridge Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions manifests/golang.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,9 @@ manifest:
tests/ffe/test_exposures.py: v2.6.0-dev # Easy win for chi, echo, gin, net-http, net-http-orchestrion, uds-echo and version 2.5.0
tests/ffe/test_flag_eval_evp.py: v2.10.0-dev
tests/ffe/test_flag_eval_evp.py::Test_FFE_EVP_Flagevaluation_Degradation::test_ffe_evp_flagevaluation_degradation: flaky (FFL-2676)
tests/ffe/test_flag_eval_evp.py::Test_FFE_EVP_Flagevaluation_ObserveFullData_Absent_Hashed: missing_feature (FFL-2784)
tests/ffe/test_flag_eval_evp.py::Test_FFE_EVP_Flagevaluation_ObserveFullData_False_Hashed: missing_feature (FFL-2784)
tests/ffe/test_flag_eval_evp.py::Test_FFE_EVP_Flagevaluation_ObserveFullData_True_Unhashed: missing_feature (FFL-2784)
tests/ffe/test_flag_eval_metrics.py: v2.8.0
tests/ffe/test_flag_eval_metrics.py::Test_FFE_Eval_Metric_Parse_Error_Invalid_Regex: irrelevant (Go validates regex at config load time)
tests/ffe/test_flag_eval_metrics.py::Test_FFE_Eval_Nested_Attributes_Ignored: irrelevant (FFL-1980)
Expand Down
3 changes: 3 additions & 0 deletions manifests/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,9 @@ manifest:
symfony7x: incomplete_test_app
tests/ffe/test_exposures.py::Test_FFE_Exposure_Events::test_ffe_multiple_remote_config_files: flaky (FFL-2676)
tests/ffe/test_flag_eval_evp.py: missing_feature (FFL-2446)
tests/ffe/test_flag_eval_evp.py::Test_FFE_EVP_Flagevaluation_Burst_Aggregation: bug (FFL-2676)
tests/ffe/test_flag_eval_evp.py::Test_FFE_EVP_Flagevaluation_Degradation: bug (FFL-2676)
tests/ffe/test_flag_eval_evp.py::Test_FFE_EVP_Flagevaluation_High_Cardinality_Aggregation: bug (FFL-2676)
tests/ffe/test_flag_eval_metrics.py:
- weblog_declaration:
"*": v1.21.0-dev
Expand Down
188 changes: 179 additions & 9 deletions tests/ffe/test_flag_eval_evp.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
from concurrent.futures import ThreadPoolExecutor
from typing import cast

import pytest

from tests.ffe.utils.fixtures import JSON, make_ufc_fixture
from utils import HttpResponse
from utils import features
from utils import interfaces
from utils import remote_config as rc
from utils import scenario_crash
from utils import scenarios
from utils import weblog

Expand All @@ -23,6 +23,18 @@
EVP_FULL_TIER_PER_FLAG_CAP = 10_000
EVP_DEGRADATION_OVERFLOW_EVALS = 2_000

# Fixed input/output vector for the PII-protection tests. Every SDK's unit tests
# should assert against the same input to prove byte-identical hashing across SDKs.
PII_TARGETING_KEY = "jane.doe@datadoghq.com"
PII_TARGETING_KEY_HASHED = "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b"
Comment thread
vjfridge marked this conversation as resolved.
PII_ATTRIBUTES: dict[str, object] = {
"org_id": 1234,
"user_email": "jane.doe@datadoghq.com",
"plan": "enterprise",
"region": "us-east-1",
"account.tier": "gold",
}


def make_multi_flag_fixture(flag_keys: list[str]) -> JSON:
fixture = make_ufc_fixture(flag_keys[0])
Expand Down Expand Up @@ -184,6 +196,33 @@ def event_identity(event: JSON) -> str:
return json.dumps(visible_identity, sort_keys=True, default=str)


def _assert_hashed_targeting_key(event: JSON) -> None:
targeting_key = event.get("targeting_key")
assert targeting_key == PII_TARGETING_KEY_HASHED, (
f"Expected hashed targeting_key {PII_TARGETING_KEY_HASHED}, got {targeting_key!r}"
)
assert isinstance(targeting_key, str)
assert targeting_key.startswith("sha256_"), f"hashed targeting_key must start with 'sha256_': {targeting_key!r}"
assert len(targeting_key) == 71, f"hashed targeting_key must be 71 chars, got {len(targeting_key)}"
hex_suffix = targeting_key[len("sha256_") :]
assert len(hex_suffix) == 64, f"hashed targeting_key suffix must be 64 chars: {targeting_key!r}"
assert all(c in "0123456789abcdef" for c in hex_suffix), (
f"hashed targeting_key suffix must be lowercase hex: {targeting_key!r}"
)


def assert_no_raw_pii_in_event(event: JSON, forbidden_values: list[str]) -> None:
"""Walk the entire serialized event and assert none of the raw PII strings appear anywhere.

Guards against SDK bugs that route unhashed values into unexpected fields (e.g., a raw
email leaking into ``context.user_email`` even when ``context.evaluation`` is correctly
omitted).
"""
serialized = json.dumps(event, default=str)
for value in forbidden_values:
assert value not in serialized, f"raw PII value {value!r} must not appear anywhere in event: {event}"


def assert_no_duplicate_visible_events(events: list[tuple[JSON, JSON]]) -> None:
seen_by_batch: dict[int, set[str]] = {}
duplicates: set[str] = set()
Expand All @@ -199,7 +238,6 @@ def assert_no_duplicate_visible_events(events: list[tuple[JSON, JSON]]) -> None:

@scenarios.feature_flagging_and_experimentation
@features.feature_flags_evp_flagevaluation
@pytest.mark.skip_if_xfail
class Test_FFE_EVP_Flagevaluation_Basic:
"""Test that flag evaluation produces an EVP flagevaluation payload."""

Expand All @@ -226,7 +264,6 @@ def test_ffe_evp_flagevaluation_basic(self) -> None:

@scenarios.feature_flagging_and_experimentation
@features.feature_flags_evp_flagevaluation
@pytest.mark.skip_if_xfail
class Test_FFE_EVP_Flagevaluation_Count:
"""Test that repeated evaluations are counted in EVP flagevaluation payloads."""

Expand Down Expand Up @@ -257,7 +294,6 @@ def test_ffe_evp_flagevaluation_count(self) -> None:

@scenarios.feature_flagging_and_experimentation
@features.feature_flags_evp_flagevaluation
@pytest.mark.skip_if_xfail
class Test_FFE_EVP_Flagevaluation_Context_Bounds:
"""Test that EVP evaluation context is bounded before it reaches payloads."""

Expand Down Expand Up @@ -303,7 +339,6 @@ def test_ffe_evp_flagevaluation_context_bounds(self) -> None:

@scenarios.feature_flagging_and_experimentation
@features.feature_flags_evp_flagevaluation
@pytest.mark.skip_if_xfail
class Test_FFE_EVP_Flagevaluation_Runtime_Default:
"""Test that runtime defaults are surfaced without OpenFeature reason."""

Expand All @@ -330,7 +365,6 @@ def test_ffe_evp_flagevaluation_runtime_default(self) -> None:

@scenarios.feature_flagging_and_experimentation
@features.feature_flags_evp_flagevaluation
@pytest.mark.skip_if_xfail
class Test_FFE_EVP_Flagevaluation_Load_Aggregation:
"""Test CI-safe load aggregation without treating system-tests as a perf test."""

Expand Down Expand Up @@ -371,7 +405,7 @@ def test_ffe_evp_flagevaluation_load_aggregation(self) -> None:

@scenarios.feature_flagging_and_experimentation
@features.feature_flags_evp_flagevaluation
@pytest.mark.skip_if_xfail
@scenario_crash
class Test_FFE_EVP_Flagevaluation_Burst_Aggregation:
"""Test a bounded request burst through the async EVP aggregation path."""

Expand Down Expand Up @@ -410,7 +444,7 @@ def test_ffe_evp_flagevaluation_burst_aggregation(self) -> None:

@scenarios.feature_flagging_and_experimentation
@features.feature_flags_evp_flagevaluation
@pytest.mark.skip_if_xfail
@scenario_crash
class Test_FFE_EVP_Flagevaluation_High_Cardinality_Aggregation:
"""Test many full-tier aggregation buckets stay distinct and counted."""

Expand Down Expand Up @@ -450,7 +484,7 @@ def test_ffe_evp_flagevaluation_high_cardinality_aggregation(self) -> None:

@scenarios.feature_flagging_and_experimentation
@features.feature_flags_evp_flagevaluation
@pytest.mark.skip_if_xfail
@scenario_crash
class Test_FFE_EVP_Flagevaluation_Degradation:
"""Test degraded EVP shape after the production per-flag full-tier cap is exceeded."""

Expand Down Expand Up @@ -489,3 +523,139 @@ def test_ffe_evp_flagevaluation_degradation(self) -> None:
assert "targeting_key" not in event, f"degraded event must omit targeting_key: {event}"
assert object_key(event.get("variant"), "variant") == "on"
assert object_key(event.get("allocation"), "allocation") == "default-allocation"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a test for DD_FLAGGING_EVALUATION_COUNTS_ENABLED=false + observeFullEvaluationData=true - sorry I missed adding coverage for the false case.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to add coverage for DD_FLAGGING_EVALUATION_COUNTS_ENABLED =false + observeFullEvaluationData=true, but I think it lives better at L1 unit tests/L2 SDK integration layers.

The assertion is "SDK-level killswitch overrides UFC opt-in" which is purely internal to the SDK and doesn't exercise anything system-level. Adding an L3 scenario here means a new weblog boot per SDK (~minutes of CI × N languages) to verify the boolean killswitch.

I propose we cover this in each SDK's unit or integration suite instead. Let me know if you have strong feelings otherwise!


@scenarios.feature_flagging_and_experimentation
@features.feature_flags_evp_flagevaluation
class Test_FFE_EVP_Flagevaluation_ObserveFullData_Absent_Hashed:
"""Test that when observeFullEvaluationData is absent from UFC, targeting_key is hashed and context.evaluation is omitted (default PII-protection)."""

def setup_ffe_evp_flagevaluation_observe_full_data_absent(self) -> None:
config_id = "ffe-evp-observe-absent"
self.flag_key = "evp-observe-absent-flag"
rc.tracer_rc_state.reset().set_config(
f"{RC_PATH}/{config_id}/config",
make_ufc_fixture(self.flag_key),
).apply()
Comment on lines +536 to +539

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move fallible setup work into the test

These new setup_* methods perform remote-config application and the weblog request before the test body; if either path raises or times out in CI, pytest reports a setup error and the scenario can stop instead of recording a normal test failure. .cursor/rules/pr-review.mdc says setup methods must only trigger observability and failures/assertions belong in test_*, so keep setup minimal and move the fallible validation path into the matching test method.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not addressing this one. The other eight test classes in this file (Test_FFE_EVP_Flagevaluation_Basic, _Count, _Context_Bounds, _Runtime_Default, _Load_Aggregation, _Burst_Aggregation, _High_Cardinality_Aggregation, _Degradation) all place rc...apply() and evaluate_flag(...) in setup_* and only run assert self.r.status_code == 200 in test_*. Diverging in only the three new classes would create an inconsistency reviewers would flag; if the setup-vs-test split needs enforcement here, it belongs in a dedicated refactor across the whole file.


self.r = evaluate_flag(
self.flag_key,
targeting_key=PII_TARGETING_KEY,
attributes=PII_ATTRIBUTES,
)

def test_ffe_evp_flagevaluation_observe_full_data_absent(self) -> None:
assert self.r.status_code == 200, f"Flag evaluation failed: {self.r.text}"

wait_for_evp_flagevaluation_event(self.flag_key)
events = find_evp_flagevaluation_events(self.flag_key)
assert events, f"Expected EVP flagevaluation event for flag {self.flag_key}"

forbidden_raw_values = [PII_TARGETING_KEY, *[str(v) for v in PII_ATTRIBUTES.values()]]

for batch, event in events:
assert_batch_context(batch)
assert_event_contract(event, self.flag_key)
_assert_hashed_targeting_key(event)

context = event.get("context")
if isinstance(context, dict):
assert "evaluation" not in context, (
f"context.evaluation must be omitted when observeFullEvaluationData is absent: {context}"
)

assert_no_raw_pii_in_event(event, forbidden_raw_values)


@scenarios.feature_flagging_and_experimentation
@features.feature_flags_evp_flagevaluation
class Test_FFE_EVP_Flagevaluation_ObserveFullData_False_Hashed:
"""Test that when observeFullEvaluationData=false in UFC, targeting_key is hashed and context.evaluation is omitted."""

def setup_ffe_evp_flagevaluation_observe_full_data_false(self) -> None:
config_id = "ffe-evp-observe-false"
self.flag_key = "evp-observe-false-flag"
rc.tracer_rc_state.reset().set_config(
f"{RC_PATH}/{config_id}/config",
make_ufc_fixture(self.flag_key, observe_full_evaluation_data=False),
).apply()

self.r = evaluate_flag(
self.flag_key,
targeting_key=PII_TARGETING_KEY,
attributes=PII_ATTRIBUTES,
)

def test_ffe_evp_flagevaluation_observe_full_data_false(self) -> None:
assert self.r.status_code == 200, f"Flag evaluation failed: {self.r.text}"

wait_for_evp_flagevaluation_event(self.flag_key)
events = find_evp_flagevaluation_events(self.flag_key)
assert events, f"Expected EVP flagevaluation event for flag {self.flag_key}"

forbidden_raw_values = [PII_TARGETING_KEY, *[str(v) for v in PII_ATTRIBUTES.values()]]

for batch, event in events:
assert_batch_context(batch)
assert_event_contract(event, self.flag_key)
_assert_hashed_targeting_key(event)

context = event.get("context")
if isinstance(context, dict):
assert "evaluation" not in context, (
f"context.evaluation must be omitted when observeFullEvaluationData=false: {context}"
)

assert_no_raw_pii_in_event(event, forbidden_raw_values)


@scenarios.feature_flagging_and_experimentation
@features.feature_flags_evp_flagevaluation
class Test_FFE_EVP_Flagevaluation_ObserveFullData_True_Unhashed:
"""Test that when observeFullEvaluationData=true in UFC, targeting_key is raw and context.evaluation is populated."""

def setup_ffe_evp_flagevaluation_observe_full_data_true(self) -> None:
config_id = "ffe-evp-observe-true"
self.flag_key = "evp-observe-true-flag"
rc.tracer_rc_state.reset().set_config(
f"{RC_PATH}/{config_id}/config",
make_ufc_fixture(self.flag_key, observe_full_evaluation_data=True),
).apply()

self.r = evaluate_flag(
self.flag_key,
targeting_key=PII_TARGETING_KEY,
attributes=PII_ATTRIBUTES,
)

def test_ffe_evp_flagevaluation_observe_full_data_true(self) -> None:
assert self.r.status_code == 200, f"Flag evaluation failed: {self.r.text}"

wait_for_evp_flagevaluation_event(self.flag_key)
events = find_evp_flagevaluation_events(self.flag_key)
assert events, f"Expected EVP flagevaluation event for flag {self.flag_key}"

for batch, event in events:
assert_batch_context(batch)
assert_event_contract(event, self.flag_key)

targeting_key = event.get("targeting_key")
assert targeting_key == PII_TARGETING_KEY, (
f"Expected raw targeting_key {PII_TARGETING_KEY!r}, got {targeting_key!r}"
)
assert isinstance(targeting_key, str)
assert not targeting_key.startswith("sha256_"), (
f"unhashed targeting_key must not start with 'sha256_': {targeting_key!r}"
)

context = event.get("context")
assert isinstance(context, dict), f"context must be an object when observeFullEvaluationData=true: {event}"
evaluation_context = context.get("evaluation")
assert isinstance(evaluation_context, dict), (
f"context.evaluation must be an object when observeFullEvaluationData=true: {context}"
)
for key, expected_value in PII_ATTRIBUTES.items():
assert key in evaluation_context, f"context.evaluation missing attribute {key!r}: {evaluation_context}"
assert evaluation_context[key] == expected_value, (
f"context.evaluation[{key!r}] expected {expected_value!r}, got {evaluation_context[key]!r}"
)
6 changes: 5 additions & 1 deletion tests/ffe/utils/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ def make_ufc_fixture(
enabled: bool = True,
allocation_key: str = "default-allocation",
variation_values: dict[str, VariationValue] | None = None,
observe_full_evaluation_data: bool | None = None,
) -> JSON:
values = variation_values or DEFAULT_VARIATION_VALUES[variation_type]

return {
ufc: JSON = {
"createdAt": "2024-04-17T19:40:53.716Z",
"format": "SERVER",
"environment": {"name": "Test"},
Expand All @@ -47,6 +48,9 @@ def make_ufc_fixture(
}
},
}
if observe_full_evaluation_data is not None:
ufc["observeFullEvaluationData"] = observe_full_evaluation_data
return ufc


def make_exposure_ufc_fixture(
Expand Down
Loading