Skip to content

Commit 03d2ff8

Browse files
committed
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. - When `bucketing_identifier: "device_id"`, use device_id for hash calculations instead of distinct_id - device_id can be passed as method parameter or resolved from context via `get_context_device_id()` - If device_id is required but not provided, raises InconclusiveMatchError to trigger server fallback - Group flags ignore bucketing_identifier and always use group identifier
1 parent 4f32fa4 commit 03d2ff8

3 files changed

Lines changed: 448 additions & 12 deletions

File tree

posthog/client.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1418,6 +1418,7 @@ def _compute_flag_locally(
14181418
person_properties=None,
14191419
group_properties=None,
14201420
warn_on_unknown_groups=True,
1421+
device_id=None,
14211422
) -> FlagValue:
14221423
groups = groups or {}
14231424
person_properties = person_properties or {}
@@ -1459,12 +1460,14 @@ def _compute_flag_locally(
14591460
return False
14601461

14611462
focused_group_properties = group_properties[group_name]
1463+
# Group flags use group identifier for hashing, ignore bucketing_identifier
14621464
return match_feature_flag_properties(
14631465
feature_flag,
14641466
groups[group_name],
14651467
focused_group_properties,
14661468
self.feature_flags_by_key,
14671469
evaluation_cache,
1470+
skip_bucketing_identifier=True,
14681471
)
14691472
else:
14701473
return match_feature_flag_properties(
@@ -1474,6 +1477,7 @@ def _compute_flag_locally(
14741477
self.cohorts,
14751478
self.feature_flags_by_key,
14761479
evaluation_cache,
1480+
device_id=device_id,
14771481
)
14781482

14791483
def feature_enabled(
@@ -1580,8 +1584,12 @@ def _get_feature_flag_result(
15801584
evaluated_at = None
15811585
feature_flag_error: Optional[str] = None
15821586

1587+
# Resolve device_id from context if not provided
1588+
if device_id is None:
1589+
device_id = get_context_device_id()
1590+
15831591
flag_value = self._locally_evaluate_flag(
1584-
key, distinct_id, groups, person_properties, group_properties
1592+
key, distinct_id, groups, person_properties, group_properties, device_id
15851593
)
15861594
flag_was_locally_evaluated = flag_value is not None
15871595

@@ -1785,6 +1793,7 @@ def _locally_evaluate_flag(
17851793
groups: dict[str, str],
17861794
person_properties: dict[str, str],
17871795
group_properties: dict[str, str],
1796+
device_id: Optional[str] = None,
17881797
) -> Optional[FlagValue]:
17891798
if self.feature_flags is None and self.personal_api_key:
17901799
self.load_feature_flags()
@@ -1804,6 +1813,7 @@ def _locally_evaluate_flag(
18041813
groups=groups,
18051814
person_properties=person_properties,
18061815
group_properties=group_properties,
1816+
device_id=device_id,
18071817
)
18081818
self.log.debug(
18091819
f"Successfully computed flag locally: {key} -> {response}"
@@ -2106,12 +2116,17 @@ def get_all_flags_and_payloads(
21062116
)
21072117
)
21082118

2119+
# Resolve device_id from context if not provided
2120+
if device_id is None:
2121+
device_id = get_context_device_id()
2122+
21092123
response, fallback_to_flags = self._get_all_flags_and_payloads_locally(
21102124
distinct_id,
21112125
groups=groups,
21122126
person_properties=person_properties,
21132127
group_properties=group_properties,
21142128
flag_keys_to_evaluate=flag_keys_to_evaluate,
2129+
device_id=device_id,
21152130
)
21162131

21172132
if fallback_to_flags and not only_evaluate_locally:
@@ -2142,6 +2157,7 @@ def _get_all_flags_and_payloads_locally(
21422157
group_properties=None,
21432158
warn_on_unknown_groups=False,
21442159
flag_keys_to_evaluate: Optional[list[str]] = None,
2160+
device_id: Optional[str] = None,
21452161
) -> tuple[FlagsAndPayloads, bool]:
21462162
person_properties = person_properties or {}
21472163
group_properties = group_properties or {}
@@ -2171,6 +2187,7 @@ def _get_all_flags_and_payloads_locally(
21712187
person_properties=person_properties,
21722188
group_properties=group_properties,
21732189
warn_on_unknown_groups=warn_on_unknown_groups,
2190+
device_id=device_id,
21742191
)
21752192
matched_payload = self._compute_payload_locally(
21762193
flag["key"], flags[flag["key"]]

posthog/feature_flags.py

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ def _hash(key: str, distinct_id: str, salt: str = "") -> float:
4444
return hash_val / __LONG_SCALE__
4545

4646

47-
def get_matching_variant(flag, distinct_id):
48-
hash_value = _hash(flag["key"], distinct_id, salt="variant")
47+
def get_matching_variant(flag, hashing_identifier):
48+
hash_value = _hash(flag["key"], hashing_identifier, salt="variant")
4949
for variant in variant_lookup_table(flag):
5050
if hash_value >= variant["value_min"] and hash_value < variant["value_max"]:
5151
return variant["key"]
@@ -68,7 +68,13 @@ def variant_lookup_table(feature_flag):
6868

6969

7070
def evaluate_flag_dependency(
71-
property, flags_by_key, evaluation_cache, distinct_id, properties, cohort_properties
71+
property,
72+
flags_by_key,
73+
evaluation_cache,
74+
distinct_id,
75+
properties,
76+
cohort_properties,
77+
device_id=None,
7278
):
7379
"""
7480
Evaluate a flag dependency property according to the dependency chain algorithm.
@@ -80,6 +86,7 @@ def evaluate_flag_dependency(
8086
distinct_id: The distinct ID being evaluated
8187
properties: Person properties for evaluation
8288
cohort_properties: Cohort properties for evaluation
89+
device_id: The device ID for bucketing (optional)
8390
8491
Returns:
8592
bool: True if all dependencies in the chain evaluate to True, False otherwise
@@ -131,6 +138,7 @@ def evaluate_flag_dependency(
131138
cohort_properties,
132139
flags_by_key,
133140
evaluation_cache,
141+
device_id=device_id,
134142
)
135143
evaluation_cache[dep_flag_key] = dep_result
136144
except InconclusiveMatchError as e:
@@ -222,16 +230,32 @@ def match_feature_flag_properties(
222230
cohort_properties=None,
223231
flags_by_key=None,
224232
evaluation_cache=None,
233+
device_id=None,
234+
skip_bucketing_identifier=False,
225235
) -> FlagValue:
226-
flag_conditions = (flag.get("filters") or {}).get("groups") or []
236+
flag_filters = flag.get("filters") or {}
237+
flag_conditions = flag_filters.get("groups") or []
227238
is_inconclusive = False
228239
cohort_properties = cohort_properties or {}
229240
# 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 []
241+
flag_variants = (flag_filters.get("multivariate") or {}).get("variants") or []
233242
valid_variant_keys = [variant["key"] for variant in flag_variants]
234243

244+
# Determine the hashing identifier based on bucketing_identifier setting
245+
# For group flags, skip_bucketing_identifier is True and we always use the passed identifier
246+
if skip_bucketing_identifier:
247+
hashing_identifier = distinct_id
248+
else:
249+
bucketing_identifier = flag_filters.get("bucketing_identifier")
250+
if bucketing_identifier == "device_id":
251+
if not device_id:
252+
raise InconclusiveMatchError(
253+
"Flag requires device_id for bucketing but none was provided"
254+
)
255+
hashing_identifier = device_id
256+
else:
257+
hashing_identifier = distinct_id
258+
235259
for condition in flag_conditions:
236260
try:
237261
# if any one condition resolves to True, we can shortcircuit and return
@@ -244,12 +268,14 @@ def match_feature_flag_properties(
244268
cohort_properties,
245269
flags_by_key,
246270
evaluation_cache,
271+
hashing_identifier=hashing_identifier,
272+
device_id=device_id,
247273
):
248274
variant_override = condition.get("variant")
249275
if variant_override and variant_override in valid_variant_keys:
250276
variant = variant_override
251277
else:
252-
variant = get_matching_variant(flag, distinct_id)
278+
variant = get_matching_variant(flag, hashing_identifier)
253279
return variant or True
254280
except RequiresServerEvaluation:
255281
# Static cohort or other missing server-side data - must fallback to API
@@ -277,7 +303,13 @@ def is_condition_match(
277303
cohort_properties,
278304
flags_by_key=None,
279305
evaluation_cache=None,
306+
hashing_identifier=None,
307+
device_id=None,
280308
) -> bool:
309+
# Use hashing_identifier if provided, otherwise fall back to distinct_id
310+
if hashing_identifier is None:
311+
hashing_identifier = distinct_id
312+
281313
rollout_percentage = condition.get("rollout_percentage")
282314
if len(condition.get("properties") or []) > 0:
283315
for prop in condition.get("properties"):
@@ -290,6 +322,7 @@ def is_condition_match(
290322
flags_by_key,
291323
evaluation_cache,
292324
distinct_id,
325+
device_id=device_id,
293326
)
294327
elif property_type == "flag":
295328
matches = evaluate_flag_dependency(
@@ -299,6 +332,7 @@ def is_condition_match(
299332
distinct_id,
300333
properties,
301334
cohort_properties,
335+
device_id=device_id,
302336
)
303337
else:
304338
matches = match_property(prop, properties)
@@ -308,9 +342,9 @@ def is_condition_match(
308342
if rollout_percentage is None:
309343
return True
310344

311-
if rollout_percentage is not None and _hash(feature_flag["key"], distinct_id) > (
312-
rollout_percentage / 100
313-
):
345+
if rollout_percentage is not None and _hash(
346+
feature_flag["key"], hashing_identifier
347+
) > (rollout_percentage / 100):
314348
return False
315349

316350
return True
@@ -454,6 +488,7 @@ def match_cohort(
454488
flags_by_key=None,
455489
evaluation_cache=None,
456490
distinct_id=None,
491+
device_id=None,
457492
) -> bool:
458493
# Cohort properties are in the form of property groups like this:
459494
# {
@@ -478,6 +513,7 @@ def match_cohort(
478513
flags_by_key,
479514
evaluation_cache,
480515
distinct_id,
516+
device_id=device_id,
481517
)
482518

483519

@@ -488,6 +524,7 @@ def match_property_group(
488524
flags_by_key=None,
489525
evaluation_cache=None,
490526
distinct_id=None,
527+
device_id=None,
491528
) -> bool:
492529
if not property_group:
493530
return True
@@ -512,6 +549,7 @@ def match_property_group(
512549
flags_by_key,
513550
evaluation_cache,
514551
distinct_id,
552+
device_id=device_id,
515553
)
516554
if property_group_type == "AND":
517555
if not matches:
@@ -545,6 +583,7 @@ def match_property_group(
545583
flags_by_key,
546584
evaluation_cache,
547585
distinct_id,
586+
device_id=device_id,
548587
)
549588
elif prop.get("type") == "flag":
550589
matches = evaluate_flag_dependency(
@@ -554,6 +593,7 @@ def match_property_group(
554593
distinct_id,
555594
property_values,
556595
cohort_properties,
596+
device_id=device_id,
557597
)
558598
else:
559599
matches = match_property(prop, property_values)

0 commit comments

Comments
 (0)