diff --git a/.sampo/changesets/ardent-runesinger-lempo.md b/.sampo/changesets/ardent-runesinger-lempo.md new file mode 100644 index 00000000..1a6f7153 --- /dev/null +++ b/.sampo/changesets/ardent-runesinger-lempo.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Warn on duplicate async PostHog clients and document client lifecycle guidance diff --git a/posthog/client.py b/posthog/client.py index 800cc577..b0f55fc7 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -164,6 +164,11 @@ class Client(object): You can also follow [Flask](/docs/libraries/flask) and [Django](/docs/libraries/django) guides to integrate PostHog into your project. + For long-running applications, create one client during application startup + and reuse it for the lifetime of the process. This keeps background queues + predictable and makes shutdown flushing straightforward. Multiple clients are + still supported for intentional multi-project or multi-host setups. + Examples: ```python from posthog import Posthog @@ -175,6 +180,9 @@ class Client(object): """ log = logging.getLogger("posthog") + _client_registry_lock = threading.Lock() + _client_registry: dict[tuple[str, str], weakref.WeakSet] = {} + _duplicate_client_warnings: set[tuple[str, str]] = set() def __init__( self, @@ -311,6 +319,7 @@ def __init__( # Used for session replay URL generation - we don't want the server host here. self.raw_host = normalize_host(host) self.host = determine_server_host(host) + self._duplicate_client_registry_key: Optional[tuple[str, str]] = None self.gzip = gzip self.timeout = timeout self._feature_flags: Optional[list[Any]] = ( @@ -446,6 +455,54 @@ def __init__( after_in_child=lambda: Client._reinit_after_fork_weak(weak_self) ) + self._warn_if_duplicate_async_client() + + def _warn_if_duplicate_async_client(self): + if self.disabled or not self.send or self.sync_mode or not self.api_key: + return + + registry_key = (self.api_key, self.host) + should_warn = False + + with Client._client_registry_lock: + clients = Client._client_registry.setdefault( + registry_key, weakref.WeakSet() + ) + has_existing_client = len(clients) > 0 + clients.add(self) + self._duplicate_client_registry_key = registry_key + + if ( + has_existing_client + and registry_key not in Client._duplicate_client_warnings + ): + Client._duplicate_client_warnings.add(registry_key) + should_warn = True + + if should_warn: + self.log.warning( + "Multiple active PostHog clients detected for the same project " + "API key and host. Reuse one Posthog instance per app or " + "process when possible to avoid competing background queues " + "and missed shutdown flushes. Multiple clients are supported " + "when intentional." + ) + + def _unregister_duplicate_client(self): + registry_key = self._duplicate_client_registry_key + if registry_key is None: + return + + with Client._client_registry_lock: + clients = Client._client_registry.get(registry_key) + if clients is not None: + clients.discard(self) + if not clients: + del Client._client_registry[registry_key] + Client._duplicate_client_warnings.discard(registry_key) + + self._duplicate_client_registry_key = None + def _set_before_send(self, before_send): if before_send is not None: if callable(before_send): @@ -1463,6 +1520,7 @@ def join(self) -> None: # Shutdown the cache provider (release locks, cleanup) self._shutdown_flag_definition_cache_provider() + self._unregister_duplicate_client() def shutdown(self) -> None: """ diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index e9d7054c..58ff0a88 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -80,6 +80,77 @@ def test_client_with_empty_api_key_is_noop(self): self.assertIsNone(client.capture("event", distinct_id="distinct_id")) + def _reset_duplicate_client_registry(self): + Client._client_registry.clear() + Client._duplicate_client_warnings.clear() + + def test_warns_once_on_duplicate_async_client_same_key_and_host(self): + self._reset_duplicate_client_registry() + self.addCleanup(self._reset_duplicate_client_registry) + host = "https://us.i.posthog.com" + registry_key = (FAKE_TEST_API_KEY, host) + + with ( + mock.patch("posthog.client.atexit.register"), + mock.patch("posthog.client.Consumer.start"), + mock.patch.object(Client.log, "warning") as mock_warning, + ): + first = Client(FAKE_TEST_API_KEY, host=host) + second = Client(FAKE_TEST_API_KEY, host=host) + third = Client(FAKE_TEST_API_KEY, host=host) + + self.assertIsNot(first, second) + self.assertIsNot(second, third) + mock_warning.assert_called_once_with( + "Multiple active PostHog clients detected for the same project " + "API key and host. Reuse one Posthog instance per app or " + "process when possible to avoid competing background queues " + "and missed shutdown flushes. Multiple clients are supported " + "when intentional." + ) + + first.shutdown() + second.shutdown() + third.shutdown() + + self.assertNotIn(registry_key, Client._client_registry) + self.assertNotIn(registry_key, Client._duplicate_client_warnings) + + fourth = Client(FAKE_TEST_API_KEY, host=host) + fifth = Client(FAKE_TEST_API_KEY, host=host) + + self.assertEqual(mock_warning.call_count, 2) + + fourth.shutdown() + fifth.shutdown() + + @parameterized.expand( + [ + ("different_host", {"host": "https://two.example.com"}), + ("sync_mode", {"host": "https://one.example.com", "sync_mode": True}), + ("send_disabled", {"host": "https://one.example.com", "send": False}), + ] + ) + def test_duplicate_client_warning_allows_intentional_multi_client_cases( + self, _, duplicate_kwargs + ): + self._reset_duplicate_client_registry() + self.addCleanup(self._reset_duplicate_client_registry) + + with ( + mock.patch("posthog.client.atexit.register"), + mock.patch("posthog.client.Consumer.start"), + mock.patch.object(Client.log, "warning") as mock_warning, + ): + first = Client(FAKE_TEST_API_KEY, host="https://one.example.com") + duplicate = Client(FAKE_TEST_API_KEY, **duplicate_kwargs) + + self.assertIsNot(first, duplicate) + mock_warning.assert_not_called() + + first.shutdown() + duplicate.shutdown() + @mock.patch("posthog.client.get") def test_disabled_client_does_not_load_feature_flags(self, patch_get): client = Client("", personal_api_key="test", send=False)