From 60fd3d9a60c7dcdaa1fa7b1b1e4212776d29e3a0 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 21 Sep 2026 11:29:20 +0200 Subject: [PATCH 1/4] feat: fetch project remote config at startup and periodically --- .sampo/changesets/gallant-seer-mielikki.md | 5 + posthog/__init__.py | 6 + posthog/_remote_config.py | 63 +++++ posthog/client.py | 52 +++- posthog/test/conftest.py | 8 + posthog/test/test_project_remote_config.py | 313 +++++++++++++++++++++ references/public_api_snapshot.txt | 4 +- typings/requests/__init__.pyi | 13 +- 8 files changed, 460 insertions(+), 4 deletions(-) create mode 100644 .sampo/changesets/gallant-seer-mielikki.md create mode 100644 posthog/_remote_config.py create mode 100644 posthog/test/test_project_remote_config.py diff --git a/.sampo/changesets/gallant-seer-mielikki.md b/.sampo/changesets/gallant-seer-mielikki.md new file mode 100644 index 000000000..0885607bd --- /dev/null +++ b/.sampo/changesets/gallant-seer-mielikki.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Fetch project remote configuration in the background at startup and every 300 seconds by default. Set remote_config_poll_interval_seconds to None to disable fetching. Fetched configuration does not change SDK settings yet. diff --git a/posthog/__init__.py b/posthog/__init__.py index 092fb00b2..c51835aa1 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -325,6 +325,10 @@ def get_tags() -> Dict[str, Any]: secret_key: A Personal API Key or Project Secret API Key used for local feature flag evaluation and remote config payloads. personal_api_key: Deprecated alias for secret_key. + remote_config_poll_interval_seconds: Seconds between background project config + fetches (default 300), also fetched at startup. None disables fetching. + Responses do not change SDK settings. Disabled clients and send=False + do not fetch. Applies when the default client is first constructed. poll_interval: Seconds between local feature flag definition refreshes. disable_geoip: Whether to disable server-side GeoIP enrichment. Defaults to True. @@ -405,6 +409,7 @@ def get_tags() -> Dict[str, Any]: # Preferred project token setting; takes precedence over the legacy api_key alias. project_api_key = None # type: Optional[str] poll_interval = 30 # type: int +remote_config_poll_interval_seconds = 300 # type: Optional[float] disable_geoip = True # type: bool is_server = True # type: bool feature_flags_request_timeout_seconds = 3 # type: int @@ -1345,6 +1350,7 @@ def setup() -> Client: secret_key=secret_key, personal_api_key=personal_api_key, poll_interval=poll_interval, + remote_config_poll_interval_seconds=remote_config_poll_interval_seconds, disabled=disabled, disable_geoip=disable_geoip, is_server=is_server, diff --git a/posthog/_remote_config.py b/posthog/_remote_config.py new file mode 100644 index 000000000..474d7c0bc --- /dev/null +++ b/posthog/_remote_config.py @@ -0,0 +1,63 @@ +import logging +from threading import Event, Thread +from typing import Any, Callable, Optional +from urllib.parse import quote + +from .request import _get_session, determine_server_host + + +class _RemoteConfigPoller(Thread): + def __init__( + self, + api_key: str, + host: str, + interval: float, + timeout: float, + is_enabled: Optional[Callable[[], bool]] = None, + ): + super().__init__(name="posthog-remote-config", daemon=True) + self._api_key = api_key + self._host = host + self._interval = interval + self._timeout = timeout + self._is_enabled = is_enabled + self._stopped = Event() + self._config: Optional[dict[str, Any]] = None + + def stop(self) -> None: + self._stopped.set() + self.join() + + def run(self) -> None: + while not self._stopped.is_set(): + try: + if self._is_enabled is None or self._is_enabled(): + config = _fetch_remote_config( + self._api_key, self._host, self._timeout + ) + if not self._stopped.is_set(): + self._config = config + except Exception: + # Request exceptions can contain proxy credentials in their URL. + logging.getLogger("posthog").debug( + "Failed to fetch project remote config" + ) + if self._stopped.wait(self._interval): + break + + +def _fetch_remote_config( + api_key: str, host: Optional[str], timeout: float +) -> dict[str, Any]: + base = determine_server_host(host).rstrip("/") + base = { + "https://us.i.posthog.com": "https://us-assets.i.posthog.com", + "https://eu.i.posthog.com": "https://eu-assets.i.posthog.com", + }.get(base, base) + url = f"{base}/array/{quote(api_key, safe='')}/config" + with _get_session().get(url, timeout=timeout) as response: + response.raise_for_status() + config = response.json() + if not isinstance(config, dict): + raise ValueError("Project remote config must be a JSON object") + return config diff --git a/posthog/client.py b/posthog/client.py index 857bd743e..7293a99c3 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -2,6 +2,7 @@ import inspect import json import logging +import math import os import sys import threading @@ -82,6 +83,7 @@ FlagDefinitionCacheProvider, ) from posthog.poller import Poller +from ._remote_config import _RemoteConfigPoller from posthog.request import ( AI_EVENTS_ENDPOINT, EVENTS_ENDPOINT, @@ -725,6 +727,7 @@ def __init__( _use_ai_lane=False, _enable_multimodal_capture=False, traces: Optional[dict] = None, + remote_config_poll_interval_seconds: Optional[float] = 300, ): """ Initialize a new PostHog client instance. @@ -752,8 +755,16 @@ def __init__( background worker threads. This blocks the calling thread; in asyncio applications such as FastAPI, use ``AsyncPosthog`` instead. - timeout: HTTP request timeout in seconds for event uploads. + timeout: HTTP request timeout in seconds for event uploads and project + remote configuration fetches. thread: Number of background consumer threads. + remote_config_poll_interval_seconds: Fetch project configuration in the + background at startup, then wait this many seconds between fetches + (default 300), including after failures. + None disables fetching. Must be positive and finite when enabled. + Disabled clients and send=False do not fetch. Responses are cached + in memory only and do not change SDK settings. Uses timeout for HTTP + requests; shutdown waits for an in-flight request to finish. poll_interval: Seconds between local feature flag definition refreshes. secret_key: A Personal API Key or Project Secret API Key, used to authenticate local feature flag evaluation, remote config @@ -904,6 +915,17 @@ def __init__( self.feature_flags_by_key: Optional[dict[str, Any]] = None self.group_type_mapping: Optional[dict[str, str]] = None self.cohorts: Optional[dict[str, Any]] = None + if remote_config_poll_interval_seconds is not None and ( + isinstance(remote_config_poll_interval_seconds, bool) + or not isinstance(remote_config_poll_interval_seconds, (int, float)) + or not math.isfinite(remote_config_poll_interval_seconds) + or remote_config_poll_interval_seconds <= 0 + ): + raise ValueError( + "remote_config_poll_interval_seconds must be positive and finite or None" + ) + self.remote_config_poll_interval_seconds = remote_config_poll_interval_seconds + self._remote_config_poller: Optional[_RemoteConfigPoller] = None self.poll_interval = poll_interval self.feature_flags_request_timeout_seconds = ( feature_flags_request_timeout_seconds @@ -1110,6 +1132,23 @@ def __init__( ) self._warn_if_duplicate_async_client() + self._start_remote_config() + + def _start_remote_config(self) -> None: + if ( + self.disabled + or not self.send + or self.remote_config_poll_interval_seconds is None + ): + return + self._remote_config_poller = _RemoteConfigPoller( + self.api_key, + self.host, + self.remote_config_poll_interval_seconds, + self.timeout, + is_enabled=lambda: not self.disabled and self.send, + ) + self._remote_config_poller.start() def _set_library_identity(self, library_id: str, library_version: str) -> None: """Override the SDK identity stamped on events and outbound requests.""" @@ -2290,6 +2329,9 @@ def _reinit_after_fork(self): reset_sessions() # Start child threads only after replacing every lock they can touch. + self._remote_config_poller = None + if not terminal_requested: + self._start_remote_config() if terminal_requested: self.poller = None elif self.enable_local_evaluation: @@ -2849,6 +2891,12 @@ def _join_once( self._workers_joined = True if not self._join_cleanup_complete: + if self._remote_config_poller: + self._run_lifecycle_cleanup( + "Failed to stop remote config poller during lifecycle cleanup", + self._remote_config_poller.stop, + errors, + ) if self.poller: self._run_lifecycle_cleanup( "Failed to stop feature flag poller during lifecycle cleanup", @@ -2998,6 +3046,8 @@ def _atexit(self) -> None: lane.flush(max(0.0, deadline - time.monotonic())) self._join_span_flush(span_flush, deadline) finally: + if self._remote_config_poller: + self._remote_config_poller._stopped.set() # Consumers are daemon threads. Publish a non-draining stop to # every consumer, but do not join in-flight requests at exit. for lane in self._lanes: diff --git a/posthog/test/conftest.py b/posthog/test/conftest.py index 1aa9d03fd..f93768ab6 100644 --- a/posthog/test/conftest.py +++ b/posthog/test/conftest.py @@ -5,6 +5,14 @@ import posthog.client as client_module +@pytest.fixture(autouse=True) +def disable_remote_config_for_unrelated_tests(monkeypatch, request): + if request.module.__name__ != "posthog.test.test_project_remote_config": + monkeypatch.setattr( + client_module.Client, "_start_remote_config", lambda self: None + ) + + @pytest.fixture(autouse=True) def disable_client_atexit_join(monkeypatch): monkeypatch.setattr(client_module.atexit, "register", lambda *args, **kwargs: None) diff --git a/posthog/test/test_project_remote_config.py b/posthog/test/test_project_remote_config.py new file mode 100644 index 000000000..0baa70cca --- /dev/null +++ b/posthog/test/test_project_remote_config.py @@ -0,0 +1,313 @@ +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from queue import Queue +from unittest.mock import Mock, patch + +import pytest +import requests + +import posthog +from posthog._remote_config import _RemoteConfigPoller, _fetch_remote_config +from posthog.client import Client + + +@pytest.mark.parametrize( + "host,base", + [ + (None, "https://us-assets.i.posthog.com"), + ("https://us.i.posthog.com/", "https://us-assets.i.posthog.com"), + ("https://eu.i.posthog.com", "https://eu-assets.i.posthog.com"), + ("https://app.posthog.com", "https://us-assets.i.posthog.com"), + ("https://eu.posthog.com", "https://eu-assets.i.posthog.com"), + ("https://proxy.example/posthog/", "https://proxy.example/posthog"), + ], +) +def test_request_contract(host, base): + config = {"hasFeatureFlags": False, "futureSetting": {"enabled": True}} + with patch("posthog._remote_config._get_session") as session: + response = session.return_value.get.return_value.__enter__.return_value + response.json.return_value = config + assert _fetch_remote_config("phc_test", host, 3) == config + session.return_value.get.assert_called_once_with( + base + "/array/phc_test/config", timeout=3 + ) + response.raise_for_status.assert_called_once() + + +def test_token_is_one_path_segment(): + with patch("posthog._remote_config._get_session") as session: + response = session.return_value.get.return_value.__enter__.return_value + response.json.return_value = {} + _fetch_remote_config("a/b?c", "https://proxy.example", 3) + assert session.return_value.get.call_args.args[0].endswith( + "/array/a%2Fb%3Fc/config" + ) + + +@pytest.mark.parametrize("value", [[], None, True, "config", 42]) +def test_reject_non_object(value): + with patch("posthog._remote_config._get_session") as session: + response = session.return_value.get.return_value.__enter__.return_value + response.json.return_value = value + with pytest.raises(ValueError): + _fetch_remote_config("phc_test", "https://proxy.example", 3) + + +@pytest.mark.parametrize( + "failure", [requests.Timeout(), ValueError(), requests.HTTPError()] +) +def test_refresh_failure_preserves_last_success(failure): + worker = _RemoteConfigPoller("phc_test", "https://proxy.example", 300, 3) + worker._stopped = Mock() + worker._stopped.is_set.return_value = False + worker._stopped.wait.side_effect = [False, True] + with patch( + "posthog._remote_config._fetch_remote_config", side_effect=[{"x": 1}, failure] + ) as fetch: + worker.run() + assert worker._config == {"x": 1} + assert fetch.call_count == 2 + assert worker._stopped.wait.call_args.args == (300,) + + +@pytest.mark.parametrize("interval", [0, -1, float("nan"), float("inf"), True, "300"]) +def test_invalid_interval(interval): + with pytest.raises(ValueError, match="remote_config_poll_interval_seconds"): + Client("phc_test", remote_config_poll_interval_seconds=interval) + + +@pytest.mark.parametrize( + "options", + [ + {"disabled": True}, + {"send": False}, + {"remote_config_poll_interval_seconds": None}, + ], +) +def test_disabled_does_not_start(options): + with patch("posthog.client._RemoteConfigPoller") as worker: + client = Client("phc_test", **options) + try: + worker.assert_not_called() + finally: + client.shutdown() + + +def test_empty_key_does_not_start(): + with patch("posthog.client._RemoteConfigPoller") as worker: + client = Client(" ") + try: + worker.assert_not_called() + finally: + client.shutdown() + + +def test_default_interval_and_module_option(monkeypatch): + with patch("posthog.client._RemoteConfigPoller") as worker: + client = Client("phc_test", sync_mode=True) + try: + assert client.remote_config_poll_interval_seconds == 300 + assert worker.call_args.args[2] == 300 + worker.return_value.start.assert_called_once() + is_enabled = worker.call_args.kwargs["is_enabled"] + assert is_enabled() + client.disabled = True + assert not is_enabled() + client.disabled = False + client.send = False + assert not is_enabled() + finally: + client.shutdown() + worker.return_value.stop.assert_called_once() + monkeypatch.setattr(posthog, "default_client", None) + monkeypatch.setattr(posthog, "api_key", "phc_test") + monkeypatch.setattr(posthog, "remote_config_poll_interval_seconds", None) + posthog.flush() + try: + assert posthog.default_client.remote_config_poll_interval_seconds is None + assert posthog.default_client._remote_config_poller is None + finally: + posthog.shutdown() + + +@pytest.fixture +def server(): + replies = Queue() + requests_seen = Queue() + release = threading.Event() + release.set() + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + requests_seen.put((self.command, self.path, dict(self.headers))) + release.wait(5) + status, body = replies.get(timeout=5) + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield ( + f"http://127.0.0.1:{httpd.server_port}/proxy", + replies, + requests_seen, + release, + ) + finally: + release.set() + httpd.shutdown() + httpd.server_close() + thread.join() + + +def wait_for_config(worker, expected): + deadline = time.monotonic() + 5 + while worker._config != expected and time.monotonic() < deadline: + time.sleep(0.005) + assert worker._config == expected + + +@pytest.mark.parametrize("sync_mode", [False, True]) +def test_startup_refresh_and_shutdown_with_http_server(server, sync_mode): + host, replies, seen, release = server + first = {"hasFeatureFlags": False, "errorTracking": True, "futureSetting": 1} + last = {"surveys": False} + for status, body in [ + (200, json.dumps(first).encode()), + (503, b"unavailable"), + (200, b"invalid json"), + (200, b"[]"), + (200, json.dumps(last).encode()), + ]: + replies.put((status, body)) + release.clear() + client = Client( + "phc_test", + host=host, + sync_mode=sync_mode, + remote_config_poll_interval_seconds=0.1, + timeout=1, + ) + worker = client._remote_config_poller + try: + # The constructor returned while the startup response is still blocked. + method, path, headers = seen.get(timeout=5) + assert (method, path) == ("GET", "/proxy/array/phc_test/config") + assert "Authorization" not in headers + assert headers.get("Content-Length", "0") == "0" + assert worker._config is None + release.set() + wait_for_config(worker, first) + for _ in range(3): + seen.get(timeout=5) + assert worker._config == first + seen.get(timeout=5) + wait_for_config(worker, last) + assert client.enable_exception_autocapture is False + assert client._feature_flags is None + finally: + release.set() + client.shutdown() + assert not worker.is_alive() + assert seen.empty() + + +def test_polling_skips_requests_while_disabled(): + worker = _RemoteConfigPoller( + "phc_test", + "https://proxy.example", + 300, + 3, + is_enabled=Mock(side_effect=[True, False, True]), + ) + worker._stopped = Mock() + worker._stopped.is_set.return_value = False + worker._stopped.wait.side_effect = [False, False, True] + with patch("posthog._remote_config._fetch_remote_config", return_value={}) as fetch: + worker.run() + assert fetch.call_count == 2 + + +def test_startup_failure_recovers_on_next_interval(): + worker = _RemoteConfigPoller("phc_test", "https://proxy.example", 300, 3) + worker._stopped = Mock() + worker._stopped.is_set.return_value = False + worker._stopped.wait.side_effect = [False, True] + with patch( + "posthog._remote_config._fetch_remote_config", + side_effect=[requests.ConnectionError(), {"recovered": True}], + ): + worker.run() + assert worker._config == {"recovered": True} + + +def test_join_waits_for_inflight_fetch_without_publishing_after_stop(): + entered = threading.Event() + release = threading.Event() + + def fetch(*args): + entered.set() + assert release.wait(5) + return {"late": True} + + with patch("posthog._remote_config._fetch_remote_config", side_effect=fetch): + client = Client("phc_test", sync_mode=True) + worker = client._remote_config_poller + cleanup = threading.Thread(target=client.join) + try: + assert entered.wait(5) + cleanup.start() + assert worker._stopped.wait(5) + assert cleanup.is_alive() + release.set() + cleanup.join(5) + assert not cleanup.is_alive() + assert not worker.is_alive() + assert worker._config is None + finally: + release.set() + client.shutdown() + + +def test_atexit_signals_stop_without_waiting_for_request(): + entered = threading.Event() + release = threading.Event() + + def fetch(*args): + entered.set() + assert release.wait(5) + return {} + + with patch("posthog._remote_config._fetch_remote_config", side_effect=fetch): + client = Client("phc_test", sync_mode=True) + try: + assert entered.wait(5) + client._atexit() + assert client._remote_config_poller._stopped.is_set() + assert client._remote_config_poller.is_alive() + finally: + release.set() + client.shutdown() + + +def test_fork_recreates_worker_and_terminal_client_stays_stopped(): + with patch("posthog.client._RemoteConfigPoller") as worker: + client = Client("phc_test", sync_mode=True, enable_local_evaluation=False) + try: + client._reinit_after_fork() + assert worker.call_count == 2 + client.shutdown() + client._reinit_after_fork() + assert worker.call_count == 2 + assert client._remote_config_poller is None + finally: + client.shutdown() diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 3eb20c41f..71810acc4 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -634,6 +634,7 @@ attribute posthog.client.Client.privacy_mode = privacy_mode attribute posthog.client.Client.project_root = project_root attribute posthog.client.Client.queue: Queue attribute posthog.client.Client.raw_host = normalize_host(host) +attribute posthog.client.Client.remote_config_poll_interval_seconds = remote_config_poll_interval_seconds attribute posthog.client.Client.secret_key = (resolved_secret_key.strip() if isinstance(resolved_secret_key, str) else resolved_secret_key) or None attribute posthog.client.Client.send = send attribute posthog.client.Client.super_properties = super_properties @@ -869,6 +870,7 @@ attribute posthog.poller.Poller.stopped = threading.Event() attribute posthog.privacy_mode = False attribute posthog.project_api_key = None attribute posthog.project_root = None +attribute posthog.remote_config_poll_interval_seconds = 300 attribute posthog.request.AI_EVENTS_ENDPOINT = '/i/v0/ai/batch/' attribute posthog.request.APIError.message = message attribute posthog.request.APIError.retry_after = retry_after @@ -1019,7 +1021,7 @@ class posthog.bucketed_rate_limiter.BucketedRateLimiter(bucket_size: Number, ref class posthog.capture_compression.CaptureCompression class posthog.capture_mode.CaptureMode class posthog.capture_v1.CaptureV1Error(status: int | str, message: str, *, retry_after: Optional[float] = None, request_id: Optional[str] = None, attempts: Optional[int] = None, retry_exhausted: Optional[list[str]] = None, drops: Optional[list[tuple[str, Optional[str]]]] = None) -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, feature_flags_request_max_retries=1, 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, code_variables_mask_url_credentials=None, code_variables_detect_secrets=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, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False, traces: Optional[dict] = None) +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, feature_flags_request_max_retries=1, 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, code_variables_mask_url_credentials=None, code_variables_detect_secrets=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, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False, traces: Optional[dict] = None, remote_config_poll_interval_seconds: Optional[float] = 300) 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, endpoint=EVENTS_ENDPOINT, max_msg_size=MAX_MSG_SIZE, capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE) 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) diff --git a/typings/requests/__init__.pyi b/typings/requests/__init__.pyi index 75a3fa48c..60a819641 100644 --- a/typings/requests/__init__.pyi +++ b/typings/requests/__init__.pyi @@ -1,3 +1,4 @@ +from types import TracebackType from typing import Any from . import adapters as adapters, exceptions as exceptions @@ -9,6 +10,14 @@ class Response: headers: dict[str, str] def json(self) -> Any: ... def close(self) -> None: ... + def raise_for_status(self) -> None: ... + def __enter__(self) -> Response: ... + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: ... class Session: def mount(self, prefix: str, adapter: adapters.HTTPAdapter) -> None: ... @@ -26,6 +35,6 @@ class Session: self, url: str, *, - headers: dict[str, str], - timeout: int | None = ..., + headers: dict[str, str] | None = ..., + timeout: float | None = ..., ) -> Response: ... From 7e8e89171125f11847ddf992f8f11f7821b68067 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 21 Sep 2026 11:42:33 +0200 Subject: [PATCH 2/4] refactor: reuse Poller for project remote config refreshes --- posthog/_remote_config.py | 43 ++++++++++------------ posthog/client.py | 2 +- posthog/test/test_project_remote_config.py | 34 +++++++++++------ 3 files changed, 42 insertions(+), 37 deletions(-) diff --git a/posthog/_remote_config.py b/posthog/_remote_config.py index 474d7c0bc..49a22161f 100644 --- a/posthog/_remote_config.py +++ b/posthog/_remote_config.py @@ -1,12 +1,13 @@ import logging -from threading import Event, Thread +from datetime import timedelta from typing import Any, Callable, Optional from urllib.parse import quote +from .poller import Poller from .request import _get_session, determine_server_host -class _RemoteConfigPoller(Thread): +class _RemoteConfigPoller(Poller): def __init__( self, api_key: str, @@ -15,35 +16,29 @@ def __init__( timeout: float, is_enabled: Optional[Callable[[], bool]] = None, ): - super().__init__(name="posthog-remote-config", daemon=True) + super().__init__(interval=timedelta(seconds=interval), execute=self._refresh) + self.name = "posthog-remote-config" self._api_key = api_key self._host = host - self._interval = interval self._timeout = timeout self._is_enabled = is_enabled - self._stopped = Event() self._config: Optional[dict[str, Any]] = None - def stop(self) -> None: - self._stopped.set() - self.join() - def run(self) -> None: - while not self._stopped.is_set(): - try: - if self._is_enabled is None or self._is_enabled(): - config = _fetch_remote_config( - self._api_key, self._host, self._timeout - ) - if not self._stopped.is_set(): - self._config = config - except Exception: - # Request exceptions can contain proxy credentials in their URL. - logging.getLogger("posthog").debug( - "Failed to fetch project remote config" - ) - if self._stopped.wait(self._interval): - break + self._refresh() + super().run() + + def _refresh(self) -> None: + if self.stopped.is_set(): + return + try: + if self._is_enabled is None or self._is_enabled(): + config = _fetch_remote_config(self._api_key, self._host, self._timeout) + if not self.stopped.is_set(): + self._config = config + except Exception: + # Request exceptions can contain proxy credentials in their URL. + logging.getLogger("posthog").debug("Failed to fetch project remote config") def _fetch_remote_config( diff --git a/posthog/client.py b/posthog/client.py index 7293a99c3..1a7d03c34 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -3047,7 +3047,7 @@ def _atexit(self) -> None: self._join_span_flush(span_flush, deadline) finally: if self._remote_config_poller: - self._remote_config_poller._stopped.set() + self._remote_config_poller.stopped.set() # Consumers are daemon threads. Publish a non-draining stop to # every consumer, but do not join in-flight requests at exit. for lane in self._lanes: diff --git a/posthog/test/test_project_remote_config.py b/posthog/test/test_project_remote_config.py index 0baa70cca..be9d5a264 100644 --- a/posthog/test/test_project_remote_config.py +++ b/posthog/test/test_project_remote_config.py @@ -36,6 +36,16 @@ def test_request_contract(host, base): response.raise_for_status.assert_called_once() +def test_stopped_before_start_does_not_fetch(): + worker = _RemoteConfigPoller("phc_test", "https://proxy.example", 300, 3) + worker.stopped.set() + with patch("posthog._remote_config._fetch_remote_config") as fetch: + worker.start() + worker.join(5) + assert not worker.is_alive() + fetch.assert_not_called() + + def test_token_is_one_path_segment(): with patch("posthog._remote_config._get_session") as session: response = session.return_value.get.return_value.__enter__.return_value @@ -60,16 +70,16 @@ def test_reject_non_object(value): ) def test_refresh_failure_preserves_last_success(failure): worker = _RemoteConfigPoller("phc_test", "https://proxy.example", 300, 3) - worker._stopped = Mock() - worker._stopped.is_set.return_value = False - worker._stopped.wait.side_effect = [False, True] + worker.stopped = Mock() + worker.stopped.is_set.return_value = False + worker.stopped.wait.side_effect = [False, True] with patch( "posthog._remote_config._fetch_remote_config", side_effect=[{"x": 1}, failure] ) as fetch: worker.run() assert worker._config == {"x": 1} assert fetch.call_count == 2 - assert worker._stopped.wait.call_args.args == (300,) + assert worker.stopped.wait.call_args.args == (300,) @pytest.mark.parametrize("interval", [0, -1, float("nan"), float("inf"), True, "300"]) @@ -229,9 +239,9 @@ def test_polling_skips_requests_while_disabled(): 3, is_enabled=Mock(side_effect=[True, False, True]), ) - worker._stopped = Mock() - worker._stopped.is_set.return_value = False - worker._stopped.wait.side_effect = [False, False, True] + worker.stopped = Mock() + worker.stopped.is_set.return_value = False + worker.stopped.wait.side_effect = [False, False, True] with patch("posthog._remote_config._fetch_remote_config", return_value={}) as fetch: worker.run() assert fetch.call_count == 2 @@ -239,9 +249,9 @@ def test_polling_skips_requests_while_disabled(): def test_startup_failure_recovers_on_next_interval(): worker = _RemoteConfigPoller("phc_test", "https://proxy.example", 300, 3) - worker._stopped = Mock() - worker._stopped.is_set.return_value = False - worker._stopped.wait.side_effect = [False, True] + worker.stopped = Mock() + worker.stopped.is_set.return_value = False + worker.stopped.wait.side_effect = [False, True] with patch( "posthog._remote_config._fetch_remote_config", side_effect=[requests.ConnectionError(), {"recovered": True}], @@ -266,7 +276,7 @@ def fetch(*args): try: assert entered.wait(5) cleanup.start() - assert worker._stopped.wait(5) + assert worker.stopped.wait(5) assert cleanup.is_alive() release.set() cleanup.join(5) @@ -292,7 +302,7 @@ def fetch(*args): try: assert entered.wait(5) client._atexit() - assert client._remote_config_poller._stopped.is_set() + assert client._remote_config_poller.stopped.is_set() assert client._remote_config_poller.is_alive() finally: release.set() From d6eae90ae67a8810f7bd5e68442b86385d55191f Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 21 Sep 2026 12:38:44 +0200 Subject: [PATCH 3/4] fix: handle remote config re-enabling and sync exit cleanup --- posthog/client.py | 23 +++++--- posthog/test/test_project_remote_config.py | 67 +++++++++++++++++++++- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 1a7d03c34..c88b2487b 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -762,8 +762,10 @@ def __init__( background at startup, then wait this many seconds between fetches (default 300), including after failures. None disables fetching. Must be positive and finite when enabled. - Disabled clients and send=False do not fetch. Responses are cached - in memory only and do not change SDK settings. Uses timeout for HTTP + Disabled clients skip requests until re-enabled, at which point + fetching resumes on the next interval. send=False does not start + a poller. Responses are cached in memory only and do not change + SDK settings. Uses timeout for HTTP requests; shutdown waits for an in-flight request to finish. poll_interval: Seconds between local feature flag definition refreshes. secret_key: A Personal API Key or Project Secret API Key, used to @@ -1132,15 +1134,16 @@ def __init__( ) self._warn_if_duplicate_async_client() - self._start_remote_config() + if self._start_remote_config() and self.sync_mode: + atexit.register(self._atexit_remote_config) - def _start_remote_config(self) -> None: + def _start_remote_config(self) -> bool: if ( - self.disabled + not self.api_key or not self.send or self.remote_config_poll_interval_seconds is None ): - return + return False self._remote_config_poller = _RemoteConfigPoller( self.api_key, self.host, @@ -1149,6 +1152,7 @@ def _start_remote_config(self) -> None: is_enabled=lambda: not self.disabled and self.send, ) self._remote_config_poller.start() + return True def _set_library_identity(self, library_id: str, library_version: str) -> None: """Override the SDK identity stamped on events and outbound requests.""" @@ -3023,6 +3027,10 @@ def _run_lifecycle(self, require_shutdown: bool = False) -> None: self._lifecycle_condition.notify_all() raise + def _atexit_remote_config(self) -> None: + if self._remote_config_poller: + self._remote_config_poller.stopped.set() + @no_throw() def _atexit(self) -> None: """Make a bounded delivery attempt, then stop daemon workers.""" @@ -3046,8 +3054,7 @@ def _atexit(self) -> None: lane.flush(max(0.0, deadline - time.monotonic())) self._join_span_flush(span_flush, deadline) finally: - if self._remote_config_poller: - self._remote_config_poller.stopped.set() + self._atexit_remote_config() # Consumers are daemon threads. Publish a non-draining stop to # every consumer, but do not join in-flight requests at exit. for lane in self._lanes: diff --git a/posthog/test/test_project_remote_config.py b/posthog/test/test_project_remote_config.py index be9d5a264..c44c4d2ad 100644 --- a/posthog/test/test_project_remote_config.py +++ b/posthog/test/test_project_remote_config.py @@ -91,12 +91,11 @@ def test_invalid_interval(interval): @pytest.mark.parametrize( "options", [ - {"disabled": True}, {"send": False}, {"remote_config_poll_interval_seconds": None}, ], ) -def test_disabled_does_not_start(options): +def test_fetching_opt_out_does_not_start(options): with patch("posthog.client._RemoteConfigPoller") as worker: client = Client("phc_test", **options) try: @@ -105,6 +104,70 @@ def test_disabled_does_not_start(options): client.shutdown() +@pytest.mark.parametrize("module_client", [False, True]) +def test_disabled_client_fetches_after_reenabling(monkeypatch, module_client): + fetched = threading.Event() + + def fetch(*args): + fetched.set() + return {} + + with patch("posthog._remote_config._fetch_remote_config", side_effect=fetch): + if module_client: + monkeypatch.setattr(posthog, "default_client", None) + monkeypatch.setattr(posthog, "project_api_key", "phc_test") + monkeypatch.setattr(posthog, "disabled", True) + monkeypatch.setattr(posthog, "sync_mode", True) + monkeypatch.setattr(posthog, "send", True) + monkeypatch.setattr(posthog, "remote_config_poll_interval_seconds", 0.01) + client = posthog.setup() + else: + client = Client( + "phc_test", + disabled=True, + sync_mode=True, + remote_config_poll_interval_seconds=0.01, + ) + try: + assert not fetched.wait(0.05) + if module_client: + posthog.disabled = False + assert posthog.setup() is client + else: + client.disabled = False + assert fetched.wait(2), "Re-enabled client never fetched remote config" + finally: + client.shutdown() + + +def test_sync_client_registers_nonblocking_exit_cleanup(): + entered = threading.Event() + release = threading.Event() + + def fetch(*args): + entered.set() + assert release.wait(5) + return {} + + with ( + patch("posthog._remote_config._fetch_remote_config", side_effect=fetch), + patch("posthog.client.atexit.register") as register, + ): + client = Client("phc_test", sync_mode=True) + try: + assert entered.wait(5) + for call in register.call_args_list: + callback, *args = call.args + callback(*args, **call.kwargs) + assert client._remote_config_poller.stopped.is_set(), ( + "Registered exit callbacks did not signal the sync poller" + ) + assert client._remote_config_poller.is_alive() + finally: + release.set() + client.shutdown() + + def test_empty_key_does_not_start(): with patch("posthog.client._RemoteConfigPoller") as worker: client = Client(" ") From 06332f2d3bb95fc368a3e4b2d881101e238915ef Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 21 Sep 2026 14:03:08 +0200 Subject: [PATCH 4/4] feat: read experimental SDK diagnostics permission from remote config --- .sampo/changesets/gallant-seer-mielikki.md | 2 +- posthog/__init__.py | 7 ++ posthog/client.py | 16 +++ posthog/test/test_project_remote_config.py | 108 ++++++++++++++++++++- references/public_api_snapshot.txt | 4 +- 5 files changed, 133 insertions(+), 4 deletions(-) diff --git a/.sampo/changesets/gallant-seer-mielikki.md b/.sampo/changesets/gallant-seer-mielikki.md index 0885607bd..48b95de00 100644 --- a/.sampo/changesets/gallant-seer-mielikki.md +++ b/.sampo/changesets/gallant-seer-mielikki.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Fetch project remote configuration in the background at startup and every 300 seconds by default. Set remote_config_poll_interval_seconds to None to disable fetching. Fetched configuration does not change SDK settings yet. +Fetch project remote configuration in the background at startup and every 300 seconds by default. Set remote_config_poll_interval_seconds to None to disable fetching. Add the experimental sdk_diagnostics_enabled option, defaulting to True, which requires remote sdkDiagnosticsEnabled to also be true. Setting the local option to False always disables permission. No diagnostics are collected yet, and fetched configuration does not change other SDK settings. diff --git a/posthog/__init__.py b/posthog/__init__.py index c51835aa1..95020faa9 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -325,6 +325,9 @@ def get_tags() -> Dict[str, Any]: secret_key: A Personal API Key or Project Secret API Key used for local feature flag evaluation and remote config payloads. personal_api_key: Deprecated alias for secret_key. + sdk_diagnostics_enabled: Experimental; no diagnostics are collected yet. + Defaults to True. Diagnostics require both this local setting and the + remote sdkDiagnosticsEnabled value to be True. False locally always wins. remote_config_poll_interval_seconds: Seconds between background project config fetches (default 300), also fetched at startup. None disables fetching. Responses do not change SDK settings. Disabled clients and send=False @@ -410,6 +413,8 @@ def get_tags() -> Dict[str, Any]: project_api_key = None # type: Optional[str] poll_interval = 30 # type: int remote_config_poll_interval_seconds = 300 # type: Optional[float] +# Experimental permission only; no diagnostics are collected yet. +sdk_diagnostics_enabled = True # type: bool disable_geoip = True # type: bool is_server = True # type: bool feature_flags_request_timeout_seconds = 3 # type: int @@ -1351,6 +1356,7 @@ def setup() -> Client: personal_api_key=personal_api_key, poll_interval=poll_interval, remote_config_poll_interval_seconds=remote_config_poll_interval_seconds, + sdk_diagnostics_enabled=sdk_diagnostics_enabled, disabled=disabled, disable_geoip=disable_geoip, is_server=is_server, @@ -1386,6 +1392,7 @@ def setup() -> Client: # Always set in case user changes it. Preserve Client's auto-disabled state # for API keys that become empty after trimming. default_client.disabled = disabled or not default_client.api_key + default_client.sdk_diagnostics_enabled = sdk_diagnostics_enabled default_client.debug = debug default_client.privacy_mode = bool(privacy_mode) default_client._set_before_send(before_send) diff --git a/posthog/client.py b/posthog/client.py index c88b2487b..b69c7ecab 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -728,6 +728,7 @@ def __init__( _enable_multimodal_capture=False, traces: Optional[dict] = None, remote_config_poll_interval_seconds: Optional[float] = 300, + sdk_diagnostics_enabled: bool = True, ): """ Initialize a new PostHog client instance. @@ -758,6 +759,10 @@ def __init__( timeout: HTTP request timeout in seconds for event uploads and project remote configuration fetches. thread: Number of background consumer threads. + sdk_diagnostics_enabled: Experimental; no diagnostics are collected yet. + Defaults to True. Diagnostics require both this local setting and + the remote sdkDiagnosticsEnabled value to be True. False locally + always disables diagnostics regardless of the remote value. remote_config_poll_interval_seconds: Fetch project configuration in the background at startup, then wait this many seconds between fetches (default 300), including after failures. @@ -926,6 +931,8 @@ def __init__( raise ValueError( "remote_config_poll_interval_seconds must be positive and finite or None" ) + # Experimental permission only; no diagnostics are collected yet. + self.sdk_diagnostics_enabled = sdk_diagnostics_enabled self.remote_config_poll_interval_seconds = remote_config_poll_interval_seconds self._remote_config_poller: Optional[_RemoteConfigPoller] = None self.poll_interval = poll_interval @@ -1137,6 +1144,15 @@ def __init__( if self._start_remote_config() and self.sync_mode: atexit.register(self._atexit_remote_config) + @property + def _sdk_diagnostics_enabled(self) -> bool: + """Experimental effective permission; no diagnostics are collected yet.""" + if self.sdk_diagnostics_enabled is not True: + return False + poller = self._remote_config_poller + config = poller._config if poller is not None else None + return config is not None and config.get("sdkDiagnosticsEnabled") is True + def _start_remote_config(self) -> bool: if ( not self.api_key diff --git a/posthog/test/test_project_remote_config.py b/posthog/test/test_project_remote_config.py index c44c4d2ad..68bc9b4f5 100644 --- a/posthog/test/test_project_remote_config.py +++ b/posthog/test/test_project_remote_config.py @@ -36,6 +36,101 @@ def test_request_contract(host, base): response.raise_for_status.assert_called_once() +@pytest.mark.parametrize( + "config,expected", + [ + ({"sdkDiagnosticsEnabled": True}, True), + ({"sdkDiagnosticsEnabled": False}, False), + ({}, False), + *[ + ({"sdkDiagnosticsEnabled": value}, False) + for value in [None, 1, 0, "true", "false", [], {}, [True]] + ], + ], +) +@pytest.mark.parametrize("local_enabled", [True, False]) +def test_sdk_diagnostics_remote_config_value(config, expected, local_enabled): + with ( + patch("posthog._remote_config._RemoteConfigPoller.start"), + patch("posthog._remote_config._RemoteConfigPoller.stop"), + patch("posthog._remote_config._fetch_remote_config", return_value=config), + ): + client = Client( + "phc_test", sync_mode=True, sdk_diagnostics_enabled=local_enabled + ) + try: + assert client._sdk_diagnostics_enabled is False + client._remote_config_poller._refresh() + assert client._sdk_diagnostics_enabled is (local_enabled and expected) + assert client.sdk_diagnostics_enabled is local_enabled + finally: + client.shutdown() + + +def test_module_sdk_diagnostics_local_setting(monkeypatch): + monkeypatch.setattr(posthog, "default_client", None) + monkeypatch.setattr(posthog, "project_api_key", "phc_test") + monkeypatch.setattr(posthog, "sync_mode", True) + monkeypatch.setattr(posthog, "sdk_diagnostics_enabled", False) + with ( + patch("posthog._remote_config._RemoteConfigPoller.start"), + patch("posthog._remote_config._RemoteConfigPoller.stop"), + patch( + "posthog._remote_config._fetch_remote_config", + return_value={ + "sdkDiagnosticsEnabled": True, + }, + ), + ): + client = posthog.setup() + try: + client._remote_config_poller._refresh() + assert client.sdk_diagnostics_enabled is False + assert client._sdk_diagnostics_enabled is False + posthog.sdk_diagnostics_enabled = True + assert posthog.setup() is client + assert client._sdk_diagnostics_enabled is True + posthog.sdk_diagnostics_enabled = False + posthog.setup() + assert client._sdk_diagnostics_enabled is False + finally: + client.shutdown() + + +def test_sdk_diagnostics_disabled_without_remote_config(): + client = Client( + "phc_test", sync_mode=True, remote_config_poll_interval_seconds=None + ) + try: + assert client.sdk_diagnostics_enabled is True + assert client._sdk_diagnostics_enabled is False + finally: + client.shutdown() + + +@pytest.mark.parametrize("replacement", [{}, {"sdkDiagnosticsEnabled": "true"}]) +def test_sdk_diagnostics_missing_or_invalid_refresh_disables(replacement): + with ( + patch("posthog._remote_config._RemoteConfigPoller.start"), + patch("posthog._remote_config._RemoteConfigPoller.stop"), + patch( + "posthog._remote_config._fetch_remote_config", + side_effect=[ + {"sdkDiagnosticsEnabled": True}, + replacement, + ], + ), + ): + client = Client("phc_test", sync_mode=True) + try: + client._remote_config_poller._refresh() + assert client._sdk_diagnostics_enabled is True + client._remote_config_poller._refresh() + assert client._sdk_diagnostics_enabled is False + finally: + client.shutdown() + + def test_stopped_before_start_does_not_fetch(): worker = _RemoteConfigPoller("phc_test", "https://proxy.example", 300, 3) worker.stopped.set() @@ -252,8 +347,13 @@ def wait_for_config(worker, expected): @pytest.mark.parametrize("sync_mode", [False, True]) def test_startup_refresh_and_shutdown_with_http_server(server, sync_mode): host, replies, seen, release = server - first = {"hasFeatureFlags": False, "errorTracking": True, "futureSetting": 1} - last = {"surveys": False} + first = { + "hasFeatureFlags": False, + "errorTracking": True, + "futureSetting": 1, + "sdkDiagnosticsEnabled": True, + } + last = {"surveys": False, "sdkDiagnosticsEnabled": False} for status, body in [ (200, json.dumps(first).encode()), (503, b"unavailable"), @@ -278,13 +378,17 @@ def test_startup_refresh_and_shutdown_with_http_server(server, sync_mode): assert "Authorization" not in headers assert headers.get("Content-Length", "0") == "0" assert worker._config is None + assert client._sdk_diagnostics_enabled is False release.set() wait_for_config(worker, first) + assert client._sdk_diagnostics_enabled is True for _ in range(3): seen.get(timeout=5) assert worker._config == first + assert client._sdk_diagnostics_enabled is True seen.get(timeout=5) wait_for_config(worker, last) + assert client._sdk_diagnostics_enabled is False assert client.enable_exception_autocapture is False assert client._feature_flags is None finally: diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 71810acc4..02dae66bd 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -635,6 +635,7 @@ attribute posthog.client.Client.project_root = project_root attribute posthog.client.Client.queue: Queue attribute posthog.client.Client.raw_host = normalize_host(host) attribute posthog.client.Client.remote_config_poll_interval_seconds = remote_config_poll_interval_seconds +attribute posthog.client.Client.sdk_diagnostics_enabled = sdk_diagnostics_enabled attribute posthog.client.Client.secret_key = (resolved_secret_key.strip() if isinstance(resolved_secret_key, str) else resolved_secret_key) or None attribute posthog.client.Client.send = send attribute posthog.client.Client.super_properties = super_properties @@ -891,6 +892,7 @@ attribute posthog.request.RequestsTimeout = requests.exceptions.Timeout attribute posthog.request.SocketOptions = List[Tuple[int, int, Union[int, bytes]]] attribute posthog.request.USER_AGENT = 'posthog-python/' + VERSION attribute posthog.request.US_INGESTION_ENDPOINT = 'https://us.i.posthog.com' +attribute posthog.sdk_diagnostics_enabled = True attribute posthog.secret_key = None attribute posthog.send = True attribute posthog.super_properties = None @@ -1021,7 +1023,7 @@ class posthog.bucketed_rate_limiter.BucketedRateLimiter(bucket_size: Number, ref class posthog.capture_compression.CaptureCompression class posthog.capture_mode.CaptureMode class posthog.capture_v1.CaptureV1Error(status: int | str, message: str, *, retry_after: Optional[float] = None, request_id: Optional[str] = None, attempts: Optional[int] = None, retry_exhausted: Optional[list[str]] = None, drops: Optional[list[tuple[str, Optional[str]]]] = None) -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, feature_flags_request_max_retries=1, 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, code_variables_mask_url_credentials=None, code_variables_detect_secrets=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, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False, traces: Optional[dict] = None, remote_config_poll_interval_seconds: Optional[float] = 300) +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, feature_flags_request_max_retries=1, 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, code_variables_mask_url_credentials=None, code_variables_detect_secrets=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, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False, traces: Optional[dict] = None, remote_config_poll_interval_seconds: Optional[float] = 300, sdk_diagnostics_enabled: bool = 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, endpoint=EVENTS_ENDPOINT, max_msg_size=MAX_MSG_SIZE, capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE) 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)