Skip to content
Draft
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/gallant-seer-mielikki.md
Original file line number Diff line number Diff line change
@@ -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. 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.
13 changes: 13 additions & 0 deletions posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,13 @@ 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
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.
Expand Down Expand Up @@ -405,6 +412,9 @@ 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]
# 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
Expand Down Expand Up @@ -1345,6 +1355,8 @@ 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,
sdk_diagnostics_enabled=sdk_diagnostics_enabled,
disabled=disabled,
disable_geoip=disable_geoip,
is_server=is_server,
Expand Down Expand Up @@ -1380,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)
Expand Down
58 changes: 58 additions & 0 deletions posthog/_remote_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import logging
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(Poller):
def __init__(
self,
api_key: str,
host: str,
interval: float,
timeout: float,
is_enabled: Optional[Callable[[], bool]] = None,
):
super().__init__(interval=timedelta(seconds=interval), execute=self._refresh)
self.name = "posthog-remote-config"
self._api_key = api_key
self._host = host
self._timeout = timeout
self._is_enabled = is_enabled
self._config: Optional[dict[str, Any]] = None

def run(self) -> None:
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(
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
75 changes: 74 additions & 1 deletion posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import inspect
import json
import logging
import math
import os
import sys
import threading
Expand Down Expand Up @@ -82,6 +83,7 @@
FlagDefinitionCacheProvider,
)
from posthog.poller import Poller
from ._remote_config import _RemoteConfigPoller
from posthog.request import (
AI_EVENTS_ENDPOINT,
EVENTS_ENDPOINT,
Expand Down Expand Up @@ -725,6 +727,8 @@ def __init__(
_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,
):
"""
Initialize a new PostHog client instance.
Expand Down Expand Up @@ -752,8 +756,22 @@ 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.
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.
None disables fetching. Must be positive and finite when enabled.
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
authenticate local feature flag evaluation, remote config
Expand Down Expand Up @@ -904,6 +922,19 @@ 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"
)
# 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
self.feature_flags_request_timeout_seconds = (
feature_flags_request_timeout_seconds
Expand Down Expand Up @@ -1110,6 +1141,34 @@ def __init__(
)

self._warn_if_duplicate_async_client()
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
or not self.send
or self.remote_config_poll_interval_seconds is None
):
return False
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()
return True

def _set_library_identity(self, library_id: str, library_version: str) -> None:
"""Override the SDK identity stamped on events and outbound requests."""
Expand Down Expand Up @@ -2290,6 +2349,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:
Expand Down Expand Up @@ -2849,6 +2911,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",
Expand Down Expand Up @@ -2975,6 +3043,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."""
Expand All @@ -2998,6 +3070,7 @@ def _atexit(self) -> None:
lane.flush(max(0.0, deadline - time.monotonic()))
self._join_span_flush(span_flush, deadline)
finally:
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:
Expand Down
8 changes: 8 additions & 0 deletions posthog/test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading