Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4739945a82 | ||
|
|
50ab10c858 | ||
|
|
37bd30194e | ||
|
|
b41dc8568e | ||
|
|
f0e1cdf870 | ||
|
|
e23ca94296 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
"""
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
VERSION = "5.3.0"
|
||||
VERSION = "6.0.2"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
+1
-1
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user