Skip to content

Commit ceb786b

Browse files
authored
Merge branch 'develop' into na/chat-after-decline
2 parents 1688bc5 + 36e90d1 commit ceb786b

24 files changed

Lines changed: 560 additions & 129 deletions

app/backend/resources/badges.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
{
1919
"id": "moderator",
2020
"color": "#c74f5b",
21-
"admin_editable": false
21+
"admin_editable": false,
22+
"flag": "show_moderator_badge"
2223
},
2324
{
2425
"id": "volunteer",

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: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,33 @@
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_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

1219
import json
1320
import logging
1421
import threading
22+
from collections.abc import Callable
1523
from typing import Any
1624

1725
import urllib3
1826
from growthbook import GrowthBook
19-
from growthbook.common_types import Experiment, Result
27+
from growthbook.common_types import Experiment, FeatureResult, Result
2028
from sqlalchemy.dialects.postgresql import insert
2129

2230
from couchers.config import config
2331
from couchers.db import session_scope
24-
from couchers.models.logging import ExperimentExposure
32+
from couchers.models.logging import ExperimentExposure, FeatureUsage
2533

2634
logger = 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+
142155
def _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)

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

Lines changed: 4 additions & 0 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,
@@ -838,6 +839,9 @@ def update_badges(payload: empty_pb2.Empty) -> None:
838839

839840
def update_badge(badge_id: str, members: Sequence[int]) -> None:
840841
badge = get_badge_dict()[badge_id]
842+
# this batch job has no per-user context to evaluate the gate against, so it's global
843+
if badge.flag is not None and not experimentation.get_global_boolean_value(badge.flag, default=True):
844+
members = []
841845
user_ids = session.execute(select(UserBadge.user_id).where(UserBadge.badge_id == badge.id)).scalars().all()
842846
# in case the user ids don't exist in the db
843847
actual_members = session.execute(select(User.id).where(User.id.in_(members))).scalars().all()
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Log feature flag usage per user
2+
3+
Revision ID: 0157
4+
Revises: 0156
5+
Create Date: 2026-05-23 05:13:52.193316
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
from sqlalchemy.dialects import postgresql
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "0157"
15+
down_revision = "0156"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade() -> None:
21+
op.create_table(
22+
"feature_usage",
23+
sa.Column("id", sa.BigInteger(), nullable=False),
24+
sa.Column("time", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
25+
sa.Column("user_id", sa.BigInteger(), nullable=False),
26+
sa.Column("feature_key", sa.String(), nullable=False),
27+
sa.Column("value", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
28+
sa.PrimaryKeyConstraint("id", name=op.f("pk_feature_usage")),
29+
schema="logging",
30+
)
31+
op.create_index(
32+
"ix_logging_feature_usage_feature_key_time",
33+
"feature_usage",
34+
["feature_key", "time"],
35+
unique=False,
36+
schema="logging",
37+
)
38+
op.create_index(
39+
"ix_logging_feature_usage_user_id_time", "feature_usage", ["user_id", "time"], unique=False, schema="logging"
40+
)
41+
42+
43+
def downgrade() -> None:
44+
op.drop_index("ix_logging_feature_usage_user_id_time", table_name="feature_usage", schema="logging")
45+
op.drop_index("ix_logging_feature_usage_feature_key_time", table_name="feature_usage", schema="logging")
46+
op.drop_table("feature_usage", schema="logging")

app/backend/src/couchers/models/logging.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,33 @@ class ExperimentExposure(Base, kw_only=True):
156156
Index("ix_logging_experiment_exposures_user_id_created", "user_id", "created"),
157157
{"schema": "logging"},
158158
)
159+
160+
161+
class FeatureUsage(Base, kw_only=True):
162+
"""
163+
Append-only log of feature flag evaluations.
164+
165+
Populated by GrowthBook's on_feature_usage callback - one row per check.
166+
"""
167+
168+
__tablename__ = "feature_usage"
169+
170+
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, init=False)
171+
172+
# when the feature was checked
173+
time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), init=False)
174+
175+
# user the feature was checked for
176+
user_id: Mapped[int] = mapped_column(BigInteger)
177+
178+
# feature identifier from GrowthBook
179+
feature_key: Mapped[str] = mapped_column(String)
180+
181+
# the feature value the user received
182+
value: Mapped[Any] = mapped_column(JSONB)
183+
184+
__table_args__ = (
185+
Index("ix_logging_feature_usage_feature_key_time", "feature_key", "time"),
186+
Index("ix_logging_feature_usage_user_id_time", "user_id", "time"),
187+
{"schema": "logging"},
188+
)

app/backend/src/couchers/resources.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ class Badge:
7777
id: str
7878
color: str
7979
admin_editable: bool
80+
# if set, the badge is only awarded while this feature flag is on (the flag defaults to on, so
81+
# the badge keeps being awarded until the flag is turned off)
82+
flag: str | None = None
8083

8184

8285
@functools.cache

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import json
22
import time
33
from datetime import UTC, datetime
4-
from typing import cast
4+
from typing import Any, cast
55

66
import grpc
77
import requests
8-
from google.protobuf import empty_pb2
8+
from google.protobuf import empty_pb2, struct_pb2
99
from sqlalchemy import insert, select
1010
from sqlalchemy.orm import Session
1111
from sqlalchemy.sql import func
@@ -137,3 +137,20 @@ def GeolocationClickInfo(
137137
self, request: bugs_pb2.GeolocationClickInfoReq, context: CouchersContext, session: Session
138138
) -> empty_pb2.Empty:
139139
return empty_pb2.Empty()
140+
141+
def EvaluateFeatureFlag(
142+
self, request: bugs_pb2.EvaluateFeatureFlagReq, context: CouchersContext, session: Session
143+
) -> bugs_pb2.EvaluateFeatureFlagRes:
144+
# None default: an unconfigured flag comes back as None and the value field is left unset, so
145+
# the frontend applies its own in-code default. get_object_value is the generic typed
146+
# accessor; like every value method it fires exposure/usage logging as a side effect, here
147+
# for exactly the one flag the client is reading.
148+
value: Any = context.get_object_value(request.flag_key, None)
149+
res = bugs_pb2.EvaluateFeatureFlagRes()
150+
if value is not None:
151+
# google.protobuf.Value has no direct constructor from a Python value; round-trip
152+
# through a Struct, which knows how to encode bool/number/str/list/dict.
153+
holder = struct_pb2.Struct()
154+
holder["value"] = value
155+
res.value.CopyFrom(holder.fields["value"])
156+
return res

app/backend/src/tests/test_bg_jobs.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from sqlalchemy.sql import delete, func
1111

1212
import couchers.jobs.worker
13+
from couchers import experimentation
1314
from couchers.config import config
1415
from couchers.constants import HOST_REQUEST_MAX_REMINDERS, HOST_REQUEST_REMINDER_INTERVAL
1516
from couchers.crypto import urlsafe_secure_token
@@ -1342,6 +1343,50 @@ def test_update_badges(db, push_collector: PushCollector):
13421343
assert push.content.body == "The Verified Phone badge was added to your profile."
13431344

13441345

1346+
def test_update_badges_awards_moderator_to_superuser(db):
1347+
"""The show_moderator_badge flag defaults on, so superusers are awarded the moderator badge."""
1348+
superuser, _ = generate_user(is_superuser=True, last_donated=None)
1349+
1350+
update_badges(empty_pb2.Empty())
1351+
1352+
with session_scope() as session:
1353+
assert (
1354+
session.execute(
1355+
select(func.count())
1356+
.select_from(UserBadge)
1357+
.where(UserBadge.user_id == superuser.id, UserBadge.badge_id == "moderator")
1358+
).scalar()
1359+
== 1
1360+
)
1361+
1362+
1363+
def test_update_badges_skips_moderator_when_flag_off(db, monkeypatch):
1364+
"""With show_moderator_badge forced off, superusers are not awarded the moderator badge."""
1365+
# force show_moderator_badge off for everyone (force rule with no coverage applies globally)
1366+
monkeypatch.setattr(experimentation, "_initialized", True)
1367+
monkeypatch.setattr(
1368+
experimentation,
1369+
"_state",
1370+
{"features": {"show_moderator_badge": {"defaultValue": True, "rules": [{"force": False}]}}, "savedGroups": {}},
1371+
)
1372+
monkeypatch.setitem(config, "EXPERIMENTATION_ENABLED", True)
1373+
monkeypatch.setitem(config, "EXPERIMENTATION_PASS_ALL_GATES", False)
1374+
1375+
superuser, _ = generate_user(is_superuser=True, last_donated=None)
1376+
1377+
update_badges(empty_pb2.Empty())
1378+
1379+
with session_scope() as session:
1380+
assert (
1381+
session.execute(
1382+
select(func.count())
1383+
.select_from(UserBadge)
1384+
.where(UserBadge.user_id == superuser.id, UserBadge.badge_id == "moderator")
1385+
).scalar()
1386+
== 0
1387+
)
1388+
1389+
13451390
def test_send_request_notifications_blocked_users_no_notification(db, moderator):
13461391
"""
13471392
Regression test: send_request_notifications should not send notifications

0 commit comments

Comments
 (0)