33
44Uses GrowthBook under the hood, but abstracts the implementation details.
55
6- Don't evaluate flags by calling into this module directly - go through the CouchersContext methods
7- (context.get_boolean_value, get_string_value, etc.), which own the per-request evaluator cache and
8- the enabled / pass-all-gates gating. The underscore-prefixed helpers here are internal to that
9- wiring; setup_experimentation() is the only public entry point, called once at process startup.
6+ Two ways to evaluate a flag:
7+ - Per-user/request: use the CouchersContext methods (context.get_boolean_value, get_string_value,
8+ etc.), which evaluate for the context's user and own the per-request evaluator cache.
9+ - Global (no user/request): use the module-level get_global_boolean_value / get_global_string_value
10+ / ... below. Use these ONLY when there is genuinely no user to evaluate for and no way to thread
11+ one through - per-user evaluation is impossible here, not merely that you don't expect the value
12+ to vary per user. Whenever a user is (or could reasonably be) available, use the context: only the
13+ per-user path can do percentage rollouts, experiments, and feature-usage tracking.
14+
15+ Both paths share the enabled / pass-all-gates gating helpers here. setup_experimentation() is called
16+ once at process startup.
1017"""
1118
1219import json
1320import logging
1421import threading
22+ from collections .abc import Callable
1523from typing import Any
1624
1725import urllib3
1826from growthbook import GrowthBook
19- from growthbook .common_types import Experiment , Result
27+ from growthbook .common_types import Experiment , FeatureResult , Result
2028from sqlalchemy .dialects .postgresql import insert
2129
2230from couchers .config import config
2331from couchers .db import session_scope
24- from couchers .models .logging import ExperimentExposure
32+ from couchers .models .logging import ExperimentExposure , FeatureUsage
2533
2634logger = logging .getLogger (__name__ )
2735
@@ -139,13 +147,18 @@ def _record_exposure(user_id: int, experiment: Experiment, result: Result, **_:
139147 session .execute (stmt )
140148
141149
150+ def _record_feature_usage (user_id : int , key : str , result : FeatureResult , ** _ : Any ) -> None :
151+ with session_scope () as session :
152+ session .add (FeatureUsage (user_id = user_id , feature_key = key , value = result .value ))
153+
154+
142155def _create_evaluator (user_id : int | None ) -> GrowthBook :
143156 """
144157 Build a per-request GrowthBook evaluator over the current feature snapshot.
145158
146159 Pass user_id=None for an anonymous (logged-out) evaluation: with no `id` attribute GrowthBook
147160 can't bucket the user, so experiments and percentage rollouts are skipped and flags fall
148- through to their defaults. No exposure is recorded without a user.
161+ through to their defaults. No exposure or usage is recorded without a user.
149162
150163 Reads the in-memory snapshot maintained by the background refresh thread - never does HTTP
151164 from the request path. Constructing without `client_key` keeps the GrowthBook a pure
@@ -164,9 +177,60 @@ def on_experiment_viewed(experiment: Experiment, result: Result, **kwargs: Any)
164177 if user_id is not None :
165178 _record_exposure (user_id , experiment , result )
166179
180+ def on_feature_usage (key : str , result : FeatureResult , * args : Any , ** kwargs : Any ) -> None :
181+ if user_id is not None :
182+ _record_feature_usage (user_id , key , result )
183+
167184 return GrowthBook (
168185 attributes = {"id" : str (user_id )} if user_id is not None else {},
169186 features = features ,
170187 savedGroups = saved_groups ,
171188 on_experiment_viewed = on_experiment_viewed ,
189+ on_feature_usage = on_feature_usage ,
172190 )
191+
192+
193+ def _global_evaluator () -> GrowthBook :
194+ """Build an anonymous evaluator for flag evaluation with no user/request context."""
195+ return _create_evaluator (None )
196+
197+
198+ # These two helpers are the single home of the gating logic, shared by the global functions below
199+ # and by CouchersContext (which passes its own cached per-request evaluator). get_evaluator is only
200+ # invoked once gating passes, so it stays lazy.
201+ def _feature_value [T ](flag_key : str , default : T , get_evaluator : Callable [[], GrowthBook ]) -> T :
202+ if not config ["EXPERIMENTATION_ENABLED" ]:
203+ return default
204+ return get_evaluator ().get_feature_value (flag_key , default ) # type: ignore[no-any-return]
205+
206+
207+ def _boolean_value (flag_key : str , default : bool , get_evaluator : Callable [[], GrowthBook ]) -> bool :
208+ if config ["EXPERIMENTATION_PASS_ALL_GATES" ]:
209+ return True
210+ return _feature_value (flag_key , default , get_evaluator )
211+
212+
213+ # Global (no-user) flag evaluation. Use these ONLY when there is genuinely no user to evaluate for and
214+ # no way to thread one through - per-user evaluation is impossible here, not merely that you don't
215+ # expect the value to vary per user. If a user is (or could reasonably be) available, use the
216+ # CouchersContext methods instead: only the per-user path does percentage rollouts, experiments, and
217+ # feature-usage tracking. With no user to bucket, rollouts and experiments are skipped and flags fall
218+ # through to their in-code defaults unless a rule forces a value globally.
219+ def get_global_boolean_value (flag_key : str , default : bool ) -> bool :
220+ return _boolean_value (flag_key , default , _global_evaluator )
221+
222+
223+ def get_global_string_value (flag_key : str , default : str ) -> str :
224+ return _feature_value (flag_key , default , _global_evaluator )
225+
226+
227+ def get_global_integer_value (flag_key : str , default : int ) -> int :
228+ return _feature_value (flag_key , default , _global_evaluator )
229+
230+
231+ def get_global_float_value (flag_key : str , default : float ) -> float :
232+ return _feature_value (flag_key , default , _global_evaluator )
233+
234+
235+ def get_global_object_value [T ](flag_key : str , default : T ) -> T :
236+ return _feature_value (flag_key , default , _global_evaluator )
0 commit comments