From 8b8c92dbc4b802a6de8038aa41619cbf7b5828db Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 19 Jun 2026 15:36:17 +0200 Subject: [PATCH 1/3] fix: warn on duplicate async clients --- .sampo/changesets/ardent-runesinger-lempo.md | 5 ++ README.md | 15 ++++++ posthog/__init__.py | 4 ++ posthog/client.py | 51 ++++++++++++++++++++ posthog/test/test_client.py | 51 ++++++++++++++++++++ references/public_api_snapshot.txt | 3 +- 6 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 .sampo/changesets/ardent-runesinger-lempo.md 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/README.md b/README.md index e8e2f708..7c1754de 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,21 @@ SDK usage examples and code snippets live in the official documentation so they - [Django framework docs](https://posthog.com/docs/libraries/django) - [Flask framework docs](https://posthog.com/docs/libraries/flask) +## Client lifecycle best practices + +Create one `Posthog` client during application startup and reuse it throughout +that app or worker process. This avoids competing background queues and makes it +clear where to call `shutdown()` during graceful shutdown. + +Multiple clients are still supported when you intentionally send to different +projects or hosts. If the SDK detects multiple async clients with the same +project API key and host, it logs a non-fatal warning. For intentional duplicate +clients, pass `warn_on_duplicate_clients=False`. + +For short-lived jobs, serverless functions, and shutdown-sensitive workers, +consider `sync_mode=True` or call `shutdown()` before the process exits so queued +events are flushed. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for local setup and test instructions. diff --git a/posthog/__init__.py b/posthog/__init__.py index 9f0b4893..a2199684 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -314,6 +314,8 @@ def get_tags() -> Dict[str, Any]: each exception type's bucket. exception_autocapture_refill_interval_seconds: Seconds between token refills for autocaptured exception rate limiting. + warn_on_duplicate_clients: If True, log a warning when multiple async + clients with the same project API key and host are active in one process. """ api_key = None # type: Optional[str] host = None # type: Optional[str] @@ -353,6 +355,7 @@ def get_tags() -> Dict[str, Any]: exception_autocapture_refill_interval_seconds = ( ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS ) # type: float +warn_on_duplicate_clients = True # type: bool # NOTE - this and following functions take unpacked kwargs because we needed to make @@ -1123,6 +1126,7 @@ def setup() -> Client: exception_autocapture_bucket_size=exception_autocapture_bucket_size, exception_autocapture_refill_rate=exception_autocapture_refill_rate, exception_autocapture_refill_interval_seconds=exception_autocapture_refill_interval_seconds, + warn_on_duplicate_clients=warn_on_duplicate_clients, ) # Always set in case user changes it. Preserve Client's auto-disabled state diff --git a/posthog/client.py b/posthog/client.py index 800cc577..604a5dea 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, @@ -216,6 +224,7 @@ def __init__( exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, _dedicated_ai_endpoint=False, + warn_on_duplicate_clients=True, ): """ Initialize a new PostHog client instance. @@ -287,6 +296,9 @@ def __init__( interval for each exception type's bucket. exception_autocapture_refill_interval_seconds: Seconds between token refills for autocaptured exception rate limiting. + warn_on_duplicate_clients: If True, log a warning when multiple + async clients with the same project API key and host are active + in one process. Set to False for intentional duplicate clients. Examples: ```python @@ -446,6 +458,45 @@ def __init__( after_in_child=lambda: Client._reinit_after_fork_weak(weak_self) ) + self._warn_if_duplicate_async_client(warn_on_duplicate_clients) + + def _warn_if_duplicate_async_client(self, warn_on_duplicate_clients): + if ( + not warn_on_duplicate_clients + or 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 = any(client is not self for client in clients) + clients.add(self) + + 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; pass warn_on_duplicate_clients=False to " + "suppress this warning." + ) + def _set_before_send(self, before_send): if before_send is not None: if callable(before_send): diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index e9d7054c..49a6fa19 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -80,6 +80,57 @@ def test_client_with_empty_api_key_is_noop(self): self.assertIsNone(client.capture("event", distinct_id="distinct_id")) + def test_warns_once_on_duplicate_async_client_same_key_and_host(self): + Client._client_registry.clear() + Client._duplicate_client_warnings.clear() + + 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://us.i.posthog.com") + second = Client(FAKE_TEST_API_KEY, host="https://us.i.posthog.com") + third = Client(FAKE_TEST_API_KEY, host="https://us.i.posthog.com") + + 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; pass warn_on_duplicate_clients=False to " + "suppress this warning." + ) + + def test_duplicate_client_warning_allows_intentional_multi_client_cases(self): + Client._client_registry.clear() + Client._duplicate_client_warnings.clear() + + 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") + different_host = Client(FAKE_TEST_API_KEY, host="https://two.example.com") + sync_duplicate = Client( + FAKE_TEST_API_KEY, + host="https://one.example.com", + sync_mode=True, + ) + opted_out_duplicate = Client( + FAKE_TEST_API_KEY, + host="https://one.example.com", + warn_on_duplicate_clients=False, + ) + + self.assertIsNot(first, different_host) + self.assertIsNot(first, sync_duplicate) + self.assertIsNot(first, opted_out_duplicate) + mock_warning.assert_not_called() + @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) diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 2ba8a253..37bba93f 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -686,6 +686,7 @@ attribute posthog.utils.RedisFlagCache.version_key = f'{key_prefix}version' attribute posthog.utils.SizeLimitedDict.max_size = max_size attribute posthog.utils.log = logging.getLogger('posthog') attribute posthog.version.VERSION = +attribute posthog.warn_on_duplicate_clients = True class posthog.Posthog class posthog.ai.anthropic.anthropic.Anthropic(posthog_client: Optional[PostHogClient] = None, **kwargs) class posthog.ai.anthropic.anthropic.WrappedMessages @@ -744,7 +745,7 @@ class posthog.ai.types.ToolInProgress class posthog.args.OptionalCaptureArgs class posthog.args.OptionalSetArgs class posthog.bucketed_rate_limiter.BucketedRateLimiter(bucket_size: Number, refill_rate: Number, refill_interval_seconds: Number, on_bucket_rate_limited: Optional[Callable[[Hashable], None]] = None, clock: Callable[[], float] = time.monotonic) -class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, _dedicated_ai_endpoint=False) +class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, _dedicated_ai_endpoint=False, warn_on_duplicate_clients=True) class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, gzip=False, retries=10, timeout=15, historical_migration=False, dedicated_ai_endpoint=False) class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None) class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS) From dbc0b3a9e8d36921b988da148967eb0403b028bc Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 19 Jun 2026 15:43:00 +0200 Subject: [PATCH 2/3] address pr review feedback --- README.md | 3 +- posthog/__init__.py | 4 -- posthog/client.py | 37 +++++++++++-------- posthog/test/test_client.py | 59 ++++++++++++++++++++---------- references/public_api_snapshot.txt | 3 +- 5 files changed, 63 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 7c1754de..624ebd03 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,7 @@ clear where to call `shutdown()` during graceful shutdown. Multiple clients are still supported when you intentionally send to different projects or hosts. If the SDK detects multiple async clients with the same -project API key and host, it logs a non-fatal warning. For intentional duplicate -clients, pass `warn_on_duplicate_clients=False`. +project API key and host, it logs a non-fatal warning. For short-lived jobs, serverless functions, and shutdown-sensitive workers, consider `sync_mode=True` or call `shutdown()` before the process exits so queued diff --git a/posthog/__init__.py b/posthog/__init__.py index a2199684..9f0b4893 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -314,8 +314,6 @@ def get_tags() -> Dict[str, Any]: each exception type's bucket. exception_autocapture_refill_interval_seconds: Seconds between token refills for autocaptured exception rate limiting. - warn_on_duplicate_clients: If True, log a warning when multiple async - clients with the same project API key and host are active in one process. """ api_key = None # type: Optional[str] host = None # type: Optional[str] @@ -355,7 +353,6 @@ def get_tags() -> Dict[str, Any]: exception_autocapture_refill_interval_seconds = ( ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS ) # type: float -warn_on_duplicate_clients = True # type: bool # NOTE - this and following functions take unpacked kwargs because we needed to make @@ -1126,7 +1123,6 @@ def setup() -> Client: exception_autocapture_bucket_size=exception_autocapture_bucket_size, exception_autocapture_refill_rate=exception_autocapture_refill_rate, exception_autocapture_refill_interval_seconds=exception_autocapture_refill_interval_seconds, - warn_on_duplicate_clients=warn_on_duplicate_clients, ) # Always set in case user changes it. Preserve Client's auto-disabled state diff --git a/posthog/client.py b/posthog/client.py index 604a5dea..a7bc5604 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -224,7 +224,6 @@ def __init__( exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, _dedicated_ai_endpoint=False, - warn_on_duplicate_clients=True, ): """ Initialize a new PostHog client instance. @@ -296,9 +295,6 @@ def __init__( interval for each exception type's bucket. exception_autocapture_refill_interval_seconds: Seconds between token refills for autocaptured exception rate limiting. - warn_on_duplicate_clients: If True, log a warning when multiple - async clients with the same project API key and host are active - in one process. Set to False for intentional duplicate clients. Examples: ```python @@ -323,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]] = ( @@ -458,16 +455,10 @@ def __init__( after_in_child=lambda: Client._reinit_after_fork_weak(weak_self) ) - self._warn_if_duplicate_async_client(warn_on_duplicate_clients) + self._warn_if_duplicate_async_client() - def _warn_if_duplicate_async_client(self, warn_on_duplicate_clients): - if ( - not warn_on_duplicate_clients - or self.disabled - or not self.send - or self.sync_mode - or not self.api_key - ): + 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) @@ -479,6 +470,7 @@ def _warn_if_duplicate_async_client(self, warn_on_duplicate_clients): ) has_existing_client = any(client is not self for client in clients) clients.add(self) + self._duplicate_client_registry_key = registry_key if ( has_existing_client @@ -493,10 +485,24 @@ def _warn_if_duplicate_async_client(self, warn_on_duplicate_clients): "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; pass warn_on_duplicate_clients=False to " - "suppress this warning." + "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): @@ -1514,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 49a6fa19..605d669a 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -83,26 +83,42 @@ def test_client_with_empty_api_key_is_noop(self): def test_warns_once_on_duplicate_async_client_same_key_and_host(self): Client._client_registry.clear() Client._duplicate_client_warnings.clear() + 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="https://us.i.posthog.com") - second = Client(FAKE_TEST_API_KEY, host="https://us.i.posthog.com") - third = Client(FAKE_TEST_API_KEY, host="https://us.i.posthog.com") - - 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; pass warn_on_duplicate_clients=False to " - "suppress this 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() def test_duplicate_client_warning_allows_intentional_multi_client_cases(self): Client._client_registry.clear() @@ -120,16 +136,19 @@ def test_duplicate_client_warning_allows_intentional_multi_client_cases(self): host="https://one.example.com", sync_mode=True, ) - opted_out_duplicate = Client( + send_disabled_duplicate = Client( FAKE_TEST_API_KEY, host="https://one.example.com", - warn_on_duplicate_clients=False, + send=False, ) - self.assertIsNot(first, different_host) - self.assertIsNot(first, sync_duplicate) - self.assertIsNot(first, opted_out_duplicate) - mock_warning.assert_not_called() + self.assertIsNot(first, different_host) + self.assertIsNot(first, sync_duplicate) + self.assertIsNot(first, send_disabled_duplicate) + mock_warning.assert_not_called() + + first.shutdown() + different_host.shutdown() @mock.patch("posthog.client.get") def test_disabled_client_does_not_load_feature_flags(self, patch_get): diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 37bba93f..2ba8a253 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -686,7 +686,6 @@ attribute posthog.utils.RedisFlagCache.version_key = f'{key_prefix}version' attribute posthog.utils.SizeLimitedDict.max_size = max_size attribute posthog.utils.log = logging.getLogger('posthog') attribute posthog.version.VERSION = -attribute posthog.warn_on_duplicate_clients = True class posthog.Posthog class posthog.ai.anthropic.anthropic.Anthropic(posthog_client: Optional[PostHogClient] = None, **kwargs) class posthog.ai.anthropic.anthropic.WrappedMessages @@ -745,7 +744,7 @@ class posthog.ai.types.ToolInProgress class posthog.args.OptionalCaptureArgs class posthog.args.OptionalSetArgs class posthog.bucketed_rate_limiter.BucketedRateLimiter(bucket_size: Number, refill_rate: Number, refill_interval_seconds: Number, on_bucket_rate_limited: Optional[Callable[[Hashable], None]] = None, clock: Callable[[], float] = time.monotonic) -class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, _dedicated_ai_endpoint=False, warn_on_duplicate_clients=True) +class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, _dedicated_ai_endpoint=False) class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, gzip=False, retries=10, timeout=15, historical_migration=False, dedicated_ai_endpoint=False) class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None) class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS) From 5c1305c2f4c9da07cbcecf6b5fec49075b2b3fbb Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 19 Jun 2026 15:45:30 +0200 Subject: [PATCH 3/3] address remaining pr review feedback --- README.md | 14 ------------- posthog/client.py | 2 +- posthog/test/test_client.py | 39 +++++++++++++++++++------------------ 3 files changed, 21 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 624ebd03..e8e2f708 100644 --- a/README.md +++ b/README.md @@ -28,20 +28,6 @@ SDK usage examples and code snippets live in the official documentation so they - [Django framework docs](https://posthog.com/docs/libraries/django) - [Flask framework docs](https://posthog.com/docs/libraries/flask) -## Client lifecycle best practices - -Create one `Posthog` client during application startup and reuse it throughout -that app or worker process. This avoids competing background queues and makes it -clear where to call `shutdown()` during graceful shutdown. - -Multiple clients are still supported when you intentionally send to different -projects or hosts. If the SDK detects multiple async clients with the same -project API key and host, it logs a non-fatal warning. - -For short-lived jobs, serverless functions, and shutdown-sensitive workers, -consider `sync_mode=True` or call `shutdown()` before the process exits so queued -events are flushed. - ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for local setup and test instructions. diff --git a/posthog/client.py b/posthog/client.py index a7bc5604..b0f55fc7 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -468,7 +468,7 @@ def _warn_if_duplicate_async_client(self): clients = Client._client_registry.setdefault( registry_key, weakref.WeakSet() ) - has_existing_client = any(client is not self for client in clients) + has_existing_client = len(clients) > 0 clients.add(self) self._duplicate_client_registry_key = registry_key diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 605d669a..58ff0a88 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -80,9 +80,13 @@ def test_client_with_empty_api_key_is_noop(self): self.assertIsNone(client.capture("event", distinct_id="distinct_id")) - def test_warns_once_on_duplicate_async_client_same_key_and_host(self): + 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) @@ -120,9 +124,18 @@ def test_warns_once_on_duplicate_async_client_same_key_and_host(self): fourth.shutdown() fifth.shutdown() - def test_duplicate_client_warning_allows_intentional_multi_client_cases(self): - Client._client_registry.clear() - Client._duplicate_client_warnings.clear() + @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"), @@ -130,25 +143,13 @@ def test_duplicate_client_warning_allows_intentional_multi_client_cases(self): mock.patch.object(Client.log, "warning") as mock_warning, ): first = Client(FAKE_TEST_API_KEY, host="https://one.example.com") - different_host = Client(FAKE_TEST_API_KEY, host="https://two.example.com") - sync_duplicate = Client( - FAKE_TEST_API_KEY, - host="https://one.example.com", - sync_mode=True, - ) - send_disabled_duplicate = Client( - FAKE_TEST_API_KEY, - host="https://one.example.com", - send=False, - ) + duplicate = Client(FAKE_TEST_API_KEY, **duplicate_kwargs) - self.assertIsNot(first, different_host) - self.assertIsNot(first, sync_duplicate) - self.assertIsNot(first, send_disabled_duplicate) + self.assertIsNot(first, duplicate) mock_warning.assert_not_called() first.shutdown() - different_host.shutdown() + duplicate.shutdown() @mock.patch("posthog.client.get") def test_disabled_client_does_not_load_feature_flags(self, patch_get):