Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/ardent-runesinger-lempo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: patch
---

Warn on duplicate async PostHog clients and document client lifecycle guidance
58 changes: 58 additions & 0 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Comment thread
marandaneto marked this conversation as resolved.

def __init__(
self,
Expand Down Expand Up @@ -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]] = (
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
"""
Expand Down
71 changes: 71 additions & 0 deletions posthog/test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading