Compare commits

..

1 Commits

Author SHA1 Message Date
Roee Sivan eb422309b9 ✨(backend) allow static feature flags without an analytics backend
Feature flags are read from the analytics backend. Without one,
NoOpAnalytics returned no flag, so "summary-enabled" was always off and
a self-hosted instance could not get automatic summaries without PostHog.

NoOpAnalytics now accepts a static "feature_flags" mapping through the
existing ANALYTICS_BACKEND_SETTINGS, and get_analytics() passes the
settings to it. Nothing changes when no flag is declared.

Fixes #1610
2026-10-14 00:19:55 +02:00
10 changed files with 172 additions and 243 deletions
+1 -1
View File
@@ -11,7 +11,6 @@ and this project adheres to
### Added
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
- 🔒️(backend) throttle the start recording endpoint
## [1.35.0] - 2026-10-13
@@ -20,6 +19,7 @@ and this project adheres to
- ✨(frontend) let users pick the recording encoding
- ✨(backend) introduce a token exchange endpoint for iframe embeds
- ⚡️(backend) send recording notification emails from a Celery task
- ✨(backend) allow static feature flags without an analytics backend #1809
### Changed
+20
View File
@@ -120,6 +120,26 @@ DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Nu
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
```
### Feature flags without an analytics backend
Some features, such as automatic meeting summaries (`summary-enabled`), are gated by a feature flag read from the analytics backend. You do not need an analytics backend to enable them: leave `ANALYTICS_BACKEND` unset and declare the flags in `env.d/common`. No data is sent anywhere.
```env
ANALYTICS_BACKEND_SETTINGS={"feature_flags": {"summary-enabled": True}}
```
You can also set it directly in `compose.yaml`:
```yaml
services:
backend:
environment:
ANALYTICS_BACKEND_SETTINGS: '{"feature_flags": {"summary-enabled": True}}'
```
> [!NOTE]
> The value is a Python dictionary, not JSON: write `True`, not `true`. Set it on every container running the backend image.
## Step 3: Configure your firewall
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
+2 -2
View File
@@ -335,8 +335,6 @@ These are the environmental options available on meet backend.
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
| ROOM_CREATION_THROTTLE_RATES | Room creation throttle rate per authenticated user | 50/minute | 50/minute |
| ROOM_CREATION_DAILY_THROTTLE_RATES | Daily room creation cap per authenticated user | 1000/day |
| START_RECORDING_THROTTLE_RATES | Recording start throttle rate per authenticated user | 10/minute |
| START_RECORDING_DAILY_THROTTLE_RATES | Daily recording start cap per authenticated user | 100/day |
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
@@ -418,6 +416,8 @@ These are the environmental options available on meet backend.
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| ANALYTICS_BACKEND | Analytics backend class, e.g. `core.analytics.posthog.PostHogAnalytics`. Nothing is sent when unset | |
| ANALYTICS_BACKEND_SETTINGS | Analytics backend settings. Without a backend, `{"feature_flags": {"summary-enabled": True}}` sets feature flags statically | {} |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
+1 -1
View File
@@ -39,7 +39,7 @@ def get_analytics() -> AnalyticsBackend:
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
if not dotted_path:
return NoOpAnalytics()
return NoOpAnalytics(**options)
backend_class = import_string(dotted_path)
return backend_class(**options)
+24 -1
View File
@@ -53,7 +53,23 @@ class AnalyticsBackend(ABC):
class NoOpAnalytics(AnalyticsBackend):
"""Default backend: silently discards everything."""
"""
Default backend: discards events and serves static feature flags.
Feature flags can be declared without any analytics provider, e.g.:
ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
"""
def __init__(
self,
*,
feature_flags: Mapping[str, bool | str | None] | None = None,
**_kwargs: Any,
) -> None:
# Other settings are ignored, so that settings meant for another
# backend never break an instance running without one.
self._feature_flags = dict(feature_flags or {})
def identify(self, user: User, properties=None) -> None:
"""No-op: discards identify calls."""
@@ -63,3 +79,10 @@ class NoOpAnalytics(AnalyticsBackend):
def shutdown(self) -> None:
"""No-op: nothing to flush."""
def get_user_feature_flags(
self,
user: User,
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Return the feature flags declared in settings, for any user."""
return self._feature_flags
-12
View File
@@ -47,18 +47,6 @@ class RoomCreationDailyUserRateThrottle(RoomCreationUserRateThrottle):
scope = "room_creation_daily"
class StartRecordingUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle recording starts per authenticated user."""
scope = "start_recording"
class StartRecordingDailyUserRateThrottle(StartRecordingUserRateThrottle):
"""Cap recording starts per authenticated user over a day."""
scope = "start_recording_daily"
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated user requesting room entry"""
-4
View File
@@ -406,10 +406,6 @@ class RoomViewSet(
permission_classes=[
permissions.HasPrivilegesOnRoom,
],
throttle_classes=[
throttling.StartRecordingUserRateThrottle,
throttling.StartRecordingDailyUserRateThrottle,
],
)
@FeatureFlag.require("recording")
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
@@ -0,0 +1,124 @@
"""
Unit tests for NoOpAnalytics and the analytics backend loader.
"""
# pylint: disable=redefined-outer-name,unused-argument
import pytest
from core.analytics import get_analytics
from core.analytics.base import NoOpAnalytics
from core.analytics.user_feature_flags import UserFeatureFlag
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_analytics_cache():
"""Clear the cached backend before and after each test."""
get_analytics.cache_clear()
yield
get_analytics.cache_clear()
# ==============================
# NoOpAnalytics
# ==============================
def test_noop_has_no_feature_flags_by_default():
"""Should keep every feature flag disabled when none is declared."""
backend = NoOpAnalytics()
user = UserFactory()
assert not backend.get_user_feature_flags(user)
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is False
)
def test_noop_enables_declared_feature_flags():
"""Should enable a feature flag declared as True."""
backend = NoOpAnalytics(feature_flags={"summary-enabled": True})
user = UserFactory()
assert backend.get_user_feature_flags(user) == {"summary-enabled": True}
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
@pytest.mark.parametrize("value", [False, None, "true"])
def test_noop_only_enables_feature_flags_set_to_true(value):
"""Should keep a feature flag disabled unless its value is exactly True."""
backend = NoOpAnalytics(feature_flags={"summary-enabled": value})
user = UserFactory()
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is False
)
def test_noop_ignores_unrelated_settings():
"""Should accept settings meant for another backend without failing."""
backend = NoOpAnalytics(api_key="my-key", host="https://eu.i.posthog.com")
assert not backend.get_user_feature_flags(UserFactory())
# ==============================
# get_analytics
# ==============================
def test_get_analytics_defaults_to_noop_without_feature_flags(settings):
"""Should return a no-op backend with no feature flag by default."""
settings.ANALYTICS_BACKEND = None
settings.ANALYTICS_BACKEND_SETTINGS = {}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert not backend.get_user_feature_flags(UserFactory())
def test_get_analytics_passes_feature_flags_to_noop(settings):
"""Should forward the feature flags when no backend is configured."""
settings.ANALYTICS_BACKEND = None
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert (
backend.is_user_feature_enabled(
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
def test_get_analytics_instantiates_configured_backend_with_settings(settings):
"""Should instantiate the configured backend with its settings."""
settings.ANALYTICS_BACKEND = "core.analytics.base.NoOpAnalytics"
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert (
backend.is_user_feature_enabled(
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
@@ -1,212 +0,0 @@
"""
Test rooms API endpoints in the Meet core app: start recording throttling.
"""
# pylint: disable=redefined-outer-name,unused-argument
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...api.throttling import (
StartRecordingDailyUserRateThrottle,
StartRecordingUserRateThrottle,
)
from ...factories import RoomFactory, UserFactory
from ...models import Recording
pytestmark = pytest.mark.django_db
@pytest.fixture
def start_recording_throttle(monkeypatch):
"""Lower the start recording rates."""
rates = StartRecordingUserRateThrottle.THROTTLE_RATES
monkeypatch.setitem(rates, "start_recording", "2/minute")
monkeypatch.setitem(rates, "start_recording_daily", "100/day")
@pytest.fixture
def daily_start_recording_throttle(monkeypatch):
"""Lower the daily start recording rate and control the clock."""
rates = StartRecordingDailyUserRateThrottle.THROTTLE_RATES
monkeypatch.setitem(rates, "start_recording", "100/minute")
monkeypatch.setitem(rates, "start_recording_daily", "3/day")
now = [1_000_000.0]
monkeypatch.setattr(StartRecordingUserRateThrottle, "timer", lambda self: now[0])
return now
@mock.patch("core.api.viewsets.WorkerServiceMediator")
@mock.patch("core.api.viewsets.get_worker_service")
def test_start_recording_throttled(
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
):
"""Starting a recording should return 429 when exceeding the per-minute rate."""
settings.RECORDING_ENABLE = True
user = UserFactory()
client = APIClient()
client.force_login(user)
for _ in range(2):
room = RoomFactory(users=[(user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 201
room = RoomFactory(users=[(user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 429
assert 0 < int(response["Retry-After"]) <= 60
assert Recording.objects.count() == 2
assert mock_mediator_class.return_value.start.call_count == 2
@mock.patch("core.api.viewsets.WorkerServiceMediator")
@mock.patch("core.api.viewsets.get_worker_service")
def test_start_recording_throttle_counts_failed_attempts(
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
):
"""Failed recording starts should count toward the rate."""
settings.RECORDING_ENABLE = True
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
Recording.objects.create(room=room, mode="screen_recording", status="active")
client = APIClient()
client.force_login(user)
for _ in range(2):
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 409
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 429
@mock.patch("core.api.viewsets.WorkerServiceMediator")
@mock.patch("core.api.viewsets.get_worker_service")
def test_start_recording_throttle_per_user(
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
):
"""Starting a recording should succeed when another user is throttled."""
settings.RECORDING_ENABLE = True
client = APIClient()
user = UserFactory()
client.force_login(user)
for _ in range(2):
room = RoomFactory(users=[(user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 201
room = RoomFactory(users=[(user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 429
other_user = UserFactory()
client.force_login(other_user)
room = RoomFactory(users=[(other_user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 201
@mock.patch("core.api.viewsets.WorkerServiceMediator")
@mock.patch("core.api.viewsets.get_worker_service")
def test_start_recording_throttle_does_not_limit_other_actions(
mock_get_worker_service, mock_mediator_class, start_recording_throttle, settings
):
"""Other room actions should succeed when starting a recording is throttled."""
settings.RECORDING_ENABLE = True
user = UserFactory()
client = APIClient()
client.force_login(user)
rooms = RoomFactory.create_batch(3, users=[(user, "owner")])
for room in rooms[:2]:
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 201
response = client.post(
f"/api/v1.0/rooms/{rooms[2].id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 429
Recording.objects.filter(room=rooms[0]).update(status="active")
response = client.post(f"/api/v1.0/rooms/{rooms[0].id}/stop-recording/")
assert response.status_code == 200
response = client.get("/api/v1.0/rooms/")
assert response.status_code == 200
response = client.post("/api/v1.0/rooms/", {"name": "New room"})
assert response.status_code == 201
@mock.patch("core.api.viewsets.WorkerServiceMediator")
@mock.patch("core.api.viewsets.get_worker_service")
def test_start_recording_daily_throttled(
mock_get_worker_service,
mock_mediator_class,
daily_start_recording_throttle,
settings,
):
"""Starting a recording should return 429 when exceeding the daily rate."""
settings.RECORDING_ENABLE = True
now = daily_start_recording_throttle
user = UserFactory()
client = APIClient()
client.force_login(user)
for _ in range(3):
room = RoomFactory(users=[(user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 201
now[0] += 120
room = RoomFactory(users=[(user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 429
assert 60 < int(response["Retry-After"]) <= 86400
now[0] += 86400
room = RoomFactory(users=[(user, "owner")])
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 201
-10
View File
@@ -395,16 +395,6 @@ class Base(Configuration):
environ_name="ROOM_CREATION_DAILY_THROTTLE_RATES",
environ_prefix=None,
),
"start_recording": values.Value(
default="10/minute",
environ_name="START_RECORDING_THROTTLE_RATES",
environ_prefix=None,
),
"start_recording_daily": values.Value(
default="100/day",
environ_name="START_RECORDING_DAILY_THROTTLE_RATES",
environ_prefix=None,
),
"request_entry": values.Value(
default="150/minute",
environ_name="REQUEST_ENTRY_THROTTLE_RATES",