Skip to content

Commit a95378c

Browse files
authored
Merge pull request #8760 from Couchers-org/backend/feature/feature-usage-logging
Log feature flag usage per user
2 parents c93053d + 39f9a50 commit a95378c

4 files changed

Lines changed: 145 additions & 4 deletions

File tree

app/backend/src/couchers/experimentation.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@
1616

1717
import urllib3
1818
from growthbook import GrowthBook
19-
from growthbook.common_types import Experiment, Result
19+
from growthbook.common_types import Experiment, FeatureResult, Result
2020
from sqlalchemy.dialects.postgresql import insert
2121

2222
from couchers.config import config
2323
from couchers.db import session_scope
24-
from couchers.models.logging import ExperimentExposure
24+
from couchers.models.logging import ExperimentExposure, FeatureUsage
2525

2626
logger = logging.getLogger(__name__)
2727

@@ -139,13 +139,18 @@ def _record_exposure(user_id: int, experiment: Experiment, result: Result, **_:
139139
session.execute(stmt)
140140

141141

142+
def _record_feature_usage(user_id: int, key: str, result: FeatureResult, **_: Any) -> None:
143+
with session_scope() as session:
144+
session.add(FeatureUsage(user_id=user_id, feature_key=key, value=result.value))
145+
146+
142147
def _create_evaluator(user_id: int | None) -> GrowthBook:
143148
"""
144149
Build a per-request GrowthBook evaluator over the current feature snapshot.
145150
146151
Pass user_id=None for an anonymous (logged-out) evaluation: with no `id` attribute GrowthBook
147152
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.
153+
through to their defaults. No exposure or usage is recorded without a user.
149154
150155
Reads the in-memory snapshot maintained by the background refresh thread - never does HTTP
151156
from the request path. Constructing without `client_key` keeps the GrowthBook a pure
@@ -164,9 +169,14 @@ def on_experiment_viewed(experiment: Experiment, result: Result, **kwargs: Any)
164169
if user_id is not None:
165170
_record_exposure(user_id, experiment, result)
166171

172+
def on_feature_usage(key: str, result: FeatureResult, *args: Any, **kwargs: Any) -> None:
173+
if user_id is not None:
174+
_record_feature_usage(user_id, key, result)
175+
167176
return GrowthBook(
168177
attributes={"id": str(user_id)} if user_id is not None else {},
169178
features=features,
170179
savedGroups=saved_groups,
171180
on_experiment_viewed=on_experiment_viewed,
181+
on_feature_usage=on_feature_usage,
172182
)
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/tests/test_experimentation.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import pytest
2+
from growthbook.common_types import FeatureResult
3+
from sqlalchemy import select
24

35
from couchers import experimentation
46
from couchers.config import config
57
from couchers.context import make_background_user_context, make_logged_out_context
8+
from couchers.db import session_scope
9+
from couchers.experimentation import _record_feature_usage
610
from couchers.i18n import LocalizationContext
11+
from couchers.models.logging import FeatureUsage
712

813

914
@pytest.fixture
@@ -22,7 +27,7 @@ def experimentation_snapshot(monkeypatch):
2227
monkeypatch.setitem(config, "EXPERIMENTATION_PASS_ALL_GATES", False)
2328

2429

25-
def test_logged_in_user_is_bucketed_into_rollout(experimentation_snapshot):
30+
def test_logged_in_user_is_bucketed_into_rollout(db, experimentation_snapshot):
2631
context = make_background_user_context(123)
2732
assert context.get_string_value("rollout_flag", "fallback") == "treatment"
2833

@@ -41,3 +46,53 @@ def test_anonymous_user_still_gets_global_force_on_flag(experimentation_snapshot
4146
def test_unknown_feature_returns_in_code_default(experimentation_snapshot):
4247
context = make_logged_out_context(LocalizationContext.en_utc())
4348
assert context.get_string_value("does_not_exist", "my_default") == "my_default"
49+
50+
51+
def _get_usage(session, user_id):
52+
return (
53+
session.execute(select(FeatureUsage).where(FeatureUsage.user_id == user_id).order_by(FeatureUsage.id))
54+
.scalars()
55+
.all()
56+
)
57+
58+
59+
def test_record_feature_usage_appends_a_row(db):
60+
_record_feature_usage(1, "my_feature", FeatureResult(value=True, source="defaultValue"))
61+
62+
with session_scope() as session:
63+
rows = _get_usage(session, 1)
64+
assert len(rows) == 1
65+
assert rows[0].feature_key == "my_feature"
66+
assert rows[0].value is True
67+
assert rows[0].time is not None
68+
69+
70+
def test_record_feature_usage_appends_a_row_per_check(db):
71+
# every check appends - the log is append-only, not deduplicated per (user, feature)
72+
_record_feature_usage(1, "my_feature", FeatureResult(value="first", source="force"))
73+
_record_feature_usage(1, "my_feature", FeatureResult(value="second", source="force"))
74+
75+
with session_scope() as session:
76+
rows = _get_usage(session, 1)
77+
assert len(rows) == 2
78+
assert [row.value for row in rows] == ["first", "second"]
79+
80+
81+
def test_record_feature_usage_records_each_user_and_feature(db):
82+
_record_feature_usage(1, "feature_a", FeatureResult(value=1, source="force"))
83+
_record_feature_usage(1, "feature_b", FeatureResult(value=2, source="force"))
84+
_record_feature_usage(2, "feature_a", FeatureResult(value=3, source="force"))
85+
86+
with session_scope() as session:
87+
assert {row.feature_key for row in _get_usage(session, 1)} == {"feature_a", "feature_b"}
88+
assert len(_get_usage(session, 2)) == 1
89+
90+
91+
def test_record_feature_usage_none_value(db):
92+
# unknown features evaluate to a None value - must persist without violating NOT NULL
93+
_record_feature_usage(1, "unknown_feature", FeatureResult(value=None, source="unknownFeature"))
94+
95+
with session_scope() as session:
96+
rows = _get_usage(session, 1)
97+
assert len(rows) == 1
98+
assert rows[0].value is None

0 commit comments

Comments
 (0)