diff --git a/CHANGELOG.md b/CHANGELOG.md index b637b12fc..320c91207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,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 diff --git a/docs/installation/compose.md b/docs/installation/compose.md index 411ed1f94..b91135a1b 100644 --- a/docs/installation/compose.md +++ b/docs/installation/compose.md @@ -120,6 +120,26 @@ DJANGO_EMAIL_BRAND_NAME= # e.g. "La Suite Nu DJANGO_EMAIL_LOGO_IMG= # 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. diff --git a/docs/installation/kubernetes.md b/docs/installation/kubernetes.md index 008cac6db..06421c531 100644 --- a/docs/installation/kubernetes.md +++ b/docs/installation/kubernetes.md @@ -416,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 | | diff --git a/src/backend/core/analytics/__init__.py b/src/backend/core/analytics/__init__.py index 8dbabe4f0..9028b2f94 100644 --- a/src/backend/core/analytics/__init__.py +++ b/src/backend/core/analytics/__init__.py @@ -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) diff --git a/src/backend/core/analytics/base.py b/src/backend/core/analytics/base.py index 9bcd41752..2ef63bfd2 100644 --- a/src/backend/core/analytics/base.py +++ b/src/backend/core/analytics/base.py @@ -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 diff --git a/src/backend/core/tests/analytics/test_base.py b/src/backend/core/tests/analytics/test_base.py new file mode 100644 index 000000000..54f20da91 --- /dev/null +++ b/src/backend/core/tests/analytics/test_base.py @@ -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 + )