Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(sdk): use a singleton factory pattern to instantiate the client, improve docs on using it #151

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 3.7.5 - 2024-12-11

1. Modify the SDK to use a Singleton factory pattern; warn on non-singleton instantiation.

## 3.7.4 - 2024-11-25

1. Fix bug where this SDK incorrectly sent feature flag events with null values when calling `get_feature_flag_payload`.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,4 @@ Updated are released using GitHub Actions: after bumping `version.py` in `master

## Questions?

### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ)
### [Check out our community page.](https://posthog.com/posts)
44 changes: 43 additions & 1 deletion posthog/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,48 @@
"""
PostHog Python SDK - Main module for interacting with PostHog analytics.

This module provides the main interface for sending analytics data to PostHog.
It includes functions for tracking events, identifying users, managing feature flags,
and handling group analytics.

Basic usage:
import posthog

# Configure the client
posthog.api_key = 'your_api_key'

# Track an event
posthog.capture('distinct_id', 'event_name')
"""
import datetime # noqa: F401
from typing import Callable, Dict, List, Optional, Tuple # noqa: F401

from posthog.client import Client
from posthog.exception_capture import Integrations # noqa: F401
from posthog.version import VERSION
from posthog.factory import PostHogFactory

__version__ = VERSION

"""Settings."""
"""Settings.
These settings control the behavior of the PostHog client:

api_key: Your PostHog API key
host: PostHog server URL (defaults to Cloud instance)
debug: Enable debug logging
send: Enable/disable sending events to PostHog
sync_mode: Run in synchronous mode instead of async
disabled: Completely disable the client
personal_api_key: Personal API key for feature flag evaluation
project_api_key: Project API key for direct feature flag access
poll_interval: Interval in seconds between feature flag updates
disable_geoip: Disable IP geolocation (recommended for server-side)
feature_flags_request_timeout_seconds: Timeout for feature flag requests
super_properties: Properties to be added to every event
enable_exception_autocapture: (Alpha) Enable automatic exception capturing
exception_autocapture_integrations: List of exception capture integrations
project_root: Root directory for exception source mapping
"""
api_key = None # type: Optional[str]
host = None # type: Optional[str]
on_error = None # type: Optional[Callable]
Expand Down Expand Up @@ -521,5 +556,12 @@ def _proxy(method, *args, **kwargs):
return fn(*args, **kwargs)


# For backwards compatibility with older versions of the SDK.
# This class is deprecated and will be removed in a future version.
class Posthog(Client):
pass

# The recommended way to create and manage PostHog client instances.
# These factory methods ensure proper singleton management and configuration.
create_posthog_client = PostHogFactory.create # Create a new PostHog client instance
get_posthog_client = PostHogFactory.get_instance # Get the existing client instance
65 changes: 65 additions & 0 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@

class Client(object):
"""Create a new PostHog client."""
_instance = None
_enforce_singleton = True # Can be disabled for testing

def __new__(cls, *args, **kwargs):
if cls._enforce_singleton:
if not cls._instance:
cls._instance = super(Client, cls).__new__(cls)
# Move initialization flag to __new__ since it needs to exist
# before __init__ is called
cls._instance._initialized = False
return cls._instance
# For non-singleton case (tests), still need to set _initialized
instance = super(Client, cls).__new__(cls)
instance._initialized = False
return instance



log = logging.getLogger("posthog")

Expand Down Expand Up @@ -60,6 +77,11 @@ def __init__(
exception_autocapture_integrations=None,
project_root=None,
):
if self._initialized:
self._warn_multiple_initialization()
return

self._initialized = True
self.queue = queue.Queue(max_queue_size)

# api_key: This should be the Team API Key (token), public
Expand Down Expand Up @@ -925,6 +947,49 @@ def _add_local_person_and_group_properties(self, distinct_id, groups, person_pro

return all_person_properties, all_group_properties

def _warn_multiple_initialization(self):
self.log.warning(
"Warning: Attempting to create multiple PostHog client instances. "
"PostHog client should be used as a singleton. "
"The existing instance will be reused instead of creating a new one. "
"Consider using PostHog.get_instance() to access the client."
)


@classmethod
def get_instance(cls):
"""
Get the singleton instance of the PostHog client.

This method returns the existing PostHog client instance that was previously
initialized. It ensures only one client instance exists throughout your application.

Returns:
Client: The singleton PostHog client instance

Raises:
RuntimeError: If no PostHog client has been initialized yet

Example:
```python
# First, initialize the client
posthog.create_posthog_client('api_key', host='https://app.posthog.com')

# Later, get the same instance
client = posthog.get_posthog_client()
client.capture('user_id', 'event_name')
```

Note:
Make sure to initialize a client with `create_posthog_client()` or
`Client(api_key, ...)` before calling this method.
"""
if not cls._instance:
raise RuntimeError(
"PostHog client has not been initialized. "
"Please create an instance with Client(api_key, ...) first."
)
return cls._instance

def require(name, field, data_type):
"""Require that the named `field` has the right `data_type`"""
Expand Down
20 changes: 20 additions & 0 deletions posthog/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from posthog.client import Client

class PostHogFactory:
@staticmethod
def create(
api_key=None,
host=None,
**kwargs
):
"""
Create a new PostHog client instance or return the existing one.
"""
return Client(api_key=api_key, host=host, **kwargs)

@staticmethod
def get_instance():
"""
Get the existing PostHog client instance.
"""
return Client.get_instance()
58 changes: 58 additions & 0 deletions posthog/test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def set_fail(self, e, batch):
def setUp(self):
self.failed = False
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
Client._enforce_singleton = False # Disable singleton for tests

def test_requires_api_key(self):
self.assertRaises(AssertionError, Client)
Expand Down Expand Up @@ -159,6 +160,7 @@ def test_basic_capture_exception_with_correct_host_generation(self):

with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://aloha.com")
print(client.host)
exception = Exception("test exception")
client.capture_exception(exception, "distinct_id")

Expand Down Expand Up @@ -187,6 +189,7 @@ def test_basic_capture_exception_with_correct_host_generation_for_server_hosts(s

with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://app.posthog.com")
print(client.host)
exception = Exception("test exception")
client.capture_exception(exception, "distinct_id")

Expand Down Expand Up @@ -1073,3 +1076,58 @@ def test_default_properties_get_added_properly(self, patch_decide):
group_properties={},
disable_geoip=False,
)

def test_singleton_behavior(self):
# Reset singleton state
Client._instance = None
Client._enforce_singleton = True

# Create first instance
client1 = Client(FAKE_TEST_API_KEY, host="https://host1.com")

# Create second instance with different params
client2 = Client(FAKE_TEST_API_KEY, host="https://host2.com")

# Both should reference the same instance
self.assertIs(client1, client2)

# Host should be from first initialization
self.assertEqual(client1.host, "https://host1.com")
self.assertEqual(client2.host, "https://host1.com")

def test_singleton_disabled_for_testing(self):
# Reset singleton state
Client._instance = None
Client._enforce_singleton = False

# Create instances with different params
client1 = Client(FAKE_TEST_API_KEY, host="https://host1.com")
client2 = Client(FAKE_TEST_API_KEY, host="https://host2.com")

# Should be different instances
self.assertIsNot(client1, client2)

# Each should maintain their own host
self.assertEqual(client1.host, "https://host1.com")
self.assertEqual(client2.host, "https://host2.com")

def test_singleton_warning_on_multiple_initialization(self):
# Reset singleton state
Client._instance = None
Client._enforce_singleton = True

# Create first instance
client1 = Client(FAKE_TEST_API_KEY)

# Second initialization should log warning
with self.assertLogs("posthog", level="WARNING") as logs:
client2 = Client(FAKE_TEST_API_KEY)
self.assertEqual(
logs.output[0],
"WARNING:posthog:Warning: Attempting to create multiple PostHog client instances. "
"PostHog client should be used as a singleton. "
"The existing instance will be reused instead of creating a new one. "
"Consider using PostHog.get_instance() to access the client."
)


2 changes: 1 addition & 1 deletion posthog/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
VERSION = "3.7.4"
VERSION = "3.7.5"

if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
Loading