Compare commits

...
6 Commits
Author SHA1 Message Date
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
29 changed files with 3924 additions and 4041 deletions
+37
View File
@@ -1,3 +1,40 @@
# 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
+3 -1
View File
@@ -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"})
# #############################################################################
+14 -7
View File
@@ -22,13 +22,20 @@ 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]
+98 -247
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')
# Capture an event associated with some other user (overriding the context-level distinct ID)
posthog.capture('movie joined', distinct_id='some-other-user')
# 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'})
# Adding a tag to the current context will cause it to appear on all subsequent events
posthog.tag_context('some-tag', 'some-value')
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(
"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,
)
return _proxy("capture", event, **kwargs)
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
For example:
```python
posthog.identify('distinct id', {
'email': 'dwayne@gmail.com',
'name': 'Dwayne Johnson'
})
```
"""
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,
)
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`.
This will not overwrite previous people property values, unlike `set`.
A `set_once` call requires
- `distinct id` which uniquely identifies your user
- `properties` with a dict with any key: value pairs
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.
`capture_exception` takes the same set of optional arguments as `capture`.
"""
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(
"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]
+144 -314
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,13 @@ from posthog.types import (
to_payloads,
to_values,
)
from posthog.utils import SizeLimitedDict, clean, guess_timezone, remove_trailing_slash
from posthog.utils import (
SizeLimitedDict,
clean,
guess_timezone,
remove_trailing_slash,
system_context,
)
from posthog.version import VERSION
try:
@@ -59,62 +64,34 @@ 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 = ""
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)
platform_name = sys.platform
context_id = get_context_distinct_id()
if context_id:
return (context_id, False)
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
return (str(uuid4()), True)
def system_context() -> dict[str, Any]:
os_name, os_version = get_os_info()
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
return {
"$python_runtime": platform.python_implementation(),
"$python_version": "%s.%s.%s" % (sys.version_info[:3]),
"$os": os_name,
"$os_version": os_version,
}
if "$session_id" not in properties and get_context_session_id():
properties["$session_id"] = get_context_session_id()
return properties
class Client(object):
@@ -124,7 +101,7 @@ class Client(object):
def __init__(
self,
api_key=None,
project_api_key: str,
host=None,
debug=False,
max_queue_size=10000,
@@ -139,7 +116,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,7 +123,6 @@ 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,
@@ -155,9 +130,7 @@ class Client(object):
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
@@ -184,7 +157,6 @@ class Client(object):
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 +188,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 +221,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 +248,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 +298,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 +310,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 +335,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()
properties = add_context_tags(properties)
if distinct_id is None:
distinct_id = get_context_distinct_id()
(distinct_id, personless) = get_identity_state(distinct_id)
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
require("event", event, string_types)
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 +363,6 @@ class Client(object):
}
if groups:
require("groups", groups, dict)
msg["properties"]["$groups"] = groups
extra_properties: dict[str, Any] = {}
@@ -486,28 +402,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 +428,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,
)
if distinct_id is None:
distinct_id = get_context_distinct_id()
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 {}
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_once() does nothing
msg = {
"timestamp": timestamp,
@@ -554,30 +455,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 +480,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,
)
(distinct_id, personless) = get_identity_state(distinct_id)
if distinct_id is None:
distinct_id = get_context_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 +507,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 +532,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 +541,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 +570,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 +641,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 +651,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 +664,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"""
@@ -1006,21 +850,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
@@ -1146,7 +986,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 +1050,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 +1069,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 +1112,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 +1232,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()
@@ -1463,13 +1300,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)
+84 -167
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
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
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,
)
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)
@@ -451,7 +409,6 @@ 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]
@@ -499,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)
]
@@ -573,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
@@ -589,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,
@@ -617,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__",
@@ -638,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__",
@@ -653,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,
@@ -666,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]]
@@ -681,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,
@@ -691,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()
@@ -783,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,
@@ -796,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):
@@ -865,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
+118 -71
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"})
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.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")
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
success, msg = client.capture("user1", "test_drop_me")
self.assertTrue(success)
self.assertIsNone(msg)
# Event should be dropped
msg_uuid = client.capture("test_drop_me", distinct_id="user1")
self.assertIsNone(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")
# Event should go through
msg_uuid = client.capture("keep_me", distinct_id="user1")
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"], "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
)
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
success, msg = client.capture("user1", "event")
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Test capture
msg_uuid = client.capture("event", distinct_id="user1")
self.assertIsNotNone(msg_uuid)
# Test identify
success, msg = client.identify("user1", {"trait": "value"})
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Test set
msg_uuid = client.set(distinct_id="user1", properties={"prop": "value"})
self.assertIsNotNone(msg_uuid)
# 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"])
# 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")
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)
self.assertTrue(success)
self.assertIsNotNone(msg)
self.assertEqual(msg["event"], "normal_event")
# 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",
},
)
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.assertTrue(success)
self.assertEqual(msg["properties"]["email"], "***@example.com")
self.assertNotIn("credit_card", msg["properties"])
self.assertEqual(msg["properties"]["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")
+1076 -570
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()
+57
View File
@@ -7,6 +7,9 @@ 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
@@ -198,3 +201,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.3.0"
VERSION = "6.0.2"
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
+2080 -2151
View File
File diff suppressed because it is too large Load Diff