Skip to content

Commit 50a7b87

Browse files
authored
Merge branch 'develop' into na/web/message-read-notifs
2 parents 2e44db6 + b5f9163 commit 50a7b87

9 files changed

Lines changed: 140 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/servicers/bugs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def ReportBug(
5858
f"**Backend version**: `{self._version()}`\n"
5959
f"**Frontend version**: `{request.frontend_version}`\n"
6060
f"**User Agent**: `{request.user_agent}`\n"
61+
f"**Locale**: `{context.localization.locale}`\n"
6162
f"**Screen resolution**: {request.screen_resolution.width}x{request.screen_resolution.height}\n"
6263
f"**Page**: {request.page}\n"
6364
f"**User**: {user_details}"

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

app/backend/src/tests/test_bugs.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def dud_post(url, auth, json):
5454
**Backend version**: `{config["VERSION"]}`
5555
**Frontend version**: `frontend_version`
5656
**User Agent**: `user_agent`
57+
**Locale**: `en`
5758
**Screen resolution**: 1920x1080
5859
**Page**: page
5960
**User**: <not logged in>""".strip()
@@ -114,6 +115,7 @@ def dud_post(url, auth, json):
114115
**Backend version**: `{config["VERSION"]}`
115116
**Frontend version**: `frontend_version`
116117
**User Agent**: `user_agent`
118+
**Locale**: `en`
117119
**Screen resolution**: 390x844
118120
**Page**: page
119121
**User**: [@testing_user](http://localhost:3000/user/testing_user) (1)""".strip()
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"

docs/meetings/dev/2026-05-22.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Weekly Dev meeting
2+
3+
When: 22/5/26, 12:30 UTC.
4+
Present: Aapeli, Bruna V, Chris K, Dale, Darren, Jesse, Kevin, Nick, Nicole, Valeriia
5+
Scribe: Nicole
6+
7+
# Notes
8+
- Nicole is looking into US/EU/UK grant options for Couchers.
9+
- 2100+ downloads on Android, 1300+ on iOS, good reviews so far in the app stores
10+
- Our growth rate per day has tripled since Couchsurfing's update
11+
- Intros - welcome Bruna V!
12+
- We really need messaging rate limiting
13+
- Yay or Nay: Confirmation dialog for friend requests. Vote: Yay.
14+
- Aapeli is comparing GrowthBook to Statsig for the purpose of feature flags and feature experiments
15+
- How to handle people not noticing "My Home" section to fill out - combine them to one page? Add a reminder? Let's start with a reminder and see if it helps.
16+
17+
* What everyone's working on:
18+
- Jesse: Working closely with Aapeli on improving moderation tooling stuff, especially making it more scalable. Funnel feedback from support and Reddit about bugs and feature requests to us. Next: Working with Nick a lot to train him on our moderation tooling.
19+
- Chris K: Translations, feature testing, merchandise. Next: Same things.
20+
- Kevin: Helped replicate some bugs, tested decline flow feature. Next: Will pick up UI fix bug.
21+
- Valeriia: Was sick. Will find a new ticket next.
22+
- Aapeli: Working on moderation and support infrastructure and experiments. Got mobile dev environment running and has a mobile fix. Next: Will iron out moderation tools with Jesse and Nick, metrics for mobile app, wants to measure who is push request reachable.
23+
- Nick: Will meet with Jesse to go over moderation stuff.
24+
- Bruna: Will read the READMEs and familiarize herself with the codebase.
25+
- Nicole: Non-tech: Writing newsletters, blog posts, social media and LinkedIn posts about mobile release. Opened draft for spring release newsletter to go out this weekend, started looking in grants and funding options for Couchers, business verified our TikTok account. Tech: Merged backend and frontend for dashboard discussions widget, removed event `maybe_going` from backend and frontend, added mobile app badges to landing page and footer, PR open to improve decline flow, pushed some changes to update and edit discussions/replies, UI fixes. Next: Will start next mobile version bug fixes v1.1.20, try and get decline flow and edit/update discussions merged, do social media graphics and send out release v1.3.0 newsletter. If I finish all that, go back to public trips.

0 commit comments

Comments
 (0)