Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39070babfb | ||
|
|
716eab0bc2 | ||
|
|
1c0a61d6b5 | ||
|
|
ffa35fa5cd | ||
|
|
24b7b918f7 | ||
|
|
16cbd10f1b | ||
|
|
b83d544931 | ||
|
|
72c0ed1935 | ||
|
|
5fdd6177ee | ||
|
|
fc1da7d589 | ||
|
|
cba6e86537 | ||
|
|
4e45255207 | ||
|
|
bc37351ab4 | ||
|
|
a5e8b7d7fb | ||
|
|
efb0ccf3c7 | ||
|
|
8554b51a48 | ||
|
|
d0d962a8ba | ||
|
|
e348106094 | ||
|
|
e60d52c199 | ||
|
|
a2c73d0536 | ||
|
|
33ba5d6843 | ||
|
|
3515c40483 | ||
|
|
139258cacb |
@@ -62,4 +62,4 @@ jobs:
|
||||
|
||||
- name: Run posthog tests
|
||||
run: |
|
||||
python setup.py test
|
||||
pytest --verbose --timeout=30
|
||||
|
||||
+79
-1
@@ -1,3 +1,81 @@
|
||||
## 3.6.4 - 2024-09-05
|
||||
|
||||
1. Add manual exception capture.
|
||||
|
||||
## 3.6.3 - 2024-09-03
|
||||
|
||||
1. Make sure setup.py for posthoganalytics package also discovers the new exception integration package.
|
||||
|
||||
## 3.6.2 - 2024-09-03
|
||||
|
||||
1. Make sure setup.py discovers the new exception integration package.
|
||||
|
||||
## 3.6.1 - 2024-09-03
|
||||
|
||||
1. Adds django integration to exception autocapture in alpha state. This feature is not yet stable and may change in future versions.
|
||||
|
||||
## 3.6.0 - 2024-08-28
|
||||
|
||||
1. Adds exception autocapture in alpha state. This feature is not yet stable and may change in future versions.
|
||||
|
||||
## 3.5.2 - 2024-08-21
|
||||
|
||||
1. Guard for None values in local evaluation
|
||||
|
||||
## 3.5.1 - 2024-08-13
|
||||
|
||||
1. Remove "-api" suffix from ingestion hostnames
|
||||
|
||||
## 3.5.0 - 2024-02-29
|
||||
|
||||
1. - Adds a new `feature_flags_request_timeout_seconds` timeout parameter for feature flags which defaults to 3 seconds, updated from the default 10s for all other API calls.
|
||||
|
||||
## 3.4.2 - 2024-02-20
|
||||
|
||||
1. Add `historical_migration` option for bulk migration to PostHog Cloud.
|
||||
|
||||
## 3.4.1 - 2024-02-09
|
||||
|
||||
1. Use new hosts for event capture as well
|
||||
|
||||
## 3.4.0 - 2024-02-05
|
||||
|
||||
1. Point given hosts to new ingestion hosts
|
||||
|
||||
## 3.3.4 - 2024-01-30
|
||||
|
||||
1. Update type hints for module variables to work with newer versions of mypy
|
||||
|
||||
## 3.3.3 - 2024-01-26
|
||||
|
||||
1. Remove new relative date operators, combine into regular date operators
|
||||
|
||||
## 3.3.2 - 2024-01-19
|
||||
|
||||
1. Return success/failure with all capture calls from module functions
|
||||
|
||||
|
||||
## 3.3.1 - 2024-01-10
|
||||
|
||||
1. Make sure we don't override any existing feature flag properties when adding locally evaluated feature flag properties.
|
||||
|
||||
## 3.3.0 - 2024-01-09
|
||||
|
||||
1. When local evaluation is enabled, we automatically add flag information to all events sent to PostHog, whenever possible. This makes it easier to use these events in experiments.
|
||||
|
||||
## 3.2.0 - 2024-01-09
|
||||
|
||||
1. Numeric property handling for feature flags now does the expected: When passed in a number, we do a numeric comparison. When passed in a string, we do a string comparison. Previously, we always did a string comparison.
|
||||
2. Add support for relative date operators for local evaluation.
|
||||
|
||||
## 3.1.0 - 2023-12-04
|
||||
|
||||
1. Increase maximum event size and batch size
|
||||
|
||||
## 3.0.2 - 2023-08-17
|
||||
|
||||
1. Returns the current flag property with $feature_flag_called events, to make it easier to use in experiments
|
||||
|
||||
## 3.0.1 - 2023-04-21
|
||||
|
||||
1. Restore how feature flags work when the client library is disabled: All requests return `None` and no events are sent when the client is disabled.
|
||||
@@ -81,7 +159,7 @@ Changes:
|
||||
Breaking changes:
|
||||
|
||||
1. The minimum version requirement for PostHog servers is now 1.38. If you're using PostHog Cloud, you satisfy this requirement automatically.
|
||||
2. Feature flag defaults apply only when there's an error fetching feature flag results. Earlier, if the default was set to `True`, even if a flag resolved to `False`, the default would override this.
|
||||
2. Feature flag defaults apply only when there's an error fetching feature flag results. Earlier, if the default was set to `True`, even if a flag resolved to `False`, the default would override this.
|
||||
**Note: These are removed in 2.0.2**
|
||||
3. Feature flag remote evaluation doesn't require a personal API key.
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@PostHog/team-feature-success
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2020 PostHog (part of Hiberly Inc)
|
||||
Copyright (c) 2023 PostHog (part of Hiberly Inc)
|
||||
|
||||
Copyright (c) 2013 Segment Inc. friends@segment.com
|
||||
|
||||
|
||||
+79
-19
@@ -1,25 +1,30 @@
|
||||
import datetime # noqa: F401
|
||||
from typing import Callable, Dict, Optional # noqa: F401
|
||||
from typing import Callable, Dict, List, Optional, Tuple # noqa: F401
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.exception_capture import DEFAULT_DISTINCT_ID, Integrations # noqa: F401
|
||||
from posthog.version import VERSION
|
||||
|
||||
__version__ = VERSION
|
||||
|
||||
"""Settings."""
|
||||
api_key = None # type: str
|
||||
host = None # type: str
|
||||
on_error = None # type: Callable
|
||||
api_key = None # type: Optional[str]
|
||||
host = None # type: Optional[str]
|
||||
on_error = None # type: Optional[Callable]
|
||||
debug = False # type: bool
|
||||
send = True # type: bool
|
||||
sync_mode = False # type: bool
|
||||
disabled = False # type: bool
|
||||
personal_api_key = None # type: str
|
||||
project_api_key = None # type: str
|
||||
personal_api_key = None # type: Optional[str]
|
||||
project_api_key = None # type: Optional[str]
|
||||
poll_interval = 30 # type: int
|
||||
disable_geoip = True # type: bool
|
||||
feature_flags_request_timeout_seconds = 3 # type: int
|
||||
# Currently alpha, use at your own risk
|
||||
enable_exception_autocapture = False # type: bool
|
||||
exception_autocapture_integrations = [] # type: List[Integrations]
|
||||
|
||||
default_client = None
|
||||
default_client = None # type: Optional[Client]
|
||||
|
||||
|
||||
def capture(
|
||||
@@ -33,7 +38,7 @@ def capture(
|
||||
send_feature_flags=False,
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> None
|
||||
# type: (...) -> Tuple[bool, dict]
|
||||
"""
|
||||
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.
|
||||
|
||||
@@ -54,7 +59,7 @@ def capture(
|
||||
posthog.capture('distinct id', 'purchase', groups={'company': 'id:5'})
|
||||
```
|
||||
"""
|
||||
_proxy(
|
||||
return _proxy(
|
||||
"capture",
|
||||
distinct_id=distinct_id,
|
||||
event=event,
|
||||
@@ -76,7 +81,7 @@ def identify(
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> None
|
||||
# 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.
|
||||
|
||||
@@ -92,7 +97,7 @@ def identify(
|
||||
})
|
||||
```
|
||||
"""
|
||||
_proxy(
|
||||
return _proxy(
|
||||
"identify",
|
||||
distinct_id=distinct_id,
|
||||
properties=properties,
|
||||
@@ -111,7 +116,7 @@ def set(
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> None
|
||||
# type: (...) -> Tuple[bool, dict]
|
||||
"""
|
||||
Set properties on a user record.
|
||||
This will overwrite previous people property values, just like `identify`.
|
||||
@@ -127,7 +132,7 @@ def set(
|
||||
})
|
||||
```
|
||||
"""
|
||||
_proxy(
|
||||
return _proxy(
|
||||
"set",
|
||||
distinct_id=distinct_id,
|
||||
properties=properties,
|
||||
@@ -146,7 +151,7 @@ def set_once(
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> None
|
||||
# type: (...) -> Tuple[bool, dict]
|
||||
"""
|
||||
Set properties on a user record, only if they do not yet exist.
|
||||
This will not overwrite previous people property values, unlike `identify`.
|
||||
@@ -162,7 +167,7 @@ def set_once(
|
||||
})
|
||||
```
|
||||
"""
|
||||
_proxy(
|
||||
return _proxy(
|
||||
"set_once",
|
||||
distinct_id=distinct_id,
|
||||
properties=properties,
|
||||
@@ -182,7 +187,7 @@ def group_identify(
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> None
|
||||
# type: (...) -> Tuple[bool, dict]
|
||||
"""
|
||||
Set properties on a group
|
||||
|
||||
@@ -198,7 +203,7 @@ def group_identify(
|
||||
})
|
||||
```
|
||||
"""
|
||||
_proxy(
|
||||
return _proxy(
|
||||
"group_identify",
|
||||
group_type=group_type,
|
||||
group_key=group_key,
|
||||
@@ -218,7 +223,7 @@ def alias(
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> None
|
||||
# type: (...) -> Tuple[bool, dict]
|
||||
"""
|
||||
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?"
|
||||
|
||||
@@ -235,7 +240,7 @@ def alias(
|
||||
posthog.alias('anonymous session id', 'distinct id')
|
||||
```
|
||||
"""
|
||||
_proxy(
|
||||
return _proxy(
|
||||
"alias",
|
||||
previous_id=previous_id,
|
||||
distinct_id=distinct_id,
|
||||
@@ -246,6 +251,50 @@ 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]
|
||||
):
|
||||
# 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.
|
||||
|
||||
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
|
||||
- `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
|
||||
|
||||
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')
|
||||
|
||||
```
|
||||
"""
|
||||
return _proxy(
|
||||
"capture_exception",
|
||||
exception=exception,
|
||||
distinct_id=distinct_id or DEFAULT_DISTINCT_ID,
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
groups=groups,
|
||||
)
|
||||
|
||||
|
||||
def feature_enabled(
|
||||
key, # type: str
|
||||
distinct_id, # type: str
|
||||
@@ -405,6 +454,11 @@ def feature_flag_definitions():
|
||||
return _proxy("feature_flag_definitions")
|
||||
|
||||
|
||||
def load_feature_flags():
|
||||
"""Load feature flag definitions from PostHog."""
|
||||
return _proxy("load_feature_flags")
|
||||
|
||||
|
||||
def page(*args, **kwargs):
|
||||
"""Send a page call."""
|
||||
_proxy("page", *args, **kwargs)
|
||||
@@ -447,6 +501,12 @@ def _proxy(method, *args, **kwargs):
|
||||
poll_interval=poll_interval,
|
||||
disabled=disabled,
|
||||
disable_geoip=disable_geoip,
|
||||
feature_flags_request_timeout_seconds=feature_flags_request_timeout_seconds,
|
||||
# TODO: Currently this monitoring begins only when the Client is initialised (which happens when you do something with the SDK)
|
||||
# This kind of initialisation is very annoying for exception capture. We need to figure out a way around this,
|
||||
# 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,
|
||||
exception_autocapture_integrations=exception_autocapture_integrations,
|
||||
)
|
||||
|
||||
# always set incase user changes it
|
||||
|
||||
+148
-26
@@ -1,6 +1,7 @@
|
||||
import atexit
|
||||
import logging
|
||||
import numbers
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
@@ -8,10 +9,12 @@ from dateutil.tz import tzutc
|
||||
from six import string_types
|
||||
|
||||
from posthog.consumer import Consumer
|
||||
from posthog.exception_capture import DEFAULT_DISTINCT_ID, ExceptionCapture
|
||||
from posthog.exception_utils import exc_info_from_error, exceptions_from_error_tuple, handle_in_app
|
||||
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
|
||||
from posthog.poller import Poller
|
||||
from posthog.request import APIError, batch_post, decide, get
|
||||
from posthog.utils import SizeLimitedDict, clean, guess_timezone
|
||||
from posthog.request import DEFAULT_HOST, APIError, batch_post, decide, determine_server_host, get
|
||||
from posthog.utils import SizeLimitedDict, clean, guess_timezone, remove_trailing_slash
|
||||
from posthog.version import VERSION
|
||||
|
||||
try:
|
||||
@@ -49,6 +52,10 @@ class Client(object):
|
||||
project_api_key=None,
|
||||
disabled=False,
|
||||
disable_geoip=True,
|
||||
historical_migration=False,
|
||||
feature_flags_request_timeout_seconds=3,
|
||||
enable_exception_autocapture=False,
|
||||
exception_autocapture_integrations=None,
|
||||
):
|
||||
self.queue = queue.Queue(max_queue_size)
|
||||
|
||||
@@ -61,7 +68,9 @@ class Client(object):
|
||||
self.debug = debug
|
||||
self.send = send
|
||||
self.sync_mode = sync_mode
|
||||
self.host = host
|
||||
# Used for session replay URL generation - we don't want the server host here.
|
||||
self.raw_host = host or DEFAULT_HOST
|
||||
self.host = determine_server_host(host)
|
||||
self.gzip = gzip
|
||||
self.timeout = timeout
|
||||
self.feature_flags = None
|
||||
@@ -69,10 +78,15 @@ class Client(object):
|
||||
self.group_type_mapping = None
|
||||
self.cohorts = None
|
||||
self.poll_interval = poll_interval
|
||||
self.feature_flags_request_timeout_seconds = feature_flags_request_timeout_seconds
|
||||
self.poller = None
|
||||
self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
|
||||
self.disabled = disabled
|
||||
self.disable_geoip = disable_geoip
|
||||
self.historical_migration = historical_migration
|
||||
self.enable_exception_autocapture = enable_exception_autocapture
|
||||
self.exception_autocapture_integrations = exception_autocapture_integrations
|
||||
self.exception_capture = None
|
||||
|
||||
# personal_api_key: This should be a generated Personal API Key, private
|
||||
self.personal_api_key = personal_api_key
|
||||
@@ -84,6 +98,9 @@ class Client(object):
|
||||
else:
|
||||
self.log.setLevel(logging.WARNING)
|
||||
|
||||
if self.enable_exception_autocapture:
|
||||
self.exception_capture = ExceptionCapture(self, integrations=self.exception_autocapture_integrations)
|
||||
|
||||
if sync_mode:
|
||||
self.consumers = None
|
||||
else:
|
||||
@@ -100,13 +117,14 @@ class Client(object):
|
||||
consumer = Consumer(
|
||||
self.queue,
|
||||
self.api_key,
|
||||
host=host,
|
||||
host=self.host,
|
||||
on_error=on_error,
|
||||
flush_at=flush_at,
|
||||
flush_interval=flush_interval,
|
||||
gzip=gzip,
|
||||
retries=max_retries,
|
||||
timeout=timeout,
|
||||
historical_migration=historical_migration,
|
||||
)
|
||||
self.consumers.append(consumer)
|
||||
|
||||
@@ -137,16 +155,6 @@ class Client(object):
|
||||
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
|
||||
return resp_data["featureFlags"]
|
||||
|
||||
def _get_active_feature_variants(
|
||||
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
|
||||
):
|
||||
feature_variants = self.get_feature_variants(
|
||||
distinct_id, groups, person_properties, group_properties, disable_geoip
|
||||
)
|
||||
return {
|
||||
k: v for (k, v) in feature_variants.items() if v is not False
|
||||
} # explicitly test for false to account for values that may seem falsy (ex: 0)
|
||||
|
||||
def get_feature_payloads(
|
||||
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
|
||||
):
|
||||
@@ -171,7 +179,7 @@ class Client(object):
|
||||
"group_properties": group_properties,
|
||||
"disable_geoip": disable_geoip,
|
||||
}
|
||||
resp_data = decide(self.api_key, self.host, timeout=10, **request_data)
|
||||
resp_data = decide(self.api_key, self.host, timeout=self.feature_flags_request_timeout_seconds, **request_data)
|
||||
|
||||
return resp_data
|
||||
|
||||
@@ -206,15 +214,29 @@ class Client(object):
|
||||
require("groups", groups, dict)
|
||||
msg["properties"]["$groups"] = groups
|
||||
|
||||
extra_properties = {}
|
||||
feature_variants = {}
|
||||
if send_feature_flags:
|
||||
try:
|
||||
feature_variants = self._get_active_feature_variants(distinct_id, groups, disable_geoip=disable_geoip)
|
||||
feature_variants = self.get_feature_variants(distinct_id, groups, disable_geoip=disable_geoip)
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get feature variants: {e}")
|
||||
else:
|
||||
for feature, variant in feature_variants.items():
|
||||
msg["properties"]["$feature/{}".format(feature)] = variant
|
||||
msg["properties"]["$active_feature_flags"] = list(feature_variants.keys())
|
||||
|
||||
elif self.feature_flags:
|
||||
# Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
|
||||
feature_variants = self.get_all_flags(
|
||||
distinct_id, groups=(groups or {}), disable_geoip=disable_geoip, only_evaluate_locally=True
|
||||
)
|
||||
|
||||
for feature, variant in feature_variants.items():
|
||||
extra_properties[f"$feature/{feature}"] = variant
|
||||
|
||||
active_feature_flags = [key for (key, value) in feature_variants.items() if value is not False]
|
||||
if active_feature_flags:
|
||||
extra_properties["$active_feature_flags"] = active_feature_flags
|
||||
|
||||
if extra_properties:
|
||||
msg["properties"] = {**extra_properties, **msg["properties"]}
|
||||
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
@@ -325,6 +347,57 @@ class Client(object):
|
||||
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def capture_exception(
|
||||
self,
|
||||
exception=None,
|
||||
distinct_id=DEFAULT_DISTINCT_ID,
|
||||
properties=None,
|
||||
context=None,
|
||||
timestamp=None,
|
||||
uuid=None,
|
||||
groups=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:
|
||||
properties = properties or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
if exception is not None:
|
||||
exc_info = exc_info_from_error(exception)
|
||||
else:
|
||||
exc_info = sys.exc_info()
|
||||
|
||||
if exc_info is None or exc_info == (None, None, None):
|
||||
self.log.warning("No exception information available")
|
||||
return
|
||||
|
||||
# Format stack trace like sentry
|
||||
all_exceptions_with_trace = exceptions_from_error_tuple(exc_info)
|
||||
|
||||
# Add in-app property to frames in the exceptions
|
||||
event = handle_in_app(
|
||||
{
|
||||
"exception": {
|
||||
"values": all_exceptions_with_trace,
|
||||
},
|
||||
}
|
||||
)
|
||||
all_exceptions_with_trace_and_in_app = event["exception"]["values"]
|
||||
|
||||
properties = {
|
||||
"$exception_type": all_exceptions_with_trace_and_in_app[0].get("type"),
|
||||
"$exception_message": all_exceptions_with_trace_and_in_app[0].get("value"),
|
||||
"$exception_list": all_exceptions_with_trace_and_in_app,
|
||||
"$exception_personURL": f"{remove_trailing_slash(self.raw_host)}/project/{self.api_key}/person/{distinct_id}",
|
||||
**properties,
|
||||
}
|
||||
|
||||
return self.capture(distinct_id, "$exception", properties, context, timestamp, uuid, groups)
|
||||
except Exception as e:
|
||||
self.log.exception(f"Failed to capture exception: {e}")
|
||||
|
||||
def _enqueue(self, msg, disable_geoip):
|
||||
"""Push a new `msg` onto the queue, return `(success, msg)`"""
|
||||
|
||||
@@ -370,7 +443,14 @@ class Client(object):
|
||||
|
||||
if self.sync_mode:
|
||||
self.log.debug("enqueued with blocking %s.", msg["event"])
|
||||
batch_post(self.api_key, self.host, gzip=self.gzip, timeout=self.timeout, batch=[msg])
|
||||
batch_post(
|
||||
self.api_key,
|
||||
self.host,
|
||||
gzip=self.gzip,
|
||||
timeout=self.timeout,
|
||||
batch=[msg],
|
||||
historical_migration=self.historical_migration,
|
||||
)
|
||||
|
||||
return True, msg
|
||||
|
||||
@@ -410,6 +490,9 @@ class Client(object):
|
||||
self.flush()
|
||||
self.join()
|
||||
|
||||
if self.exception_capture:
|
||||
self.exception_capture.close()
|
||||
|
||||
def _load_feature_flags(self):
|
||||
try:
|
||||
response = get(
|
||||
@@ -460,7 +543,16 @@ class Client(object):
|
||||
self.poller = Poller(interval=timedelta(seconds=self.poll_interval), execute=self._load_feature_flags)
|
||||
self.poller.start()
|
||||
|
||||
def _compute_flag_locally(self, feature_flag, distinct_id, *, groups={}, person_properties={}, group_properties={}):
|
||||
def _compute_flag_locally(
|
||||
self,
|
||||
feature_flag,
|
||||
distinct_id,
|
||||
*,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
warn_on_unknown_groups=True,
|
||||
):
|
||||
if feature_flag.get("ensure_experience_continuity", False):
|
||||
raise InconclusiveMatchError("Flag has experience continuity enabled")
|
||||
|
||||
@@ -482,9 +574,14 @@ class Client(object):
|
||||
if group_name not in groups:
|
||||
# Group flags are never enabled in `groups` aren't passed in
|
||||
# don't failover to `/decide/`, since response will be the same
|
||||
self.log.warning(
|
||||
f"[FEATURE FLAGS] Can't compute group feature flag: {feature_flag['key']} without group names passed in"
|
||||
)
|
||||
if warn_on_unknown_groups:
|
||||
self.log.warning(
|
||||
f"[FEATURE FLAGS] Can't compute group feature flag: {feature_flag['key']} without group names passed in"
|
||||
)
|
||||
else:
|
||||
self.log.debug(
|
||||
f"[FEATURE FLAGS] Can't compute group feature flag: {feature_flag['key']} without group names passed in"
|
||||
)
|
||||
return False
|
||||
|
||||
focused_group_properties = group_properties[group_name]
|
||||
@@ -538,6 +635,10 @@ class Client(object):
|
||||
if self.disabled:
|
||||
return None
|
||||
|
||||
person_properties, group_properties = self._add_local_person_and_group_properties(
|
||||
distinct_id, groups, person_properties, group_properties
|
||||
)
|
||||
|
||||
if self.feature_flags is None and self.personal_api_key:
|
||||
self.load_feature_flags()
|
||||
response = None
|
||||
@@ -591,6 +692,7 @@ class Client(object):
|
||||
"$feature_flag": key,
|
||||
"$feature_flag_response": response,
|
||||
"locally_evaluated": flag_was_locally_evaluated,
|
||||
f"$feature/{key}": response,
|
||||
},
|
||||
groups=groups,
|
||||
disable_geoip=disable_geoip,
|
||||
@@ -684,6 +786,10 @@ class Client(object):
|
||||
if self.disabled:
|
||||
return {"featureFlags": None, "featureFlagPayloads": None}
|
||||
|
||||
person_properties, group_properties = self._add_local_person_and_group_properties(
|
||||
distinct_id, groups, person_properties, group_properties
|
||||
)
|
||||
|
||||
flags, payloads, fallback_to_decide = self._get_all_flags_and_payloads_locally(
|
||||
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
|
||||
)
|
||||
@@ -704,7 +810,9 @@ class Client(object):
|
||||
|
||||
return response
|
||||
|
||||
def _get_all_flags_and_payloads_locally(self, distinct_id, *, groups={}, person_properties={}, group_properties={}):
|
||||
def _get_all_flags_and_payloads_locally(
|
||||
self, distinct_id, *, groups={}, person_properties={}, group_properties={}, warn_on_unknown_groups=False
|
||||
):
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("groups", groups, dict)
|
||||
|
||||
@@ -724,6 +832,7 @@ class Client(object):
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
warn_on_unknown_groups=warn_on_unknown_groups,
|
||||
)
|
||||
matched_payload = self._compute_payload_locally(flag["key"], flags[flag["key"]])
|
||||
if matched_payload:
|
||||
@@ -742,6 +851,19 @@ class Client(object):
|
||||
def feature_flag_definitions(self):
|
||||
return self.feature_flags
|
||||
|
||||
def _add_local_person_and_group_properties(self, distinct_id, groups, person_properties, group_properties):
|
||||
all_person_properties = {"distinct_id": distinct_id, **(person_properties or {})}
|
||||
|
||||
all_group_properties = {}
|
||||
if groups:
|
||||
for group_name in groups:
|
||||
all_group_properties[group_name] = {
|
||||
"$group_key": groups[group_name],
|
||||
**(group_properties.get(group_name) or {}),
|
||||
}
|
||||
|
||||
return all_person_properties, all_group_properties
|
||||
|
||||
|
||||
def require(name, field, data_type):
|
||||
"""Require that the named `field` has the right `data_type`"""
|
||||
|
||||
+16
-6
@@ -12,11 +12,12 @@ try:
|
||||
except ImportError:
|
||||
from Queue import Empty
|
||||
|
||||
MAX_MSG_SIZE = 32 << 10
|
||||
|
||||
# Our servers only accept batches less than 500KB. Here limit is set slightly
|
||||
# lower to leave space for extra data that will be added later, eg. "sentAt".
|
||||
BATCH_SIZE_LIMIT = 475000
|
||||
MAX_MSG_SIZE = 900 * 1024 # 900KiB per event
|
||||
|
||||
# The maximum request body size is currently 20MiB, let's be conservative
|
||||
# in case we want to lower it in the future.
|
||||
BATCH_SIZE_LIMIT = 5 * 1024 * 1024
|
||||
|
||||
|
||||
class Consumer(Thread):
|
||||
@@ -35,6 +36,7 @@ class Consumer(Thread):
|
||||
gzip=False,
|
||||
retries=10,
|
||||
timeout=15,
|
||||
historical_migration=False,
|
||||
):
|
||||
"""Create a consumer thread."""
|
||||
Thread.__init__(self)
|
||||
@@ -54,6 +56,7 @@ class Consumer(Thread):
|
||||
self.running = True
|
||||
self.retries = retries
|
||||
self.timeout = timeout
|
||||
self.historical_migration = historical_migration
|
||||
|
||||
def run(self):
|
||||
"""Runs the consumer."""
|
||||
@@ -104,7 +107,7 @@ class Consumer(Thread):
|
||||
item = queue.get(block=True, timeout=self.flush_interval - elapsed)
|
||||
item_size = len(json.dumps(item, cls=DatetimeSerializer).encode())
|
||||
if item_size > MAX_MSG_SIZE:
|
||||
self.log.error("Item exceeds 32kb limit, dropping. (%s)", str(item))
|
||||
self.log.error("Item exceeds 900kib limit, dropping. (%s)", str(item))
|
||||
continue
|
||||
items.append(item)
|
||||
total_size += item_size
|
||||
@@ -133,6 +136,13 @@ class Consumer(Thread):
|
||||
|
||||
@backoff.on_exception(backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception)
|
||||
def send_request():
|
||||
batch_post(self.api_key, self.host, gzip=self.gzip, timeout=self.timeout, batch=batch)
|
||||
batch_post(
|
||||
self.api_key,
|
||||
self.host,
|
||||
gzip=self.gzip,
|
||||
timeout=self.timeout,
|
||||
batch=batch,
|
||||
historical_migration=self.historical_migration,
|
||||
)
|
||||
|
||||
send_request()
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from posthog.client import Client
|
||||
|
||||
|
||||
class Integrations(str, Enum):
|
||||
Django = "django"
|
||||
|
||||
|
||||
DEFAULT_DISTINCT_ID = "python-exceptions"
|
||||
|
||||
|
||||
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):
|
||||
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.
|
||||
self.capture_exception((exc_type, exc_value, exc_traceback))
|
||||
self.original_excepthook(exc_type, exc_value, exc_traceback)
|
||||
|
||||
def thread_exception_handler(self, args):
|
||||
self.capture_exception((args.exc_type, args.exc_value, args.exc_traceback))
|
||||
|
||||
def exception_receiver(self, exc_info, extra_properties):
|
||||
if "distinct_id" in extra_properties:
|
||||
metadata = {"distinct_id": extra_properties["distinct_id"]}
|
||||
else:
|
||||
metadata = None
|
||||
self.capture_exception(exc_info[0], exc_info[1], exc_info[2], metadata)
|
||||
|
||||
def capture_exception(self, exception, metadata=None):
|
||||
try:
|
||||
# if hasattr(sys, "ps1"):
|
||||
# # Disable the excepthook for interactive Python shells
|
||||
# return
|
||||
|
||||
distinct_id = metadata.get("distinct_id") if metadata else DEFAULT_DISTINCT_ID
|
||||
# Make sure we have a distinct_id if its empty in metadata
|
||||
distinct_id = distinct_id or DEFAULT_DISTINCT_ID
|
||||
|
||||
self.client.capture_exception(exception, distinct_id)
|
||||
except Exception as e:
|
||||
self.log.exception(f"Failed to capture exception: {e}")
|
||||
@@ -0,0 +1,5 @@
|
||||
class IntegrationEnablingError(Exception):
|
||||
"""
|
||||
The integration could not be enabled due to a user error like
|
||||
`django` not being installed for the `DjangoIntegration`.
|
||||
"""
|
||||
@@ -0,0 +1,88 @@
|
||||
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 {
|
||||
"distinct_id": distinct_id,
|
||||
"ip": headers.get("X-Forwarded-For"),
|
||||
"user_agent": headers.get("User-Agent"),
|
||||
"traceparent": traceparent,
|
||||
}
|
||||
|
||||
def headers(self):
|
||||
# type: () -> Dict[str, str]
|
||||
return dict(self.request.headers)
|
||||
@@ -0,0 +1,872 @@
|
||||
# copied and adapted from https://github.com/getsentry/sentry-python/blob/269d96d6e9821122fbff280e6a26956e5ed03c0b/sentry_sdk/utils.py#L689
|
||||
# 💖open source (under MIT License)
|
||||
# We want to keep payloads as similar to Sentry as possible for easy interoperability
|
||||
|
||||
import linecache
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
try:
|
||||
# Python 3.11
|
||||
from builtins import BaseExceptionGroup
|
||||
except ImportError:
|
||||
# Python 3.10 and below
|
||||
BaseExceptionGroup = None # type: ignore
|
||||
|
||||
|
||||
DEFAULT_MAX_VALUE_LENGTH = 1024
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
from types import FrameType, TracebackType
|
||||
from typing import ( # noqa: F401
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
Type,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
ExcInfo = Union[
|
||||
Tuple[Type[BaseException], BaseException, Optional[TracebackType]],
|
||||
Tuple[None, None, None],
|
||||
]
|
||||
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
|
||||
|
||||
Event = TypedDict(
|
||||
"Event",
|
||||
{
|
||||
"breadcrumbs": Dict[Literal["values"], List[Dict[str, Any]]], # TODO: We can expand on this type
|
||||
"check_in_id": str,
|
||||
"contexts": Dict[str, Dict[str, object]],
|
||||
"dist": str,
|
||||
"duration": Optional[float],
|
||||
"environment": str,
|
||||
"errors": List[Dict[str, Any]], # TODO: We can expand on this type
|
||||
"event_id": str,
|
||||
"exception": Dict[Literal["values"], List[Dict[str, Any]]], # TODO: We can expand on this type
|
||||
# "extra": MutableMapping[str, object],
|
||||
# "fingerprint": List[str],
|
||||
"level": LogLevelStr,
|
||||
# "logentry": Mapping[str, object],
|
||||
"logger": str,
|
||||
# "measurements": Dict[str, MeasurementValue],
|
||||
"message": str,
|
||||
"modules": Dict[str, str],
|
||||
# "monitor_config": Mapping[str, object],
|
||||
"monitor_slug": Optional[str],
|
||||
"platform": Literal["python"],
|
||||
"profile": object, # Should be sentry_sdk.profiler.Profile, but we can't import that here due to circular imports
|
||||
"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)
|
||||
|
||||
|
||||
BASE64_ALPHABET = re.compile(r"^[a-zA-Z0-9/+=]*$")
|
||||
|
||||
SENSITIVE_DATA_SUBSTITUTE = "[Filtered]"
|
||||
|
||||
|
||||
def to_timestamp(value):
|
||||
# type: (datetime) -> float
|
||||
return (value - epoch).total_seconds()
|
||||
|
||||
|
||||
def format_timestamp(value):
|
||||
# type: (datetime) -> str
|
||||
return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
|
||||
|
||||
def event_hint_with_exc_info(exc_info=None):
|
||||
# type: (Optional[ExcInfo]) -> Dict[str, Optional[ExcInfo]]
|
||||
"""Creates a hint with the exc info filled in."""
|
||||
if exc_info is None:
|
||||
exc_info = sys.exc_info()
|
||||
else:
|
||||
exc_info = exc_info_from_error(exc_info)
|
||||
if exc_info[0] is None:
|
||||
exc_info = None
|
||||
return {"exc_info": exc_info}
|
||||
|
||||
|
||||
class AnnotatedValue:
|
||||
"""
|
||||
Meta information for a data field in the event payload.
|
||||
This is to tell Relay that we have tampered with the fields value.
|
||||
See:
|
||||
https://github.com/getsentry/relay/blob/be12cd49a0f06ea932ed9b9f93a655de5d6ad6d1/relay-general/src/types/meta.rs#L407-L423
|
||||
"""
|
||||
|
||||
__slots__ = ("value", "metadata")
|
||||
|
||||
def __init__(self, value, metadata):
|
||||
# type: (Optional[Any], Dict[str, Any]) -> None
|
||||
self.value = value
|
||||
self.metadata = metadata
|
||||
|
||||
def __eq__(self, other):
|
||||
# type: (Any) -> bool
|
||||
if not isinstance(other, AnnotatedValue):
|
||||
return False
|
||||
|
||||
return self.value == other.value and self.metadata == other.metadata
|
||||
|
||||
@classmethod
|
||||
def removed_because_raw_data(cls):
|
||||
# type: () -> AnnotatedValue
|
||||
"""The value was removed because it could not be parsed. This is done for request body values that are not json nor a form."""
|
||||
return AnnotatedValue(
|
||||
value="",
|
||||
metadata={
|
||||
"rem": [ # Remark
|
||||
[
|
||||
"!raw", # Unparsable raw data
|
||||
"x", # The fields original value was removed
|
||||
]
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def removed_because_over_size_limit(cls):
|
||||
# type: () -> AnnotatedValue
|
||||
"""The actual value was removed because the size of the field exceeded the configured maximum size (specified with the max_request_body_size sdk option)"""
|
||||
return AnnotatedValue(
|
||||
value="",
|
||||
metadata={
|
||||
"rem": [ # Remark
|
||||
[
|
||||
"!config", # Because of configured maximum size
|
||||
"x", # The fields original value was removed
|
||||
]
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def substituted_because_contains_sensitive_data(cls):
|
||||
# type: () -> AnnotatedValue
|
||||
"""The actual value was removed because it contained sensitive information."""
|
||||
return AnnotatedValue(
|
||||
value=SENSITIVE_DATA_SUBSTITUTE,
|
||||
metadata={
|
||||
"rem": [ # Remark
|
||||
[
|
||||
"!config", # Because of SDK configuration (in this case the config is the hard coded removal of certain django cookies)
|
||||
"s", # The fields original value was substituted
|
||||
]
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
T = TypeVar("T")
|
||||
Annotated = Union[AnnotatedValue, T]
|
||||
|
||||
|
||||
def get_type_name(cls):
|
||||
# type: (Optional[type]) -> Optional[str]
|
||||
return getattr(cls, "__qualname__", None) or getattr(cls, "__name__", None)
|
||||
|
||||
|
||||
def get_type_module(cls):
|
||||
# type: (Optional[type]) -> Optional[str]
|
||||
mod = getattr(cls, "__module__", None)
|
||||
if mod not in (None, "builtins", "__builtins__"):
|
||||
return mod
|
||||
return None
|
||||
|
||||
|
||||
def should_hide_frame(frame: "FrameType") -> bool:
|
||||
try:
|
||||
mod = frame.f_globals["__name__"]
|
||||
if mod.startswith("sentry_sdk."):
|
||||
return True
|
||||
except (AttributeError, KeyError):
|
||||
pass
|
||||
|
||||
for flag_name in "__traceback_hide__", "__tracebackhide__":
|
||||
try:
|
||||
if frame.f_locals[flag_name]:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def iter_stacks(tb):
|
||||
# type: (Optional[TracebackType]) -> Iterator[TracebackType]
|
||||
tb_ = tb # type: Optional[TracebackType]
|
||||
while tb_ is not None:
|
||||
if not should_hide_frame(tb_.tb_frame):
|
||||
yield tb_
|
||||
tb_ = tb_.tb_next
|
||||
|
||||
|
||||
def get_lines_from_file(
|
||||
filename, # type: str
|
||||
lineno, # type: int
|
||||
max_length=None, # type: Optional[int]
|
||||
loader=None, # type: Optional[Any]
|
||||
module=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> Tuple[List[Annotated[str]], Optional[Annotated[str]], List[Annotated[str]]]
|
||||
context_lines = 5
|
||||
source = None
|
||||
if loader is not None and hasattr(loader, "get_source"):
|
||||
try:
|
||||
source_str = loader.get_source(module) # type: Optional[str]
|
||||
except (ImportError, IOError):
|
||||
source_str = None
|
||||
if source_str is not None:
|
||||
source = source_str.splitlines()
|
||||
|
||||
if source is None:
|
||||
try:
|
||||
source = linecache.getlines(filename)
|
||||
except (OSError, IOError):
|
||||
return [], None, []
|
||||
|
||||
if not source:
|
||||
return [], None, []
|
||||
|
||||
lower_bound = max(0, lineno - context_lines)
|
||||
upper_bound = min(lineno + 1 + context_lines, len(source))
|
||||
|
||||
try:
|
||||
pre_context = [strip_string(line.strip("\r\n"), max_length=max_length) for line in source[lower_bound:lineno]]
|
||||
context_line = strip_string(source[lineno].strip("\r\n"), max_length=max_length)
|
||||
post_context = [
|
||||
strip_string(line.strip("\r\n"), max_length=max_length)
|
||||
for line in source[(lineno + 1) : upper_bound] # noqa: E203
|
||||
]
|
||||
return pre_context, context_line, post_context
|
||||
except IndexError:
|
||||
# the file may have changed since it was loaded into memory
|
||||
return [], None, []
|
||||
|
||||
|
||||
def get_source_context(
|
||||
frame, # type: FrameType
|
||||
tb_lineno, # type: int
|
||||
max_value_length=None, # type: Optional[int]
|
||||
):
|
||||
# type: (...) -> Tuple[List[Annotated[str]], Optional[Annotated[str]], List[Annotated[str]]]
|
||||
try:
|
||||
abs_path = frame.f_code.co_filename # type: Optional[str]
|
||||
except Exception:
|
||||
abs_path = None
|
||||
try:
|
||||
module = frame.f_globals["__name__"]
|
||||
except Exception:
|
||||
return [], None, []
|
||||
try:
|
||||
loader = frame.f_globals["__loader__"]
|
||||
except Exception:
|
||||
loader = None
|
||||
lineno = tb_lineno - 1
|
||||
if lineno is not None and abs_path:
|
||||
return get_lines_from_file(abs_path, lineno, max_value_length, loader=loader, module=module)
|
||||
return [], None, []
|
||||
|
||||
|
||||
def safe_str(value):
|
||||
# type: (Any) -> str
|
||||
try:
|
||||
return str(value)
|
||||
except Exception:
|
||||
return safe_repr(value)
|
||||
|
||||
|
||||
def safe_repr(value):
|
||||
# type: (Any) -> str
|
||||
try:
|
||||
return repr(value)
|
||||
except Exception:
|
||||
return "<broken repr>"
|
||||
|
||||
|
||||
def filename_for_module(module, abs_path):
|
||||
# type: (Optional[str], Optional[str]) -> Optional[str]
|
||||
if not abs_path or not module:
|
||||
return abs_path
|
||||
|
||||
try:
|
||||
if abs_path.endswith(".pyc"):
|
||||
abs_path = abs_path[:-1]
|
||||
|
||||
base_module = module.split(".", 1)[0]
|
||||
if base_module == module:
|
||||
return os.path.basename(abs_path)
|
||||
|
||||
base_module_path = sys.modules[base_module].__file__
|
||||
if not base_module_path:
|
||||
return abs_path
|
||||
|
||||
return abs_path.split(base_module_path.rsplit(os.sep, 2)[0], 1)[-1].lstrip(os.sep)
|
||||
except Exception:
|
||||
return 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]
|
||||
f_code = getattr(frame, "f_code", None)
|
||||
if not f_code:
|
||||
abs_path = None
|
||||
function = None
|
||||
else:
|
||||
abs_path = frame.f_code.co_filename
|
||||
function = frame.f_code.co_name
|
||||
try:
|
||||
module = frame.f_globals["__name__"]
|
||||
except Exception:
|
||||
module = None
|
||||
|
||||
if tb_lineno is None:
|
||||
tb_lineno = frame.f_lineno
|
||||
|
||||
rv = {
|
||||
"filename": filename_for_module(module, abs_path) or None,
|
||||
"abs_path": os.path.abspath(abs_path) if abs_path else None,
|
||||
"function": function or "<unknown>",
|
||||
"module": module,
|
||||
"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(nk): Sort out this current invalid import
|
||||
# from sentry_sdk.serializer import serialize
|
||||
|
||||
# rv["vars"] = serialize(
|
||||
# dict(frame.f_locals), is_vars=True, custom_repr=custom_repr
|
||||
# )
|
||||
pass
|
||||
|
||||
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}
|
||||
|
||||
|
||||
def get_errno(exc_value):
|
||||
# type: (BaseException) -> Optional[Any]
|
||||
return getattr(exc_value, "errno", None)
|
||||
|
||||
|
||||
def get_error_message(exc_value):
|
||||
# type: (Optional[BaseException]) -> str
|
||||
return getattr(exc_value, "message", "") or getattr(exc_value, "detail", "") or safe_str(exc_value)
|
||||
|
||||
|
||||
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]
|
||||
source=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> Dict[str, Any]
|
||||
"""
|
||||
Creates a dict that goes into the events `exception.values` list and is ingestible by Sentry.
|
||||
|
||||
See the Exception Interface documentation for more details:
|
||||
https://develop.sentry.dev/sdk/event-payloads/exception/
|
||||
"""
|
||||
exception_value = {} # type: Dict[str, Any]
|
||||
exception_value["mechanism"] = mechanism.copy() if mechanism else {"type": "generic", "handled": True}
|
||||
if exception_id is not None:
|
||||
exception_value["mechanism"]["exception_id"] = exception_id
|
||||
|
||||
if exc_value is not None:
|
||||
errno = get_errno(exc_value)
|
||||
else:
|
||||
errno = None
|
||||
|
||||
if errno is not None:
|
||||
exception_value["mechanism"].setdefault("meta", {}).setdefault("errno", {}).setdefault("number", errno)
|
||||
|
||||
if source is not None:
|
||||
exception_value["mechanism"]["source"] = source
|
||||
|
||||
is_root_exception = exception_id == 0
|
||||
if not is_root_exception and parent_id is not None:
|
||||
exception_value["mechanism"]["parent_id"] = parent_id
|
||||
exception_value["mechanism"]["type"] = "chained"
|
||||
|
||||
if is_root_exception and "type" not in exception_value["mechanism"]:
|
||||
exception_value["mechanism"]["type"] = "generic"
|
||||
|
||||
is_exception_group = BaseExceptionGroup is not None and isinstance(exc_value, BaseExceptionGroup)
|
||||
if is_exception_group:
|
||||
exception_value["mechanism"]["is_exception_group"] = True
|
||||
|
||||
exception_value["module"] = get_type_module(exc_type)
|
||||
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")
|
||||
|
||||
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)
|
||||
]
|
||||
|
||||
if frames:
|
||||
exception_value["stacktrace"] = {"frames": frames}
|
||||
|
||||
return exception_value
|
||||
|
||||
|
||||
HAS_CHAINED_EXCEPTIONS = hasattr(Exception, "__suppress_context__")
|
||||
|
||||
if HAS_CHAINED_EXCEPTIONS:
|
||||
|
||||
def walk_exception_chain(exc_info):
|
||||
# type: (ExcInfo) -> Iterator[ExcInfo]
|
||||
exc_type, exc_value, tb = exc_info
|
||||
|
||||
seen_exceptions = []
|
||||
seen_exception_ids = set() # type: Set[int]
|
||||
|
||||
while exc_type is not None and exc_value is not None and id(exc_value) not in seen_exception_ids:
|
||||
yield exc_type, exc_value, tb
|
||||
|
||||
# Avoid hashing random types we don't know anything
|
||||
# about. Use the list to keep a ref so that the `id` is
|
||||
# not used for another object.
|
||||
seen_exceptions.append(exc_value)
|
||||
seen_exception_ids.add(id(exc_value))
|
||||
|
||||
if exc_value.__suppress_context__:
|
||||
cause = exc_value.__cause__
|
||||
else:
|
||||
cause = exc_value.__context__
|
||||
if cause is None:
|
||||
break
|
||||
exc_type = type(cause)
|
||||
exc_value = cause
|
||||
tb = getattr(cause, "__traceback__", None)
|
||||
|
||||
else:
|
||||
|
||||
def walk_exception_chain(exc_info):
|
||||
# type: (ExcInfo) -> Iterator[ExcInfo]
|
||||
yield exc_info
|
||||
|
||||
|
||||
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
|
||||
source=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> Tuple[int, List[Dict[str, Any]]]
|
||||
"""
|
||||
Creates the list of exceptions.
|
||||
This can include chained exceptions and exceptions from an ExceptionGroup.
|
||||
|
||||
See the Exception Interface documentation for more details:
|
||||
https://develop.sentry.dev/sdk/event-payloads/exception/
|
||||
"""
|
||||
|
||||
parent = single_exception_from_error_tuple(
|
||||
exc_type=exc_type,
|
||||
exc_value=exc_value,
|
||||
tb=tb,
|
||||
client_options=client_options,
|
||||
mechanism=mechanism,
|
||||
exception_id=exception_id,
|
||||
parent_id=parent_id,
|
||||
source=source,
|
||||
)
|
||||
exceptions = [parent]
|
||||
|
||||
parent_id = exception_id
|
||||
exception_id += 1
|
||||
|
||||
should_supress_context = hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore
|
||||
if should_supress_context:
|
||||
# Add direct cause.
|
||||
# The field `__cause__` is set when raised with the exception (using the `from` keyword).
|
||||
exception_has_cause = exc_value and hasattr(exc_value, "__cause__") and exc_value.__cause__ is not None
|
||||
if exception_has_cause:
|
||||
cause = exc_value.__cause__ # type: ignore
|
||||
(exception_id, child_exceptions) = 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__",
|
||||
)
|
||||
exceptions.extend(child_exceptions)
|
||||
|
||||
else:
|
||||
# Add indirect cause.
|
||||
# The field `__context__` is assigned if another exception occurs while handling the exception.
|
||||
exception_has_content = exc_value and hasattr(exc_value, "__context__") and exc_value.__context__ is not None
|
||||
if exception_has_content:
|
||||
context = exc_value.__context__ # type: ignore
|
||||
(exception_id, child_exceptions) = 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__",
|
||||
)
|
||||
exceptions.extend(child_exceptions)
|
||||
|
||||
# Add exceptions from an ExceptionGroup.
|
||||
is_exception_group = exc_value and hasattr(exc_value, "exceptions")
|
||||
if is_exception_group:
|
||||
for idx, e in enumerate(exc_value.exceptions): # type: ignore
|
||||
(exception_id, child_exceptions) = 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,
|
||||
source="exceptions[%s]" % idx,
|
||||
)
|
||||
exceptions.extend(child_exceptions)
|
||||
|
||||
return (exception_id, exceptions)
|
||||
|
||||
|
||||
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]]
|
||||
exc_type, exc_value, tb = exc_info
|
||||
|
||||
is_exception_group = BaseExceptionGroup is not None and isinstance(exc_value, BaseExceptionGroup)
|
||||
|
||||
if is_exception_group:
|
||||
(_, exceptions) = exceptions_from_error(
|
||||
exc_type=exc_type,
|
||||
exc_value=exc_value,
|
||||
tb=tb,
|
||||
client_options=client_options,
|
||||
mechanism=mechanism,
|
||||
exception_id=0,
|
||||
parent_id=0,
|
||||
)
|
||||
|
||||
else:
|
||||
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))
|
||||
|
||||
exceptions.reverse()
|
||||
|
||||
return exceptions
|
||||
|
||||
|
||||
def to_string(value):
|
||||
# type: (str) -> str
|
||||
try:
|
||||
return str(value)
|
||||
except UnicodeDecodeError:
|
||||
return repr(value)[1:-1]
|
||||
|
||||
|
||||
def iter_event_stacktraces(event):
|
||||
# type: (Event) -> Iterator[Dict[str, Any]]
|
||||
if "stacktrace" in event:
|
||||
yield event["stacktrace"]
|
||||
if "threads" in event:
|
||||
for thread in event["threads"].get("values") or ():
|
||||
if "stacktrace" in thread:
|
||||
yield thread["stacktrace"]
|
||||
if "exception" in event:
|
||||
for exception in event["exception"].get("values") or ():
|
||||
if "stacktrace" in exception:
|
||||
yield exception["stacktrace"]
|
||||
|
||||
|
||||
def iter_event_frames(event):
|
||||
# type: (Event) -> Iterator[Dict[str, Any]]
|
||||
for stacktrace in iter_event_stacktraces(event):
|
||||
for frame in stacktrace.get("frames") or ():
|
||||
yield frame
|
||||
|
||||
|
||||
def handle_in_app(event, in_app_exclude=None, in_app_include=None, project_root=None):
|
||||
# type: (Event, Optional[List[str]], Optional[List[str]], Optional[str]) -> Event
|
||||
for stacktrace in iter_event_stacktraces(event):
|
||||
set_in_app_in_frames(
|
||||
stacktrace.get("frames"),
|
||||
in_app_exclude=in_app_exclude,
|
||||
in_app_include=in_app_include,
|
||||
project_root=project_root,
|
||||
)
|
||||
|
||||
return event
|
||||
|
||||
|
||||
def set_in_app_in_frames(frames, in_app_exclude, in_app_include, project_root=None):
|
||||
# type: (Any, Optional[List[str]], Optional[List[str]], Optional[str]) -> Optional[Any]
|
||||
if not frames:
|
||||
return None
|
||||
|
||||
for frame in frames:
|
||||
# if frame has already been marked as in_app, skip it
|
||||
current_in_app = frame.get("in_app")
|
||||
if current_in_app is not None:
|
||||
continue
|
||||
|
||||
module = frame.get("module")
|
||||
|
||||
# check if module in frame is in the list of modules to include
|
||||
if _module_in_list(module, in_app_include):
|
||||
frame["in_app"] = True
|
||||
continue
|
||||
|
||||
# check if module in frame is in the list of modules to exclude
|
||||
if _module_in_list(module, in_app_exclude):
|
||||
frame["in_app"] = False
|
||||
continue
|
||||
|
||||
# if frame has no abs_path, skip further checks
|
||||
abs_path = frame.get("abs_path")
|
||||
if abs_path is None:
|
||||
continue
|
||||
|
||||
if _is_external_source(abs_path):
|
||||
frame["in_app"] = False
|
||||
continue
|
||||
|
||||
if _is_in_project_root(abs_path, project_root):
|
||||
frame["in_app"] = True
|
||||
continue
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def exc_info_from_error(error):
|
||||
# type: (Union[BaseException, ExcInfo]) -> ExcInfo
|
||||
if isinstance(error, tuple) and len(error) == 3:
|
||||
exc_type, exc_value, tb = error
|
||||
elif isinstance(error, BaseException):
|
||||
tb = getattr(error, "__traceback__", None)
|
||||
if tb is not None:
|
||||
exc_type = type(error)
|
||||
exc_value = error
|
||||
else:
|
||||
exc_type, exc_value, tb = sys.exc_info()
|
||||
if exc_value is not error:
|
||||
tb = None
|
||||
exc_value = error
|
||||
exc_type = type(error)
|
||||
|
||||
else:
|
||||
raise ValueError("Expected Exception object to report, got %s!" % type(error))
|
||||
|
||||
exc_info = (exc_type, exc_value, tb)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This cast is safe because exc_type and exc_value are either both
|
||||
# None or both not None.
|
||||
exc_info = cast(ExcInfo, exc_info)
|
||||
|
||||
return exc_info
|
||||
|
||||
|
||||
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, Optional[List[str]]) -> bool
|
||||
if name is None:
|
||||
return False
|
||||
|
||||
if not items:
|
||||
return False
|
||||
|
||||
for item in items:
|
||||
if item == name or name.startswith(item + "."):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _is_external_source(abs_path):
|
||||
# type: (str) -> bool
|
||||
# check if frame is in 'site-packages' or 'dist-packages'
|
||||
external_source = re.search(r"[\\/](?:dist|site)-packages[\\/]", abs_path) is not None
|
||||
return external_source
|
||||
|
||||
|
||||
def _is_in_project_root(abs_path, project_root):
|
||||
# type: (str, Optional[str]) -> bool
|
||||
if project_root is None:
|
||||
return False
|
||||
|
||||
# check if path is in the project root
|
||||
if abs_path.startswith(project_root):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _truncate_by_bytes(string, max_bytes):
|
||||
# type: (str, int) -> str
|
||||
"""
|
||||
Truncate a UTF-8-encodable string to the last full codepoint so that it fits in max_bytes.
|
||||
"""
|
||||
truncated = string.encode("utf-8")[: max_bytes - 3].decode("utf-8", errors="ignore")
|
||||
|
||||
return truncated + "..."
|
||||
|
||||
|
||||
def _get_size_in_bytes(value):
|
||||
# type: (str) -> Optional[int]
|
||||
try:
|
||||
return len(value.encode("utf-8"))
|
||||
except (UnicodeEncodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def strip_string(value, max_length=None):
|
||||
# type: (str, Optional[int]) -> Union[AnnotatedValue, str]
|
||||
if not value:
|
||||
return value
|
||||
|
||||
if max_length is None:
|
||||
max_length = DEFAULT_MAX_VALUE_LENGTH
|
||||
|
||||
byte_size = _get_size_in_bytes(value)
|
||||
text_size = len(value)
|
||||
|
||||
if byte_size is not None and byte_size > max_length:
|
||||
# truncate to max_length bytes, preserving code points
|
||||
truncated_value = _truncate_by_bytes(value, max_length)
|
||||
elif text_size is not None and text_size > max_length:
|
||||
# fallback to truncating by string length
|
||||
truncated_value = value[: max_length - 3] + "..."
|
||||
else:
|
||||
return value
|
||||
|
||||
return AnnotatedValue(
|
||||
value=truncated_value,
|
||||
metadata={
|
||||
"len": byte_size or text_size,
|
||||
"rem": [["!limit", "x", max_length - 3, max_length]],
|
||||
},
|
||||
)
|
||||
+84
-21
@@ -2,8 +2,10 @@ import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from dateutil import parser
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from posthog.utils import convert_to_datetime_aware, is_valid_regex
|
||||
|
||||
@@ -11,6 +13,8 @@ __LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
NONE_VALUES_ALLOWED_OPERATORS = ["is_not"]
|
||||
|
||||
|
||||
class InconclusiveMatchError(Exception):
|
||||
pass
|
||||
@@ -117,15 +121,20 @@ def match_property(property, property_values) -> bool:
|
||||
|
||||
override_value = property_values[key]
|
||||
|
||||
if operator == "exact":
|
||||
if isinstance(value, list):
|
||||
return override_value in value
|
||||
return value == override_value
|
||||
if (operator not in NONE_VALUES_ALLOWED_OPERATORS) and override_value is None:
|
||||
return False
|
||||
|
||||
if operator == "is_not":
|
||||
if isinstance(value, list):
|
||||
return override_value not in value
|
||||
return value != override_value
|
||||
if operator in ("exact", "is_not"):
|
||||
|
||||
def compute_exact_match(value, override_value):
|
||||
if isinstance(value, list):
|
||||
return str(override_value).lower() in [str(val).lower() for val in value]
|
||||
return str(value).lower() == str(override_value).lower()
|
||||
|
||||
if operator == "exact":
|
||||
return compute_exact_match(value, override_value)
|
||||
else:
|
||||
return not compute_exact_match(value, override_value)
|
||||
|
||||
if operator == "is_set":
|
||||
return key in property_values
|
||||
@@ -142,23 +151,46 @@ def match_property(property, property_values) -> bool:
|
||||
if operator == "not_regex":
|
||||
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is None
|
||||
|
||||
if operator == "gt":
|
||||
return type(override_value) == type(value) and override_value > value
|
||||
if operator in ("gt", "gte", "lt", "lte"):
|
||||
# :TRICKY: We adjust comparison based on the override value passed in,
|
||||
# to make sure we handle both numeric and string comparisons appropriately.
|
||||
def compare(lhs, rhs, operator):
|
||||
if operator == "gt":
|
||||
return lhs > rhs
|
||||
elif operator == "gte":
|
||||
return lhs >= rhs
|
||||
elif operator == "lt":
|
||||
return lhs < rhs
|
||||
elif operator == "lte":
|
||||
return lhs <= rhs
|
||||
else:
|
||||
raise ValueError(f"Invalid operator: {operator}")
|
||||
|
||||
if operator == "gte":
|
||||
return type(override_value) == type(value) and override_value >= value
|
||||
parsed_value = None
|
||||
try:
|
||||
parsed_value = float(value) # type: ignore
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if operator == "lt":
|
||||
return type(override_value) == type(value) and override_value < value
|
||||
|
||||
if operator == "lte":
|
||||
return type(override_value) == type(value) and override_value <= value
|
||||
if parsed_value is not None and override_value is not None:
|
||||
if isinstance(override_value, str):
|
||||
return compare(override_value, str(value), operator)
|
||||
else:
|
||||
return compare(override_value, parsed_value, operator)
|
||||
else:
|
||||
return compare(str(override_value), str(value), operator)
|
||||
|
||||
if operator in ["is_date_before", "is_date_after"]:
|
||||
try:
|
||||
parsed_date = parser.parse(value)
|
||||
parsed_date = convert_to_datetime_aware(parsed_date)
|
||||
except Exception:
|
||||
parsed_date = relative_date_parse_for_feature_flag_matching(str(value))
|
||||
|
||||
if not parsed_date:
|
||||
parsed_date = parser.parse(str(value))
|
||||
parsed_date = convert_to_datetime_aware(parsed_date)
|
||||
except Exception as e:
|
||||
raise InconclusiveMatchError("The date set on the flag is not a valid format") from e
|
||||
|
||||
if not parsed_date:
|
||||
raise InconclusiveMatchError("The date set on the flag is not a valid format")
|
||||
|
||||
if isinstance(override_value, datetime.datetime):
|
||||
@@ -185,7 +217,8 @@ def match_property(property, property_values) -> bool:
|
||||
else:
|
||||
raise InconclusiveMatchError("The date provided must be a string or date object")
|
||||
|
||||
return False
|
||||
# if we get here, we don't know how to handle the operator
|
||||
raise InconclusiveMatchError(f"Unknown operator {operator}")
|
||||
|
||||
|
||||
def match_cohort(property, property_values, cohort_properties) -> bool:
|
||||
@@ -271,3 +304,33 @@ def match_property_group(property_group, property_values, cohort_properties) ->
|
||||
|
||||
# if we get here, all matched in AND case, or none matched in OR case
|
||||
return property_group_type == "AND"
|
||||
|
||||
|
||||
def relative_date_parse_for_feature_flag_matching(value: str) -> Optional[datetime.datetime]:
|
||||
regex = r"^-?(?P<number>[0-9]+)(?P<interval>[a-z])$"
|
||||
match = re.search(regex, value)
|
||||
parsed_dt = datetime.datetime.now(datetime.timezone.utc)
|
||||
if match:
|
||||
number = int(match.group("number"))
|
||||
|
||||
if number >= 10_000:
|
||||
# Guard against overflow, disallow numbers greater than 10_000
|
||||
return None
|
||||
|
||||
interval = match.group("interval")
|
||||
if interval == "h":
|
||||
parsed_dt = parsed_dt - relativedelta(hours=number)
|
||||
elif interval == "d":
|
||||
parsed_dt = parsed_dt - relativedelta(days=number)
|
||||
elif interval == "w":
|
||||
parsed_dt = parsed_dt - relativedelta(weeks=number)
|
||||
elif interval == "m":
|
||||
parsed_dt = parsed_dt - relativedelta(months=number)
|
||||
elif interval == "y":
|
||||
parsed_dt = parsed_dt - relativedelta(years=number)
|
||||
else:
|
||||
return None
|
||||
|
||||
return parsed_dt
|
||||
else:
|
||||
return None
|
||||
|
||||
+15
-1
@@ -13,10 +13,24 @@ from posthog.version import VERSION
|
||||
|
||||
_session = requests.sessions.Session()
|
||||
|
||||
DEFAULT_HOST = "https://app.posthog.com"
|
||||
US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
|
||||
EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"
|
||||
DEFAULT_HOST = US_INGESTION_ENDPOINT
|
||||
USER_AGENT = "posthog-python/" + VERSION
|
||||
|
||||
|
||||
def determine_server_host(host: Optional[str]) -> str:
|
||||
"""Determines the server host to use."""
|
||||
host_or_default = host or DEFAULT_HOST
|
||||
trimmed_host = remove_trailing_slash(host_or_default)
|
||||
if trimmed_host in ("https://app.posthog.com", "https://us.posthog.com"):
|
||||
return US_INGESTION_ENDPOINT
|
||||
elif trimmed_host == "https://eu.posthog.com":
|
||||
return EU_INGESTION_ENDPOINT
|
||||
else:
|
||||
return host_or_default
|
||||
|
||||
|
||||
def post(
|
||||
api_key: str, host: Optional[str] = None, path=None, gzip: bool = False, timeout: int = 15, **kwargs
|
||||
) -> requests.Response:
|
||||
|
||||
@@ -43,9 +43,9 @@ class PostHogIntegration(Integration):
|
||||
not not Hub.current.client.dsn and Dsn(Hub.current.client.dsn).project_id
|
||||
)
|
||||
if project_id:
|
||||
properties[
|
||||
"$sentry_url"
|
||||
] = f"{PostHogIntegration.prefix}{PostHogIntegration.organization}/issues/?project={project_id}&query={event['event_id']}"
|
||||
properties["$sentry_url"] = (
|
||||
f"{PostHogIntegration.prefix}{PostHogIntegration.organization}/issues/?project={project_id}&query={event['event_id']}"
|
||||
)
|
||||
|
||||
posthog.capture(posthog_distinct_id, "$exception", properties)
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("django")
|
||||
@@ -0,0 +1,68 @@
|
||||
from posthog.exception_integrations.django import DjangoRequestExtractor
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
def mock_request_factory(override_headers):
|
||||
class Request:
|
||||
META = {}
|
||||
# TRICKY: Actual django request dict object has case insensitive matching, and strips http from the names
|
||||
headers = {
|
||||
"User-Agent": DEFAULT_USER_AGENT,
|
||||
"Referrer": "http://example.com",
|
||||
"X-Forwarded-For": "193.4.5.12",
|
||||
**(override_headers or {}),
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
+423
-30
@@ -84,6 +84,164 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
|
||||
def test_basic_capture_exception(self):
|
||||
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
client = self.client
|
||||
exception = Exception("test exception")
|
||||
client.capture_exception(exception)
|
||||
|
||||
self.assertTrue(patch_capture.called)
|
||||
capture_call = patch_capture.call_args[0]
|
||||
self.assertEqual(capture_call[0], "python-exceptions")
|
||||
self.assertEqual(capture_call[1], "$exception")
|
||||
self.assertEqual(
|
||||
capture_call[2],
|
||||
{
|
||||
"$exception_type": "Exception",
|
||||
"$exception_message": "test exception",
|
||||
"$exception_list": [
|
||||
{
|
||||
"mechanism": {"type": "generic", "handled": True},
|
||||
"module": None,
|
||||
"type": "Exception",
|
||||
"value": "test exception",
|
||||
}
|
||||
],
|
||||
"$exception_personURL": "https://us.i.posthog.com/project/random_key/person/python-exceptions",
|
||||
},
|
||||
)
|
||||
|
||||
def test_basic_capture_exception_with_distinct_id(self):
|
||||
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
client = self.client
|
||||
exception = Exception("test exception")
|
||||
client.capture_exception(exception, "distinct_id")
|
||||
|
||||
self.assertTrue(patch_capture.called)
|
||||
capture_call = patch_capture.call_args[0]
|
||||
self.assertEqual(capture_call[0], "distinct_id")
|
||||
self.assertEqual(capture_call[1], "$exception")
|
||||
self.assertEqual(
|
||||
capture_call[2],
|
||||
{
|
||||
"$exception_type": "Exception",
|
||||
"$exception_message": "test exception",
|
||||
"$exception_list": [
|
||||
{
|
||||
"mechanism": {"type": "generic", "handled": True},
|
||||
"module": None,
|
||||
"type": "Exception",
|
||||
"value": "test exception",
|
||||
}
|
||||
],
|
||||
"$exception_personURL": "https://us.i.posthog.com/project/random_key/person/distinct_id",
|
||||
},
|
||||
)
|
||||
|
||||
def test_basic_capture_exception_with_correct_host_generation(self):
|
||||
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://aloha.com")
|
||||
exception = Exception("test exception")
|
||||
client.capture_exception(exception, "distinct_id")
|
||||
|
||||
self.assertTrue(patch_capture.called)
|
||||
capture_call = patch_capture.call_args[0]
|
||||
self.assertEqual(capture_call[0], "distinct_id")
|
||||
self.assertEqual(capture_call[1], "$exception")
|
||||
self.assertEqual(
|
||||
capture_call[2],
|
||||
{
|
||||
"$exception_type": "Exception",
|
||||
"$exception_message": "test exception",
|
||||
"$exception_list": [
|
||||
{
|
||||
"mechanism": {"type": "generic", "handled": True},
|
||||
"module": None,
|
||||
"type": "Exception",
|
||||
"value": "test exception",
|
||||
}
|
||||
],
|
||||
"$exception_personURL": "https://aloha.com/project/random_key/person/distinct_id",
|
||||
},
|
||||
)
|
||||
|
||||
def test_basic_capture_exception_with_correct_host_generation_for_server_hosts(self):
|
||||
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://app.posthog.com")
|
||||
exception = Exception("test exception")
|
||||
client.capture_exception(exception, "distinct_id")
|
||||
|
||||
self.assertTrue(patch_capture.called)
|
||||
capture_call = patch_capture.call_args[0]
|
||||
self.assertEqual(capture_call[0], "distinct_id")
|
||||
self.assertEqual(capture_call[1], "$exception")
|
||||
self.assertEqual(
|
||||
capture_call[2],
|
||||
{
|
||||
"$exception_type": "Exception",
|
||||
"$exception_message": "test exception",
|
||||
"$exception_list": [
|
||||
{
|
||||
"mechanism": {"type": "generic", "handled": True},
|
||||
"module": None,
|
||||
"type": "Exception",
|
||||
"value": "test exception",
|
||||
}
|
||||
],
|
||||
"$exception_personURL": "https://app.posthog.com/project/random_key/person/distinct_id",
|
||||
},
|
||||
)
|
||||
|
||||
def test_basic_capture_exception_with_no_exception_given(self):
|
||||
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
client = self.client
|
||||
try:
|
||||
raise Exception("test exception")
|
||||
except Exception:
|
||||
client.capture_exception()
|
||||
|
||||
self.assertTrue(patch_capture.called)
|
||||
capture_call = patch_capture.call_args[0]
|
||||
self.assertEqual(capture_call[0], "python-exceptions")
|
||||
self.assertEqual(capture_call[1], "$exception")
|
||||
self.assertEqual(capture_call[2]["$exception_type"], "Exception")
|
||||
self.assertEqual(capture_call[2]["$exception_message"], "test exception")
|
||||
self.assertEqual(capture_call[2]["$exception_list"][0]["mechanism"]["type"], "generic")
|
||||
self.assertEqual(capture_call[2]["$exception_list"][0]["mechanism"]["handled"], True)
|
||||
self.assertEqual(capture_call[2]["$exception_list"][0]["module"], None)
|
||||
self.assertEqual(capture_call[2]["$exception_list"][0]["type"], "Exception")
|
||||
self.assertEqual(capture_call[2]["$exception_list"][0]["value"], "test exception")
|
||||
self.assertEqual(
|
||||
capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["filename"],
|
||||
"posthog/test/test_client.py",
|
||||
)
|
||||
self.assertEqual(
|
||||
capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["function"],
|
||||
"test_basic_capture_exception_with_no_exception_given",
|
||||
)
|
||||
self.assertEqual(
|
||||
capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["module"], "posthog.test.test_client"
|
||||
)
|
||||
|
||||
def test_basic_capture_exception_with_no_exception_happening(self):
|
||||
|
||||
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
|
||||
with self.assertLogs("posthog", level="WARNING") as logs:
|
||||
|
||||
client = self.client
|
||||
client.capture_exception()
|
||||
|
||||
self.assertFalse(patch_capture.called)
|
||||
self.assertEqual(
|
||||
logs.output[0],
|
||||
"WARNING:posthog:No exception information available",
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_basic_capture_with_feature_flags(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
|
||||
@@ -106,24 +264,187 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_get_active_feature_flags(self, patch_decide):
|
||||
patch_decide.return_value = {
|
||||
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
|
||||
}
|
||||
|
||||
def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
|
||||
variants = client._get_active_feature_variants("some_id", None, None, None, False)
|
||||
self.assertEqual(variants, {"beta-feature": "random-variant", "alpha-feature": True})
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
distinct_id="some_id",
|
||||
groups={},
|
||||
person_properties=None,
|
||||
group_properties=None,
|
||||
disable_geoip=False,
|
||||
|
||||
multivariate_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature-local",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
},
|
||||
{
|
||||
"rollout_percentage": 50,
|
||||
},
|
||||
],
|
||||
"multivariate": {
|
||||
"variants": [
|
||||
{"key": "first-variant", "name": "First Variant", "rollout_percentage": 50},
|
||||
{"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25},
|
||||
{"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25},
|
||||
]
|
||||
},
|
||||
"payloads": {"first-variant": "some-payload", "third-variant": {"a": "json"}},
|
||||
},
|
||||
}
|
||||
basic_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "region",
|
||||
"operator": "exact",
|
||||
"value": ["USA"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
"payloads": {"true": 300},
|
||||
},
|
||||
}
|
||||
false_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "false-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [],
|
||||
"rollout_percentage": 0,
|
||||
}
|
||||
],
|
||||
"payloads": {"true": 300},
|
||||
},
|
||||
}
|
||||
client.feature_flags = [multivariate_flag, basic_flag, false_flag]
|
||||
|
||||
success, msg = client.capture("distinct_id", "python test event")
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertIsNone(msg.get("uuid"))
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
self.assertEqual(msg["properties"]["$feature/beta-feature-local"], "third-variant")
|
||||
self.assertEqual(msg["properties"]["$feature/false-flag"], False)
|
||||
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature-local"])
|
||||
assert "$feature/beta-feature" not in msg["properties"]
|
||||
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
|
||||
# test that flags are not evaluated without local evaluation
|
||||
client.feature_flags = []
|
||||
success, msg = client.capture("distinct_id", "python test event")
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
assert "$feature/beta-feature" not in msg["properties"]
|
||||
assert "$feature/beta-feature-local" not in msg["properties"]
|
||||
assert "$feature/false-flag" not in msg["properties"]
|
||||
assert "$active_feature_flags" not in msg["properties"]
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_dont_override_capture_with_local_flags(self, patch_decide):
|
||||
patch_decide.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
|
||||
|
||||
multivariate_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature-local",
|
||||
"is_simple_flag": False,
|
||||
"active": True,
|
||||
"rollout_percentage": 100,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
},
|
||||
{
|
||||
"rollout_percentage": 50,
|
||||
},
|
||||
],
|
||||
"multivariate": {
|
||||
"variants": [
|
||||
{"key": "first-variant", "name": "First Variant", "rollout_percentage": 50},
|
||||
{"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25},
|
||||
{"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25},
|
||||
]
|
||||
},
|
||||
"payloads": {"first-variant": "some-payload", "third-variant": {"a": "json"}},
|
||||
},
|
||||
}
|
||||
basic_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "region",
|
||||
"operator": "exact",
|
||||
"value": ["USA"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
"payloads": {"true": 300},
|
||||
},
|
||||
}
|
||||
client.feature_flags = [multivariate_flag, basic_flag]
|
||||
|
||||
success, msg = client.capture(
|
||||
"distinct_id", "python test event", {"$feature/beta-feature-local": "my-custom-variant"}
|
||||
)
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg["event"], "python test event")
|
||||
self.assertTrue(isinstance(msg["timestamp"], str))
|
||||
self.assertIsNone(msg.get("uuid"))
|
||||
self.assertEqual(msg["distinct_id"], "distinct_id")
|
||||
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
|
||||
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
|
||||
self.assertEqual(msg["properties"]["$feature/beta-feature-local"], "my-custom-variant")
|
||||
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature-local"])
|
||||
assert "$feature/beta-feature" not in msg["properties"]
|
||||
assert "$feature/person-flag" not in msg["properties"]
|
||||
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_basic_capture_with_feature_flags_returns_active_only(self, patch_decide):
|
||||
@@ -151,8 +472,8 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
"https://us.i.posthog.com",
|
||||
timeout=3,
|
||||
distinct_id="distinct_id",
|
||||
groups={},
|
||||
person_properties=None,
|
||||
@@ -167,7 +488,12 @@ class TestClient(unittest.TestCase):
|
||||
}
|
||||
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY, disable_geoip=True
|
||||
FAKE_TEST_API_KEY,
|
||||
host="https://app.posthog.com",
|
||||
on_error=self.set_fail,
|
||||
personal_api_key=FAKE_TEST_API_KEY,
|
||||
disable_geoip=True,
|
||||
feature_flags_request_timeout_seconds=12,
|
||||
)
|
||||
success, msg = client.capture("distinct_id", "python test event", send_feature_flags=True, disable_geoip=False)
|
||||
client.flush()
|
||||
@@ -188,8 +514,8 @@ class TestClient(unittest.TestCase):
|
||||
self.assertEqual(patch_decide.call_count, 1)
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
"https://us.i.posthog.com",
|
||||
timeout=12,
|
||||
distinct_id="distinct_id",
|
||||
groups={},
|
||||
person_properties=None,
|
||||
@@ -616,11 +942,11 @@ class TestClient(unittest.TestCase):
|
||||
client.get_feature_flag("random_key", "some_id", disable_geoip=True)
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
"https://us.i.posthog.com",
|
||||
timeout=3,
|
||||
distinct_id="some_id",
|
||||
groups={},
|
||||
person_properties={},
|
||||
person_properties={"distinct_id": "some_id"},
|
||||
group_properties={},
|
||||
disable_geoip=True,
|
||||
)
|
||||
@@ -628,11 +954,11 @@ class TestClient(unittest.TestCase):
|
||||
client.feature_enabled("random_key", "feature_enabled_distinct_id", disable_geoip=True)
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
"https://us.i.posthog.com",
|
||||
timeout=3,
|
||||
distinct_id="feature_enabled_distinct_id",
|
||||
groups={},
|
||||
person_properties={},
|
||||
person_properties={"distinct_id": "feature_enabled_distinct_id"},
|
||||
group_properties={},
|
||||
disable_geoip=True,
|
||||
)
|
||||
@@ -640,11 +966,11 @@ class TestClient(unittest.TestCase):
|
||||
client.get_all_flags_and_payloads("all_flags_payloads_id")
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
"https://us.i.posthog.com",
|
||||
timeout=3,
|
||||
distinct_id="all_flags_payloads_id",
|
||||
groups={},
|
||||
person_properties={},
|
||||
person_properties={"distinct_id": "all_flags_payloads_id"},
|
||||
group_properties={},
|
||||
disable_geoip=False,
|
||||
)
|
||||
@@ -660,3 +986,70 @@ class TestClient(unittest.TestCase):
|
||||
client.feature_flags = [{"key": "example", "is_simple_flag": False}]
|
||||
|
||||
self.assertFalse(client.feature_enabled("example", "distinct_id"))
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
def test_default_properties_get_added_properly(self, patch_decide):
|
||||
patch_decide.return_value = {
|
||||
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
|
||||
}
|
||||
client = Client(FAKE_TEST_API_KEY, host="http://app2.posthog.com", on_error=self.set_fail, disable_geoip=False)
|
||||
client.get_feature_flag(
|
||||
"random_key",
|
||||
"some_id",
|
||||
groups={"company": "id:5", "instance": "app.posthog.com"},
|
||||
person_properties={"x1": "y1"},
|
||||
group_properties={"company": {"x": "y"}},
|
||||
)
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
"http://app2.posthog.com",
|
||||
timeout=3,
|
||||
distinct_id="some_id",
|
||||
groups={"company": "id:5", "instance": "app.posthog.com"},
|
||||
person_properties={"distinct_id": "some_id", "x1": "y1"},
|
||||
group_properties={
|
||||
"company": {"$group_key": "id:5", "x": "y"},
|
||||
"instance": {"$group_key": "app.posthog.com"},
|
||||
},
|
||||
disable_geoip=False,
|
||||
)
|
||||
|
||||
patch_decide.reset_mock()
|
||||
client.get_feature_flag(
|
||||
"random_key",
|
||||
"some_id",
|
||||
groups={"company": "id:5", "instance": "app.posthog.com"},
|
||||
person_properties={"distinct_id": "override"},
|
||||
group_properties={
|
||||
"company": {
|
||||
"$group_key": "group_override",
|
||||
}
|
||||
},
|
||||
)
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
"http://app2.posthog.com",
|
||||
timeout=3,
|
||||
distinct_id="some_id",
|
||||
groups={"company": "id:5", "instance": "app.posthog.com"},
|
||||
person_properties={"distinct_id": "override"},
|
||||
group_properties={
|
||||
"company": {"$group_key": "group_override"},
|
||||
"instance": {"$group_key": "app.posthog.com"},
|
||||
},
|
||||
disable_geoip=False,
|
||||
)
|
||||
|
||||
patch_decide.reset_mock()
|
||||
# test nones
|
||||
client.get_all_flags_and_payloads("some_id", groups={}, person_properties=None, group_properties=None)
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
"http://app2.posthog.com",
|
||||
timeout=3,
|
||||
distinct_id="some_id",
|
||||
groups={},
|
||||
person_properties={"distinct_id": "some_id"},
|
||||
group_properties={},
|
||||
disable_geoip=False,
|
||||
)
|
||||
|
||||
@@ -145,15 +145,20 @@ class TestConsumer(unittest.TestCase):
|
||||
def test_max_batch_size(self):
|
||||
q = Queue()
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=100000, flush_interval=3)
|
||||
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
|
||||
properties = {}
|
||||
for n in range(0, 500):
|
||||
properties[str(n)] = "one_long_property_value_to_build_a_big_event"
|
||||
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id", "properties": properties}
|
||||
msg_size = len(json.dumps(track).encode())
|
||||
# number of messages in a maximum-size batch
|
||||
n_msgs = int(475000 / msg_size)
|
||||
# Let's capture 8MB of data to trigger two batches
|
||||
n_msgs = int(8_000_000 / msg_size)
|
||||
|
||||
def mock_post_fn(_, data, **kwargs):
|
||||
res = mock.Mock()
|
||||
res.status_code = 200
|
||||
self.assertTrue(len(data.encode()) < 500000, "batch size (%d) exceeds 500KB limit" % len(data.encode()))
|
||||
request_size = len(data.encode())
|
||||
# Batches close after the first message bringing it bigger than BATCH_SIZE_LIMIT, let's add 10% of margin
|
||||
self.assertTrue(request_size < (5 * 1024 * 1024) * 1.1, "batch size (%d) higher than limit" % request_size)
|
||||
return res
|
||||
|
||||
with mock.patch("posthog.request._session.post", side_effect=mock_post_fn) as mock_post:
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_excepthook(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
from posthog import Posthog
|
||||
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, 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": [{"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": [{"filename": "app.py", "abs_path"'
|
||||
in output
|
||||
)
|
||||
@@ -6,7 +6,7 @@ from dateutil import parser, tz
|
||||
from freezegun import freeze_time
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.feature_flags import InconclusiveMatchError, match_property
|
||||
from posthog.feature_flags import InconclusiveMatchError, match_property, relative_date_parse_for_feature_flag_matching
|
||||
from posthog.request import APIError
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
|
||||
@@ -960,6 +960,60 @@ class TestLocalEvaluation(unittest.TestCase):
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_capture.call_count, 0)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_flags_local_evaluation_None_values(self, patch_get, patch_decide):
|
||||
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
|
||||
client.feature_flags = [
|
||||
{
|
||||
id: 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "beta-feature",
|
||||
"is_simple_flag": True,
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"variant": None,
|
||||
"properties": [
|
||||
{"key": "latestBuildVersion", "type": "person", "value": ".+", "operator": "regex"},
|
||||
{"key": "latestBuildVersionMajor", "type": "person", "value": "23", "operator": "gt"},
|
||||
{"key": "latestBuildVersionMinor", "type": "person", "value": "31", "operator": "gt"},
|
||||
{"key": "latestBuildVersionPatch", "type": "person", "value": "0", "operator": "gt"},
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
feature_flag_match = client.get_feature_flag(
|
||||
"beta-feature",
|
||||
"some-distinct-id",
|
||||
person_properties={
|
||||
"latestBuildVersion": None,
|
||||
"latestBuildVersionMajor": None,
|
||||
"latestBuildVersionMinor": None,
|
||||
"latestBuildVersionPatch": None,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(feature_flag_match, False)
|
||||
self.assertEqual(patch_decide.call_count, 0)
|
||||
self.assertEqual(patch_get.call_count, 0)
|
||||
|
||||
feature_flag_match = client.get_feature_flag(
|
||||
"beta-feature",
|
||||
"some-distinct-id",
|
||||
person_properties={
|
||||
"latestBuildVersion": "24.32..1",
|
||||
"latestBuildVersionMajor": "24",
|
||||
"latestBuildVersionMinor": "32",
|
||||
"latestBuildVersionPatch": "1",
|
||||
},
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.decide")
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_feature_flags_local_evaluation_for_cohorts(self, patch_get, patch_decide):
|
||||
@@ -1714,7 +1768,7 @@ class TestMatchProperties(unittest.TestCase):
|
||||
self.assertTrue(match_property(property_a, {"key": "value"}))
|
||||
self.assertTrue(match_property(property_a, {"key": "value2"}))
|
||||
self.assertTrue(match_property(property_a, {"key": ""}))
|
||||
self.assertTrue(match_property(property_a, {"key": None}))
|
||||
self.assertFalse(match_property(property_a, {"key": None}))
|
||||
|
||||
with self.assertRaises(InconclusiveMatchError):
|
||||
match_property(property_a, {"key2": "value"})
|
||||
@@ -1775,7 +1829,8 @@ class TestMatchProperties(unittest.TestCase):
|
||||
|
||||
self.assertFalse(match_property(property_a, {"key": 0}))
|
||||
self.assertFalse(match_property(property_a, {"key": -1}))
|
||||
self.assertFalse(match_property(property_a, {"key": "23"}))
|
||||
# now we handle type mismatches so this should be true
|
||||
self.assertTrue(match_property(property_a, {"key": "23"}))
|
||||
|
||||
property_b = self.property(key="key", value=1, operator="lt")
|
||||
self.assertTrue(match_property(property_b, {"key": 0}))
|
||||
@@ -1792,7 +1847,8 @@ class TestMatchProperties(unittest.TestCase):
|
||||
|
||||
self.assertFalse(match_property(property_c, {"key": 0}))
|
||||
self.assertFalse(match_property(property_c, {"key": -1}))
|
||||
self.assertFalse(match_property(property_c, {"key": "3"}))
|
||||
# now we handle type mismatches so this should be true
|
||||
self.assertTrue(match_property(property_c, {"key": "3"}))
|
||||
|
||||
property_d = self.property(key="key", value="43", operator="lte")
|
||||
self.assertTrue(match_property(property_d, {"key": "41"}))
|
||||
@@ -1801,6 +1857,21 @@ class TestMatchProperties(unittest.TestCase):
|
||||
|
||||
self.assertFalse(match_property(property_d, {"key": "44"}))
|
||||
self.assertFalse(match_property(property_d, {"key": 44}))
|
||||
self.assertTrue(match_property(property_d, {"key": 42}))
|
||||
|
||||
property_e = self.property(key="key", value="30", operator="lt")
|
||||
self.assertTrue(match_property(property_e, {"key": "29"}))
|
||||
|
||||
# depending on the type of override, we adjust type comparison
|
||||
self.assertTrue(match_property(property_e, {"key": "100"}))
|
||||
self.assertFalse(match_property(property_e, {"key": 100}))
|
||||
|
||||
property_f = self.property(key="key", value="123aloha", operator="gt")
|
||||
self.assertFalse(match_property(property_f, {"key": "123"}))
|
||||
self.assertFalse(match_property(property_f, {"key": 122}))
|
||||
|
||||
# this turns into a string comparison
|
||||
self.assertTrue(match_property(property_f, {"key": 129}))
|
||||
|
||||
def test_match_property_date_operators(self):
|
||||
property_a = self.property(key="key", value="2022-05-01", operator="is_date_before")
|
||||
@@ -1854,6 +1925,303 @@ class TestMatchProperties(unittest.TestCase):
|
||||
self.assertTrue(match_property(property_d, {"key": "2022-04-05 11:34:11 +00:00"}))
|
||||
self.assertFalse(match_property(property_d, {"key": "2022-04-05 11:34:13 +00:00"}))
|
||||
|
||||
@freeze_time("2022-05-01")
|
||||
def test_match_property_relative_date_operators(self):
|
||||
property_a = self.property(key="key", value="-6h", operator="is_date_before")
|
||||
self.assertTrue(match_property(property_a, {"key": "2022-03-01"}))
|
||||
self.assertTrue(match_property(property_a, {"key": "2022-04-30"}))
|
||||
self.assertTrue(match_property(property_a, {"key": datetime.datetime(2022, 4, 30, 1, 2, 3)}))
|
||||
# false because date comparison, instead of datetime, so reduces to same date
|
||||
self.assertFalse(match_property(property_a, {"key": datetime.date(2022, 4, 30)}))
|
||||
|
||||
self.assertFalse(match_property(property_a, {"key": datetime.datetime(2022, 4, 30, 19, 2, 3)}))
|
||||
self.assertTrue(
|
||||
match_property(
|
||||
property_a,
|
||||
{"key": datetime.datetime(2022, 4, 30, 1, 2, 3, tzinfo=tz.gettz("Europe/Madrid"))},
|
||||
)
|
||||
)
|
||||
self.assertTrue(match_property(property_a, {"key": parser.parse("2022-04-30")}))
|
||||
self.assertFalse(match_property(property_a, {"key": "2022-05-30"}))
|
||||
|
||||
# Can't be a number
|
||||
with self.assertRaises(InconclusiveMatchError):
|
||||
match_property(property_a, {"key": 1})
|
||||
|
||||
# can't be invalid string
|
||||
with self.assertRaises(InconclusiveMatchError):
|
||||
match_property(property_a, {"key": "abcdef"})
|
||||
|
||||
property_b = self.property(key="key", value="1h", operator="is_date_after")
|
||||
self.assertTrue(match_property(property_b, {"key": "2022-05-02"}))
|
||||
self.assertTrue(match_property(property_b, {"key": "2022-05-30"}))
|
||||
self.assertTrue(match_property(property_b, {"key": datetime.datetime(2022, 5, 30)}))
|
||||
self.assertTrue(match_property(property_b, {"key": parser.parse("2022-05-30")}))
|
||||
self.assertFalse(match_property(property_b, {"key": "2022-04-30"}))
|
||||
|
||||
# can't be invalid string
|
||||
with self.assertRaises(InconclusiveMatchError):
|
||||
self.assertFalse(match_property(property_b, {"key": "abcdef"}))
|
||||
|
||||
# Invalid flag property
|
||||
property_c = self.property(key="key", value=1234, operator="is_date_after")
|
||||
|
||||
with self.assertRaises(InconclusiveMatchError):
|
||||
self.assertFalse(match_property(property_c, {"key": 1}))
|
||||
|
||||
# parsed as 1234-05-01 for some reason?
|
||||
self.assertTrue(match_property(property_c, {"key": "2022-05-30"}))
|
||||
|
||||
# # Timezone aware property
|
||||
property_d = self.property(key="key", value="12d", operator="is_date_before")
|
||||
self.assertFalse(match_property(property_d, {"key": "2022-05-30"}))
|
||||
|
||||
self.assertTrue(match_property(property_d, {"key": "2022-03-30"}))
|
||||
self.assertTrue(match_property(property_d, {"key": "2022-04-05 12:34:11+01:00"}))
|
||||
self.assertTrue(match_property(property_d, {"key": "2022-04-19 01:34:11+02:00"}))
|
||||
|
||||
self.assertFalse(match_property(property_d, {"key": "2022-04-19 02:00:01+02:00"}))
|
||||
|
||||
# Try all possible relative dates
|
||||
property_e = self.property(key="key", value="1h", operator="is_date_before")
|
||||
self.assertFalse(match_property(property_e, {"key": "2022-05-01 00:00:00"}))
|
||||
self.assertTrue(match_property(property_e, {"key": "2022-04-30 22:00:00"}))
|
||||
|
||||
property_f = self.property(key="key", value="-1d", operator="is_date_before")
|
||||
self.assertTrue(match_property(property_f, {"key": "2022-04-29 23:59:00"}))
|
||||
self.assertFalse(match_property(property_f, {"key": "2022-04-30 00:00:01"}))
|
||||
|
||||
property_g = self.property(key="key", value="1w", operator="is_date_before")
|
||||
self.assertTrue(match_property(property_g, {"key": "2022-04-23 00:00:00"}))
|
||||
self.assertFalse(match_property(property_g, {"key": "2022-04-24 00:00:00"}))
|
||||
self.assertFalse(match_property(property_g, {"key": "2022-04-24 00:00:01"}))
|
||||
|
||||
property_h = self.property(key="key", value="1m", operator="is_date_before")
|
||||
self.assertTrue(match_property(property_h, {"key": "2022-03-01 00:00:00"}))
|
||||
self.assertFalse(match_property(property_h, {"key": "2022-04-05 00:00:00"}))
|
||||
|
||||
property_i = self.property(key="key", value="1y", operator="is_date_before")
|
||||
self.assertTrue(match_property(property_i, {"key": "2021-04-28 00:00:00"}))
|
||||
self.assertFalse(match_property(property_i, {"key": "2021-05-01 00:00:01"}))
|
||||
|
||||
property_j = self.property(key="key", value="122h", operator="is_date_after")
|
||||
self.assertTrue(match_property(property_j, {"key": "2022-05-01 00:00:00"}))
|
||||
self.assertFalse(match_property(property_j, {"key": "2022-04-23 01:00:00"}))
|
||||
|
||||
property_k = self.property(key="key", value="2d", operator="is_date_after")
|
||||
self.assertTrue(match_property(property_k, {"key": "2022-05-01 00:00:00"}))
|
||||
self.assertTrue(match_property(property_k, {"key": "2022-04-29 00:00:01"}))
|
||||
self.assertFalse(match_property(property_k, {"key": "2022-04-29 00:00:00"}))
|
||||
|
||||
property_l = self.property(key="key", value="-02w", operator="is_date_after")
|
||||
self.assertTrue(match_property(property_l, {"key": "2022-05-01 00:00:00"}))
|
||||
self.assertFalse(match_property(property_l, {"key": "2022-04-16 00:00:00"}))
|
||||
|
||||
property_m = self.property(key="key", value="1m", operator="is_date_after")
|
||||
self.assertTrue(match_property(property_m, {"key": "2022-04-01 00:00:01"}))
|
||||
self.assertFalse(match_property(property_m, {"key": "2022-04-01 00:00:00"}))
|
||||
|
||||
property_n = self.property(key="key", value="1y", operator="is_date_after")
|
||||
self.assertTrue(match_property(property_n, {"key": "2022-05-01 00:00:00"}))
|
||||
self.assertTrue(match_property(property_n, {"key": "2021-05-01 00:00:01"}))
|
||||
self.assertFalse(match_property(property_n, {"key": "2021-05-01 00:00:00"}))
|
||||
self.assertFalse(match_property(property_n, {"key": "2021-04-30 00:00:00"}))
|
||||
self.assertFalse(match_property(property_n, {"key": "2021-03-01 12:13:00"}))
|
||||
|
||||
def test_none_property_value_with_all_operators(self):
|
||||
property_a = self.property(key="key", value="none", operator="is_not")
|
||||
self.assertFalse(match_property(property_a, {"key": None}))
|
||||
self.assertTrue(match_property(property_a, {"key": "non"}))
|
||||
|
||||
property_b = self.property(key="key", value=None, operator="is_set")
|
||||
self.assertFalse(match_property(property_b, {"key": None}))
|
||||
|
||||
property_c = self.property(key="key", value="no", operator="icontains")
|
||||
self.assertFalse(match_property(property_c, {"key": None}))
|
||||
self.assertFalse(match_property(property_c, {"key": "smh"}))
|
||||
|
||||
property_d = self.property(key="key", value="No", operator="regex")
|
||||
self.assertFalse(match_property(property_d, {"key": None}))
|
||||
|
||||
property_d_lower_case = self.property(key="key", value="no", operator="regex")
|
||||
self.assertFalse(match_property(property_d_lower_case, {"key": None}))
|
||||
|
||||
property_e = self.property(key="key", value=1, operator="gt")
|
||||
self.assertFalse(match_property(property_e, {"key": None}))
|
||||
|
||||
property_f = self.property(key="key", value=1, operator="lt")
|
||||
self.assertFalse(match_property(property_f, {"key": None}))
|
||||
|
||||
property_g = self.property(key="key", value="xyz", operator="gte")
|
||||
self.assertFalse(match_property(property_g, {"key": None}))
|
||||
|
||||
property_h = self.property(key="key", value="Oo", operator="lte")
|
||||
self.assertFalse(match_property(property_h, {"key": None}))
|
||||
|
||||
property_i = self.property(key="key", value="2022-05-01", operator="is_date_before")
|
||||
self.assertFalse(match_property(property_i, {"key": None}))
|
||||
|
||||
property_j = self.property(key="key", value="2022-05-01", operator="is_date_after")
|
||||
self.assertFalse(match_property(property_j, {"key": None}))
|
||||
|
||||
property_k = self.property(key="key", value="2022-05-01", operator="is_date_before")
|
||||
with self.assertRaises(InconclusiveMatchError):
|
||||
self.assertFalse(match_property(property_k, {"key": "random"}))
|
||||
|
||||
def test_unknown_operator(self):
|
||||
property_a = self.property(key="key", value="2022-05-01", operator="is_unknown")
|
||||
with self.assertRaises(InconclusiveMatchError) as exception_context:
|
||||
match_property(property_a, {"key": "random"})
|
||||
self.assertEqual(str(exception_context.exception), "Unknown operator is_unknown")
|
||||
|
||||
|
||||
class TestRelativeDateParsing(unittest.TestCase):
|
||||
def test_invalid_input(self):
|
||||
with freeze_time("2020-01-01T12:01:20.1340Z"):
|
||||
assert relative_date_parse_for_feature_flag_matching("1") is None
|
||||
assert relative_date_parse_for_feature_flag_matching("1x") is None
|
||||
assert relative_date_parse_for_feature_flag_matching("1.2y") is None
|
||||
assert relative_date_parse_for_feature_flag_matching("1z") is None
|
||||
assert relative_date_parse_for_feature_flag_matching("1s") is None
|
||||
assert relative_date_parse_for_feature_flag_matching("123344000.134m") is None
|
||||
assert relative_date_parse_for_feature_flag_matching("bazinga") is None
|
||||
assert relative_date_parse_for_feature_flag_matching("000bello") is None
|
||||
assert relative_date_parse_for_feature_flag_matching("000hello") is None
|
||||
|
||||
assert relative_date_parse_for_feature_flag_matching("000h") is not None
|
||||
assert relative_date_parse_for_feature_flag_matching("1000h") is not None
|
||||
|
||||
def test_overflow(self):
|
||||
assert relative_date_parse_for_feature_flag_matching("1000000h") is None
|
||||
assert relative_date_parse_for_feature_flag_matching("100000000000000000y") is None
|
||||
|
||||
def test_hour_parsing(self):
|
||||
with freeze_time("2020-01-01T12:01:20.1340Z"):
|
||||
assert relative_date_parse_for_feature_flag_matching("1h") == datetime.datetime(
|
||||
2020, 1, 1, 11, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("2h") == datetime.datetime(
|
||||
2020, 1, 1, 10, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("24h") == datetime.datetime(
|
||||
2019, 12, 31, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("30h") == datetime.datetime(
|
||||
2019, 12, 31, 6, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("48h") == datetime.datetime(
|
||||
2019, 12, 30, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
|
||||
assert relative_date_parse_for_feature_flag_matching(
|
||||
"24h"
|
||||
) == relative_date_parse_for_feature_flag_matching("1d")
|
||||
assert relative_date_parse_for_feature_flag_matching(
|
||||
"48h"
|
||||
) == relative_date_parse_for_feature_flag_matching("2d")
|
||||
|
||||
def test_day_parsing(self):
|
||||
with freeze_time("2020-01-01T12:01:20.1340Z"):
|
||||
assert relative_date_parse_for_feature_flag_matching("1d") == datetime.datetime(
|
||||
2019, 12, 31, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("2d") == datetime.datetime(
|
||||
2019, 12, 30, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("7d") == datetime.datetime(
|
||||
2019, 12, 25, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("14d") == datetime.datetime(
|
||||
2019, 12, 18, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("30d") == datetime.datetime(
|
||||
2019, 12, 2, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
|
||||
assert relative_date_parse_for_feature_flag_matching("7d") == relative_date_parse_for_feature_flag_matching(
|
||||
"1w"
|
||||
)
|
||||
|
||||
def test_week_parsing(self):
|
||||
with freeze_time("2020-01-01T12:01:20.1340Z"):
|
||||
assert relative_date_parse_for_feature_flag_matching("1w") == datetime.datetime(
|
||||
2019, 12, 25, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("2w") == datetime.datetime(
|
||||
2019, 12, 18, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("4w") == datetime.datetime(
|
||||
2019, 12, 4, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("8w") == datetime.datetime(
|
||||
2019, 11, 6, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
|
||||
assert relative_date_parse_for_feature_flag_matching("1m") == datetime.datetime(
|
||||
2019, 12, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("4w") != relative_date_parse_for_feature_flag_matching(
|
||||
"1m"
|
||||
)
|
||||
|
||||
def test_month_parsing(self):
|
||||
with freeze_time("2020-01-01T12:01:20.1340Z"):
|
||||
assert relative_date_parse_for_feature_flag_matching("1m") == datetime.datetime(
|
||||
2019, 12, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("2m") == datetime.datetime(
|
||||
2019, 11, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("4m") == datetime.datetime(
|
||||
2019, 9, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("8m") == datetime.datetime(
|
||||
2019, 5, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
|
||||
assert relative_date_parse_for_feature_flag_matching("1y") == datetime.datetime(
|
||||
2019, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching(
|
||||
"12m"
|
||||
) == relative_date_parse_for_feature_flag_matching("1y")
|
||||
|
||||
with freeze_time("2020-04-03T00:00:00"):
|
||||
assert relative_date_parse_for_feature_flag_matching("1m") == datetime.datetime(
|
||||
2020, 3, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("2m") == datetime.datetime(
|
||||
2020, 2, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("4m") == datetime.datetime(
|
||||
2019, 12, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("8m") == datetime.datetime(
|
||||
2019, 8, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
|
||||
assert relative_date_parse_for_feature_flag_matching("1y") == datetime.datetime(
|
||||
2019, 4, 3, 0, 0, 0, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching(
|
||||
"12m"
|
||||
) == relative_date_parse_for_feature_flag_matching("1y")
|
||||
|
||||
def test_year_parsing(self):
|
||||
with freeze_time("2020-01-01T12:01:20.1340Z"):
|
||||
assert relative_date_parse_for_feature_flag_matching("1y") == datetime.datetime(
|
||||
2019, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("2y") == datetime.datetime(
|
||||
2018, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("4y") == datetime.datetime(
|
||||
2016, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
assert relative_date_parse_for_feature_flag_matching("8y") == datetime.datetime(
|
||||
2012, 1, 1, 12, 1, 20, 134000, tzinfo=tz.gettz("UTC")
|
||||
)
|
||||
|
||||
|
||||
class TestCaptureCalls(unittest.TestCase):
|
||||
@mock.patch.object(Client, "capture")
|
||||
@@ -1888,7 +2256,12 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
|
||||
{
|
||||
"$feature_flag": "complex-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": True,
|
||||
"$feature/complex-flag": True,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
@@ -1913,7 +2286,12 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id2",
|
||||
"$feature_flag_called",
|
||||
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
|
||||
{
|
||||
"$feature_flag": "complex-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": True,
|
||||
"$feature/complex-flag": True,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
@@ -1946,7 +2324,12 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id2",
|
||||
"$feature_flag_called",
|
||||
{"$feature_flag": "decide-flag", "$feature_flag_response": "decide-value", "locally_evaluated": False},
|
||||
{
|
||||
"$feature_flag": "decide-flag",
|
||||
"$feature_flag_response": "decide-value",
|
||||
"locally_evaluated": False,
|
||||
"$feature/decide-flag": "decide-value",
|
||||
},
|
||||
groups={"organization": "org1"},
|
||||
disable_geoip=None,
|
||||
)
|
||||
@@ -1984,7 +2367,12 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
|
||||
{
|
||||
"$feature_flag": "complex-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": True,
|
||||
"$feature/complex-flag": True,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=False,
|
||||
)
|
||||
@@ -2018,7 +2406,12 @@ class TestCaptureCalls(unittest.TestCase):
|
||||
patch_capture.assert_called_with(
|
||||
distinct_id,
|
||||
"$feature_flag_called",
|
||||
{"$feature_flag": "complex-flag", "$feature_flag_response": True, "locally_evaluated": True},
|
||||
{
|
||||
"$feature_flag": "complex-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": True,
|
||||
"$feature/complex-flag": True,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,10 @@ from posthog import Posthog
|
||||
class TestModule(unittest.TestCase):
|
||||
posthog = None
|
||||
|
||||
def _assert_enqueue_result(self, result):
|
||||
self.assertEqual(type(result[0]), bool)
|
||||
self.assertEqual(type(result[1]), dict)
|
||||
|
||||
def failed(self):
|
||||
self.failed = True
|
||||
|
||||
@@ -22,15 +26,18 @@ class TestModule(unittest.TestCase):
|
||||
self.assertRaises(Exception, self.posthog.capture)
|
||||
|
||||
def test_track(self):
|
||||
self.posthog.capture("distinct_id", "python module event")
|
||||
res = self.posthog.capture("distinct_id", "python module event")
|
||||
self._assert_enqueue_result(res)
|
||||
self.posthog.flush()
|
||||
|
||||
def test_identify(self):
|
||||
self.posthog.identify("distinct_id", {"email": "user@email.com"})
|
||||
res = self.posthog.identify("distinct_id", {"email": "user@email.com"})
|
||||
self._assert_enqueue_result(res)
|
||||
self.posthog.flush()
|
||||
|
||||
def test_alias(self):
|
||||
self.posthog.alias("previousId", "distinct_id")
|
||||
res = self.posthog.alias("previousId", "distinct_id")
|
||||
self._assert_enqueue_result(res)
|
||||
self.posthog.flush()
|
||||
|
||||
def test_page(self):
|
||||
|
||||
@@ -2,9 +2,10 @@ import json
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from posthog.request import DatetimeSerializer, batch_post
|
||||
from posthog.request import DatetimeSerializer, batch_post, determine_server_host
|
||||
from posthog.test.test_utils import TEST_API_KEY
|
||||
|
||||
|
||||
@@ -42,3 +43,26 @@ class TestRequests(unittest.TestCase):
|
||||
batch_post(
|
||||
"key", batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=0.0001
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host, expected",
|
||||
[
|
||||
("https://t.posthog.com", "https://t.posthog.com"),
|
||||
("https://t.posthog.com/", "https://t.posthog.com/"),
|
||||
("t.posthog.com", "t.posthog.com"),
|
||||
("t.posthog.com/", "t.posthog.com/"),
|
||||
("https://us.posthog.com.rg.proxy.com", "https://us.posthog.com.rg.proxy.com"),
|
||||
("app.posthog.com", "app.posthog.com"),
|
||||
("eu.posthog.com", "eu.posthog.com"),
|
||||
("https://app.posthog.com", "https://us.i.posthog.com"),
|
||||
("https://eu.posthog.com", "https://eu.i.posthog.com"),
|
||||
("https://us.posthog.com", "https://us.i.posthog.com"),
|
||||
("https://app.posthog.com/", "https://us.i.posthog.com"),
|
||||
("https://eu.posthog.com/", "https://eu.i.posthog.com"),
|
||||
("https://us.posthog.com/", "https://us.i.posthog.com"),
|
||||
(None, "https://us.i.posthog.com"),
|
||||
],
|
||||
)
|
||||
def test_routing_to_custom_host(host, expected):
|
||||
assert determine_server_host(host) == expected
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
VERSION = "3.0.1"
|
||||
VERSION = "3.6.4"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
@@ -13,6 +13,7 @@ Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ extras_require = {
|
||||
"flake8-print",
|
||||
"pre-commit",
|
||||
],
|
||||
"test": ["mock>=2.0.0", "freezegun==0.3.15", "pylint", "flake8", "coverage", "pytest"],
|
||||
"test": ["mock>=2.0.0", "freezegun==0.3.15", "pylint", "flake8", "coverage", "pytest", "pytest-timeout", "django"],
|
||||
"sentry": ["sentry-sdk", "django"],
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ setup(
|
||||
maintainer="PostHog",
|
||||
maintainer_email="hey@posthog.com",
|
||||
test_suite="posthog.test.all",
|
||||
packages=["posthog", "posthog.test", "posthog.sentry"],
|
||||
packages=["posthog", "posthog.test", "posthog.sentry", "posthog.exception_integrations"],
|
||||
license="MIT License",
|
||||
install_requires=install_requires,
|
||||
extras_require=extras_require,
|
||||
|
||||
+6
-1
@@ -27,7 +27,12 @@ setup(
|
||||
maintainer="PostHog",
|
||||
maintainer_email="hey@posthog.com",
|
||||
test_suite="posthoganalytics.test.all",
|
||||
packages=["posthoganalytics", "posthoganalytics.test", "posthoganalytics.sentry"],
|
||||
packages=[
|
||||
"posthoganalytics",
|
||||
"posthoganalytics.test",
|
||||
"posthoganalytics.sentry",
|
||||
"posthoganalytics.exception_integrations",
|
||||
],
|
||||
license="MIT License",
|
||||
install_requires=install_requires,
|
||||
tests_require=tests_require,
|
||||
|
||||
Reference in New Issue
Block a user