Skip to content

Commit f977732

Browse files
authored
feat(identities): evaluate system traits when matching segments for edge identities (#8266)
1 parent 67853f3 commit f977732

4 files changed

Lines changed: 110 additions & 9 deletions

File tree

api/environments/dynamodb/wrappers/identity_wrapper.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
CapacityBudgetExceeded,
2020
SystemTraitWriteRaceError,
2121
)
22+
from environments.identities.traits.constants import (
23+
TRAIT_STRING_VALUE_MAX_LENGTH,
24+
)
2225
from util.engine_models.context.mappers import (
2326
is_context_in_segment,
2427
map_environment_identity_to_context,
@@ -103,6 +106,17 @@ def set_system_trait(
103106
Assumes stored documents never carry `system_traits` as NULL — the
104107
document mapper omits the attribute when unset.
105108
"""
109+
if (
110+
isinstance(trait_value, str)
111+
and len(trait_value) > TRAIT_STRING_VALUE_MAX_LENGTH
112+
):
113+
# The Edge API caps trait values at this length when parsing
114+
# identity documents; a longer value there makes every flag
115+
# request for the identity fail.
116+
raise ValueError(
117+
"System trait value must be at most "
118+
f"{TRAIT_STRING_VALUE_MAX_LENGTH} characters."
119+
)
106120
composite_key = IdentityModel.generate_composite_key(
107121
environment_api_key, identifier
108122
)

api/tests/unit/environments/dynamodb/wrappers/test_unit_dynamodb_identity_wrapper.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from boto3.dynamodb.types import Binary
77
from botocore.exceptions import ClientError
88
from django.core.exceptions import ObjectDoesNotExist
9-
from flag_engine.segments.constants import IN
9+
from flag_engine.segments.constants import IN, IS_SET
1010
from mypy_boto3_dynamodb.service_resource import Table
1111
from pytest_django.fixtures import SettingsWrapper
1212
from pytest_mock import MockerFixture
@@ -24,6 +24,9 @@
2424
SystemTraitWriteRaceError,
2525
)
2626
from environments.identities.models import Identity
27+
from environments.identities.traits.constants import (
28+
TRAIT_STRING_VALUE_MAX_LENGTH,
29+
)
2730
from environments.identities.traits.models import Trait
2831
from features.models import Feature, FeatureSegment, FeatureState
2932
from features.multivariate.models import (
@@ -400,6 +403,55 @@ def test_get_segment_ids__segment_with_feature_overrides__returns_correct_ids(
400403
assert segment_ids == [identity_matching_segment.id]
401404

402405

406+
def test_get_segment_ids__system_trait_backed_segment__returns_correct_ids(
407+
project: "Project",
408+
environment: "Environment",
409+
identity: "Identity",
410+
mocker: "MockerFixture",
411+
) -> None:
412+
# Given - two IS_SET segments: one keyed to a system trait the identity
413+
# carries, one keyed to a system trait it does not
414+
member_segment = Segment.objects.create(name="Cohort members", project=project)
415+
rule = SegmentRule.objects.create(segment=member_segment, type=SegmentRule.ALL_RULE)
416+
Condition.objects.create(rule=rule, operator=IS_SET, property="flagsmith_cohort_a")
417+
other_segment = Segment.objects.create(name="Other cohort", project=project)
418+
other_rule = SegmentRule.objects.create(
419+
segment=other_segment, type=SegmentRule.ALL_RULE
420+
)
421+
Condition.objects.create(
422+
rule=other_rule, operator=IS_SET, property="flagsmith_cohort_b"
423+
)
424+
425+
identity_document = map_identity_to_identity_document(identity)
426+
identity_document["system_traits"] = {"flagsmith_cohort_a": True}
427+
identity_uuid = identity_document["identity_uuid"]
428+
429+
dynamo_identity_wrapper = DynamoIdentityWrapper()
430+
mocker.patch.object(
431+
dynamo_identity_wrapper, "get_item_from_uuid", return_value=identity_document
432+
)
433+
434+
# When
435+
segment_ids = dynamo_identity_wrapper.get_segment_ids(identity_uuid) # type: ignore[arg-type]
436+
437+
# Then
438+
assert segment_ids == [member_segment.id]
439+
440+
441+
def test_set_system_trait__oversized_string_value__raises() -> None:
442+
# Given
443+
wrapper = DynamoIdentityWrapper()
444+
445+
# When / Then
446+
with pytest.raises(ValueError):
447+
wrapper.set_system_trait(
448+
environment_api_key="key",
449+
identifier="user",
450+
trait_key="flagsmith_cohort_a",
451+
trait_value="x" * (TRAIT_STRING_VALUE_MAX_LENGTH + 1),
452+
)
453+
454+
403455
def test_get_segment_ids__in_operator_with_integer_traits__returns_matching_segment(
404456
project: "Project", environment: "Environment", mocker: "MockerFixture"
405457
) -> None:
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from environments.models import Environment
2+
from util.engine_models.context.mappers import map_environment_identity_to_context
3+
from util.engine_models.identities.models import IdentityModel
4+
from util.engine_models.identities.traits.models import TraitModel
5+
6+
7+
def test_map_environment_identity_to_context__system_traits__merged_with_system_winning(
8+
environment: Environment,
9+
) -> None:
10+
# Given
11+
identity = IdentityModel(
12+
identifier="user-1",
13+
environment_api_key=environment.api_key,
14+
identity_traits=[
15+
TraitModel(trait_key="plan", trait_value="free"),
16+
TraitModel(trait_key="flagsmith_cohort_a", trait_value="user-written"),
17+
],
18+
system_traits={"flagsmith_cohort_a": True},
19+
)
20+
21+
# When
22+
context = map_environment_identity_to_context(
23+
environment=environment, identity=identity, override_traits=None
24+
)
25+
26+
# Then
27+
identity_context = context["identity"]
28+
assert identity_context is not None
29+
assert identity_context["traits"] == {
30+
"plan": "free",
31+
"flagsmith_cohort_a": True,
32+
}

api/util/engine_models/context/mappers.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ def map_environment_identity_to_context(
4343
`identity.identity_traits` if provided.
4444
:return: An EvaluationContext containing the environment and identity.
4545
"""
46+
traits = {
47+
trait.trait_key: trait.trait_value
48+
for trait in (
49+
override_traits if override_traits is not None else identity.identity_traits
50+
)
51+
}
52+
if identity.system_traits:
53+
# System-owned traits are not user data: on a key clash, the system
54+
# value wins.
55+
traits.update(identity.system_traits)
4656
return {
4757
"environment": {
4858
"key": environment.api_key,
@@ -51,14 +61,7 @@ def map_environment_identity_to_context(
5161
"identity": {
5262
"identifier": identity.identifier,
5363
"key": str(identity.django_id or identity.composite_key),
54-
"traits": {
55-
trait.trait_key: trait.trait_value
56-
for trait in (
57-
override_traits
58-
if override_traits is not None
59-
else identity.identity_traits
60-
)
61-
},
64+
"traits": traits,
6265
},
6366
}
6467

0 commit comments

Comments
 (0)