Skip to content

Commit 1c0df45

Browse files
authored
add evaluation context and target app support (#462)
<img width="654" height="135" alt="Screenshot 2025-09-08 at 4 27 56 PM" src="https://github.com/user-attachments/assets/68267381-cfe3-4072-93ee-dc355810b56b" /> statsig-io/kong#3793
1 parent f8c5a25 commit 1c0df45

File tree

8 files changed

+82
-37
lines changed

8 files changed

+82
-37
lines changed

statsig/client_initialize_formatter.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Any, Dict, Optional, Union
55

66
from .config_evaluation import _ConfigEvaluation
7+
from .evaluation_context import EvaluationContext
78
from .spec_store import _SpecStore, EntityType
89
from .statsig_metadata import _StatsigMetadata
910
from .statsig_user import StatsigUser
@@ -31,8 +32,20 @@ def get_formatted_response(
3132
evaluator,
3233
hash_algo: HashingAlgorithm,
3334
client_sdk_key=None,
34-
include_local_override=False
35+
include_local_override=False,
36+
target_app_id: Optional[str] = None,
3537
) -> ClientInitializeResponse:
38+
context = EvaluationContext(
39+
target_app_id=target_app_id,
40+
client_key=client_sdk_key
41+
)
42+
app_id = None
43+
if target_app_id is not None:
44+
app_id = target_app_id
45+
if client_sdk_key is not None:
46+
app_id = spec_store.get_target_app_for_sdk_key(client_sdk_key)
47+
context.target_app_id = app_id
48+
3649
def convert_to_entity_type(entity: str, type: str) -> Optional[EntityType]:
3750
if entity == "layer":
3851
return EntityType.LAYER
@@ -43,9 +56,8 @@ def convert_to_entity_type(entity: str, type: str) -> Optional[EntityType]:
4356
return None
4457

4558
def config_to_response(config_name, config_spec):
46-
target_app_id = spec_store.get_target_app_for_sdk_key(client_sdk_key)
4759
config_target_apps = config_spec.get("targetAppIDs", [])
48-
if target_app_id is not None and target_app_id not in config_target_apps:
60+
if app_id is not None and app_id not in config_target_apps:
4961
return None
5062

5163
eval_result = _ConfigEvaluation()
@@ -61,7 +73,7 @@ def config_to_response(config_name, config_spec):
6173
if local_override is not None:
6274
eval_result = local_override
6375
else:
64-
eval_func(user, config_name, convert_to_entity_type(entity, type), eval_result)
76+
eval_func(user, config_name, convert_to_entity_type(entity, type), eval_result, context)
6577

6678
if eval_result is None:
6779
return None
@@ -142,7 +154,7 @@ def populate_layer_fields(config_spec, eval_result, result, hash_algo):
142154
if delegate is not None and delegate != "":
143155
delegate_spec = spec_store.get_config(delegate)
144156
delegate_result = _ConfigEvaluation()
145-
eval_func(user, delegate, EntityType.CONFIG, delegate_result)
157+
eval_func(user, delegate, EntityType.CONFIG, delegate_result, context)
146158

147159
if delegate_spec is not None:
148160
result["allocated_experiment_name"] = hash_name(delegate, hash_algo)

statsig/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
class Const:
22
SUPPORTED_CONDITION_TYPES = ['public', 'fail_gate', 'pass_gate', 'ip_based', 'ua_based', 'user_field',
33
'environment_field', 'current_time', 'user_bucket', 'unit_id', 'multi_pass_gate',
4-
'multi_fail_gate']
4+
'multi_fail_gate', 'target_app']
55

66
SUPPORTED_OPERATORS = ['gt', 'gte', 'lt', 'lte',
77
'version_gt', 'version_gte', 'version_lt', 'version_lte',

statsig/evaluation_context.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from typing import Optional
2+
3+
class EvaluationContext:
4+
"""
5+
Context object that holds optional evaluation parameters that need to be passed
6+
down through the evaluation function chain.
7+
"""
8+
9+
def __init__(self,
10+
sampling_rate: Optional[float] = None,
11+
client_key: Optional[str] = None,
12+
target_app_id: Optional[str] = None):
13+
self.sampling_rate = sampling_rate
14+
self.client_key = client_key
15+
self.target_app_id = target_app_id

statsig/evaluator.py

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from .client_initialize_formatter import ClientInitializeResponseFormatter
1111
from .config_evaluation import _ConfigEvaluation
12+
from .evaluation_context import EvaluationContext
1213
from .evaluation_details import EvaluationDetails, EvaluationReason, DataSource
1314
from .globals import logger
1415
from .spec_store import _SpecStore, EntityType
@@ -113,6 +114,7 @@ def get_client_initialize_response(
113114
hash: HashingAlgorithm,
114115
client_sdk_key=None,
115116
include_local_override=False,
117+
target_app_id: Optional[str] = None,
116118
):
117119
if not self._spec_store.is_ready_for_checks():
118120
return None
@@ -122,7 +124,7 @@ def get_client_initialize_response(
122124

123125
return ClientInitializeResponseFormatter \
124126
.get_formatted_response(self.__eval_config, user, self._spec_store, self, hash, client_sdk_key,
125-
include_local_override)
127+
include_local_override, target_app_id)
126128

127129
def _create_evaluation_details(self,
128130
reason: EvaluationReason = EvaluationReason.none,
@@ -217,7 +219,8 @@ def __lookup_override(self, config_overrides, user):
217219

218220
def __lookup_config_mapping(self, user: StatsigUser, config_name: str, spec_type: EntityType,
219221
end_result: _ConfigEvaluation,
220-
maybe_config: Union[Dict[str, Any], None] = None) -> bool:
222+
context: EvaluationContext,
223+
maybe_config: Union[Dict[str, Any], None] = None,) -> bool:
221224
overrides = self._spec_store.get_overrides()
222225
if overrides is None or not isinstance(overrides, dict):
223226
return False
@@ -245,7 +248,8 @@ def __lookup_config_mapping(self, user: StatsigUser, config_name: str, spec_type
245248
continue
246249

247250
end_result.reset()
248-
self.__evaluate_rule(user, rule, end_result)
251+
context.sampling_rate = rule.get("samplingRate", None)
252+
self.__evaluate_rule(user, rule, end_result, context)
249253
if not end_result.boolean_value or end_result.evaluation_details.reason in (
250254
EvaluationReason.unsupported, EvaluationReason.unrecognized):
251255
end_result.reset()
@@ -257,7 +261,7 @@ def __lookup_config_mapping(self, user: StatsigUser, config_name: str, spec_type
257261
config_pass = self.__eval_pass_percentage(user, rule, new_config, spec_salt)
258262
if config_pass:
259263
end_result.override_config_name = override_config_name
260-
self.__evaluate(user, override_config_name, spec_type, end_result)
264+
self.__evaluate(user, override_config_name, spec_type, end_result, context)
261265
if end_result.evaluation_details.reason == EvaluationReason.none:
262266
return True
263267
return False
@@ -278,14 +282,16 @@ def unsupported_or_unrecognized(self, config_name, end_result):
278282
return self._create_evaluation_details(EvaluationReason.unsupported)
279283
return self._create_evaluation_details(EvaluationReason.unrecognized)
280284

281-
def check_gate(self, user, gate, end_result=None, is_nested=False):
285+
def check_gate(self, user, gate, end_result=None, is_nested=False, context: Optional[EvaluationContext] = None):
282286
override = self.__lookup_gate_override(user, gate)
283287
if override is not None:
284288
return override
285289

286290
if end_result is None:
287291
end_result = _ConfigEvaluation()
288-
self.__eval_config(user, gate, EntityType.GATE, end_result, is_nested)
292+
if context is None:
293+
context = EvaluationContext()
294+
self.__eval_config(user, gate, EntityType.GATE, end_result, context, is_nested)
289295
return end_result
290296

291297
def get_config(self, user, config_name):
@@ -294,7 +300,7 @@ def get_config(self, user, config_name):
294300
return override
295301

296302
result = _ConfigEvaluation()
297-
self.__eval_config(user, config_name, EntityType.CONFIG, result)
303+
self.__eval_config(user, config_name, EntityType.CONFIG, result, EvaluationContext())
298304
return result
299305

300306
def get_layer(self, user, layer_name):
@@ -303,16 +309,16 @@ def get_layer(self, user, layer_name):
303309
return override
304310

305311
result = _ConfigEvaluation()
306-
self.__eval_config(user, layer_name, EntityType.LAYER, result)
312+
self.__eval_config(user, layer_name, EntityType.LAYER, result, EvaluationContext())
307313
return result
308314

309-
def __eval_config(self, user, config_name, entity_type: EntityType, end_result, is_nested=False):
315+
def __eval_config(self, user, config_name, entity_type: EntityType, end_result, context: EvaluationContext, is_nested=False):
310316
try:
311317
if not entity_type:
312318
logger.warning("invalid entity type in evaluation: %s", config_name)
313319
end_result.rule_id = "error"
314320
return
315-
self.__evaluate(user, config_name, entity_type, end_result, is_nested)
321+
self.__evaluate(user, config_name, entity_type, end_result, context, is_nested)
316322
except RecursionError:
317323
raise
318324
except Exception:
@@ -328,10 +334,10 @@ def __check_id_in_list(self, id, list_name):
328334
sha256(str(id).encode('utf-8')).digest()).decode('utf-8')[0:8]
329335
return hashed in ids
330336

331-
def __evaluate(self, user, config_name, entity_type, end_result, is_nested=False):
337+
def __evaluate(self, user, config_name, entity_type, end_result, context: EvaluationContext, is_nested=False):
332338
maybe_config_spec = self.__get_config_by_entity_type(config_name, entity_type)
333339

334-
override_config = self.__lookup_config_mapping(user, config_name, entity_type, end_result, maybe_config_spec)
340+
override_config = self.__lookup_config_mapping(user, config_name, entity_type, end_result, context, maybe_config_spec)
335341
if override_config:
336342
return
337343

@@ -344,9 +350,10 @@ def __evaluate(self, user, config_name, entity_type, end_result, is_nested=False
344350
return
345351

346352
for rule in maybe_config_spec.get("rules", []):
347-
self.__evaluate_rule(user, rule, end_result)
353+
context.sampling_rate = rule.get("samplingRate", None)
354+
self.__evaluate_rule(user, rule, end_result, context)
348355
if end_result.boolean_value:
349-
if self.__evaluate_delegate(user, rule, end_result) is not None:
356+
if self.__evaluate_delegate(user, rule, end_result, context) is not None:
350357
self.__finalize_exposures(end_result)
351358
return
352359

@@ -389,15 +396,15 @@ def __finalize_exposures(self, end_result):
389396
end_result.secondary_exposures = self.clean_exposures(end_result.secondary_exposures)
390397
end_result.undelegated_secondary_exposures = self.clean_exposures(end_result.undelegated_secondary_exposures)
391398

392-
def __evaluate_rule(self, user, rule, end_result):
399+
def __evaluate_rule(self, user, rule, end_result, context: EvaluationContext):
393400
total_eval_result = True
394401
for condition in rule.get("conditions", []):
395-
eval_result = self.__evaluate_condition(user, condition, end_result, rule.get("samplingRate", None))
402+
eval_result = self.__evaluate_condition(user, condition, end_result, context)
396403
if not eval_result:
397404
total_eval_result = False
398405
end_result.boolean_value = total_eval_result
399406

400-
def __evaluate_delegate(self, user, rule, end_result):
407+
def __evaluate_delegate(self, user, rule, end_result, context: EvaluationContext):
401408
config_delegate = rule.get("configDelegate", None)
402409
if config_delegate is None:
403410
return None
@@ -408,23 +415,23 @@ def __evaluate_delegate(self, user, rule, end_result):
408415

409416
end_result.undelegated_secondary_exposures = end_result.secondary_exposures[:]
410417

411-
self.__evaluate(user, config_delegate, EntityType.CONFIG, end_result, True)
418+
self.__evaluate(user, config_delegate, EntityType.CONFIG, end_result, context, True)
412419
end_result.explicit_parameters = config.get(
413420
"explicitParameters", [])
414421
end_result.allocated_experiment = config_delegate
415422
return end_result
416423

417-
def __evaluate_condition(self, user, condition, end_result, sampling_rate=None):
424+
def __evaluate_condition(self, user, condition, end_result, context: EvaluationContext):
418425
value = None
419426
type = condition.get("type", "").upper()
420427
target = condition.get("targetValue")
421428
field = condition.get("field", "")
422429
id_Type = condition.get("idType", "userID")
423430
if type == "PUBLIC":
424-
end_result.analytical_condition = sampling_rate is None
431+
end_result.analytical_condition = context.sampling_rate is None
425432
return True
426433
if type in ("FAIL_GATE", "PASS_GATE"):
427-
delegated_gate = self.check_gate(user, target, end_result, True)
434+
delegated_gate = self.check_gate(user, target, end_result, True, context)
428435

429436
new_exposure = {
430437
"gate": target,
@@ -438,15 +445,15 @@ def __evaluate_condition(self, user, condition, end_result, sampling_rate=None):
438445

439446
pass_gate = delegated_gate.boolean_value if type == "PASS_GATE" else not delegated_gate.boolean_value
440447

441-
end_result.analytical_condition = sampling_rate is None
448+
end_result.analytical_condition = context.sampling_rate is None
442449
return pass_gate
443450
if type in ("MULTI_PASS_GATE", "MULTI_FAIL_GATE"):
444451
if target is None or len(target) == 0:
445-
end_result.analytical_condition = sampling_rate is None
452+
end_result.analytical_condition = context.sampling_rate is None
446453
return False
447454
pass_gate = False
448455
for gate in target:
449-
other_result = self.check_gate(user, gate)
456+
other_result = self.check_gate(user, gate, context=context)
450457

451458
new_exposure = {
452459
"gate": gate,
@@ -461,7 +468,7 @@ def __evaluate_condition(self, user, condition, end_result, sampling_rate=None):
461468
if pass_gate:
462469
break
463470

464-
end_result.analytical_condition = sampling_rate is None
471+
end_result.analytical_condition = context.sampling_rate is None
465472
return pass_gate
466473
if type == "IP_BASED":
467474
value = self.__get_from_user(user, field)
@@ -478,7 +485,7 @@ def __evaluate_condition(self, user, condition, end_result, sampling_rate=None):
478485
self._country_lookup = CountryLookup()
479486
value = self._country_lookup.lookupStr(ip)
480487
if value is None:
481-
end_result.analytical_condition = sampling_rate is None
488+
end_result.analytical_condition = context.sampling_rate is None
482489
return False
483490
elif type == "UA_BASED":
484491
value = self.__get_from_user(user, field)
@@ -499,8 +506,13 @@ def __evaluate_condition(self, user, condition, end_result, sampling_rate=None):
499506
salt_str + "." + unit_id) % 1000)
500507
elif type == "UNIT_ID":
501508
value = self.__get_unit_id(user, id_Type)
509+
elif type == "TARGET_APP":
510+
if context.client_key is not None:
511+
value = context.target_app_id
512+
else:
513+
value = self._spec_store.get_app_id()
502514

503-
end_result.analytical_condition = sampling_rate is None
515+
end_result.analytical_condition = context.sampling_rate is None
504516

505517
op = condition.get("operator")
506518
user_bucket = condition.get("user_bucket")

statsig/http_worker.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import ijson
1212
import requests
1313

14-
from statsig.stream_decompressor import StreamDecompressor
14+
from .stream_decompressor import StreamDecompressor
1515

1616
from . import globals
1717
from .diagnostics import Diagnostics, Marker

statsig/spec_store.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def __init__(
5858
self._session_replay_info: Union[None, Dict[str, Dict]] = None
5959
self._overrides: Union[None, Dict[str, Dict]] = None
6060
self._override_rules: Union[None, Dict[str, Dict]] = None
61+
self._app_id: Union[None, str] = None
6162

6263
self._id_lists: Dict[str, dict] = {}
6364
self.unsupported_configs: Set[str] = set()
@@ -143,6 +144,9 @@ def get_target_app_for_sdk_key(self, sdk_key=None):
143144
return target_app_id
144145
return self._sdk_keys_to_app_ids.get(sdk_key)
145146

147+
def get_app_id(self):
148+
return self._app_id
149+
146150
def get_default_environment(self):
147151
return self._default_environment
148152

@@ -256,6 +260,7 @@ def parse_override_rules(spec_override_rules: Union[None, Dict[str, Dict]]):
256260
self._session_replay_info = specs_json.get("session_replay_info", None)
257261
self._overrides = specs_json.get("overrides", None)
258262
self._override_rules = parse_override_rules(specs_json.get("override_rules", None))
263+
self._app_id = specs_json.get("app_id", None)
259264

260265
if self.spec_updater.last_update_time > prev_lcut:
261266
globals.logger.log_config_sync_update(self.spec_updater.initialized, True,

statsig/statsig.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ def remove_all_overrides():
286286

287287
def get_client_initialize_response(user: StatsigUser, client_sdk_key: Optional[str] = None,
288288
hash: Optional[HashingAlgorithm] = HashingAlgorithm.SHA256,
289-
include_local_overrides: Optional[bool] = False) -> ClientInitializeResponse:
289+
include_local_overrides: Optional[bool] = False, target_app_id: Optional[str] = None) -> ClientInitializeResponse:
290290
"""
291291
Gets all evaluated values for the given user.
292292
These values can then be given to a Statsig Client SDK via bootstrapping.
@@ -296,7 +296,7 @@ def get_client_initialize_response(user: StatsigUser, client_sdk_key: Optional[s
296296
:param client_sdk_key: (Optional) The client sdk key to use for bootstrapping
297297
:return: An initialize response containing evaluated gates/configs/layers
298298
"""
299-
return __instance.get_client_initialize_response(user, client_sdk_key, hash, include_local_overrides)
299+
return __instance.get_client_initialize_response(user, client_sdk_key, hash, include_local_overrides, target_app_id)
300300

301301

302302
def evaluate_all(user: StatsigUser):

statsig/statsig_server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -478,12 +478,13 @@ def get_client_initialize_response(
478478
client_sdk_key: Optional[str] = None,
479479
hash: Optional[HashingAlgorithm] = HashingAlgorithm.SHA256,
480480
include_local_overrides: Optional[bool] = False,
481+
target_app_id: Optional[str] = None,
481482
):
482483
hash_value = hash.value if hash is not None else HashingAlgorithm.SHA256.value
483484

484485
def task():
485486
result = self._evaluator.get_client_initialize_response(
486-
self.__normalize_user(user), hash or HashingAlgorithm.SHA256, client_sdk_key, include_local_overrides
487+
self.__normalize_user(user), hash or HashingAlgorithm.SHA256, client_sdk_key, include_local_overrides, target_app_id
487488
)
488489
if result is None:
489490
self._errorBoundary.log_exception("get_client_initialize_response",

0 commit comments

Comments
 (0)