Compare commits

...
8 Commits
Author SHA1 Message Date
Dylan MartinandGitHub b965332698 feat(flags): add a flag_fallback_cache that tracks feature flag evaluation results and uses them as fallback values whenever the /flags API isn't available (#275) 2025-07-07 07:13:35 +00:00
Oliver BrowneandGitHub 4739945a82 fix: default send_feature_flags false for capture_exception (#278)
* default send_feature_flags false

* bump version
2025-07-02 22:21:25 +03:00
Oliver BrowneandGitHub 50ab10c858 fix(err): permit disabling person processing (#277)
* whoops

* bump version
2025-07-01 15:26:57 +03:00
Oliver BrowneGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>David Newell
37bd30194e feat: prep for 6.0.0, bunch of breaking changes (#273)
* alright

* fix exports

* Update posthog/test/test_before_send.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update posthog/__init__.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update posthog/__init__.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix tests after rebase

* update examples

* update mypy baseline

* Revert "update mypy baseline"

This reverts commit da395dd7cc075d1f5b1c03d748544e4abcd75bb9.

* try again

* whatever

* request user

* fix middleware

* getattr is_authenticated

* allow using custom client for exception capture

* update comments

* fix circular import

* type arguments, use TypedDict

* fix setup

* mypy

* whoops

* ok

* further mypy

* mypy sync

* docs and fixes

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: David Newell <david@posthog.com>
2025-06-27 14:57:02 +03:00
Lucas RicoyandGitHub b41dc8568e docs: update release details on readme (#272) 2025-06-21 00:38:18 +01:00
Lucas RicoyGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
f0e1cdf870 feat: bump version to 5.4.0 with session_id on page method (#271)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-06-21 00:18:57 +01:00
Lucas RicoyandGitHub e23ca94296 chore: ensure session_id context works with page method (#269) 2025-06-20 23:49:53 +01:00
Oliver BrowneandGitHub 5a7f324a61 fix(err): always safe_str exception values (#267)
* always safe_str

* bump version
2025-06-19 14:38:17 +00:00
30 changed files with 4516 additions and 4084 deletions
+45
View File
@@ -1,3 +1,48 @@
# 6.0.3 - 2025-07-07
- feat: add a feature flag evaluation cache (local storage or redis) to support returning flag evaluations when the service is down
# 6.0.2 - 2025-07-02
- fix: send_feature_flags changed to default to false in `Client::capture_exception`
# 6.0.1
- fix: response `$process_person_profile` property when passed to capture
# 6.0.0
This release contains a number of major breaking changes:
- feat: make distinct_id an optional parameter in posthog.capture and related functions
- feat: make capture and related functions return `Optional[str]`, which is the UUID of the sent event, if it was sent
- fix: remove `identify` (prefer `posthog.set()`), and `page` and `screen` (prefer `posthog.capture()`)
- fix: delete exception-capture specific integrations module. Prefer the general-purpose django middleware as a replacement for the django `Integration`.
To migrate to this version, you'll mostly just need to switch to using named keyword arguments, rather than positional ones. For example:
```python
# Old calling convention
posthog.capture("user123", "button_clicked", {"button_id": "123"})
# New calling convention
posthog.capture(distinct_id="user123", event="button_clicked", properties={"button_id": "123"})
# Better pattern
with posthog.new_context():
posthog.identify_context("user123")
# The event name is the first argument, and can be passed positionally, or as a keyword argument in a later position
posthog.capture("button_pressed")
```
Generally, arguments are now appropriately typed, and docstrings have been updated. If something is unclear, please open an issue, or submit a PR!
# 5.4.0 - 2025-06-20
- feat: add support to session_id context on page method
# 5.3.0 - 2025-06-19
- fix: safely handle exception values
# 5.2.0 - 2025-06-19
- feat: construct artificial stack traces if no traceback is available on a captured exception
+4 -2
View File
@@ -32,7 +32,7 @@ We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
```bash
uv python install 3.9.19
uv python pin 3.9.19
uv venv env
uv venv
source env/bin/activate
uv sync --extra dev --extra test
pre-commit install
@@ -45,7 +45,9 @@ Assuming you have a [local version of PostHog](https://posthog.com/docs/developi
### Releasing Versions
Updates are released using GitHub Actions: after bumping `version.py` in `master` and adding to `CHANGELOG.md`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
Updates are released automatically using GitHub Actions when `version.py` is updated on `master`. After bumping `version.py` in `master` and adding to `CHANGELOG.md`, the [release workflow](https://github.com/PostHog/posthog-python/blob/master/.github/workflows/release.yaml) will automatically trigger and deploy the new version.
If you need to check the latest runs or manually trigger a release, you can go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
### Testing changes locally with the PostHog app
+14 -10
View File
@@ -41,9 +41,9 @@ print(
# Capture an event
posthog.capture(
"distinct_id",
"event",
{"property1": "value", "property2": "value"},
distinct_id="distinct_id",
properties={"property1": "value", "property2": "value"},
send_feature_flags=True,
)
@@ -65,31 +65,35 @@ exit()
posthog.alias("distinct_id", "new_distinct_id")
posthog.capture(
"new_distinct_id", "event2", {"property1": "value", "property2": "value"}
"event2",
distinct_id="new_distinct_id",
properties={"property1": "value", "property2": "value"},
)
posthog.capture(
"new_distinct_id",
"event-with-groups",
{"property1": "value", "property2": "value"},
distinct_id="new_distinct_id",
properties={"property1": "value", "property2": "value"},
groups={"company": "id:5"},
)
# # Add properties to the person
posthog.identify("new_distinct_id", {"email": "something@something.com"})
posthog.set(
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
)
# Add properties to a group
posthog.group_identify("company", "id:5", {"employees": 11})
# properties set only once to the person
posthog.set_once("new_distinct_id", {"self_serve_signup": True})
posthog.set_once(distinct_id="new_distinct_id", properties={"self_serve_signup": True})
posthog.set_once(
"new_distinct_id", {"self_serve_signup": False}
distinct_id="new_distinct_id", properties={"self_serve_signup": False}
) # this will not change the property (because it was already set)
posthog.set("new_distinct_id", {"current_browser": "Chrome"})
posthog.set("new_distinct_id", {"current_browser": "Firefox"})
posthog.set(distinct_id="new_distinct_id", properties={"current_browser": "Chrome"})
posthog.set(distinct_id="new_distinct_id", properties={"current_browser": "Firefox"})
# #############################################################################
+16 -7
View File
@@ -22,16 +22,25 @@ posthog/client.py:0: error: Library stubs not installed for "six" [import-untyp
posthog/client.py:0: note: Hint: "python3 -m pip install types-six"
posthog/client.py:0: error: Name "queue" already defined (by an import) [no-redef]
posthog/client.py:0: error: Need type annotation for "queue" [var-annotated]
posthog/client.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
simulator.py:0: error: Unexpected keyword argument "anonymous_id" for "capture" [call-arg]
posthog/__init__.py:0: note: "capture" defined here
simulator.py:0: error: Unexpected keyword argument "anonymous_id" for "identify" [call-arg]
posthog/__init__.py:0: note: "identify" defined here
simulator.py:0: error: Unexpected keyword argument "traits" for "identify" [call-arg]
posthog/__init__.py:0: note: "identify" defined here
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | list[Any]", variable has type "None") [assignment]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Any, Any]", variable has type "None") [assignment]
posthog/client.py:0: error: "None" has no attribute "__iter__" (not iterable) [attr-defined]
posthog/client.py:0: error: Statement is unreachable [unreachable]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | dict[Any, Any]", variable has type "None") [assignment]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | dict[Any, Any]", variable has type "None") [assignment]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Never, Never]", variable has type "None") [assignment]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Never, Never]", variable has type "None") [assignment]
posthog/client.py:0: error: Right operand of "and" is never evaluated [unreachable]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Poller", variable has type "None") [assignment]
posthog/client.py:0: error: "None" has no attribute "start" [attr-defined]
posthog/client.py:0: error: "None" has no attribute "get" [attr-defined]
posthog/client.py:0: error: Statement is unreachable [unreachable]
posthog/client.py:0: error: Statement is unreachable [unreachable]
example.py:0: error: Statement is unreachable [unreachable]
posthog/ai/utils.py:0: error: Need type annotation for "output" (hint: "output: list[<type>] = ...") [var-annotated]
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
posthog/client.py:0: error: Name "urlparse" already defined (possibly by an import) [no-redef]
posthog/client.py:0: error: Name "parse_qs" already defined (possibly by an import) [no-redef]
+93 -242
View File
@@ -1,17 +1,15 @@
import datetime # noqa: F401
import warnings
from typing import Callable, Dict, List, Optional, Tuple # noqa: F401
from typing import Callable, Dict, Optional, Any # noqa: F401
from typing_extensions import Unpack
from posthog.args import OptionalCaptureArgs, OptionalSetArgs, ExceptionArg
from posthog.client import Client
from posthog.exception_capture import Integrations # noqa: F401
from posthog.scopes import (
clear_tags,
get_tags,
new_context,
scoped,
tag,
set_context_session,
identify_context,
from posthog.contexts import (
new_context as inner_new_context,
scoped as inner_scoped,
tag as inner_tag,
set_context_session as inner_set_context_session,
identify_context as inner_identify_context,
)
from posthog.types import FeatureFlag, FlagsAndPayloads
from posthog.version import VERSION
@@ -19,13 +17,26 @@ from posthog.version import VERSION
__version__ = VERSION
"""Context management."""
new_context = new_context
tag = tag
get_tags = get_tags
clear_tags = clear_tags
scoped = scoped
identify_context = identify_context
set_context_session = set_context_session
def new_context(fresh=False, capture_exceptions=True):
return inner_new_context(fresh=fresh, capture_exceptions=capture_exceptions)
def scoped(fresh=False, capture_exceptions=True):
return inner_scoped(fresh=fresh, capture_exceptions=capture_exceptions)
def set_context_session(session_id: str):
return inner_set_context_session(session_id)
def identify_context(distinct_id: str):
return inner_identify_context(distinct_id)
def tag(name: str, value: Any):
return inner_tag(name, value)
"""Settings."""
@@ -44,7 +55,6 @@ feature_flags_request_timeout_seconds = 3 # type: int
super_properties = None # type: Optional[Dict]
# Currently alpha, use at your own risk
enable_exception_autocapture = False # type: bool
exception_autocapture_integrations = [] # type: List[Integrations]
log_captured_exceptions = False # type: bool
# Used to determine in app paths for exception autocapture. Defaults to the current working directory
project_root = None # type: Optional[str]
@@ -54,206 +64,100 @@ privacy_mode = False # type: bool
default_client = None # type: Optional[Client]
def capture(
distinct_id, # type: str
event, # type: str
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
groups=None, # type: Optional[Dict]
send_feature_flags=False,
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> Tuple[bool, dict]
# NOTE - this and following functions take unpacked kwargs because we needed to make
# it impossible to write `posthog.capture(distinct-id, event-name)` - basically, to enforce
# the breaking change made between 5.3.0 and 6.0.0. This decision can be unrolled in later
# versions, without a breaking change, to get back the type information in function signatures
def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]:
"""
Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up.
A `capture` call requires
- `distinct id` which uniquely identifies your user
- `event name` to specify the event
- We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on.
Optionally you can submit
- `properties`, which can be a dict with any information you'd like to add
- `groups`, which is a dict of group type -> group key mappings
Capture takes a number of optional arguments, which are defined by the `OptionalCaptureArgs` type.
For example:
```python
posthog.capture('distinct id', 'opened app')
posthog.capture('distinct id', 'movie played', {'movie_id': '123', 'category': 'romcom'})
# Enter a new context (e.g. a request/response cycle, an instance of a background job, etc)
with posthog.new_context():
# Associate this context with some user, by distinct_id
posthog.identify_context('some user')
posthog.capture('distinct id', 'purchase', groups={'company': 'id:5'})
```
"""
# Capture an event, associated with the context-level distinct ID ('some user')
posthog.capture('movie started')
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
# Capture an event associated with some other user (overriding the context-level distinct ID)
posthog.capture('movie joined', distinct_id='some-other-user')
return _proxy(
"capture",
distinct_id=distinct_id,
event=event,
properties=properties,
context=context,
timestamp=timestamp,
uuid=uuid,
groups=groups,
send_feature_flags=send_feature_flags,
disable_geoip=disable_geoip,
)
# Capture an event with some properties
posthog.capture('movie played', properties={'movie_id': '123', 'category': 'romcom'})
# Capture an event with some properties
posthog.capture('purchase', properties={'product_id': '123', 'category': 'romcom'})
# Capture an event with some associated group
posthog.capture('purchase', groups={'company': 'id:5'})
def identify(
distinct_id, # type: str
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> Tuple[bool, dict]
"""
Identify lets you add metadata on your users so you can more easily identify who they are in PostHog, and even do things like segment users by these properties.
An `identify` call requires
- `distinct id` which uniquely identifies your user
- `properties` with a dict with any key: value pairs
# Adding a tag to the current context will cause it to appear on all subsequent events
posthog.tag_context('some-tag', 'some-value')
For example:
```python
posthog.identify('distinct id', {
'email': 'dwayne@gmail.com',
'name': 'Dwayne Johnson'
})
posthog.capture('another-event') # Will be captured with `'some-tag': 'some-value'` in the properties dict
```
"""
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
return _proxy(
"identify",
distinct_id=distinct_id,
properties=properties,
context=context,
timestamp=timestamp,
uuid=uuid,
disable_geoip=disable_geoip,
)
return _proxy("capture", event, **kwargs)
def set(
distinct_id, # type: str
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> Tuple[bool, dict]
def set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
"""
Set properties on a user record.
This will overwrite previous people property values, just like `identify`.
This will overwrite previous people property values. Generally operates similar to `capture`, with
distinct_id being an optional argument, defaulting to the current context's distinct ID.
A `set` call requires
- `distinct id` which uniquely identifies your user
- `properties` with a dict with any key: value pairs
If there is no context-level distinct ID, and no override distinct_id is passed, this function
will do nothing.
Context tags are folded into $set properties, so tagging the current context and then calling `set` will
cause those tags to be set on the user (unlike capture, which causes them to just be set on the event).
For example:
```python
posthog.set('distinct id', {
posthog.set(distinct_id='distinct id', properties={
'current_browser': 'Chrome',
})
```
"""
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
return _proxy(
"set",
distinct_id=distinct_id,
properties=properties,
context=context,
timestamp=timestamp,
uuid=uuid,
disable_geoip=disable_geoip,
)
return _proxy("set", **kwargs)
def set_once(
distinct_id, # type: str
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> Tuple[bool, dict]
def set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
"""
Set properties on a user record, only if they do not yet exist.
This will not overwrite previous people property values, unlike `identify`.
A `set_once` call requires
- `distinct id` which uniquely identifies your user
- `properties` with a dict with any key: value pairs
This will not overwrite previous people property values, unlike `set`.
For example:
```python
posthog.set_once('distinct id', {
'referred_by': 'friend',
})
Otherwise, operates in an identical manner to `set`.
```
"""
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
return _proxy(
"set_once",
distinct_id=distinct_id,
properties=properties,
context=context,
timestamp=timestamp,
uuid=uuid,
disable_geoip=disable_geoip,
)
return _proxy("set_once", **kwargs)
def group_identify(
group_type, # type: str
group_key, # type: str
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> Tuple[bool, dict]
# type: (...) -> Optional[str]
"""
Set properties on a group
A `group_identify` call requires
- `group_type` type of your group
- `group_key` unique identifier of the group
- `properties` with a dict with any key: value pairs
For example:
```python
@@ -263,19 +167,11 @@ def group_identify(
```
"""
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
return _proxy(
"group_identify",
group_type=group_type,
group_key=group_key,
properties=properties,
context=context,
timestamp=timestamp,
uuid=uuid,
disable_geoip=disable_geoip,
@@ -285,18 +181,16 @@ def group_identify(
def alias(
previous_id, # type: str
distinct_id, # type: str
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> Tuple[bool, dict]
# type: (...) -> Optional[str]
"""
To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?"
In a purely back-end implementation, this means whenever an anonymous user does something, you'll want to send a session ID ([Django](https://stackoverflow.com/questions/526179/in-django-how-can-i-find-out-the-request-session-sessionid-and-use-it-as-a-vari), [Flask](https://stackoverflow.com/questions/15156132/flask-login-how-to-get-session-id)) with the capture call. Then, when that users signs up, you want to do an alias call with the session ID and the newly created user ID.
The same concept applies for when a user logs in.
To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call.
This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or
"What do users do on our website before signing up?". Particularly useful for associating user behaviour before and after
they e.g. register, login, or perform some other identifying action.
An `alias` call requires
- `previous distinct id` the unique ID of the user before
@@ -308,18 +202,10 @@ def alias(
```
"""
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
return _proxy(
"alias",
previous_id=previous_id,
distinct_id=distinct_id,
context=context,
timestamp=timestamp,
uuid=uuid,
disable_geoip=disable_geoip,
@@ -327,58 +213,33 @@ def alias(
def capture_exception(
exception=None, # type: Optional[BaseException]
distinct_id=None, # type: Optional[str]
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
groups=None, # type: Optional[Dict]
**kwargs,
exception: Optional[ExceptionArg] = None,
**kwargs: Unpack[OptionalCaptureArgs],
):
# type: (...) -> Tuple[bool, dict]
"""
capture_exception allows you to capture exceptions that happen in your code. This is useful for debugging and understanding what errors your users are encountering.
This function never raises an exception, even if it fails to send the event.
capture_exception allows you to capture exceptions that happen in your code.
A `capture_exception` call does not require any fields, but we recommend sending:
- `distinct id` which uniquely identifies your user for which this exception happens
Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog.
This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception,
if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context
boundary (e.g. by existing a `with posthog.new_context():` block already)
A `capture_exception` call does not require any fields, but we recommend passing an exception of some kind:
- `exception` to specify the exception to capture. If not provided, the current exception is captured via `sys.exc_info()`
Optionally you can submit
- `properties`, which can be a dict with any information you'd like to add
- `groups`, which is a dict of group type -> group key mappings
- remaining `kwargs` will be logged if `log_captured_exceptions` is enabled
If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised
and the point at which it is captured (the "traceback").
For example:
```python
try:
1 / 0
except Exception as e:
posthog.capture_exception(e, 'my specific distinct id')
posthog.capture_exception(distinct_id='my specific distinct id')
If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError("Some Error"))`, the stack trace
captured will be the full stack trace at the moment the exception was captured.
```
"""
Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently,
which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason.
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
`capture_exception` takes the same set of optional arguments as `capture`.
"""
return _proxy(
"capture_exception",
exception=exception,
distinct_id=distinct_id,
properties=properties,
context=context,
timestamp=timestamp,
uuid=uuid,
groups=groups,
**kwargs,
)
return _proxy("capture_exception", exception=exception, **kwargs)
def feature_enabled(
@@ -565,16 +426,6 @@ def load_feature_flags():
return _proxy("load_feature_flags")
def page(*args, **kwargs):
"""Send a page call."""
_proxy("page", *args, **kwargs)
def screen(*args, **kwargs):
"""Send a screen call."""
_proxy("screen", *args, **kwargs)
def flush():
"""Tell the client to flush."""
_proxy("flush")
@@ -594,6 +445,8 @@ def shutdown():
def setup():
global default_client
if not default_client:
if not api_key:
raise ValueError("API key is required")
default_client = Client(
api_key,
host=host,
@@ -602,7 +455,6 @@ def setup():
send=send,
sync_mode=sync_mode,
personal_api_key=personal_api_key,
project_api_key=project_api_key,
poll_interval=poll_interval,
disabled=disabled,
disable_geoip=disable_geoip,
@@ -613,7 +465,6 @@ def setup():
# or deprecate this proxy option fully (it's already in the process of deprecation, no new clients should be using this method since like 5-6 months)
enable_exception_autocapture=enable_exception_autocapture,
log_captured_exceptions=log_captured_exceptions,
exception_autocapture_integrations=exception_autocapture_integrations,
)
# always set incase user changes it
+2 -2
View File
@@ -84,7 +84,7 @@ class CallbackHandler(BaseCallbackHandler):
_client: Client
"""PostHog client instance."""
_distinct_id: Optional[Union[str, int, float, UUID]]
_distinct_id: Optional[Union[str, int, UUID]]
"""Distinct ID of the user to associate the trace with."""
_trace_id: Optional[Union[str, int, float, UUID]]
@@ -112,7 +112,7 @@ class CallbackHandler(BaseCallbackHandler):
self,
client: Optional[Client] = None,
*,
distinct_id: Optional[Union[str, int, float, UUID]] = None,
distinct_id: Optional[Union[str, int, UUID]] = None,
trace_id: Optional[Union[str, int, float, UUID]] = None,
properties: Optional[Dict[str, Any]] = None,
privacy_mode: bool = False,
+68
View File
@@ -0,0 +1,68 @@
from typing import TypedDict, Optional, Any, Dict, Union, Tuple, Type
from types import TracebackType
from typing_extensions import NotRequired # For Python < 3.11 compatibility
from datetime import datetime
import numbers
from uuid import UUID
ID_TYPES = Union[numbers.Number, str, UUID, int]
class OptionalCaptureArgs(TypedDict):
"""Optional arguments for the capture method.
Args:
distinct_id: Unique identifier for the person associated with this event. If not set, the context
distinct_id is used, if available, otherwise a UUID is generated, and the event is marked
as personless. Setting context-level distinct_id's is recommended.
properties: Dictionary of properties to track with the event
timestamp: When the event occurred (defaults to current time)
uuid: Unique identifier for this specific event. If not provided, one is generated. The event
UUID is returned, so you can correlate it with actions in your app (like showing users an
error ID if you capture an exception).
groups: Group identifiers to associate with this event (format: {group_type: group_key})
send_feature_flags: Whether to include currently active feature flags in the event properties.
Defaults to False
disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False.
"""
distinct_id: NotRequired[Optional[ID_TYPES]]
properties: NotRequired[Optional[Dict[str, Any]]]
timestamp: NotRequired[Optional[Union[datetime, str]]]
uuid: NotRequired[Optional[str]]
groups: NotRequired[Optional[Dict[str, str]]]
send_feature_flags: NotRequired[
Optional[bool]
] # Optional so we can tell if the user is intentionally overriding a client setting or not
disable_geoip: NotRequired[
Optional[bool]
] # As above, optional so we can tell if the user is intentionally overriding a client setting or not
class OptionalSetArgs(TypedDict):
"""Optional arguments for the set method.
Args:
distinct_id: Unique identifier for the user to set properties on. If not set, the context
distinct_id is used, if available, otherwise this function does nothing. Setting
context-level distinct_id's is recommended.
properties: Dictionary of properties to set on the person
timestamp: When the properties were set (defaults to current time)
uuid: Unique identifier for this operation. If not provided, one is generated. This
UUID is returned, so you can correlate it with actions in your app.
disable_geoip: Whether to disable GeoIP lookup for this operation. Defaults to False.
"""
distinct_id: NotRequired[Optional[ID_TYPES]]
properties: NotRequired[Optional[Dict[str, Any]]]
timestamp: NotRequired[Optional[Union[datetime, str]]]
uuid: NotRequired[Optional[str]]
disable_geoip: NotRequired[Optional[bool]]
ExcInfo = Union[
Tuple[Type[BaseException], BaseException, Optional[TracebackType]],
Tuple[None, None, None],
]
ExceptionArg = Union[BaseException, ExcInfo]
+303 -313
View File
@@ -1,18 +1,16 @@
import atexit
import logging
import numbers
import os
import platform
import sys
import warnings
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from uuid import UUID, uuid4
from typing import Any, Dict, Optional, Union
from typing_extensions import Unpack
from uuid import uuid4
import distro # For Linux OS detection
from dateutil.tz import tzutc
from six import string_types
from posthog.args import OptionalCaptureArgs, OptionalSetArgs, ID_TYPES, ExceptionArg
from posthog.consumer import Consumer
from posthog.exception_capture import ExceptionCapture
from posthog.exception_utils import (
@@ -33,10 +31,11 @@ from posthog.request import (
get,
remote_config,
)
from posthog.scopes import (
from posthog.contexts import (
_get_current_context,
get_context_distinct_id,
get_context_session_id,
new_context,
)
from posthog.types import (
FeatureFlag,
@@ -50,7 +49,15 @@ from posthog.types import (
to_payloads,
to_values,
)
from posthog.utils import SizeLimitedDict, clean, guess_timezone, remove_trailing_slash
from posthog.utils import (
FlagCache,
RedisFlagCache,
SizeLimitedDict,
clean,
guess_timezone,
remove_trailing_slash,
system_context,
)
from posthog.version import VERSION
try:
@@ -59,72 +66,67 @@ except ImportError:
import Queue as queue
ID_TYPES = (numbers.Number, string_types, UUID)
MAX_DICT_SIZE = 50_000
def get_os_info():
"""
Returns standardized OS name and version information.
Similar to how user agent parsing works in JS.
"""
os_name = ""
os_version = ""
platform_name = sys.platform
if platform_name.startswith("win"):
os_name = "Windows"
if hasattr(platform, "win32_ver"):
win_version = platform.win32_ver()[0]
if win_version:
os_version = win_version
elif platform_name == "darwin":
os_name = "Mac OS X"
if hasattr(platform, "mac_ver"):
mac_version = platform.mac_ver()[0]
if mac_version:
os_version = mac_version
def get_identity_state(passed) -> tuple[str, bool]:
"""Returns the distinct id to use, and whether this is a personless event or not"""
stringified = stringify_id(passed)
if stringified and len(stringified):
return (stringified, False)
elif platform_name.startswith("linux"):
os_name = "Linux"
linux_info = distro.info()
if linux_info["version"]:
os_version = linux_info["version"]
context_id = get_context_distinct_id()
if context_id:
return (context_id, False)
elif platform_name.startswith("freebsd"):
os_name = "FreeBSD"
if hasattr(platform, "release"):
os_version = platform.release()
return (str(uuid4()), True)
else:
os_name = platform_name
if hasattr(platform, "release"):
os_version = platform.release()
return os_name, os_version
def add_context_tags(properties):
current_context = _get_current_context()
if current_context:
context_tags = current_context.collect_tags()
# We want explicitly passed properties to override context tags
context_tags.update(properties)
properties = context_tags
if "$session_id" not in properties and get_context_session_id():
properties["$session_id"] = get_context_session_id()
def system_context() -> dict[str, Any]:
os_name, os_version = get_os_info()
return {
"$python_runtime": platform.python_implementation(),
"$python_version": "%s.%s.%s" % (sys.version_info[:3]),
"$os": os_name,
"$os_version": os_version,
}
return properties
class Client(object):
"""Create a new PostHog client."""
"""Create a new PostHog client.
Examples:
Basic usage:
>>> client = Client("your-api-key")
With memory-based feature flag fallback cache:
>>> client = Client(
... "your-api-key",
... flag_fallback_cache_url="memory://local/?ttl=300&size=10000"
... )
With Redis fallback cache for high-scale applications:
>>> client = Client(
... "your-api-key",
... flag_fallback_cache_url="redis://localhost:6379/0/?ttl=300"
... )
With Redis authentication:
>>> client = Client(
... "your-api-key",
... flag_fallback_cache_url="redis://username:password@localhost:6379/0/?ttl=300"
... )
"""
log = logging.getLogger("posthog")
def __init__(
self,
api_key=None,
project_api_key: str,
host=None,
debug=False,
max_queue_size=10000,
@@ -139,7 +141,6 @@ class Client(object):
thread=1,
poll_interval=30,
personal_api_key=None,
project_api_key=None,
disabled=False,
disable_geoip=True,
historical_migration=False,
@@ -147,17 +148,15 @@ class Client(object):
super_properties=None,
enable_exception_autocapture=False,
log_captured_exceptions=False,
exception_autocapture_integrations=None,
project_root=None,
privacy_mode=False,
before_send=None,
flag_fallback_cache_url=None,
):
self.queue = queue.Queue(max_queue_size)
# api_key: This should be the Team API Key (token), public
self.api_key = project_api_key or api_key
require("api_key", self.api_key, string_types)
self.api_key = project_api_key
self.on_error = on_error
self.debug = debug
@@ -178,13 +177,14 @@ class Client(object):
)
self.poller = None
self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
self.flag_cache = self._initialize_flag_cache(flag_fallback_cache_url)
self.flag_definition_version = 0
self.disabled = disabled
self.disable_geoip = disable_geoip
self.historical_migration = historical_migration
self.super_properties = super_properties
self.enable_exception_autocapture = enable_exception_autocapture
self.log_captured_exceptions = log_captured_exceptions
self.exception_autocapture_integrations = exception_autocapture_integrations
self.exception_capture = None
self.privacy_mode = privacy_mode
@@ -216,9 +216,7 @@ class Client(object):
self.before_send = None
if self.enable_exception_autocapture:
self.exception_capture = ExceptionCapture(
self, integrations=self.exception_autocapture_integrations
)
self.exception_capture = ExceptionCapture(self)
if sync_mode:
self.consumers = None
@@ -251,6 +249,11 @@ class Client(object):
if send:
consumer.start()
def new_context(self, fresh=False, capture_exceptions=True):
return new_context(
fresh=fresh, capture_exceptions=capture_exceptions, client=self
)
@property
def feature_flags(self):
"""
@@ -273,43 +276,6 @@ class Client(object):
"feature_flags_by_key should be initialized when feature_flags is set"
)
def identify(
self,
distinct_id=None,
properties=None,
context=None,
timestamp=None,
uuid=None,
disable_geoip=None,
):
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
if distinct_id is None:
distinct_id = get_context_distinct_id()
properties = properties or {}
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
if "$session_id" not in properties and get_context_session_id():
properties["$session_id"] = get_context_session_id()
msg = {
"timestamp": timestamp,
"distinct_id": distinct_id,
"$set": properties,
"event": "$identify",
"uuid": uuid,
}
return self._enqueue(msg, disable_geoip)
def get_feature_variants(
self,
distinct_id,
@@ -360,8 +326,8 @@ class Client(object):
def get_flags_decision(
self,
distinct_id,
groups=None,
distinct_id: Optional[ID_TYPES] = None,
groups: Optional[dict] = {},
person_properties=None,
group_properties=None,
disable_geoip=None,
@@ -372,14 +338,11 @@ class Client(object):
if distinct_id is None:
distinct_id = get_context_distinct_id()
require("distinct_id", distinct_id, ID_TYPES)
if disable_geoip is None:
disable_geoip = self.disable_geoip
if groups:
require("groups", groups, dict)
else:
if not groups:
groups = {}
request_data = {
@@ -400,42 +363,24 @@ class Client(object):
return normalize_flags_response(resp_data)
def capture(
self,
distinct_id=None,
event=None,
properties=None,
context=None,
timestamp=None,
uuid=None,
groups=None,
send_feature_flags=False,
disable_geoip=None,
):
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
self, event: str, **kwargs: Unpack[OptionalCaptureArgs]
) -> Optional[str]:
distinct_id = kwargs.get("distinct_id", None)
properties = kwargs.get("properties", None)
timestamp = kwargs.get("timestamp", None)
uuid = kwargs.get("uuid", None)
groups = kwargs.get("groups", None)
send_feature_flags = kwargs.get("send_feature_flags", False)
disable_geoip = kwargs.get("disable_geoip", None)
properties = {**(properties or {}), **system_context()}
if "$session_id" not in properties and get_context_session_id():
properties["$session_id"] = get_context_session_id()
if distinct_id is None:
distinct_id = get_context_distinct_id()
properties = add_context_tags(properties)
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
require("event", event, string_types)
(distinct_id, personless) = get_identity_state(distinct_id)
current_context = _get_current_context()
if current_context:
context_tags = current_context.collect_tags()
# We want explicitly passed properties to override context tags
context_tags.update(properties)
properties = context_tags
if personless and "$process_person_profile" not in properties:
properties["$process_person_profile"] = False
msg = {
"properties": properties,
@@ -446,7 +391,6 @@ class Client(object):
}
if groups:
require("groups", groups, dict)
msg["properties"]["$groups"] = groups
extra_properties: dict[str, Any] = {}
@@ -486,28 +430,21 @@ class Client(object):
return self._enqueue(msg, disable_geoip)
def set(
self,
distinct_id=None,
properties=None,
context=None,
timestamp=None,
uuid=None,
disable_geoip=None,
):
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
if distinct_id is None:
distinct_id = get_context_distinct_id()
def set(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
distinct_id = kwargs.get("distinct_id", None)
properties = kwargs.get("properties", None)
timestamp = kwargs.get("timestamp", None)
uuid = kwargs.get("uuid", None)
disable_geoip = kwargs.get("disable_geoip", None)
properties = properties or {}
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
properties = add_context_tags(properties)
(distinct_id, personless) = get_identity_state(distinct_id)
if personless or not properties:
return None # Personless set() does nothing
msg = {
"timestamp": timestamp,
@@ -519,28 +456,20 @@ class Client(object):
return self._enqueue(msg, disable_geoip)
def set_once(
self,
distinct_id=None,
properties=None,
context=None,
timestamp=None,
uuid=None,
disable_geoip=None,
):
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
def set_once(self, **kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
distinct_id = kwargs.get("distinct_id", None)
properties = kwargs.get("properties", None)
timestamp = kwargs.get("timestamp", None)
uuid = kwargs.get("uuid", None)
disable_geoip = kwargs.get("disable_geoip", None)
properties = properties or {}
if distinct_id is None:
distinct_id = get_context_distinct_id()
properties = add_context_tags(properties)
properties = properties or {}
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
(distinct_id, personless) = get_identity_state(distinct_id)
if personless or not properties:
return None # Personless set_once() does nothing
msg = {
"timestamp": timestamp,
@@ -554,30 +483,18 @@ class Client(object):
def group_identify(
self,
group_type=None,
group_key=None,
group_type: str,
group_key: str,
properties=None,
context=None,
timestamp=None,
uuid=None,
disable_geoip=None,
distinct_id=None,
):
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
properties = properties or {}
require("group_type", group_type, ID_TYPES)
require("group_key", group_key, ID_TYPES)
require("properties", properties, dict)
if distinct_id:
require("distinct_id", distinct_id, ID_TYPES)
else:
distinct_id = "${}_{}".format(group_type, group_key)
# group_identify is purposefully always personful
distinct_id = get_identity_state(distinct_id)[0]
msg = {
"event": "$groupidentify",
@@ -591,29 +508,24 @@ class Client(object):
"uuid": uuid,
}
# NOTE - group_identify doesn't generally use context properties - should it?
if get_context_session_id():
msg["properties"]["$session_id"] = get_context_session_id()
return self._enqueue(msg, disable_geoip)
def alias(
self,
previous_id=None,
distinct_id=None,
context=None,
previous_id: str,
distinct_id: Optional[str],
timestamp=None,
uuid=None,
disable_geoip=None,
):
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
if distinct_id is None:
distinct_id = get_context_distinct_id()
(distinct_id, personless) = get_identity_state(distinct_id)
require("previous_id", previous_id, ID_TYPES)
require("distinct_id", distinct_id, ID_TYPES)
if personless:
return None # Personless alias() does nothing - should this throw?
msg = {
"properties": {
@@ -623,68 +535,23 @@ class Client(object):
"timestamp": timestamp,
"event": "$create_alias",
"distinct_id": previous_id,
}
return self._enqueue(msg, disable_geoip)
def page(
self,
distinct_id=None,
url=None,
properties=None,
context=None,
timestamp=None,
uuid=None,
disable_geoip=None,
):
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
if distinct_id is None:
distinct_id = get_context_distinct_id()
properties = properties or {}
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
require("url", url, string_types)
properties["$current_url"] = url
msg = {
"event": "$pageview",
"properties": properties,
"timestamp": timestamp,
"distinct_id": distinct_id,
"uuid": uuid,
}
if get_context_session_id():
msg["properties"]["$session_id"] = get_context_session_id()
return self._enqueue(msg, disable_geoip)
def capture_exception(
self,
exception=None,
distinct_id=None,
properties=None,
context=None,
timestamp=None,
uuid=None,
groups=None,
**kwargs,
exception: Optional[ExceptionArg],
**kwargs: Unpack[OptionalCaptureArgs],
):
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
if distinct_id is None:
distinct_id = get_context_distinct_id()
distinct_id = kwargs.get("distinct_id", None)
properties = kwargs.get("properties", None)
send_feature_flags = kwargs.get("send_feature_flags", False)
disable_geoip = kwargs.get("disable_geoip", None)
# this function shouldn't ever throw an error, so it logs exceptions instead of raising them.
# this is important to ensure we don't unexpectedly re-raise exceptions in the user's code.
try:
@@ -693,16 +560,7 @@ class Client(object):
# Check if this exception has already been captured
if exception is not None and exception_is_already_captured(exception):
self.log.debug("Exception already captured, skipping")
return
# if there's no distinct_id, we'll generate one and set personless mode
# via $process_person_profile = false
if distinct_id is None:
properties["$process_person_profile"] = False
distinct_id = uuid4()
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
return None
if exception is not None:
exc_info = exc_info_from_error(exception)
@@ -711,7 +569,7 @@ class Client(object):
if exc_info is None or exc_info == (None, None, None):
self.log.warning("No exception information available")
return
return None
# Format stack trace for cymbal
all_exceptions_with_trace = exceptions_from_error_tuple(exc_info)
@@ -740,40 +598,54 @@ class Client(object):
if self.log_captured_exceptions:
self.log.exception(exception, extra=kwargs)
timestamp = kwargs.get("timestamp", None)
uuid = kwargs.get("uuid", None)
groups = kwargs.get("groups", None)
res = self.capture(
distinct_id, "$exception", properties, context, timestamp, uuid, groups
"$exception",
distinct_id=distinct_id,
properties=properties,
timestamp=timestamp,
uuid=uuid,
groups=groups,
send_feature_flags=send_feature_flags,
disable_geoip=disable_geoip,
)
# Mark the exception as captured to prevent duplicate captures
if exception is not None:
mark_exception_as_captured(exception)
if exception is not None and res is not None:
mark_exception_as_captured(exception, res)
return res
except Exception as e:
self.log.exception(f"Failed to capture exception: {e}")
def _enqueue(self, msg, disable_geoip):
# type: (...) -> Optional[str]
"""Push a new `msg` onto the queue, return `(success, msg)`"""
if self.disabled:
return False, "disabled"
return None
timestamp = msg["timestamp"]
if timestamp is None:
timestamp = datetime.now(tz=tzutc())
require("timestamp", timestamp, datetime)
# add common
timestamp = guess_timezone(timestamp)
msg["timestamp"] = timestamp.isoformat()
# only send if "uuid" is truthy
if "uuid" in msg:
uuid = msg.pop("uuid")
if uuid:
msg["uuid"] = stringify_id(uuid)
if "uuid" not in msg:
# Always send a uuid, so we can always return one
msg["uuid"] = stringify_id(uuid4())
sent_uuid = msg["uuid"]
if not msg.get("properties"):
msg["properties"] = {}
msg["properties"]["$lib"] = "posthog-python"
@@ -797,7 +669,7 @@ class Client(object):
modified_msg = self.before_send(msg)
if modified_msg is None:
self.log.debug("Event dropped by before_send callback")
return True, None
return None
msg = modified_msg
except Exception as e:
self.log.exception(f"Error in before_send callback: {e}")
@@ -807,7 +679,7 @@ class Client(object):
# if send is False, return msg as if it was successfully queued
if not self.send:
return True, msg
return sent_uuid
if self.sync_mode:
self.log.debug("enqueued with blocking %s.", msg["event"])
@@ -820,15 +692,15 @@ class Client(object):
historical_migration=self.historical_migration,
)
return True, msg
return sent_uuid
try:
self.queue.put(msg, block=False)
self.log.debug("enqueued %s.", msg["event"])
return True, msg
return sent_uuid
except queue.Full:
self.log.warning("analytics-python queue is full")
return False, msg
return None
def flush(self):
"""Forces a flush from the internal queue to the server"""
@@ -863,6 +735,9 @@ class Client(object):
def _load_feature_flags(self):
try:
# Store old flags to detect changes
old_flags_by_key: dict[str, dict] = self.feature_flags_by_key or {}
response = get(
self.personal_api_key,
f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
@@ -874,6 +749,14 @@ class Client(object):
self.group_type_mapping = response["group_type_mapping"] or {}
self.cohorts = response["cohorts"] or {}
# Check if flag definitions changed and update version
if self.flag_cache and old_flags_by_key != (
self.feature_flags_by_key or {}
):
old_version = self.flag_definition_version
self.flag_definition_version += 1
self.flag_cache.invalidate_version(old_version)
except APIError as e:
if e.status == 401:
self.log.error(
@@ -895,6 +778,10 @@ class Client(object):
self.group_type_mapping = {}
self.cohorts = {}
# Clear flag cache when quota limited
if self.flag_cache:
self.flag_cache.clear()
if self.debug:
raise APIError(
status=402,
@@ -1006,21 +893,17 @@ class Client(object):
def _get_feature_flag_result(
self,
key,
distinct_id,
key: str,
distinct_id: ID_TYPES,
*,
override_match_value: Optional[FlagValue] = None,
groups={},
groups: Dict[str, str] = {},
person_properties={},
group_properties={},
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None,
) -> Optional[FeatureFlagResult]:
require("key", key, string_types)
require("distinct_id", distinct_id, ID_TYPES)
require("groups", groups, dict)
if self.disabled:
return None
@@ -1049,6 +932,12 @@ class Client(object):
flag_result = FeatureFlagResult.from_value_and_payload(
key, lookup_match_value, payload
)
# Cache successful local evaluation
if self.flag_cache and flag_result:
self.flag_cache.set_cached_flag(
distinct_id, key, flag_result, self.flag_definition_version
)
elif not only_evaluate_locally:
try:
flag_details, request_id = self._get_feature_flag_details_from_decide(
@@ -1062,12 +951,30 @@ class Client(object):
flag_result = FeatureFlagResult.from_flag_details(
flag_details, override_match_value
)
# Cache successful remote evaluation
if self.flag_cache and flag_result:
self.flag_cache.set_cached_flag(
distinct_id, key, flag_result, self.flag_definition_version
)
self.log.debug(
f"Successfully computed flag remotely: #{key} -> #{flag_result}"
)
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
# Fallback to cached value if remote evaluation fails
if self.flag_cache:
stale_result = self.flag_cache.get_stale_cached_flag(
distinct_id, key
)
if stale_result:
self.log.info(
f"[FEATURE FLAGS] Using stale cached value for flag {key}"
)
flag_result = stale_result
if send_feature_flag_events:
self._capture_feature_flag_called(
distinct_id,
@@ -1146,7 +1053,7 @@ class Client(object):
def _locally_evaluate_flag(
self,
key: str,
distinct_id: str,
distinct_id: ID_TYPES,
groups: dict[str, str],
person_properties: dict[str, str],
group_properties: dict[str, str],
@@ -1210,7 +1117,7 @@ class Client(object):
def _get_feature_flag_details_from_decide(
self,
key: str,
distinct_id: str,
distinct_id: ID_TYPES,
groups: dict[str, str],
person_properties: dict[str, str],
group_properties: dict[str, str],
@@ -1229,12 +1136,12 @@ class Client(object):
def _capture_feature_flag_called(
self,
distinct_id: str,
distinct_id: ID_TYPES,
key: str,
response: Optional[FlagValue],
payload: Optional[str],
flag_was_locally_evaluated: bool,
groups: dict[str, str],
groups: Dict[str, str],
disable_geoip: Optional[bool],
request_id: Optional[str],
flag_details: Optional[FeatureFlag],
@@ -1272,9 +1179,9 @@ class Client(object):
properties["$feature_flag_id"] = flag_details.metadata.id
self.capture(
distinct_id,
"$feature_flag_called",
properties,
distinct_id=distinct_id,
properties=properties,
groups=groups,
disable_geoip=disable_geoip,
)
@@ -1392,16 +1299,13 @@ class Client(object):
def _get_all_flags_and_payloads_locally(
self,
distinct_id,
distinct_id: ID_TYPES,
*,
groups={},
groups: Dict[str, Union[str, int]],
person_properties={},
group_properties={},
warn_on_unknown_groups=False,
) -> tuple[FlagsAndPayloads, bool]:
require("distinct_id", distinct_id, ID_TYPES)
require("groups", groups, dict)
if self.feature_flags is None and self.personal_api_key:
self.load_feature_flags()
@@ -1441,6 +1345,99 @@ class Client(object):
"featureFlagPayloads": payloads,
}, fallback_to_decide
def _initialize_flag_cache(self, cache_url):
"""Initialize feature flag cache for graceful degradation during service outages.
When enabled, the cache stores flag evaluation results and serves them as fallback
when the PostHog API is unavailable. This ensures your application continues to
receive flag values even during outages.
Args:
cache_url: Cache configuration URL. Examples:
- None: Disable caching
- "memory://local/?ttl=300&size=10000": Memory cache with TTL and size
- "redis://localhost:6379/0/?ttl=300": Redis cache with TTL
- "redis://username:password@host:port/?ttl=300": Redis with auth
Example usage:
# Memory cache
client = Client(
"your-api-key",
flag_fallback_cache_url="memory://local/?ttl=300&size=10000"
)
# Redis cache
client = Client(
"your-api-key",
flag_fallback_cache_url="redis://localhost:6379/0/?ttl=300"
)
# Normal evaluation - cache is populated
flag_value = client.get_feature_flag("my-flag", "user123")
# During API outage - returns cached value instead of None
flag_value = client.get_feature_flag("my-flag", "user123") # Uses cache
"""
if not cache_url:
return None
try:
from urllib.parse import urlparse, parse_qs
except ImportError:
from urlparse import urlparse, parse_qs
try:
parsed = urlparse(cache_url)
scheme = parsed.scheme.lower()
query_params = parse_qs(parsed.query)
ttl = int(query_params.get("ttl", [300])[0])
if scheme == "memory":
size = int(query_params.get("size", [10000])[0])
return FlagCache(size, ttl)
elif scheme == "redis":
try:
# Not worth importing redis if we're not using it
import redis
redis_url = f"{parsed.scheme}://"
if parsed.username or parsed.password:
redis_url += f"{parsed.username or ''}:{parsed.password or ''}@"
redis_url += (
f"{parsed.hostname or 'localhost'}:{parsed.port or 6379}"
)
if parsed.path:
redis_url += parsed.path
client = redis.from_url(redis_url)
# Test connection before using it
client.ping()
return RedisFlagCache(client, default_ttl=ttl)
except ImportError:
self.log.warning(
"[FEATURE FLAGS] Redis not available, flag caching disabled"
)
return None
except Exception as e:
self.log.warning(
f"[FEATURE FLAGS] Redis connection failed: {e}, flag caching disabled"
)
return None
else:
raise ValueError(
f"Unknown cache URL scheme: {scheme}. Supported schemes: memory, redis"
)
except Exception as e:
self.log.warning(
f"[FEATURE FLAGS] Failed to parse cache URL '{cache_url}': {e}"
)
return None
def feature_flag_definitions(self):
return self.feature_flags
@@ -1463,13 +1460,6 @@ class Client(object):
return all_person_properties, all_group_properties
def require(name, field, data_type):
"""Require that the named `field` has the right `data_type`"""
if not isinstance(field, data_type):
msg = "{0} must have {1}, got: {2}".format(name, data_type, field)
raise AssertionError(msg)
def stringify_id(val):
if val is None:
return None
+27 -18
View File
@@ -1,6 +1,10 @@
import contextvars
from contextlib import contextmanager
from typing import Optional, Any, Callable, Dict, TypeVar, cast
from typing import Optional, Any, Callable, Dict, TypeVar, cast, TYPE_CHECKING
if TYPE_CHECKING:
# To avoid circular imports
from posthog.client import Client
class ContextScope:
@@ -9,7 +13,9 @@ class ContextScope:
parent=None,
fresh: bool = False,
capture_exceptions: bool = True,
client: Optional["Client"] = None,
):
self.client: Optional[Client] = client
self.parent = parent
self.fresh = fresh
self.capture_exceptions = capture_exceptions
@@ -64,7 +70,9 @@ def _get_current_context() -> Optional[ContextScope]:
@contextmanager
def new_context(fresh=False, capture_exceptions=True):
def new_context(
fresh=False, capture_exceptions=True, client: Optional["Client"] = None
):
"""
Create a new context scope that will be active for the duration of the with block.
Any tags set within this scope will be isolated to this context. Any exceptions raised
@@ -77,6 +85,13 @@ def new_context(fresh=False, capture_exceptions=True):
capture_exceptions: Whether to capture exceptions raised within the context (default: True).
If True, captures exceptions and tags them with the context tags before propagating them.
If False, exceptions will propagate without being tagged or captured.
client: Optional client instance to use for capturing exceptions (default: None).
If provided, the client will be used to capture exceptions within the context.
If not provided, the default (global) client will be used. Note that the passed
client is only used to capture exceptions within the context - other events captured
within the context via `Client.capture` or `posthog.capture` will still carry the context
state (tags, identity, session id), but will be captured by the client directly used (or
the global one, in the case of `posthog.capture`)
Examples:
# Inherit parent context tags
@@ -97,14 +112,17 @@ def new_context(fresh=False, capture_exceptions=True):
from posthog import capture_exception
current_context = _get_current_context()
new_context = ContextScope(current_context, fresh, capture_exceptions)
new_context = ContextScope(current_context, fresh, capture_exceptions, client)
_context_stack.set(new_context)
try:
yield
except Exception as e:
if new_context.capture_exceptions:
capture_exception(e)
if new_context.client:
new_context.client.capture_exception(e)
else:
capture_exception(e)
raise
finally:
_context_stack.set(new_context.get_parent())
@@ -112,7 +130,8 @@ def new_context(fresh=False, capture_exceptions=True):
def tag(key: str, value: Any) -> None:
"""
Add a tag to the current context.
Add a tag to the current context. All tags are added as properties to any event, including exceptions, captured
within the context.
Args:
key: The tag key
@@ -126,8 +145,6 @@ def tag(key: str, value: Any) -> None:
current_context.add_tag(key, value)
# NOTE: we should probably also remove this - there's no reason for the user to ever
# need to manually interact with the current tag set
def get_tags() -> Dict[str, Any]:
"""
Get all tags from the current context. Note, modifying
@@ -142,22 +159,14 @@ def get_tags() -> Dict[str, Any]:
return {}
# NOTE: We should probably remove this function - the way to clear scope context
# is by entering a new, fresh context, rather than by clearing the tags or other
# scope data directly.
def clear_tags() -> None:
"""Clear all tags in the current context. Does not clear parent tags"""
current_context = _get_current_context()
if current_context:
current_context.tags.clear()
def identify_context(distinct_id: str) -> None:
"""
Identify the current context with a distinct ID, associating all events captured in this or
child contexts with the given distinct ID (unless identify_context is called again). This is overridden by
distinct id's passed directly to posthog.capture and related methods (identify, set etc). Entering a
fresh context will clear the context-level distinct ID.
fresh context will clear the context-level distinct ID. The distinct-id passed should be uniquely associated
with one of your users. Events captured outside of a context, or in a context with no associated distinct
ID, will be assigned a random UUID, and captured as "personless".
Args:
distinct_id: The distinct ID to associate with the current context and its children.
+3 -25
View File
@@ -6,47 +6,25 @@
import logging
import sys
import threading
from enum import Enum
from typing import TYPE_CHECKING, List, Optional
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from posthog.client import Client
class Integrations(str, Enum):
Django = "django"
class ExceptionCapture:
# TODO: Add client side rate limiting to prevent spamming the server with exceptions
log = logging.getLogger("posthog")
def __init__(
self, client: "Client", integrations: Optional[List[Integrations]] = None
):
def __init__(self, client: "Client"):
self.client = client
self.original_excepthook = sys.excepthook
sys.excepthook = self.exception_handler
threading.excepthook = self.thread_exception_handler
self.enabled_integrations = []
for integration in integrations or []:
# TODO: Maybe find a better way of enabling integrations
# This is very annoying currently if we had to add any configuration per integration
if integration == Integrations.Django:
try:
from posthog.exception_integrations.django import DjangoIntegration
enabled_integration = DjangoIntegration(self.exception_receiver)
self.enabled_integrations.append(enabled_integration)
except Exception as e:
self.log.exception(f"Failed to enable Django integration: {e}")
def close(self):
sys.excepthook = self.original_excepthook
for integration in self.enabled_integrations:
integration.uninstall()
def exception_handler(self, exc_type, exc_value, exc_traceback):
# don't affect default behaviour.
@@ -66,6 +44,6 @@ class ExceptionCapture:
def capture_exception(self, exception, metadata=None):
try:
distinct_id = metadata.get("distinct_id") if metadata else None
self.client.capture_exception(exception, distinct_id)
self.client.capture_exception(exception, distinct_id=distinct_id)
except Exception as e:
self.log.exception(f"Failed to capture exception: {e}")
@@ -1,5 +0,0 @@
class IntegrationEnablingError(Exception):
"""
The integration could not be enabled due to a user error like
`django` not being installed for the `DjangoIntegration`.
"""
-117
View File
@@ -1,117 +0,0 @@
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
# Licensed under the MIT License
# 💖open source (under MIT License)
import re
import sys
from typing import TYPE_CHECKING
from posthog.exception_integrations import IntegrationEnablingError
try:
from django import VERSION as DJANGO_VERSION
from django.core import signals
except ImportError:
raise IntegrationEnablingError("Django not installed")
if TYPE_CHECKING:
from typing import Any, Dict # noqa: F401
from django.core.handlers.wsgi import WSGIRequest # noqa: F401
class DjangoIntegration:
# TODO: Abstract integrations one we have more and can see patterns
"""
Autocapture errors from a Django application.
"""
identifier = "django"
def __init__(self, capture_exception_fn=None):
if DJANGO_VERSION < (4, 2):
raise IntegrationEnablingError("Django 4.2 or newer is required.")
# TODO: Right now this seems too complicated / overkill for us, but seems like we can automatically plug in middlewares
# which is great for users (they don't need to do this) and everything should just work.
# We should consider this in the future, but for now we can just use the middleware and signals handlers.
# See: https://github.com/getsentry/sentry-python/blob/269d96d6e9821122fbff280e6a26956e5ed03c0b/sentry_sdk/integrations/django/__init__.py
self.capture_exception_fn = capture_exception_fn
def _got_request_exception(request=None, **kwargs):
# type: (WSGIRequest, **Any) -> None
extra_props = {}
if request is not None:
# get headers metadata
extra_props = DjangoRequestExtractor(request).extract_person_data()
self.capture_exception_fn(sys.exc_info(), extra_props)
signals.got_request_exception.connect(_got_request_exception)
def uninstall(self):
pass
class DjangoRequestExtractor:
def __init__(self, request):
# type: (Any) -> None
self.request = request
def extract_person_data(self):
headers = self.headers()
# Extract traceparent and tracestate headers
traceparent = headers.get("Traceparent")
tracestate = headers.get("Tracestate")
# Extract the distinct_id from tracestate
distinct_id = None
if tracestate:
# TODO: Align on the format of the distinct_id in tracestate
# We can't have comma or equals in header values here, so maybe we should base64 encode it?
match = re.search(r"posthog-distinct-id=([^,]+)", tracestate)
if match:
distinct_id = match.group(1)
return {
**self.user(),
"distinct_id": distinct_id,
"ip": headers.get("X-Forwarded-For"),
"user_agent": headers.get("User-Agent"),
"traceparent": traceparent,
"$request_path": self.request.path,
}
def user(self):
user_data: dict[str, str] = {}
user = getattr(self.request, "user", None)
if user is None or not user.is_authenticated:
return user_data
try:
user_id = str(user.pk)
if user_id:
user_data.setdefault("$user_id", user_id)
except Exception:
pass
try:
email = str(user.email)
if email:
user_data.setdefault("email", email)
except Exception:
pass
return user_data
def headers(self):
# type: () -> Dict[str, str]
return dict(self.request.headers)
+89 -170
View File
@@ -11,7 +11,24 @@ import re
import sys
import types
from datetime import datetime
from typing import TYPE_CHECKING
from types import FrameType, TracebackType # noqa: F401
from typing import ( # noqa: F401
Any,
Dict,
Iterator,
List,
Literal,
Optional,
Set,
Tuple,
TypedDict,
TypeVar,
Union,
cast,
TYPE_CHECKING,
)
from posthog.args import ExcInfo, ExceptionArg # noqa: F401
try:
# Python 3.11
@@ -23,85 +40,61 @@ except ImportError:
DEFAULT_MAX_VALUE_LENGTH = 1024
if TYPE_CHECKING:
from types import FrameType, TracebackType
from typing import ( # noqa: F401
Any,
Callable,
Dict,
Iterator,
List,
Literal,
Optional,
Set,
Tuple,
Type,
TypedDict,
TypeVar,
Union,
cast,
)
ExcInfo = Union[
Tuple[Type[BaseException], BaseException, Optional[TracebackType]],
Tuple[None, None, None],
]
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
Event = TypedDict(
"Event",
{
"breadcrumbs": Dict[
Literal["values"], List[Dict[str, Any]]
], # TODO: We can expand on this type
"check_in_id": str,
"contexts": Dict[str, Dict[str, object]],
"dist": str,
"duration": Optional[float],
"environment": str,
"errors": List[Dict[str, Any]], # TODO: We can expand on this type
"event_id": str,
"exception": Dict[
Literal["values"], List[Dict[str, Any]]
], # TODO: We can expand on this type
# "extra": MutableMapping[str, object],
# "fingerprint": List[str],
"level": LogLevelStr,
# "logentry": Mapping[str, object],
"logger": str,
# "measurements": Dict[str, MeasurementValue],
"message": str,
"modules": Dict[str, str],
# "monitor_config": Mapping[str, object],
"monitor_slug": Optional[str],
"platform": Literal["python"],
"profile": object,
"release": str,
"request": Dict[str, object],
# "sdk": Mapping[str, object],
"server_name": str,
"spans": List[Dict[str, object]],
"stacktrace": Dict[
str, object
], # We access this key in the code, but I am unsure whether we ever set it
"start_timestamp": datetime,
"status": Optional[str],
# "tags": MutableMapping[
# str, str
# ], # Tags must be less than 200 characters each
"threads": Dict[
Literal["values"], List[Dict[str, Any]]
], # TODO: We can expand on this type
"timestamp": Optional[datetime], # Must be set before sending the event
"transaction": str,
# "transaction_info": Mapping[str, Any], # TODO: We can expand on this type
"type": Literal["check_in", "transaction"],
"user": Dict[str, object],
"_metrics_summary": Dict[str, object],
},
total=False,
)
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
Event = TypedDict(
"Event",
{
"breadcrumbs": Dict[
Literal["values"], List[Dict[str, Any]]
], # TODO: We can expand on this type
"check_in_id": str,
"contexts": Dict[str, Dict[str, object]],
"dist": str,
"duration": Optional[float],
"environment": str,
"errors": List[Dict[str, Any]], # TODO: We can expand on this type
"event_id": str,
"exception": Dict[
Literal["values"], List[Dict[str, Any]]
], # TODO: We can expand on this type
# "extra": MutableMapping[str, object],
# "fingerprint": List[str],
"level": LogLevelStr,
# "logentry": Mapping[str, object],
"logger": str,
# "measurements": Dict[str, MeasurementValue],
"message": str,
"modules": Dict[str, str],
# "monitor_config": Mapping[str, object],
"monitor_slug": Optional[str],
"platform": Literal["python"],
"profile": object,
"release": str,
"request": Dict[str, object],
# "sdk": Mapping[str, object],
"server_name": str,
"spans": List[Dict[str, object]],
"stacktrace": Dict[
str, object
], # We access this key in the code, but I am unsure whether we ever set it
"start_timestamp": datetime,
"status": Optional[str],
# "tags": MutableMapping[
# str, str
# ], # Tags must be less than 200 characters each
"threads": Dict[
Literal["values"], List[Dict[str, Any]]
], # TODO: We can expand on this type
"timestamp": Optional[datetime], # Must be set before sending the event
"transaction": str,
# "transaction_info": Mapping[str, Any], # TODO: We can expand on this type
"type": Literal["check_in", "transaction"],
"user": Dict[str, object],
"_metrics_summary": Dict[str, object],
},
total=False,
)
epoch = datetime(1970, 1, 1)
@@ -362,12 +355,9 @@ def filename_for_module(module, abs_path):
def serialize_frame(
frame,
tb_lineno=None,
include_local_variables=True,
include_source_context=True,
max_value_length=None,
custom_repr=None,
):
# type: (FrameType, Optional[int], bool, bool, Optional[int], Optional[Callable[..., Optional[str]]]) -> Dict[str, Any]
# type: (FrameType, Optional[int], Optional[int]) -> Dict[str, Any]
f_code = getattr(frame, "f_code", None)
if not f_code:
abs_path = None
@@ -392,45 +382,13 @@ def serialize_frame(
"lineno": tb_lineno,
} # type: Dict[str, Any]
if include_source_context:
rv["pre_context"], rv["context_line"], rv["post_context"] = get_source_context(
frame, tb_lineno, max_value_length
)
if include_local_variables:
# TODO - we don't support local variables, yet
pass
rv["pre_context"], rv["context_line"], rv["post_context"] = get_source_context(
frame, tb_lineno, max_value_length
)
return rv
def current_stacktrace(
include_local_variables=True, # type: bool
include_source_context=True, # type: bool
max_value_length=None, # type: Optional[int]
):
# type: (...) -> Dict[str, Any]
__tracebackhide__ = True
frames = []
f = sys._getframe() # type: Optional[FrameType]
while f is not None:
if not should_hide_frame(f):
frames.append(
serialize_frame(
f,
include_local_variables=include_local_variables,
include_source_context=include_source_context,
max_value_length=max_value_length,
)
)
f = f.f_back
frames.reverse()
return {"frames": frames, "type": "raw"}
def get_errno(exc_value):
# type: (BaseException) -> Optional[Any]
return getattr(exc_value, "errno", None)
@@ -438,18 +396,19 @@ def get_errno(exc_value):
def get_error_message(exc_value):
# type: (Optional[BaseException]) -> str
return (
message = (
getattr(exc_value, "message", "")
or getattr(exc_value, "detail", "")
or safe_str(exc_value)
or exc_value
)
return safe_str(message)
def single_exception_from_error_tuple(
exc_type, # type: Optional[type]
exc_value, # type: Optional[BaseException]
tb, # type: Optional[TracebackType]
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
exception_id=None, # type: Optional[int]
parent_id=None, # type: Optional[int]
@@ -497,25 +456,13 @@ def single_exception_from_error_tuple(
exception_value["type"] = get_type_name(exc_type)
exception_value["value"] = get_error_message(exc_value)
if client_options is None:
include_local_variables = True
include_source_context = True
max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback
custom_repr = None
else:
include_local_variables = client_options["include_local_variables"]
include_source_context = client_options["include_source_context"]
max_value_length = client_options["max_value_length"]
custom_repr = client_options.get("custom_repr")
max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback
frames = [
serialize_frame(
tb.tb_frame,
tb_lineno=tb.tb_lineno,
include_local_variables=include_local_variables,
include_source_context=include_source_context,
max_value_length=max_value_length,
custom_repr=custom_repr,
)
for tb in iter_stacks(tb)
]
@@ -571,7 +518,6 @@ def exceptions_from_error(
exc_type, # type: Optional[type]
exc_value, # type: Optional[BaseException]
tb, # type: Optional[TracebackType]
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
exception_id=0, # type: int
parent_id=0, # type: int
@@ -587,7 +533,6 @@ def exceptions_from_error(
exc_type=exc_type,
exc_value=exc_value,
tb=tb,
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
parent_id=parent_id,
@@ -615,7 +560,6 @@ def exceptions_from_error(
exc_type=type(cause),
exc_value=cause,
tb=getattr(cause, "__traceback__", None),
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
source="__cause__",
@@ -636,7 +580,6 @@ def exceptions_from_error(
exc_type=type(context),
exc_value=context,
tb=getattr(context, "__traceback__", None),
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
source="__context__",
@@ -651,7 +594,6 @@ def exceptions_from_error(
exc_type=type(e),
exc_value=e,
tb=getattr(e, "__traceback__", None),
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
parent_id=parent_id,
@@ -664,7 +606,6 @@ def exceptions_from_error(
def exceptions_from_error_tuple(
exc_info, # type: ExcInfo
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
):
# type: (...) -> List[Dict[str, Any]]
@@ -679,7 +620,6 @@ def exceptions_from_error_tuple(
exc_type=exc_type,
exc_value=exc_value,
tb=tb,
client_options=client_options,
mechanism=mechanism,
exception_id=0,
parent_id=0,
@@ -689,9 +629,7 @@ def exceptions_from_error_tuple(
exceptions = []
for exc_type, exc_value, tb in walk_exception_chain(exc_info):
exceptions.append(
single_exception_from_error_tuple(
exc_type, exc_value, tb, client_options, mechanism
)
single_exception_from_error_tuple(exc_type, exc_value, tb, mechanism)
)
exceptions.reverse()
@@ -781,7 +719,7 @@ def set_in_app_in_frames(frames, in_app_exclude, in_app_include, project_root=No
def exception_is_already_captured(error):
# type: (Union[BaseException, ExcInfo]) -> bool
# type: (ExceptionArg) -> bool
if isinstance(error, BaseException):
return hasattr(error, "__posthog_exception_captured")
# Autocaptured exceptions are passed as a tuple from our system hooks,
@@ -794,19 +732,21 @@ def exception_is_already_captured(error):
return False # type: ignore[unreachable]
def mark_exception_as_captured(error):
# type: (Union[BaseException, ExcInfo]) -> None
def mark_exception_as_captured(error, uuid):
# type: (ExceptionArg, str) -> None
if isinstance(error, BaseException):
setattr(error, "__posthog_exception_captured", True)
setattr(error, "__posthog_exception_uuid", uuid)
# Autocaptured exceptions are passed as a tuple from our system hooks,
# the second item is the exception value (the first is the exception type)
elif isinstance(error, tuple) and len(error) > 1:
if error[1] is not None:
setattr(error[1], "__posthog_exception_captured", True)
setattr(error[1], "__posthog_exception_uuid", uuid)
def exc_info_from_error(error):
# type: (Union[BaseException, ExcInfo]) -> ExcInfo
# type: (ExceptionArg) -> ExcInfo
if isinstance(error, tuple) and len(error) == 3:
exc_type, exc_value, tb = error
elif isinstance(error, BaseException):
@@ -863,27 +803,6 @@ def construct_artificial_traceback(e):
setattr(e, "__traceback__", tb)
def event_from_exception(
exc_info, # type: Union[BaseException, ExcInfo]
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
):
# type: (...) -> Tuple[Event, Dict[str, Any]]
exc_info = exc_info_from_error(exc_info)
hint = event_hint_with_exc_info(exc_info)
return (
{
"level": "error",
"exception": {
"values": exceptions_from_error_tuple(
exc_info, client_options, mechanism
)
},
},
hint,
)
def _module_in_list(name, items):
# type: (str | None, Optional[List[str]]) -> bool
if name is None:
+46 -7
View File
@@ -1,5 +1,5 @@
from typing import TYPE_CHECKING, cast
from posthog import scopes
from posthog import contexts
if TYPE_CHECKING:
from django.http import HttpRequest, HttpResponse # noqa: F401
@@ -78,15 +78,21 @@ class PosthogContextMiddleware:
# type: (HttpRequest) -> Dict[str, Any]
tags = {}
(user_id, user_email) = self.extract_request_user(request)
# Extract session ID from X-POSTHOG-SESSION-ID header
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
if session_id:
scopes.set_context_session(session_id)
contexts.set_context_session(session_id)
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header
distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID")
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id
distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID") or user_id
if distinct_id:
scopes.identify_context(distinct_id)
contexts.identify_context(distinct_id)
# Extract user email
if user_email:
tags["email"] = user_email
# Extract current URL
absolute_url = request.build_absolute_uri()
@@ -97,6 +103,20 @@ class PosthogContextMiddleware:
if request.method:
tags["$request_method"] = request.method
# Extract request path
if request.path:
tags["$request_path"] = request.path
# Extract IP address
ip_address = request.headers.get("X-Forwarded-For")
if ip_address:
tags["$ip_address"] = ip_address
# Extract user agent
user_agent = request.headers.get("User-Agent")
if user_agent:
tags["$user_agent"] = user_agent
# Apply extra tags if configured
if self.extra_tags:
extra = self.extra_tags(request)
@@ -109,13 +129,32 @@ class PosthogContextMiddleware:
return tags
def extract_request_user(self, request):
user_id = None
email = None
user = getattr(request, "user", None)
if user and getattr(user, "is_authenticated", False):
try:
user_id = str(user.pk)
except Exception:
pass
try:
email = str(user.email)
except Exception:
pass
return user_id, email
def __call__(self, request):
# type: (HttpRequest) -> HttpResponse
if self.request_filter and not self.request_filter(request):
return self.get_response(request)
with scopes.new_context(self.capture_exceptions):
with contexts.new_context(self.capture_exceptions):
for k, v in self.extract_tags(request).items():
scopes.tag(k, v)
contexts.tag(k, v)
return self.get_response(request)
@@ -1,3 +0,0 @@
import pytest
pytest.importorskip("django")
@@ -1,121 +0,0 @@
from posthog.exception_integrations.django import DjangoRequestExtractor
from django.test import RequestFactory
from django.conf import settings
from django.core.management import call_command
import django
DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
# setup a test app
if not settings.configured:
settings.configure(
SECRET_KEY="test",
DEFAULT_CHARSET="utf-8",
INSTALLED_APPS=[
"django.contrib.auth",
"django.contrib.contenttypes",
],
DATABASES={
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
},
)
django.setup()
call_command("migrate", verbosity=0, interactive=False)
def mock_request_factory(override_headers):
factory = RequestFactory(
headers={
"User-Agent": DEFAULT_USER_AGENT,
"Referrer": "http://example.com",
"X-Forwarded-For": "193.4.5.12",
**(override_headers or {}),
}
)
request = factory.get("/api/endpoint")
return request
def test_request_extractor_with_no_trace():
request = mock_request_factory(None)
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": None,
"$request_path": "/api/endpoint",
}
def test_request_extractor_with_trace():
request = mock_request_factory(
{"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"}
)
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"distinct_id": None,
"$request_path": "/api/endpoint",
}
def test_request_extractor_with_tracestate():
request = mock_request_factory(
{
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"tracestate": "posthog-distinct-id=1234",
}
)
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"distinct_id": "1234",
"$request_path": "/api/endpoint",
}
def test_request_extractor_with_complicated_tracestate():
request = mock_request_factory(
{"tracestate": "posthog-distinct-id=alohaMountainsXUYZ,rojo=00f067aa0ba902b7"}
)
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": "alohaMountainsXUYZ",
"$request_path": "/api/endpoint",
}
def test_request_extractor_with_request_user():
from django.contrib.auth.models import User
user = User.objects.create_user(
username="test", email="test@posthog.com", password="top_secret"
)
request = mock_request_factory(None)
request.user = user
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": None,
"$request_path": "/api/endpoint",
"email": "test@posthog.com",
"$user_id": "1",
}
+5 -1
View File
@@ -1,4 +1,8 @@
from posthog.scopes import new_context, get_context_session_id, get_context_distinct_id
from posthog.contexts import (
new_context,
get_context_session_id,
get_context_distinct_id,
)
import unittest
from unittest.mock import Mock
+124 -77
View File
@@ -40,16 +40,30 @@ class TestClient(unittest.TestCase):
event["properties"]["processed_by_before_send"] = True
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=my_before_send
)
success, msg = client.capture("user1", "test_event", {"original": "value"})
self.assertTrue(success)
self.assertEqual(msg["properties"]["processed_by_before_send"], True)
self.assertEqual(msg["properties"]["original"], "value")
self.assertEqual(len(processed_events), 1)
self.assertEqual(processed_events[0]["event"], "test_event")
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
before_send=my_before_send,
sync_mode=True,
)
msg_uuid = client.capture(
"test_event", distinct_id="user1", properties={"original": "value"}
)
self.assertIsNotNone(msg_uuid)
# Get the enqueued message from the mock
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
enqueued_msg = batch_data[0]
self.assertEqual(
enqueued_msg["properties"]["processed_by_before_send"], True
)
self.assertEqual(enqueued_msg["properties"]["original"], "value")
self.assertEqual(len(processed_events), 1)
self.assertEqual(processed_events[0]["event"], "test_event")
def test_before_send_callback_drops_event(self):
"""Test that before_send callback can drop events by returning None."""
@@ -59,20 +73,27 @@ class TestClient(unittest.TestCase):
return None
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=drop_test_events
)
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
before_send=drop_test_events,
sync_mode=True,
)
# Event should be dropped
msg_uuid = client.capture("test_drop_me", distinct_id="user1")
self.assertIsNone(msg_uuid)
# Event should be dropped
success, msg = client.capture("user1", "test_drop_me")
self.assertTrue(success)
self.assertIsNone(msg)
# Event should go through
msg_uuid = client.capture("keep_me", distinct_id="user1")
self.assertIsNotNone(msg_uuid)
# Event should go through
success, msg = client.capture("user1", "keep_me")
self.assertTrue(success)
self.assertIsNotNone(msg)
self.assertEqual(msg["event"], "keep_me")
# Check the enqueued message
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
enqueued_msg = batch_data[0]
self.assertEqual(enqueued_msg["event"], "keep_me")
def test_before_send_callback_handles_exceptions(self):
"""Test that exceptions in before_send don't crash the client."""
@@ -80,18 +101,26 @@ class TestClient(unittest.TestCase):
def buggy_before_send(event):
raise ValueError("Oops!")
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=buggy_before_send
)
success, msg = client.capture("user1", "robust_event")
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
before_send=buggy_before_send,
sync_mode=True,
)
msg_uuid = client.capture("robust_event", distinct_id="user1")
# Event should still be sent despite the exception
self.assertTrue(success)
self.assertIsNotNone(msg)
self.assertEqual(msg["event"], "robust_event")
# Event should still be sent despite the exception
self.assertIsNotNone(msg_uuid)
# Check the enqueued message
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
enqueued_msg = batch_data[0]
self.assertEqual(enqueued_msg["event"], "robust_event")
def test_before_send_callback_works_with_all_event_types(self):
"""Test that before_send works with capture, identify, set, etc."""
"""Test that before_send works with capture, set, etc."""
def add_marker(event):
if "properties" not in event:
@@ -99,38 +128,46 @@ class TestClient(unittest.TestCase):
event["properties"]["marked"] = True
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=add_marker
)
# Test capture
success, msg = client.capture("user1", "event")
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Test identify
success, msg = client.identify("user1", {"trait": "value"})
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Test set
success, msg = client.set("user1", {"prop": "value"})
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Test page
success, msg = client.page("user1", "https://example.com")
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
before_send=add_marker,
sync_mode=True,
)
# Test capture
msg_uuid = client.capture("event", distinct_id="user1")
self.assertIsNotNone(msg_uuid)
# Test set
msg_uuid = client.set(distinct_id="user1", properties={"prop": "value"})
self.assertIsNotNone(msg_uuid)
# Check all events were marked
self.assertEqual(mock_post.call_count, 2)
for call in mock_post.call_args_list:
batch_data = call[1]["batch"]
enqueued_msg = batch_data[0]
self.assertTrue(enqueued_msg["properties"]["marked"])
def test_before_send_callback_disabled_when_none(self):
"""Test that client works normally when before_send is None."""
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=None)
success, msg = client.capture("user1", "normal_event")
self.assertTrue(success)
self.assertIsNotNone(msg)
self.assertEqual(msg["event"], "normal_event")
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
before_send=None,
sync_mode=True,
)
msg_uuid = client.capture("normal_event", distinct_id="user1")
self.assertIsNotNone(msg_uuid)
# Check the event was sent normally
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
enqueued_msg = batch_data[0]
self.assertEqual(enqueued_msg["event"], "normal_event")
def test_before_send_callback_pii_scrubbing_example(self):
"""Test a realistic PII scrubbing use case."""
@@ -152,20 +189,30 @@ class TestClient(unittest.TestCase):
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=scrub_pii
)
success, msg = client.capture(
"user1",
"form_submit",
{
"email": "user@example.com",
"credit_card": "1234-5678-9012-3456",
"form_name": "contact",
},
)
self.assertTrue(success)
self.assertEqual(msg["properties"]["email"], "***@example.com")
self.assertNotIn("credit_card", msg["properties"])
self.assertEqual(msg["properties"]["form_name"], "contact")
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
before_send=scrub_pii,
sync_mode=True,
)
msg_uuid = client.capture(
"form_submit",
distinct_id="user1",
properties={
"email": "user@example.com",
"credit_card": "1234-5678-9012-3456",
"form_name": "contact",
},
)
self.assertIsNotNone(msg_uuid)
# Check the enqueued message was scrubbed
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
enqueued_msg = batch_data[0]
self.assertEqual(enqueued_msg["properties"]["email"], "***@example.com")
self.assertNotIn("credit_card", enqueued_msg["properties"])
self.assertEqual(enqueued_msg["properties"]["form_name"], "contact")
+1081 -575
View File
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,7 @@
import unittest
from unittest.mock import patch
from posthog.scopes import (
clear_tags,
from posthog.contexts import (
get_tags,
new_context,
scoped,
@@ -14,11 +13,7 @@ from posthog.scopes import (
)
class TestScopes(unittest.TestCase):
def setUp(self):
# Reset any context between tests
clear_tags()
class TestContexts(unittest.TestCase):
def test_tag_and_get_tags(self):
with new_context(fresh=True):
tag("key1", "value1")
@@ -28,14 +23,6 @@ class TestScopes(unittest.TestCase):
assert tags["key1"] == "value1"
assert tags["key2"] == 2
def test_clear_tags(self):
with new_context(fresh=True):
tag("key1", "value1")
assert get_tags()["key1"] == "value1"
clear_tags()
assert get_tags() == {}
def test_new_context_isolation(self):
with new_context(fresh=True):
# Set tag in outer context
-29
View File
@@ -32,32 +32,3 @@ def test_excepthook(tmpdir):
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
in output
)
def test_trying_to_use_django_integration(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
from posthog import Posthog, Integrations
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, exception_autocapture_integrations=[Integrations.Django], debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
# frame_value = "LOL"
1/0
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output
assert b"ZeroDivisionError" in output
assert b"LOL" in output
assert b"DEBUG:posthog:data uploaded successfully" in output
assert (
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
in output
)
+12 -12
View File
@@ -229,9 +229,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
self.assertEqual(flag_result.variant, None)
self.assertEqual(flag_result.payload, 300)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
@@ -283,9 +283,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
self.assertEqual(flag_result.payload, {"some": "value"})
patch_capture.assert_called_with(
"distinct_id",
"$feature_flag_called",
{
distinct_id="distinct_id",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": "variant-1",
"locally_evaluated": True,
@@ -305,9 +305,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
self.assertIsNone(another_flag_result.payload)
patch_capture.assert_called_with(
"another-distinct-id",
"$feature_flag_called",
{
distinct_id="another-distinct-id",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": "variant-2",
"locally_evaluated": True,
@@ -345,9 +345,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
self.assertEqual(flag_result.variant, None)
self.assertEqual(flag_result.payload, 300)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"locally_evaluated": False,
@@ -388,9 +388,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
self.assertEqual(flag_result.get_value(), "variant-1")
self.assertEqual(flag_result.payload, [1, 2, 3])
patch_capture.assert_called_with(
"distinct_id",
"$feature_flag_called",
{
distinct_id="distinct_id",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": "variant-1",
"locally_evaluated": False,
@@ -431,9 +431,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
self.assertIsNone(flag_result)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "no-person-flag",
"$feature_flag_response": None,
"locally_evaluated": False,
+30 -22
View File
@@ -2695,9 +2695,9 @@ class TestCaptureCalls(unittest.TestCase):
)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
@@ -2729,9 +2729,9 @@ class TestCaptureCalls(unittest.TestCase):
)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id2",
"$feature_flag_called",
{
distinct_id="some-distinct-id2",
properties={
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
@@ -2767,9 +2767,9 @@ class TestCaptureCalls(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 1)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id2",
"$feature_flag_called",
{
distinct_id="some-distinct-id2",
properties={
"$feature_flag": "decide-flag",
"$feature_flag_response": "decide-value",
"locally_evaluated": False,
@@ -2820,9 +2820,9 @@ class TestCaptureCalls(unittest.TestCase):
)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "decide-flag",
"$feature_flag_response": "decide-variant",
"locally_evaluated": False,
@@ -2871,9 +2871,9 @@ class TestCaptureCalls(unittest.TestCase):
)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "decide-flag-with-payload",
"$feature_flag_response": True,
"locally_evaluated": False,
@@ -2948,7 +2948,9 @@ class TestCaptureCalls(unittest.TestCase):
"featureFlags": {"person-flag": True},
"featureFlagPayloads": {"person-flag": 300},
}
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client = Client(
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
)
client.feature_flags = [
{
@@ -2977,9 +2979,9 @@ class TestCaptureCalls(unittest.TestCase):
# Assert that capture was called once, with the correct parameters
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
@@ -3012,9 +3014,9 @@ class TestCaptureCalls(unittest.TestCase):
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id2",
"$feature_flag_called",
{
distinct_id="some-distinct-id2",
properties={
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
@@ -3058,9 +3060,9 @@ class TestCaptureCalls(unittest.TestCase):
)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
distinct_id="some-distinct-id",
properties={
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
@@ -3102,9 +3104,9 @@ class TestCaptureCalls(unittest.TestCase):
person_properties={"region": "USA", "name": "Aloha"},
)
patch_capture.assert_called_with(
distinct_id,
"$feature_flag_called",
{
distinct_id=distinct_id,
properties={
"$feature_flag": "complex-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
@@ -5229,7 +5231,9 @@ class TestConsistency(unittest.TestCase):
"featureFlags": {}
} # Ensure decide returns empty flags
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client = Client(
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
)
client.feature_flags = [
{
"id": 1,
@@ -5253,7 +5257,9 @@ class TestConsistency(unittest.TestCase):
"featureFlagPayloads": {"Beta-Feature": {"some": "value"}},
}
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client = Client(
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
)
client.feature_flags = [
{
"id": 1,
@@ -5282,7 +5288,9 @@ class TestConsistency(unittest.TestCase):
"featureFlagPayloads": {"Beta-Feature": {"some": "value"}},
}
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client = Client(
project_api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY
)
client.feature_flags = [
{
"id": 1,
+2 -12
View File
@@ -7,8 +7,7 @@ class TestModule(unittest.TestCase):
posthog = None
def _assert_enqueue_result(self, result):
self.assertEqual(type(result[0]), bool)
self.assertEqual(type(result[1]), dict)
self.assertEqual(type(result[0]), str)
def failed(self):
self.failed = True
@@ -28,12 +27,7 @@ class TestModule(unittest.TestCase):
self.assertRaises(Exception, self.posthog.capture)
def test_track(self):
res = self.posthog.capture("distinct_id", "python module event")
self._assert_enqueue_result(res)
self.posthog.flush()
def test_identify(self):
res = self.posthog.identify("distinct_id", {"email": "user@email.com"})
res = self.posthog.capture("python module event", distinct_id="distinct_id")
self._assert_enqueue_result(res)
self.posthog.flush()
@@ -42,9 +36,5 @@ class TestModule(unittest.TestCase):
self._assert_enqueue_result(res)
self.posthog.flush()
def test_page(self):
self.posthog.page("distinct_id", "https://posthog.com/contact")
self.posthog.flush()
def test_flush(self):
self.posthog.flush()
+123
View File
@@ -1,3 +1,4 @@
import time
import unittest
from dataclasses import dataclass
from datetime import date, datetime, timedelta
@@ -12,6 +13,7 @@ from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from posthog import utils
from posthog.types import FeatureFlagResult
TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4"
FAKE_TEST_API_KEY = "random_key"
@@ -173,3 +175,124 @@ class TestUtils(unittest.TestCase):
"inner_optional": None,
},
}
class TestFlagCache(unittest.TestCase):
def setUp(self):
self.cache = utils.FlagCache(max_size=3, default_ttl=1)
self.flag_result = FeatureFlagResult.from_value_and_payload(
"test-flag", True, None
)
def test_cache_basic_operations(self):
distinct_id = "user123"
flag_key = "test-flag"
flag_version = 1
# Test cache miss
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
assert result is None
# Test cache set and hit
self.cache.set_cached_flag(
distinct_id, flag_key, self.flag_result, flag_version
)
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
assert result is not None
assert result.get_value()
def test_cache_ttl_expiration(self):
distinct_id = "user123"
flag_key = "test-flag"
flag_version = 1
# Set flag in cache
self.cache.set_cached_flag(
distinct_id, flag_key, self.flag_result, flag_version
)
# Should be available immediately
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
assert result is not None
# Wait for TTL to expire (1 second + buffer)
time.sleep(1.1)
# Should be expired
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
assert result is None
def test_cache_version_invalidation(self):
distinct_id = "user123"
flag_key = "test-flag"
old_version = 1
new_version = 2
# Set flag with old version
self.cache.set_cached_flag(distinct_id, flag_key, self.flag_result, old_version)
# Should hit with old version
result = self.cache.get_cached_flag(distinct_id, flag_key, old_version)
assert result is not None
# Should miss with new version
result = self.cache.get_cached_flag(distinct_id, flag_key, new_version)
assert result is None
# Invalidate old version
self.cache.invalidate_version(old_version)
# Should miss even with old version after invalidation
result = self.cache.get_cached_flag(distinct_id, flag_key, old_version)
assert result is None
def test_stale_cache_functionality(self):
distinct_id = "user123"
flag_key = "test-flag"
flag_version = 1
# Set flag in cache
self.cache.set_cached_flag(
distinct_id, flag_key, self.flag_result, flag_version
)
# Wait for TTL to expire
time.sleep(1.1)
# Should not get fresh cache
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
assert result is None
# Should get stale cache (within 1 hour default)
stale_result = self.cache.get_stale_cached_flag(distinct_id, flag_key)
assert stale_result is not None
assert stale_result.get_value()
def test_lru_eviction(self):
# Cache has max_size=3, so adding 4 users should evict the LRU one
flag_version = 1
# Add 3 users
for i in range(3):
user_id = f"user{i}"
self.cache.set_cached_flag(
user_id, "test-flag", self.flag_result, flag_version
)
# Access user0 to make it recently used
self.cache.get_cached_flag("user0", "test-flag", flag_version)
# Add 4th user, should evict user1 (least recently used)
self.cache.set_cached_flag("user3", "test-flag", self.flag_result, flag_version)
# user0 should still be there (was recently accessed)
result = self.cache.get_cached_flag("user0", "test-flag", flag_version)
assert result is not None
# user2 should still be there (was recently added)
result = self.cache.get_cached_flag("user2", "test-flag", flag_version)
assert result is not None
# user3 should be there (just added)
result = self.cache.get_cached_flag("user3", "test-flag", flag_version)
assert result is not None
+319
View File
@@ -1,12 +1,17 @@
import json
import logging
import numbers
import re
import time
from collections import defaultdict
from dataclasses import asdict, is_dataclass
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Any, Optional
from uuid import UUID
import sys
import platform
import distro # For Linux OS detection
import six
from dateutil.tz import tzlocal, tzutc
@@ -154,6 +159,266 @@ class SizeLimitedDict(defaultdict):
super().__setitem__(key, value)
class FlagCacheEntry:
def __init__(self, flag_result, flag_definition_version, timestamp=None):
self.flag_result = flag_result
self.flag_definition_version = flag_definition_version
self.timestamp = timestamp or time.time()
def is_valid(self, current_time, ttl, current_flag_version):
time_valid = (current_time - self.timestamp) < ttl
version_valid = self.flag_definition_version == current_flag_version
return time_valid and version_valid
def is_stale_but_usable(self, current_time, max_stale_age=3600):
return (current_time - self.timestamp) < max_stale_age
class FlagCache:
def __init__(self, max_size=10000, default_ttl=300):
self.cache = {} # distinct_id -> {flag_key: FlagCacheEntry}
self.access_times = {} # distinct_id -> last_access_time
self.max_size = max_size
self.default_ttl = default_ttl
def get_cached_flag(self, distinct_id, flag_key, current_flag_version):
current_time = time.time()
if distinct_id not in self.cache:
return None
user_flags = self.cache[distinct_id]
if flag_key not in user_flags:
return None
entry = user_flags[flag_key]
if entry.is_valid(current_time, self.default_ttl, current_flag_version):
self.access_times[distinct_id] = current_time
return entry.flag_result
return None
def get_stale_cached_flag(self, distinct_id, flag_key, max_stale_age=3600):
current_time = time.time()
if distinct_id not in self.cache:
return None
user_flags = self.cache[distinct_id]
if flag_key not in user_flags:
return None
entry = user_flags[flag_key]
if entry.is_stale_but_usable(current_time, max_stale_age):
return entry.flag_result
return None
def set_cached_flag(
self, distinct_id, flag_key, flag_result, flag_definition_version
):
current_time = time.time()
# Evict LRU users if we're at capacity
if distinct_id not in self.cache and len(self.cache) >= self.max_size:
self._evict_lru()
# Initialize user cache if needed
if distinct_id not in self.cache:
self.cache[distinct_id] = {}
# Store the flag result
self.cache[distinct_id][flag_key] = FlagCacheEntry(
flag_result, flag_definition_version, current_time
)
self.access_times[distinct_id] = current_time
def invalidate_version(self, old_version):
users_to_remove = []
for distinct_id, user_flags in self.cache.items():
flags_to_remove = []
for flag_key, entry in user_flags.items():
if entry.flag_definition_version == old_version:
flags_to_remove.append(flag_key)
# Remove invalidated flags
for flag_key in flags_to_remove:
del user_flags[flag_key]
# Remove user entirely if no flags remain
if not user_flags:
users_to_remove.append(distinct_id)
# Clean up empty users
for distinct_id in users_to_remove:
del self.cache[distinct_id]
if distinct_id in self.access_times:
del self.access_times[distinct_id]
def _evict_lru(self):
if not self.access_times:
return
# Remove 20% of least recently used entries
sorted_users = sorted(self.access_times.items(), key=lambda x: x[1])
to_remove = max(1, len(sorted_users) // 5)
for distinct_id, _ in sorted_users[:to_remove]:
if distinct_id in self.cache:
del self.cache[distinct_id]
if distinct_id in self.access_times:
del self.access_times[distinct_id]
def clear(self):
self.cache.clear()
self.access_times.clear()
class RedisFlagCache:
def __init__(
self, redis_client, default_ttl=300, stale_ttl=3600, key_prefix="posthog:flags:"
):
self.redis = redis_client
self.default_ttl = default_ttl
self.stale_ttl = stale_ttl
self.key_prefix = key_prefix
self.version_key = f"{key_prefix}version"
def _get_cache_key(self, distinct_id, flag_key):
return f"{self.key_prefix}{distinct_id}:{flag_key}"
def _serialize_entry(self, flag_result, flag_definition_version, timestamp=None):
if timestamp is None:
timestamp = time.time()
# Use clean to make flag_result JSON-serializable for cross-platform compatibility
serialized_result = clean(flag_result)
entry = {
"flag_result": serialized_result,
"flag_version": flag_definition_version,
"timestamp": timestamp,
}
return json.dumps(entry)
def _deserialize_entry(self, data):
try:
entry = json.loads(data)
flag_result = entry["flag_result"]
return FlagCacheEntry(
flag_result=flag_result,
flag_definition_version=entry["flag_version"],
timestamp=entry["timestamp"],
)
except (json.JSONDecodeError, KeyError, ValueError):
# If deserialization fails, treat as cache miss
return None
def get_cached_flag(self, distinct_id, flag_key, current_flag_version):
try:
cache_key = self._get_cache_key(distinct_id, flag_key)
data = self.redis.get(cache_key)
if data:
entry = self._deserialize_entry(data)
if entry and entry.is_valid(
time.time(), self.default_ttl, current_flag_version
):
return entry.flag_result
return None
except Exception:
# Redis error - return None to fall back to normal evaluation
return None
def get_stale_cached_flag(self, distinct_id, flag_key, max_stale_age=None):
try:
if max_stale_age is None:
max_stale_age = self.stale_ttl
cache_key = self._get_cache_key(distinct_id, flag_key)
data = self.redis.get(cache_key)
if data:
entry = self._deserialize_entry(data)
if entry and entry.is_stale_but_usable(time.time(), max_stale_age):
return entry.flag_result
return None
except Exception:
# Redis error - return None
return None
def set_cached_flag(
self, distinct_id, flag_key, flag_result, flag_definition_version
):
try:
cache_key = self._get_cache_key(distinct_id, flag_key)
serialized_entry = self._serialize_entry(
flag_result, flag_definition_version
)
# Set with TTL for automatic cleanup (use stale_ttl for total lifetime)
self.redis.setex(cache_key, self.stale_ttl, serialized_entry)
# Update the current version
self.redis.set(self.version_key, flag_definition_version)
except Exception:
# Redis error - silently fail, don't break flag evaluation
pass
def invalidate_version(self, old_version):
try:
# For Redis, we use a simple approach: scan for keys with old version
# and delete them. This could be expensive with many keys, but it's
# necessary for correctness.
cursor = 0
pattern = f"{self.key_prefix}*"
while True:
cursor, keys = self.redis.scan(cursor, match=pattern, count=100)
for key in keys:
if key.decode() == self.version_key:
continue
try:
data = self.redis.get(key)
if data:
entry_dict = json.loads(data)
if entry_dict.get("flag_version") == old_version:
self.redis.delete(key)
except (json.JSONDecodeError, KeyError):
# If we can't parse the entry, delete it to be safe
self.redis.delete(key)
if cursor == 0:
break
except Exception:
# Redis error - silently fail
pass
def clear(self):
try:
# Delete all keys matching our pattern
cursor = 0
pattern = f"{self.key_prefix}*"
while True:
cursor, keys = self.redis.scan(cursor, match=pattern, count=100)
if keys:
self.redis.delete(*keys)
if cursor == 0:
break
except Exception:
# Redis error - silently fail
pass
def convert_to_datetime_aware(date_obj):
if date_obj.tzinfo is None:
date_obj = date_obj.replace(tzinfo=timezone.utc)
@@ -198,3 +463,57 @@ def str_iequals(value, comparand):
False
"""
return str(value).casefold() == str(comparand).casefold()
def get_os_info():
"""
Returns standardized OS name and version information.
Similar to how user agent parsing works in JS.
"""
os_name = ""
os_version = ""
platform_name = sys.platform
if platform_name.startswith("win"):
os_name = "Windows"
if hasattr(platform, "win32_ver"):
win_version = platform.win32_ver()[0]
if win_version:
os_version = win_version
elif platform_name == "darwin":
os_name = "Mac OS X"
if hasattr(platform, "mac_ver"):
mac_version = platform.mac_ver()[0]
if mac_version:
os_version = mac_version
elif platform_name.startswith("linux"):
os_name = "Linux"
linux_info = distro.info()
if linux_info["version"]:
os_version = linux_info["version"]
elif platform_name.startswith("freebsd"):
os_name = "FreeBSD"
if hasattr(platform, "release"):
os_version = platform.release()
else:
os_name = platform_name
if hasattr(platform, "release"):
os_version = platform.release()
return os_name, os_version
def system_context() -> dict[str, Any]:
os_name, os_version = get_os_info()
return {
"$python_runtime": platform.python_implementation(),
"$python_version": "%s.%s.%s" % (sys.version_info[:3]),
"$os": os_name,
"$os_version": os_version,
}
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "5.2.0"
VERSION = "6.0.3"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
+1 -1
View File
@@ -29,6 +29,7 @@ dependencies = [
"python-dateutil>=2.2",
"backoff>=1.10.0",
"distro>=1.5.0",
"typing-extensions>=4.2.0",
]
[project.urls]
@@ -86,7 +87,6 @@ packages = [
"posthog.ai.anthropic",
"posthog.ai.gemini",
"posthog.test",
"posthog.exception_integrations",
"posthog.integrations",
]
-112
View File
@@ -1,112 +0,0 @@
import argparse
import json
import logging
import posthog
__name__ = "simulator.py"
__version__ = "0.0.1"
__description__ = "scripting simulator"
def json_hash(str):
if str:
return json.loads(str)
# posthog -method=<method> -posthog-write-key=<posthogWriteKey> [options]
parser = argparse.ArgumentParser(description="send a posthog message")
parser.add_argument("--writeKey", help="the posthog writeKey")
parser.add_argument("--type", help="The posthog message type")
parser.add_argument("--distinct_id", help="the user id to send the event as")
parser.add_argument("--anonymousId", help="the anonymous user id to send the event as")
parser.add_argument("--event", help="the event name to send with the event")
parser.add_argument("--properties", help="the event properties to send (JSON-encoded)")
parser.add_argument(
"--name", help="name of the screen or page to send with the message"
)
parser.add_argument("--traits", help="the identify/group traits to send (JSON-encoded)")
parser.add_argument("--groupId", help="the group id")
options = parser.parse_args()
def failed(status, msg):
raise Exception(msg)
def capture():
posthog.capture(
options.distinct_id,
options.event,
anonymous_id=options.anonymousId,
properties=json_hash(options.properties),
)
def page():
posthog.page(
options.distinct_id,
name=options.name,
anonymous_id=options.anonymousId,
properties=json_hash(options.properties),
)
def identify():
posthog.identify(
options.distinct_id,
anonymous_id=options.anonymousId,
traits=json_hash(options.traits),
)
def set_once():
posthog.set_once(
options.distinct_id,
properties=json_hash(options.traits),
)
def set():
posthog.set(
options.distinct_id,
properties=json_hash(options.traits),
)
def unknown():
print()
posthog.api_key = options.writeKey
posthog.on_error = failed
posthog.debug = True
log = logging.getLogger("posthog")
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
log.addHandler(ch)
switcher = {
"capture": capture,
"page": page,
"identify": identify,
"set_once": set_once,
"set": set,
}
func = switcher.get(options.type)
if func:
func()
posthog.shutdown()
else:
print("Invalid Message Type " + options.type)
Generated
+2106 -2185
View File
File diff suppressed because it is too large Load Diff