diff --git a/posthog/apps.py b/posthog/apps.py index 8a84e8cd08a8..d6a5dda52fbf 100644 --- a/posthog/apps.py +++ b/posthog/apps.py @@ -77,15 +77,17 @@ def ready(self): properties={"git_rev": get_git_commit_short(), "git_branch": get_git_branch()}, ) # Use HyperCache to provide flag definitions instead of per-process API polling. - # Falls back to the SDK's emergency API fetch (via personal_api_key) only when - # the cache is cold. In E2E testing personal_api_key is None, so a cold cache - # will result in no flag definitions being loaded — which is acceptable there. + # Only wired up in US, where local team_id=2 corresponds to the PostHog company + # project. Outside US the helper returns None so the SDK falls back to its API + # path against `posthoganalytics.host` (us.i.posthog.com) — see helper docstring. + # In E2E testing personal_api_key is None, so the SDK's API fallback is a no-op + # and no flag definitions are loaded — which is acceptable there. if not posthoganalytics.disabled: - from posthog.feature_flags.sdk_cache_provider import HyperCacheFlagProvider + from posthog.feature_flags.sdk_cache_provider import get_default_flag_definition_cache_provider - posthoganalytics.flag_definition_cache_provider = HyperCacheFlagProvider( # ty: ignore[invalid-assignment] - team_id=int(os.environ.get("POSTHOG_SELF_TEAM_ID", "2")) - ) + provider = get_default_flag_definition_cache_provider() + if provider is not None: + posthoganalytics.flag_definition_cache_provider = provider # ty: ignore[invalid-assignment] # load feature flag definitions if not already loaded if not posthoganalytics.disabled and posthoganalytics.feature_flag_definitions() is None: diff --git a/posthog/feature_flags/sdk_cache_provider.py b/posthog/feature_flags/sdk_cache_provider.py index 9d5a1cad7892..12aac336e73a 100644 --- a/posthog/feature_flags/sdk_cache_provider.py +++ b/posthog/feature_flags/sdk_cache_provider.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from typing import TYPE_CHECKING, Optional import structlog @@ -72,3 +73,20 @@ def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None: def shutdown(self) -> None: pass # No-op — no locks or resources to release + + +def get_default_flag_definition_cache_provider() -> Optional[HyperCacheFlagProvider]: + """Build the flag-definition cache provider for this region, or None to fall back to API polling. + + HyperCache is keyed by team_id (defaults to 2 via POSTHOG_SELF_TEAM_ID), which is only + the PostHog company project in US Postgres — in EU and other regions, team 2 is an + unrelated org. Returning None outside US lets the SDK poll posthoganalytics.host + (us.i.posthog.com) directly via personal_api_key, which is the cross-region behavior + that worked before this provider was wired up. + """ + from django.conf import settings + + if settings.CLOUD_DEPLOYMENT != "US": + return None + + return HyperCacheFlagProvider(team_id=int(os.environ.get("POSTHOG_SELF_TEAM_ID", "2"))) diff --git a/posthog/feature_flags/test_sdk_cache_provider.py b/posthog/feature_flags/test_sdk_cache_provider.py index 4bb14ef3eae2..742b883add93 100644 --- a/posthog/feature_flags/test_sdk_cache_provider.py +++ b/posthog/feature_flags/test_sdk_cache_provider.py @@ -1,11 +1,13 @@ +import os + from unittest.mock import MagicMock, patch -from django.test import SimpleTestCase +from django.test import SimpleTestCase, override_settings from parameterized import parameterized from posthoganalytics.client import Client -from posthog.feature_flags.sdk_cache_provider import HyperCacheFlagProvider +from posthog.feature_flags.sdk_cache_provider import HyperCacheFlagProvider, get_default_flag_definition_cache_provider class TestHyperCacheFlagProvider(SimpleTestCase): @@ -206,3 +208,34 @@ def test_sdk_falls_back_to_api_when_provider_raises(self): client._load_feature_flags() mock_api.assert_called_once() + + +class TestGetDefaultFlagDefinitionCacheProvider(SimpleTestCase): + @parameterized.expand( + [ + # (name, CLOUD_DEPLOYMENT, env_overrides, expected_team_id) + ("us_default", "US", {}, 2), + ("us_env_override", "US", {"POSTHOG_SELF_TEAM_ID": "42"}, 42), + ("eu_no_env", "EU", {}, None), + # documents that the gate is regional, not env-overridable + ("eu_env_override", "EU", {"POSTHOG_SELF_TEAM_ID": "42"}, None), + ("dev", "DEV", {}, None), + ("e2e", "E2E", {}, None), + ("local", "LOCAL", {}, None), + ("self_hosted", None, {}, None), + ] + ) + def test_provider(self, _name, deployment, env_overrides, expected_team_id): + # clear=True wipes os.environ inside the context (and restores on exit), so the + # test sees only env_overrides — no leakage from the outer environment. + with ( + patch.dict(os.environ, env_overrides, clear=True), + override_settings(CLOUD_DEPLOYMENT=deployment), + ): + provider = get_default_flag_definition_cache_provider() + + if expected_team_id is None: + assert provider is None + else: + assert isinstance(provider, HyperCacheFlagProvider) + assert provider._team_id == expected_team_id