Skip to content

Commit b5f9163

Browse files
authored
Merge pull request #8759 from Couchers-org/backend/refactor/openfeature-style-flag-methods
Backend: move feature-flag checks onto context with OpenFeature-style API
2 parents 01ea3c7 + 9296213 commit b5f9163

6 files changed

Lines changed: 112 additions & 65 deletions

File tree

app/backend/src/couchers/context.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import grpc
44

5+
from couchers import experimentation
6+
from couchers.config import config
57
from couchers.i18n import LocalizationContext
68

79
if TYPE_CHECKING:
@@ -179,6 +181,36 @@ def token(self) -> str:
179181
def localization(self) -> LocalizationContext:
180182
return self.__localization
181183

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.
186+
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)
190+
191+
def get_string_value(self, flag_key: str, default: str) -> str:
192+
return self._get_feature_value(flag_key, default)
193+
194+
def get_integer_value(self, flag_key: str, default: int) -> int:
195+
return self._get_feature_value(flag_key, default)
196+
197+
def get_float_value(self, flag_key: str, default: float) -> float:
198+
return self._get_feature_value(flag_key, default)
199+
200+
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]
207+
208+
def _get_growthbook(self) -> GrowthBook:
209+
if self._growthbook is None:
210+
# _user_id is None when logged out: evaluate anonymously, falling through to defaults.
211+
self._growthbook = experimentation._create_evaluator(self._user_id)
212+
return self._growthbook
213+
182214

183215
def make_interactive_context(
184216
grpc_context: grpc.ServicerContext,

app/backend/src/couchers/db.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from contextlib import contextmanager
77
from os import getpid
88
from threading import get_ident
9+
from typing import TYPE_CHECKING
910

1011
from alembic import command
1112
from alembic.config import Config
@@ -19,7 +20,6 @@
1920

2021
from couchers.config import config
2122
from couchers.constants import SERVER_THREADS, WORKER_THREADS
22-
from couchers.context import CouchersContext
2323
from couchers.models import (
2424
Cluster,
2525
ClusterRole,
@@ -33,6 +33,9 @@
3333
)
3434
from couchers.sql import where_users_column_visible
3535

36+
if TYPE_CHECKING:
37+
from couchers.context import CouchersContext
38+
3639
# Register psycopg (psycopg3) as the default driver for postgresql:// URLs
3740
# This must happen before any engine is created
3841
registry.register("postgresql", "sqlalchemy.dialects.postgresql.psycopg", "PGDialect_psycopg")

app/backend/src/couchers/experimentation.py

Lines changed: 31 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22
Experimentation framework for feature flags and experiments.
33
44
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.
510
"""
611

712
import json
813
import logging
914
import threading
10-
from typing import TYPE_CHECKING, Any
15+
from typing import Any
1116

1217
import urllib3
1318
from growthbook import GrowthBook
@@ -18,9 +23,6 @@
1823
from couchers.db import session_scope
1924
from couchers.models.logging import ExperimentExposure
2025

21-
if TYPE_CHECKING:
22-
from couchers.context import CouchersContext
23-
2426
logger = logging.getLogger(__name__)
2527

2628
_REFRESH_INTERVAL_SECONDS = 60
@@ -110,13 +112,6 @@ def setup_experimentation() -> None:
110112
logger.info(f"Experimentation integration test: gate 'test_growthbook_integration' = {test_gate_result}")
111113

112114

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-
120115
def _record_exposure(user_id: int, experiment: Experiment, result: Result, **_: Any) -> None:
121116
data = {
122117
"experiment_name": experiment.name,
@@ -144,59 +139,34 @@ def _record_exposure(user_id: int, experiment: Experiment, result: Result, **_:
144139
session.execute(stmt)
145140

146141

147-
def _get_growthbook(context: CouchersContext) -> GrowthBook:
142+
def _create_evaluator(user_id: int | None) -> GrowthBook:
148143
"""
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.
175145
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.
176149
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.
178154
"""
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"]
190162

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)
194166

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+
)

app/backend/src/couchers/servicers/account.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
verify_token,
2828
)
2929
from couchers.event_log import log_event
30-
from couchers.experimentation import check_gate
3130
from couchers.helpers.completed_profile import has_completed_profile
3231
from couchers.helpers.geoip import geoip_approximate_location
3332
from couchers.helpers.strong_verification import get_strong_verification_fields
@@ -169,7 +168,7 @@ def GetAccountInfo(
169168

170169
# Test experimentation integration - check if user is in the test gate
171170
# Create 'test_growthbook_integration' in GrowthBook to test
172-
test_gate = check_gate(context, "test_growthbook_integration")
171+
test_gate = context.get_boolean_value("test_growthbook_integration", default=False)
173172
logger.info(f"Experimentation gate 'test_growthbook_integration' for user {user.id}: {test_gate}")
174173

175174
should_show_donation_banner = DONATION_DRIVE_START is not None and (

app/backend/src/couchers/sql.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from sqlalchemy.orm import InstrumentedAttribute, aliased
55
from sqlalchemy.sql import Select, exists, union
66

7-
from couchers.context import CouchersContext
87
from couchers.models import (
98
ModerationState,
109
ModerationVisibility,
@@ -16,6 +15,7 @@
1615
from couchers.utils import is_valid_email, is_valid_user_id, is_valid_username
1716

1817
if TYPE_CHECKING:
18+
from couchers.context import CouchersContext
1919
from couchers.materialized_views import LiteUser
2020
from couchers.models.moderation import ModeratedContent
2121

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import pytest
2+
3+
from couchers import experimentation
4+
from couchers.config import config
5+
from couchers.context import make_background_user_context, make_logged_out_context
6+
from couchers.i18n import LocalizationContext
7+
8+
9+
@pytest.fixture
10+
def experimentation_snapshot(monkeypatch):
11+
"""Enable experimentation with an in-memory feature snapshot for evaluation."""
12+
monkeypatch.setattr(experimentation, "_initialized", True)
13+
features = {
14+
# A rollout with explicit coverage: bucketing needs a hash, so anonymous (logged-out) users
15+
# are excluded even at 100% coverage and get the feature's default value instead.
16+
"rollout_flag": {"defaultValue": "control", "rules": [{"force": "treatment", "coverage": 1.0}]},
17+
# A global force with no coverage: applies to everyone, including anonymous users.
18+
"global_flag": {"defaultValue": False, "rules": [{"force": True}]},
19+
}
20+
monkeypatch.setattr(experimentation, "_state", {"features": features, "savedGroups": {}})
21+
monkeypatch.setitem(config, "EXPERIMENTATION_ENABLED", True)
22+
monkeypatch.setitem(config, "EXPERIMENTATION_PASS_ALL_GATES", False)
23+
24+
25+
def test_logged_in_user_is_bucketed_into_rollout(experimentation_snapshot):
26+
context = make_background_user_context(123)
27+
assert context.get_string_value("rollout_flag", "fallback") == "treatment"
28+
29+
30+
def test_anonymous_user_excluded_from_rollout_gets_feature_default(experimentation_snapshot):
31+
context = make_logged_out_context(LocalizationContext.en_utc())
32+
# Previously this raised NotLoggedInContextException via context.user_id.
33+
assert context.get_string_value("rollout_flag", "fallback") == "control"
34+
35+
36+
def test_anonymous_user_still_gets_global_force_on_flag(experimentation_snapshot):
37+
context = make_logged_out_context(LocalizationContext.en_utc())
38+
assert context.get_boolean_value("global_flag", default=False) is True
39+
40+
41+
def test_unknown_feature_returns_in_code_default(experimentation_snapshot):
42+
context = make_logged_out_context(LocalizationContext.en_utc())
43+
assert context.get_string_value("does_not_exist", "my_default") == "my_default"

0 commit comments

Comments
 (0)