Skip to content

Commit a49a2a0

Browse files
aapelivclaude
andcommitted
Add global flag evaluation; drop logged-out-context hack in update_badges
Add module-level get_boolean_value / get_string_value / ... to experimentation for evaluating flags with no user/request context (anonymous, like a logged-out user). The enabled / pass-all-gates gating moves into shared helpers that both the new global functions and CouchersContext use - the context keeps its cached per-request evaluator by passing it in. update_badges now calls experimentation.get_boolean_value directly instead of constructing a throwaway logged-out CouchersContext just to read a flag. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2a48087 commit a49a2a0

4 files changed

Lines changed: 77 additions & 24 deletions

File tree

app/backend/src/couchers/context.py

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import grpc
44

55
from couchers import experimentation
6-
from couchers.config import config
76
from couchers.i18n import LocalizationContext
87

98
if TYPE_CHECKING:
@@ -181,29 +180,23 @@ def token(self) -> str:
181180
def localization(self) -> LocalizationContext:
182181
return self.__localization
183182

184-
# Feature-flag evaluation methods mirror the OpenFeature evaluation API. The in-code default is
185-
# honored even for flags not yet set up in GrowthBook, since get_feature_value falls back to it.
183+
# Feature-flag evaluation methods mirror the OpenFeature evaluation API, evaluating for this
184+
# context's user. The gating lives in experimentation; we just pass our cached per-request
185+
# evaluator. The in-code default is honored even for flags not yet set up in GrowthBook.
186186
def get_boolean_value(self, flag_key: str, default: bool) -> bool:
187-
if config["EXPERIMENTATION_PASS_ALL_GATES"]:
188-
return True
189-
return self._get_feature_value(flag_key, default)
187+
return experimentation._boolean_value(flag_key, default, self._get_growthbook)
190188

191189
def get_string_value(self, flag_key: str, default: str) -> str:
192-
return self._get_feature_value(flag_key, default)
190+
return experimentation._feature_value(flag_key, default, self._get_growthbook)
193191

194192
def get_integer_value(self, flag_key: str, default: int) -> int:
195-
return self._get_feature_value(flag_key, default)
193+
return experimentation._feature_value(flag_key, default, self._get_growthbook)
196194

197195
def get_float_value(self, flag_key: str, default: float) -> float:
198-
return self._get_feature_value(flag_key, default)
196+
return experimentation._feature_value(flag_key, default, self._get_growthbook)
199197

200198
def get_object_value[T](self, flag_key: str, default: T) -> T:
201-
return self._get_feature_value(flag_key, default)
202-
203-
def _get_feature_value[T](self, flag_key: str, default: T) -> T:
204-
if not config["EXPERIMENTATION_ENABLED"]:
205-
return default
206-
return self._get_growthbook().get_feature_value(flag_key, default) # type: ignore[no-any-return]
199+
return experimentation._feature_value(flag_key, default, self._get_growthbook)
207200

208201
def _get_growthbook(self) -> GrowthBook:
209202
if self._growthbook is None:

app/backend/src/couchers/experimentation.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,20 @@
33
44
Uses 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_boolean_value / get_string_value / ... below.
10+
These evaluate anonymously and are for background jobs and other code with no CouchersContext.
11+
12+
Both paths share the enabled / pass-all-gates gating helpers here. setup_experimentation() is called
13+
once at process startup.
1014
"""
1115

1216
import json
1317
import logging
1418
import threading
19+
from collections.abc import Callable
1520
from typing import Any
1621

1722
import urllib3
@@ -170,3 +175,46 @@ def on_experiment_viewed(experiment: Experiment, result: Result, **kwargs: Any)
170175
savedGroups=saved_groups,
171176
on_experiment_viewed=on_experiment_viewed,
172177
)
178+
179+
180+
def _global_evaluator() -> GrowthBook:
181+
"""Build an anonymous evaluator for flag evaluation with no user/request context."""
182+
return _create_evaluator(None)
183+
184+
185+
# These two helpers are the single home of the gating logic, shared by the global functions below
186+
# and by CouchersContext (which passes its own cached per-request evaluator). get_evaluator is only
187+
# invoked once gating passes, so it stays lazy.
188+
def _feature_value[T](flag_key: str, default: T, get_evaluator: Callable[[], GrowthBook]) -> T:
189+
if not config["EXPERIMENTATION_ENABLED"]:
190+
return default
191+
return get_evaluator().get_feature_value(flag_key, default) # type: ignore[no-any-return]
192+
193+
194+
def _boolean_value(flag_key: str, default: bool, get_evaluator: Callable[[], GrowthBook]) -> bool:
195+
if config["EXPERIMENTATION_PASS_ALL_GATES"]:
196+
return True
197+
return _feature_value(flag_key, default, get_evaluator)
198+
199+
200+
# Global (no user/request) flag evaluation. Mirrors the CouchersContext.get_*_value API but
201+
# evaluates anonymously: experiments and percentage rollouts are skipped (no user to bucket), so
202+
# flags fall through to their in-code defaults unless a rule forces a value globally.
203+
def get_boolean_value(flag_key: str, default: bool) -> bool:
204+
return _boolean_value(flag_key, default, _global_evaluator)
205+
206+
207+
def get_string_value(flag_key: str, default: str) -> str:
208+
return _feature_value(flag_key, default, _global_evaluator)
209+
210+
211+
def get_integer_value(flag_key: str, default: int) -> int:
212+
return _feature_value(flag_key, default, _global_evaluator)
213+
214+
215+
def get_float_value(flag_key: str, default: float) -> float:
216+
return _feature_value(flag_key, default, _global_evaluator)
217+
218+
219+
def get_object_value[T](flag_key: str, default: T) -> T:
220+
return _feature_value(flag_key, default, _global_evaluator)

app/backend/src/couchers/jobs/handlers.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
update,
3030
)
3131

32+
from couchers import experimentation
3233
from couchers.config import config
3334
from couchers.constants import (
3435
ACTIVENESS_PROBE_EXPIRY_TIME,
@@ -38,7 +39,7 @@
3839
HOST_REQUEST_MAX_REMINDERS,
3940
HOST_REQUEST_REMINDER_INTERVAL,
4041
)
41-
from couchers.context import make_background_user_context, make_logged_out_context
42+
from couchers.context import make_background_user_context
4243
from couchers.crypto import (
4344
USER_LOCATION_RANDOMIZATION_NAME,
4445
asym_encrypt,
@@ -53,7 +54,6 @@
5354
from couchers.event_log import log_event
5455
from couchers.helpers.badges import user_add_badge, user_remove_badge
5556
from couchers.helpers.completed_profile import has_completed_profile_expression
56-
from couchers.i18n import LocalizationContext
5757
from couchers.materialized_views import (
5858
UserResponseRate,
5959
)
@@ -836,12 +836,11 @@ def float_(stmt: Any) -> Function[float]:
836836

837837
def update_badges(payload: empty_pb2.Empty) -> None:
838838
with session_scope() as session:
839-
# feature flags that gate a badge are evaluated globally (no per-user request here)
840-
flag_context = make_logged_out_context(LocalizationContext.en_utc())
841839

842840
def update_badge(badge_id: str, members: Sequence[int]) -> None:
843841
badge = get_badge_dict()[badge_id]
844-
if badge.flag is not None and not flag_context.get_boolean_value(badge.flag, default=True):
842+
# a flag that gates a badge is evaluated globally (no per-user request here)
843+
if badge.flag is not None and not experimentation.get_boolean_value(badge.flag, default=True):
845844
members = []
846845
user_ids = session.execute(select(UserBadge.user_id).where(UserBadge.badge_id == badge.id)).scalars().all()
847846
# in case the user ids don't exist in the db

app/backend/src/tests/test_experimentation.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,16 @@ def test_anonymous_user_still_gets_global_force_on_flag(experimentation_snapshot
4141
def test_unknown_feature_returns_in_code_default(experimentation_snapshot):
4242
context = make_logged_out_context(LocalizationContext.en_utc())
4343
assert context.get_string_value("does_not_exist", "my_default") == "my_default"
44+
45+
46+
def test_global_evaluation_excluded_from_rollout_gets_feature_default(experimentation_snapshot):
47+
# global (no-user) evaluation can't bucket into a rollout, so it gets the feature default
48+
assert experimentation.get_string_value("rollout_flag", "fallback") == "control"
49+
50+
51+
def test_global_evaluation_gets_global_force_on_flag(experimentation_snapshot):
52+
assert experimentation.get_boolean_value("global_flag", default=False) is True
53+
54+
55+
def test_global_evaluation_unknown_feature_returns_in_code_default(experimentation_snapshot):
56+
assert experimentation.get_string_value("does_not_exist", "my_default") == "my_default"

0 commit comments

Comments
 (0)