|
2 | 2 | Experimentation framework for feature flags and experiments. |
3 | 3 |
|
4 | 4 | Uses GrowthBook under the hood, but abstracts the implementation details. |
| 5 | +
|
| 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. |
5 | 10 | """ |
6 | 11 |
|
7 | 12 | import json |
8 | 13 | import logging |
9 | 14 | import threading |
10 | | -from typing import TYPE_CHECKING, Any |
| 15 | +from typing import Any |
11 | 16 |
|
12 | 17 | import urllib3 |
13 | 18 | from growthbook import GrowthBook |
|
18 | 23 | from couchers.db import session_scope |
19 | 24 | from couchers.models.logging import ExperimentExposure |
20 | 25 |
|
21 | | -if TYPE_CHECKING: |
22 | | - from couchers.context import CouchersContext |
23 | | - |
24 | 26 | logger = logging.getLogger(__name__) |
25 | 27 |
|
26 | 28 | _REFRESH_INTERVAL_SECONDS = 60 |
@@ -110,13 +112,6 @@ def setup_experimentation() -> None: |
110 | 112 | logger.info(f"Experimentation integration test: gate 'test_growthbook_integration' = {test_gate_result}") |
111 | 113 |
|
112 | 114 |
|
113 | | -def _check_initialized() -> None: |
114 | | - if config["EXPERIMENTATION_ENABLED"] and not _initialized: |
115 | | - raise ExperimentationNotInitializedError( |
116 | | - "Experimentation is not initialized - call setup_experimentation() first" |
117 | | - ) |
118 | | - |
119 | | - |
120 | 115 | def _record_exposure(user_id: int, experiment: Experiment, result: Result, **_: Any) -> None: |
121 | 116 | data = { |
122 | 117 | "experiment_name": experiment.name, |
@@ -144,59 +139,34 @@ def _record_exposure(user_id: int, experiment: Experiment, result: Result, **_: |
144 | 139 | session.execute(stmt) |
145 | 140 |
|
146 | 141 |
|
147 | | -def _get_growthbook(context: CouchersContext) -> GrowthBook: |
| 142 | +def _create_evaluator(user_id: int | None) -> GrowthBook: |
148 | 143 | """ |
149 | | - Get or create a cached GrowthBook instance for the given context. |
150 | | -
|
151 | | - Reads the in-memory feature snapshot maintained by the background refresh |
152 | | - thread - never does HTTP from the request path. Constructing without |
153 | | - `client_key` keeps the GrowthBook a pure evaluator: no callback |
154 | | - registration on the library's process-wide singleton. |
155 | | - """ |
156 | | - gb = context._growthbook |
157 | | - if gb is None: |
158 | | - with _state_lock: |
159 | | - features = _state["features"] |
160 | | - saved_groups = _state["savedGroups"] |
161 | | - |
162 | | - user_id = context.user_id |
163 | | - |
164 | | - def on_experiment_viewed(experiment: Experiment, result: Result, **kwargs: Any) -> None: |
165 | | - _record_exposure(user_id, experiment, result) |
166 | | - |
167 | | - gb = GrowthBook( |
168 | | - attributes={"id": str(user_id)}, |
169 | | - features=features, |
170 | | - savedGroups=saved_groups, |
171 | | - on_experiment_viewed=on_experiment_viewed, |
172 | | - ) |
173 | | - context._growthbook = gb |
174 | | - return gb |
| 144 | + Build a per-request GrowthBook evaluator over the current feature snapshot. |
175 | 145 |
|
| 146 | + Pass user_id=None for an anonymous (logged-out) evaluation: with no `id` attribute GrowthBook |
| 147 | + 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. |
176 | 149 |
|
177 | | -def check_gate(context: CouchersContext, gate_name: str) -> bool: |
| 150 | + Reads the in-memory snapshot maintained by the background refresh thread - never does HTTP |
| 151 | + from the request path. Constructing without `client_key` keeps the GrowthBook a pure |
| 152 | + evaluator: no callback registration on the library's process-wide singleton. The caller is |
| 153 | + responsible for caching this for the lifetime of a request. |
178 | 154 | """ |
179 | | - Check if a feature gate is enabled for the user in this context. |
180 | | -
|
181 | | - Returns False if experimentation is disabled, True if EXPERIMENTATION_PASS_ALL_GATES is set. |
182 | | - """ |
183 | | - _check_initialized() |
184 | | - if config["EXPERIMENTATION_PASS_ALL_GATES"]: |
185 | | - return True |
186 | | - if not config["EXPERIMENTATION_ENABLED"]: |
187 | | - return False |
188 | | - return _get_growthbook(context).is_on(gate_name) |
189 | | - |
| 155 | + if not _initialized: |
| 156 | + raise ExperimentationNotInitializedError( |
| 157 | + "Experimentation is not initialized - call setup_experimentation() first" |
| 158 | + ) |
| 159 | + with _state_lock: |
| 160 | + features = _state["features"] |
| 161 | + saved_groups = _state["savedGroups"] |
190 | 162 |
|
191 | | -def get_feature_value[T](context: CouchersContext, feature_name: str, default: T) -> T: |
192 | | - """ |
193 | | - Get the value of a feature for the user in this context. |
| 163 | + def on_experiment_viewed(experiment: Experiment, result: Result, **kwargs: Any) -> None: |
| 164 | + if user_id is not None: |
| 165 | + _record_exposure(user_id, experiment, result) |
194 | 166 |
|
195 | | - Use this for non-boolean features: strings, numbers, dicts, experiment variations, |
196 | | - dynamic configs - anything other than a simple on/off gate. The default's type |
197 | | - determines the return type and is returned verbatim when experimentation is disabled. |
198 | | - """ |
199 | | - _check_initialized() |
200 | | - if not config["EXPERIMENTATION_ENABLED"]: |
201 | | - return default |
202 | | - return _get_growthbook(context).get_feature_value(feature_name, default) # type: ignore[no-any-return] |
| 167 | + return GrowthBook( |
| 168 | + attributes={"id": str(user_id)} if user_id is not None else {}, |
| 169 | + features=features, |
| 170 | + savedGroups=saved_groups, |
| 171 | + on_experiment_viewed=on_experiment_viewed, |
| 172 | + ) |
0 commit comments