Skip to content

Commit 603ed37

Browse files
authored
feat: Support device_id as bucketing identifier for local evaluation (#424)
* feat: Support device_id as bucketing identifier for local evaluation Add support for `bucketing_identifier` field on feature flags to allow using `device_id` instead of `distinct_id` for hashing/bucketing in local evaluation.
1 parent bb0c7b4 commit 603ed37

3 files changed

Lines changed: 678 additions & 26 deletions

File tree

posthog/client.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
InconclusiveMatchError,
3939
RequiresServerEvaluation,
4040
match_feature_flag_properties,
41+
resolve_bucketing_value,
4142
)
4243
from posthog.flag_definition_cache import (
4344
FlagDefinitionCacheData,
@@ -1418,6 +1419,7 @@ def _compute_flag_locally(
14181419
person_properties=None,
14191420
group_properties=None,
14201421
warn_on_unknown_groups=True,
1422+
device_id=None,
14211423
) -> FlagValue:
14221424
groups = groups or {}
14231425
person_properties = person_properties or {}
@@ -1458,22 +1460,35 @@ def _compute_flag_locally(
14581460
)
14591461
return False
14601462

1463+
if group_name not in group_properties:
1464+
raise InconclusiveMatchError(
1465+
f"Flag has no group properties for group '{group_name}'"
1466+
)
14611467
focused_group_properties = group_properties[group_name]
1468+
group_key = groups[group_name]
14621469
return match_feature_flag_properties(
14631470
feature_flag,
1464-
groups[group_name],
1471+
group_key,
14651472
focused_group_properties,
1466-
self.feature_flags_by_key,
1467-
evaluation_cache,
1473+
cohort_properties=self.cohorts,
1474+
flags_by_key=self.feature_flags_by_key,
1475+
evaluation_cache=evaluation_cache,
1476+
device_id=device_id,
1477+
bucketing_value=group_key,
14681478
)
14691479
else:
1480+
bucketing_value = resolve_bucketing_value(
1481+
feature_flag, distinct_id, device_id
1482+
)
14701483
return match_feature_flag_properties(
14711484
feature_flag,
14721485
distinct_id,
14731486
person_properties,
1474-
self.cohorts,
1475-
self.feature_flags_by_key,
1476-
evaluation_cache,
1487+
cohort_properties=self.cohorts,
1488+
flags_by_key=self.feature_flags_by_key,
1489+
evaluation_cache=evaluation_cache,
1490+
device_id=device_id,
1491+
bucketing_value=bucketing_value,
14771492
)
14781493

14791494
def feature_enabled(
@@ -1580,8 +1595,12 @@ def _get_feature_flag_result(
15801595
evaluated_at = None
15811596
feature_flag_error: Optional[str] = None
15821597

1598+
# Resolve device_id from context if not provided
1599+
if device_id is None:
1600+
device_id = get_context_device_id()
1601+
15831602
flag_value = self._locally_evaluate_flag(
1584-
key, distinct_id, groups, person_properties, group_properties
1603+
key, distinct_id, groups, person_properties, group_properties, device_id
15851604
)
15861605
flag_was_locally_evaluated = flag_value is not None
15871606

@@ -1785,6 +1804,7 @@ def _locally_evaluate_flag(
17851804
groups: dict[str, str],
17861805
person_properties: dict[str, str],
17871806
group_properties: dict[str, str],
1807+
device_id: Optional[str] = None,
17881808
) -> Optional[FlagValue]:
17891809
if self.feature_flags is None and self.personal_api_key:
17901810
self.load_feature_flags()
@@ -1804,6 +1824,7 @@ def _locally_evaluate_flag(
18041824
groups=groups,
18051825
person_properties=person_properties,
18061826
group_properties=group_properties,
1827+
device_id=device_id,
18071828
)
18081829
self.log.debug(
18091830
f"Successfully computed flag locally: {key} -> {response}"
@@ -2106,12 +2127,17 @@ def get_all_flags_and_payloads(
21062127
)
21072128
)
21082129

2130+
# Resolve device_id from context if not provided
2131+
if device_id is None:
2132+
device_id = get_context_device_id()
2133+
21092134
response, fallback_to_flags = self._get_all_flags_and_payloads_locally(
21102135
distinct_id,
21112136
groups=groups,
21122137
person_properties=person_properties,
21132138
group_properties=group_properties,
21142139
flag_keys_to_evaluate=flag_keys_to_evaluate,
2140+
device_id=device_id,
21152141
)
21162142

21172143
if fallback_to_flags and not only_evaluate_locally:
@@ -2142,6 +2168,7 @@ def _get_all_flags_and_payloads_locally(
21422168
group_properties=None,
21432169
warn_on_unknown_groups=False,
21442170
flag_keys_to_evaluate: Optional[list[str]] = None,
2171+
device_id: Optional[str] = None,
21452172
) -> tuple[FlagsAndPayloads, bool]:
21462173
person_properties = person_properties or {}
21472174
group_properties = group_properties or {}
@@ -2171,6 +2198,7 @@ def _get_all_flags_and_payloads_locally(
21712198
person_properties=person_properties,
21722199
group_properties=group_properties,
21732200
warn_on_unknown_groups=warn_on_unknown_groups,
2201+
device_id=device_id,
21742202
)
21752203
matched_payload = self._compute_payload_locally(
21762204
flag["key"], flags[flag["key"]]

posthog/feature_flags.py

Lines changed: 87 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import hashlib
33
import logging
44
import re
5+
import warnings
56
from typing import Optional
67

78
from dateutil import parser
@@ -34,18 +35,18 @@ class RequiresServerEvaluation(Exception):
3435
pass
3536

3637

37-
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
38-
# Given the same distinct_id and key, it'll always return the same float. These floats are
38+
# This function takes a bucketing value and a feature flag key and returns a float between 0 and 1.
39+
# Given the same bucketing value and key, it'll always return the same float. These floats are
3940
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
40-
# we can do _hash(key, distinct_id) < 0.2
41-
def _hash(key: str, distinct_id: str, salt: str = "") -> float:
42-
hash_key = f"{key}.{distinct_id}{salt}"
41+
# we can do _hash(key, bucketing_value) < 0.2
42+
def _hash(key: str, bucketing_value: str, salt: str = "") -> float:
43+
hash_key = f"{key}.{bucketing_value}{salt}"
4344
hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16)
4445
return hash_val / __LONG_SCALE__
4546

4647

47-
def get_matching_variant(flag, distinct_id):
48-
hash_value = _hash(flag["key"], distinct_id, salt="variant")
48+
def get_matching_variant(flag, bucketing_value):
49+
hash_value = _hash(flag["key"], bucketing_value, salt="variant")
4950
for variant in variant_lookup_table(flag):
5051
if hash_value >= variant["value_min"] and hash_value < variant["value_max"]:
5152
return variant["key"]
@@ -68,7 +69,13 @@ def variant_lookup_table(feature_flag):
6869

6970

7071
def evaluate_flag_dependency(
71-
property, flags_by_key, evaluation_cache, distinct_id, properties, cohort_properties
72+
property,
73+
flags_by_key,
74+
evaluation_cache,
75+
distinct_id,
76+
properties,
77+
cohort_properties,
78+
device_id=None,
7279
):
7380
"""
7481
Evaluate a flag dependency property according to the dependency chain algorithm.
@@ -80,6 +87,7 @@ def evaluate_flag_dependency(
8087
distinct_id: The distinct ID being evaluated
8188
properties: Person properties for evaluation
8289
cohort_properties: Cohort properties for evaluation
90+
device_id: The device ID for bucketing (optional)
8391
8492
Returns:
8593
bool: True if all dependencies in the chain evaluate to True, False otherwise
@@ -124,13 +132,27 @@ def evaluate_flag_dependency(
124132
else:
125133
# Recursively evaluate the dependency
126134
try:
135+
dep_flag_filters = dep_flag.get("filters") or {}
136+
dep_aggregation_group_type_index = dep_flag_filters.get(
137+
"aggregation_group_type_index"
138+
)
139+
if dep_aggregation_group_type_index is not None:
140+
# Group flags should continue bucketing by the group key
141+
# from the current evaluation context.
142+
dep_bucketing_value = distinct_id
143+
else:
144+
dep_bucketing_value = resolve_bucketing_value(
145+
dep_flag, distinct_id, device_id
146+
)
127147
dep_result = match_feature_flag_properties(
128148
dep_flag,
129149
distinct_id,
130150
properties,
131-
cohort_properties,
132-
flags_by_key,
133-
evaluation_cache,
151+
cohort_properties=cohort_properties,
152+
flags_by_key=flags_by_key,
153+
evaluation_cache=evaluation_cache,
154+
device_id=device_id,
155+
bucketing_value=dep_bucketing_value,
134156
)
135157
evaluation_cache[dep_flag_key] = dep_result
136158
except InconclusiveMatchError as e:
@@ -215,21 +237,54 @@ def matches_dependency_value(expected_value, actual_value):
215237
return False
216238

217239

240+
def resolve_bucketing_value(flag, distinct_id, device_id=None):
241+
"""Resolve the bucketing value for a flag based on its bucketing_identifier setting.
242+
243+
Returns:
244+
The appropriate identifier string to use for hashing/bucketing.
245+
246+
Raises:
247+
InconclusiveMatchError: If the flag requires device_id but none was provided.
248+
"""
249+
flag_filters = flag.get("filters") or {}
250+
bucketing_identifier = flag.get("bucketing_identifier") or flag_filters.get(
251+
"bucketing_identifier"
252+
)
253+
if bucketing_identifier == "device_id":
254+
if not device_id:
255+
raise InconclusiveMatchError(
256+
"Flag requires device_id for bucketing but none was provided"
257+
)
258+
return device_id
259+
return distinct_id
260+
261+
218262
def match_feature_flag_properties(
219263
flag,
220264
distinct_id,
221265
properties,
266+
*,
222267
cohort_properties=None,
223268
flags_by_key=None,
224269
evaluation_cache=None,
270+
device_id=None,
271+
bucketing_value=None,
225272
) -> FlagValue:
226-
flag_conditions = (flag.get("filters") or {}).get("groups") or []
273+
if bucketing_value is None:
274+
warnings.warn(
275+
"Calling match_feature_flag_properties() without bucketing_value is deprecated. "
276+
"Pass bucketing_value explicitly. This fallback will be removed in a future major release.",
277+
DeprecationWarning,
278+
stacklevel=2,
279+
)
280+
bucketing_value = resolve_bucketing_value(flag, distinct_id, device_id)
281+
282+
flag_filters = flag.get("filters") or {}
283+
flag_conditions = flag_filters.get("groups") or []
227284
is_inconclusive = False
228285
cohort_properties = cohort_properties or {}
229286
# Some filters can be explicitly set to null, which require accessing variants like so
230-
flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get(
231-
"variants"
232-
) or []
287+
flag_variants = (flag_filters.get("multivariate") or {}).get("variants") or []
233288
valid_variant_keys = [variant["key"] for variant in flag_variants]
234289

235290
for condition in flag_conditions:
@@ -244,12 +299,14 @@ def match_feature_flag_properties(
244299
cohort_properties,
245300
flags_by_key,
246301
evaluation_cache,
302+
bucketing_value=bucketing_value,
303+
device_id=device_id,
247304
):
248305
variant_override = condition.get("variant")
249306
if variant_override and variant_override in valid_variant_keys:
250307
variant = variant_override
251308
else:
252-
variant = get_matching_variant(flag, distinct_id)
309+
variant = get_matching_variant(flag, bucketing_value)
253310
return variant or True
254311
except RequiresServerEvaluation:
255312
# Static cohort or other missing server-side data - must fallback to API
@@ -277,6 +334,9 @@ def is_condition_match(
277334
cohort_properties,
278335
flags_by_key=None,
279336
evaluation_cache=None,
337+
*,
338+
bucketing_value,
339+
device_id=None,
280340
) -> bool:
281341
rollout_percentage = condition.get("rollout_percentage")
282342
if len(condition.get("properties") or []) > 0:
@@ -290,6 +350,7 @@ def is_condition_match(
290350
flags_by_key,
291351
evaluation_cache,
292352
distinct_id,
353+
device_id=device_id,
293354
)
294355
elif property_type == "flag":
295356
matches = evaluate_flag_dependency(
@@ -299,6 +360,7 @@ def is_condition_match(
299360
distinct_id,
300361
properties,
301362
cohort_properties,
363+
device_id=device_id,
302364
)
303365
else:
304366
matches = match_property(prop, properties)
@@ -308,9 +370,9 @@ def is_condition_match(
308370
if rollout_percentage is None:
309371
return True
310372

311-
if rollout_percentage is not None and _hash(feature_flag["key"], distinct_id) > (
312-
rollout_percentage / 100
313-
):
373+
if rollout_percentage is not None and _hash(
374+
feature_flag["key"], bucketing_value
375+
) > (rollout_percentage / 100):
314376
return False
315377

316378
return True
@@ -454,6 +516,7 @@ def match_cohort(
454516
flags_by_key=None,
455517
evaluation_cache=None,
456518
distinct_id=None,
519+
device_id=None,
457520
) -> bool:
458521
# Cohort properties are in the form of property groups like this:
459522
# {
@@ -478,6 +541,7 @@ def match_cohort(
478541
flags_by_key,
479542
evaluation_cache,
480543
distinct_id,
544+
device_id=device_id,
481545
)
482546

483547

@@ -488,6 +552,7 @@ def match_property_group(
488552
flags_by_key=None,
489553
evaluation_cache=None,
490554
distinct_id=None,
555+
device_id=None,
491556
) -> bool:
492557
if not property_group:
493558
return True
@@ -512,6 +577,7 @@ def match_property_group(
512577
flags_by_key,
513578
evaluation_cache,
514579
distinct_id,
580+
device_id=device_id,
515581
)
516582
if property_group_type == "AND":
517583
if not matches:
@@ -545,6 +611,7 @@ def match_property_group(
545611
flags_by_key,
546612
evaluation_cache,
547613
distinct_id,
614+
device_id=device_id,
548615
)
549616
elif prop.get("type") == "flag":
550617
matches = evaluate_flag_dependency(
@@ -554,6 +621,7 @@ def match_property_group(
554621
distinct_id,
555622
property_values,
556623
cohort_properties,
624+
device_id=device_id,
557625
)
558626
else:
559627
matches = match_property(prop, property_values)

0 commit comments

Comments
 (0)