From 2d0e38adbb7254991c680f89dbead818cf8349c1 Mon Sep 17 00:00:00 2001 From: Samuel Pennington Date: Tue, 28 Apr 2026 15:52:22 +0000 Subject: [PATCH 1/3] fix(flags): only install HyperCacheFlagProvider in US MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider is keyed by team_id (defaults to 2), which only refers to the PostHog company project in US Postgres. In EU and other regions team 2 is an unrelated org, so the SDK reads the wrong flag definitions and local evaluation of company flags returns None. Compounding the issue, the provider always returns a truthy dict (even for an empty flags list), which the SDK accepts as authoritative — so the emergency API fallback never fires and the bad cache state sticks until restart. Gate the provider installation on CLOUD_DEPLOYMENT == "US". Outside US, leave flag_definition_cache_provider unset so the SDK falls back to its normal API polling against posthoganalytics.host (us.i.posthog.com), which is the cross-region behavior that worked before #50737. Generated-By: PostHog Code Task-Id: 2dfd1690-4890-4d79-b62e-167289f38dca --- posthog/apps.py | 14 +++--- posthog/feature_flags/sdk_cache_provider.py | 18 ++++++++ .../feature_flags/test_sdk_cache_provider.py | 46 ++++++++++++++++++- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/posthog/apps.py b/posthog/apps.py index 8a84e8cd08a8..e556f0426811 100644 --- a/posthog/apps.py +++ b/posthog/apps.py @@ -77,15 +77,15 @@ 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. 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..56c66adfa61c 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,43 @@ def test_sdk_falls_back_to_api_when_provider_raises(self): client._load_feature_flags() mock_api.assert_called_once() + + +class TestGetDefaultFlagDefinitionCacheProvider(SimpleTestCase): + """The HyperCache provider is only safe to install in US where team_id=2 maps to + the PostHog company project. Elsewhere it must return None so the SDK falls back + to API polling against posthoganalytics.host.""" + + def setUp(self): + # Isolate POSTHOG_SELF_TEAM_ID so test order doesn't matter + self._env_patcher = patch.dict(os.environ, {}, clear=False) + self._env_patcher.start() + os.environ.pop("POSTHOG_SELF_TEAM_ID", None) + + def tearDown(self): + self._env_patcher.stop() + + @override_settings(CLOUD_DEPLOYMENT="US") + def test_us_returns_provider_with_default_team_id(self): + provider = get_default_flag_definition_cache_provider() + assert isinstance(provider, HyperCacheFlagProvider) + assert provider._team_id == 2 + + @override_settings(CLOUD_DEPLOYMENT="US") + def test_us_respects_self_team_id_env_override(self): + os.environ["POSTHOG_SELF_TEAM_ID"] = "42" + provider = get_default_flag_definition_cache_provider() + assert isinstance(provider, HyperCacheFlagProvider) + assert provider._team_id == 42 + + @parameterized.expand([("EU",), ("DEV",), ("E2E",), (None,)]) + def test_non_us_regions_return_none(self, deployment): + with override_settings(CLOUD_DEPLOYMENT=deployment): + assert get_default_flag_definition_cache_provider() is None + + @override_settings(CLOUD_DEPLOYMENT="EU") + def test_eu_ignores_self_team_id_env(self): + # Even with an explicit team id, EU should not install the provider — + # the assumption that local team_id maps to the company project doesn't hold. + os.environ["POSTHOG_SELF_TEAM_ID"] = "42" + assert get_default_flag_definition_cache_provider() is None From de53c29f5976f474db5a34eab1f304bec784f10c Mon Sep 17 00:00:00 2001 From: Samuel Pennington Date: Tue, 28 Apr 2026 15:59:43 +0000 Subject: [PATCH 2/3] chore(flags): parameterize hypercache provider gate tests Consolidate the four region/env-override test methods into a single parameterized test_provider, per AGENTS.md preference for parameterized tests. Drops the setUp/tearDown env-isolation boilerplate in favor of a @patch.dict(os.environ, {}, clear=False) decorator at the method level. Generated-By: PostHog Code Task-Id: 2dfd1690-4890-4d79-b62e-167289f38dca --- .../feature_flags/test_sdk_cache_provider.py | 60 +++++++++---------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/posthog/feature_flags/test_sdk_cache_provider.py b/posthog/feature_flags/test_sdk_cache_provider.py index 56c66adfa61c..6deaaf85dae9 100644 --- a/posthog/feature_flags/test_sdk_cache_provider.py +++ b/posthog/feature_flags/test_sdk_cache_provider.py @@ -213,38 +213,32 @@ def test_sdk_falls_back_to_api_when_provider_raises(self): class TestGetDefaultFlagDefinitionCacheProvider(SimpleTestCase): """The HyperCache provider is only safe to install in US where team_id=2 maps to the PostHog company project. Elsewhere it must return None so the SDK falls back - to API polling against posthoganalytics.host.""" + to API polling against posthoganalytics.host. The EU env-override case documents + that the gate is regional, not env-overridable.""" + + @parameterized.expand( + [ + ("us_default", "US", None, 2), + ("us_env_override", "US", "42", 42), + ("eu_no_env", "EU", None, None), + ("eu_env_override", "EU", "42", None), + ("dev", "DEV", None, None), + ("e2e", "E2E", None, None), + ("self_hosted", None, None, None), + ] + ) + @patch.dict(os.environ, {}, clear=False) + def test_provider(self, _name, deployment, env_team_id, expected_team_id): + if env_team_id is not None: + os.environ["POSTHOG_SELF_TEAM_ID"] = env_team_id + else: + os.environ.pop("POSTHOG_SELF_TEAM_ID", None) - def setUp(self): - # Isolate POSTHOG_SELF_TEAM_ID so test order doesn't matter - self._env_patcher = patch.dict(os.environ, {}, clear=False) - self._env_patcher.start() - os.environ.pop("POSTHOG_SELF_TEAM_ID", None) - - def tearDown(self): - self._env_patcher.stop() - - @override_settings(CLOUD_DEPLOYMENT="US") - def test_us_returns_provider_with_default_team_id(self): - provider = get_default_flag_definition_cache_provider() - assert isinstance(provider, HyperCacheFlagProvider) - assert provider._team_id == 2 - - @override_settings(CLOUD_DEPLOYMENT="US") - def test_us_respects_self_team_id_env_override(self): - os.environ["POSTHOG_SELF_TEAM_ID"] = "42" - provider = get_default_flag_definition_cache_provider() - assert isinstance(provider, HyperCacheFlagProvider) - assert provider._team_id == 42 - - @parameterized.expand([("EU",), ("DEV",), ("E2E",), (None,)]) - def test_non_us_regions_return_none(self, deployment): with override_settings(CLOUD_DEPLOYMENT=deployment): - assert get_default_flag_definition_cache_provider() is None - - @override_settings(CLOUD_DEPLOYMENT="EU") - def test_eu_ignores_self_team_id_env(self): - # Even with an explicit team id, EU should not install the provider — - # the assumption that local team_id maps to the company project doesn't hold. - os.environ["POSTHOG_SELF_TEAM_ID"] = "42" - assert get_default_flag_definition_cache_provider() is None + 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 From 21463abdb037d808d3ee3557279438c48215eb68 Mon Sep 17 00:00:00 2001 From: Samuel Pennington Date: Tue, 28 Apr 2026 16:16:32 +0000 Subject: [PATCH 3/3] chore(flags): address review nits on hypercache gate - Add LOCAL row to parameterized test (documented CLOUD_DEPLOYMENT value). - Drop test class docstring per AGENTS.md ("Python tests: do not add doc comments"). - Rewrite env handling declaratively: each row carries an env_overrides dict and the test uses patch.dict(..., clear=True) for per-row isolation instead of mutating os.environ inside the test body. - Restore the E2E cold-cache note in apps.py. Generated-By: PostHog Code Task-Id: 2dfd1690-4890-4d79-b62e-167289f38dca --- posthog/apps.py | 2 + .../feature_flags/test_sdk_cache_provider.py | 37 +++++++++---------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/posthog/apps.py b/posthog/apps.py index e556f0426811..d6a5dda52fbf 100644 --- a/posthog/apps.py +++ b/posthog/apps.py @@ -80,6 +80,8 @@ def ready(self): # 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 get_default_flag_definition_cache_provider diff --git a/posthog/feature_flags/test_sdk_cache_provider.py b/posthog/feature_flags/test_sdk_cache_provider.py index 6deaaf85dae9..742b883add93 100644 --- a/posthog/feature_flags/test_sdk_cache_provider.py +++ b/posthog/feature_flags/test_sdk_cache_provider.py @@ -211,30 +211,27 @@ def test_sdk_falls_back_to_api_when_provider_raises(self): class TestGetDefaultFlagDefinitionCacheProvider(SimpleTestCase): - """The HyperCache provider is only safe to install in US where team_id=2 maps to - the PostHog company project. Elsewhere it must return None so the SDK falls back - to API polling against posthoganalytics.host. The EU env-override case documents - that the gate is regional, not env-overridable.""" - @parameterized.expand( [ - ("us_default", "US", None, 2), - ("us_env_override", "US", "42", 42), - ("eu_no_env", "EU", None, None), - ("eu_env_override", "EU", "42", None), - ("dev", "DEV", None, None), - ("e2e", "E2E", None, None), - ("self_hosted", None, None, None), + # (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), ] ) - @patch.dict(os.environ, {}, clear=False) - def test_provider(self, _name, deployment, env_team_id, expected_team_id): - if env_team_id is not None: - os.environ["POSTHOG_SELF_TEAM_ID"] = env_team_id - else: - os.environ.pop("POSTHOG_SELF_TEAM_ID", None) - - with override_settings(CLOUD_DEPLOYMENT=deployment): + 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: