Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c0a61d6b5 | ||
|
|
ffa35fa5cd | ||
|
|
24b7b918f7 | ||
|
|
16cbd10f1b | ||
|
|
b83d544931 | ||
|
|
72c0ed1935 | ||
|
|
5fdd6177ee | ||
|
|
fc1da7d589 | ||
|
|
cba6e86537 | ||
|
|
4e45255207 | ||
|
|
bc37351ab4 | ||
|
|
a5e8b7d7fb | ||
|
|
efb0ccf3c7 | ||
|
|
8554b51a48 | ||
|
|
d0d962a8ba | ||
|
|
e348106094 |
+54
-1
@@ -1,3 +1,56 @@
|
||||
## 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.
|
||||
@@ -98,7 +151,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
|
||||
+35
-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 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,
|
||||
@@ -405,6 +410,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 +457,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
|
||||
|
||||
+67
-31
@@ -8,9 +8,10 @@ from dateutil.tz import tzutc
|
||||
from six import string_types
|
||||
|
||||
from posthog.consumer import Consumer
|
||||
from posthog.exception_capture import ExceptionCapture
|
||||
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.request import APIError, batch_post, decide, determine_server_host, get
|
||||
from posthog.utils import SizeLimitedDict, clean, guess_timezone
|
||||
from posthog.version import VERSION
|
||||
|
||||
@@ -49,6 +50,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 +66,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
|
||||
self.host = determine_server_host(host)
|
||||
self.gzip = gzip
|
||||
self.timeout = timeout
|
||||
self.feature_flags = None
|
||||
@@ -69,10 +76,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 +96,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 +115,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 +153,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 +177,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,26 +212,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"][f"$feature/{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():
|
||||
msg["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:
|
||||
msg["properties"]["$active_feature_flags"] = active_feature_flags
|
||||
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)
|
||||
|
||||
@@ -381,7 +390,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
|
||||
|
||||
@@ -421,6 +437,9 @@ class Client(object):
|
||||
self.flush()
|
||||
self.join()
|
||||
|
||||
if self.exception_capture:
|
||||
self.exception_capture.close()
|
||||
|
||||
def _load_feature_flags(self):
|
||||
try:
|
||||
response = get(
|
||||
@@ -471,7 +490,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")
|
||||
|
||||
@@ -493,9 +521,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]
|
||||
@@ -724,7 +757,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)
|
||||
|
||||
@@ -744,6 +779,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:
|
||||
@@ -763,7 +799,7 @@ class Client(object):
|
||||
return self.feature_flags
|
||||
|
||||
def _add_local_person_and_group_properties(self, distinct_id, groups, person_properties, group_properties):
|
||||
all_person_properties = {"$current_distinct_id": distinct_id, **(person_properties or {})}
|
||||
all_person_properties = {"distinct_id": distinct_id, **(person_properties or {})}
|
||||
|
||||
all_group_properties = {}
|
||||
if groups:
|
||||
|
||||
+10
-1
@@ -36,6 +36,7 @@ class Consumer(Thread):
|
||||
gzip=False,
|
||||
retries=10,
|
||||
timeout=15,
|
||||
historical_migration=False,
|
||||
):
|
||||
"""Create a consumer thread."""
|
||||
Thread.__init__(self)
|
||||
@@ -55,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."""
|
||||
@@ -134,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,100 @@
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from posthog.exception_utils import exceptions_from_error_tuple, handle_in_app
|
||||
from posthog.utils import remove_trailing_slash
|
||||
|
||||
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, exc_type, exc_value, exc_traceback, metadata=None):
|
||||
try:
|
||||
# if hasattr(sys, "ps1"):
|
||||
# # Disable the excepthook for interactive Python shells
|
||||
# return
|
||||
|
||||
# Format stack trace like sentry
|
||||
all_exceptions_with_trace = exceptions_from_error_tuple((exc_type, exc_value, exc_traceback))
|
||||
|
||||
# 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"]
|
||||
|
||||
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
|
||||
|
||||
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.client.raw_host)}/project/{self.client.api_key}/person/{distinct_id}",
|
||||
}
|
||||
|
||||
# TODO: What distinct id should we attach these server-side exceptions to?
|
||||
# Any heuristic seems prone to errors - how can we know if exception occurred in the context of a user that captured some other event?
|
||||
|
||||
self.client.capture(distinct_id, "$exception", properties=properties)
|
||||
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]],
|
||||
},
|
||||
)
|
||||
@@ -13,6 +13,8 @@ __LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
NONE_VALUES_ALLOWED_OPERATORS = ["is_not"]
|
||||
|
||||
|
||||
class InconclusiveMatchError(Exception):
|
||||
pass
|
||||
@@ -119,6 +121,9 @@ def match_property(property, property_values) -> bool:
|
||||
|
||||
override_value = property_values[key]
|
||||
|
||||
if (operator not in NONE_VALUES_ALLOWED_OPERATORS) and override_value is None:
|
||||
return False
|
||||
|
||||
if operator in ("exact", "is_not"):
|
||||
|
||||
def compute_exact_match(value, override_value):
|
||||
@@ -175,11 +180,11 @@ def match_property(property, property_values) -> bool:
|
||||
else:
|
||||
return compare(str(override_value), str(value), operator)
|
||||
|
||||
if operator in ["is_date_before", "is_date_after", "is_relative_date_before", "is_relative_date_after"]:
|
||||
if operator in ["is_date_before", "is_date_after"]:
|
||||
try:
|
||||
if operator in ["is_relative_date_before", "is_relative_date_after"]:
|
||||
parsed_date = relative_date_parse_for_feature_flag_matching(str(value))
|
||||
else:
|
||||
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:
|
||||
@@ -190,12 +195,12 @@ def match_property(property, property_values) -> bool:
|
||||
|
||||
if isinstance(override_value, datetime.datetime):
|
||||
override_date = convert_to_datetime_aware(override_value)
|
||||
if operator in ("is_date_before", "is_relative_date_before"):
|
||||
if operator == "is_date_before":
|
||||
return override_date < parsed_date
|
||||
else:
|
||||
return override_date > parsed_date
|
||||
elif isinstance(override_value, datetime.date):
|
||||
if operator in ("is_date_before", "is_relative_date_before"):
|
||||
if operator == "is_date_before":
|
||||
return override_value < parsed_date.date()
|
||||
else:
|
||||
return override_value > parsed_date.date()
|
||||
@@ -203,7 +208,7 @@ def match_property(property, property_values) -> bool:
|
||||
try:
|
||||
override_date = parser.parse(override_value)
|
||||
override_date = convert_to_datetime_aware(override_date)
|
||||
if operator in ("is_date_before", "is_relative_date_before"):
|
||||
if operator == "is_date_before":
|
||||
return override_date < parsed_date
|
||||
else:
|
||||
return override_date > parsed_date
|
||||
@@ -302,7 +307,7 @@ def match_property_group(property_group, property_values, cohort_properties) ->
|
||||
|
||||
|
||||
def relative_date_parse_for_feature_flag_matching(value: str) -> Optional[datetime.datetime]:
|
||||
regex = r"^(?P<number>[0-9]+)(?P<interval>[a-z])$"
|
||||
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:
|
||||
|
||||
+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",
|
||||
}
|
||||
+105
-41
@@ -210,24 +210,83 @@ class TestClient(unittest.TestCase):
|
||||
assert "$active_feature_flags" not in msg["properties"]
|
||||
|
||||
@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_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)
|
||||
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},
|
||||
},
|
||||
}
|
||||
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):
|
||||
@@ -255,8 +314,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,
|
||||
@@ -271,7 +330,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()
|
||||
@@ -292,8 +356,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,
|
||||
@@ -720,11 +784,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={"$current_distinct_id": "some_id"},
|
||||
person_properties={"distinct_id": "some_id"},
|
||||
group_properties={},
|
||||
disable_geoip=True,
|
||||
)
|
||||
@@ -732,11 +796,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={"$current_distinct_id": "feature_enabled_distinct_id"},
|
||||
person_properties={"distinct_id": "feature_enabled_distinct_id"},
|
||||
group_properties={},
|
||||
disable_geoip=True,
|
||||
)
|
||||
@@ -744,11 +808,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={"$current_distinct_id": "all_flags_payloads_id"},
|
||||
person_properties={"distinct_id": "all_flags_payloads_id"},
|
||||
group_properties={},
|
||||
disable_geoip=False,
|
||||
)
|
||||
@@ -770,7 +834,7 @@ class TestClient(unittest.TestCase):
|
||||
patch_decide.return_value = {
|
||||
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
|
||||
}
|
||||
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disable_geoip=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",
|
||||
@@ -780,11 +844,11 @@ class TestClient(unittest.TestCase):
|
||||
)
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
"http://app2.posthog.com",
|
||||
timeout=3,
|
||||
distinct_id="some_id",
|
||||
groups={"company": "id:5", "instance": "app.posthog.com"},
|
||||
person_properties={"$current_distinct_id": "some_id", "x1": "y1"},
|
||||
person_properties={"distinct_id": "some_id", "x1": "y1"},
|
||||
group_properties={
|
||||
"company": {"$group_key": "id:5", "x": "y"},
|
||||
"instance": {"$group_key": "app.posthog.com"},
|
||||
@@ -797,7 +861,7 @@ class TestClient(unittest.TestCase):
|
||||
"random_key",
|
||||
"some_id",
|
||||
groups={"company": "id:5", "instance": "app.posthog.com"},
|
||||
person_properties={"$current_distinct_id": "override"},
|
||||
person_properties={"distinct_id": "override"},
|
||||
group_properties={
|
||||
"company": {
|
||||
"$group_key": "group_override",
|
||||
@@ -806,11 +870,11 @@ class TestClient(unittest.TestCase):
|
||||
)
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
"http://app2.posthog.com",
|
||||
timeout=3,
|
||||
distinct_id="some_id",
|
||||
groups={"company": "id:5", "instance": "app.posthog.com"},
|
||||
person_properties={"$current_distinct_id": "override"},
|
||||
person_properties={"distinct_id": "override"},
|
||||
group_properties={
|
||||
"company": {"$group_key": "group_override"},
|
||||
"instance": {"$group_key": "app.posthog.com"},
|
||||
@@ -823,11 +887,11 @@ class TestClient(unittest.TestCase):
|
||||
client.get_all_flags_and_payloads("some_id", groups={}, person_properties=None, group_properties=None)
|
||||
patch_decide.assert_called_with(
|
||||
"random_key",
|
||||
None,
|
||||
timeout=10,
|
||||
"http://app2.posthog.com",
|
||||
timeout=3,
|
||||
distinct_id="some_id",
|
||||
groups={},
|
||||
person_properties={"$current_distinct_id": "some_id"},
|
||||
person_properties={"distinct_id": "some_id"},
|
||||
group_properties={},
|
||||
disable_geoip=False,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"})
|
||||
@@ -1873,7 +1927,7 @@ class TestMatchProperties(unittest.TestCase):
|
||||
|
||||
@freeze_time("2022-05-01")
|
||||
def test_match_property_relative_date_operators(self):
|
||||
property_a = self.property(key="key", value="6h", operator="is_relative_date_before")
|
||||
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)}))
|
||||
@@ -1898,7 +1952,7 @@ class TestMatchProperties(unittest.TestCase):
|
||||
with self.assertRaises(InconclusiveMatchError):
|
||||
match_property(property_a, {"key": "abcdef"})
|
||||
|
||||
property_b = self.property(key="key", value="1h", operator="is_relative_date_after")
|
||||
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)}))
|
||||
@@ -1910,16 +1964,16 @@ class TestMatchProperties(unittest.TestCase):
|
||||
self.assertFalse(match_property(property_b, {"key": "abcdef"}))
|
||||
|
||||
# Invalid flag property
|
||||
property_c = self.property(key="key", value=1234, operator="is_relative_date_after")
|
||||
property_c = self.property(key="key", value=1234, operator="is_date_after")
|
||||
|
||||
with self.assertRaises(InconclusiveMatchError):
|
||||
self.assertFalse(match_property(property_c, {"key": 1}))
|
||||
|
||||
with self.assertRaises(InconclusiveMatchError):
|
||||
self.assertFalse(match_property(property_c, {"key": "2022-05-30"}))
|
||||
# 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_relative_date_before")
|
||||
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"}))
|
||||
@@ -1929,45 +1983,45 @@ class TestMatchProperties(unittest.TestCase):
|
||||
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_relative_date_before")
|
||||
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_relative_date_before")
|
||||
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_relative_date_before")
|
||||
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_relative_date_before")
|
||||
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_relative_date_before")
|
||||
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_relative_date_after")
|
||||
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_relative_date_after")
|
||||
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_relative_date_after")
|
||||
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_relative_date_after")
|
||||
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_relative_date_after")
|
||||
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"}))
|
||||
@@ -1980,20 +2034,20 @@ class TestMatchProperties(unittest.TestCase):
|
||||
self.assertTrue(match_property(property_a, {"key": "non"}))
|
||||
|
||||
property_b = self.property(key="key", value=None, operator="is_set")
|
||||
self.assertTrue(match_property(property_b, {"key": None}))
|
||||
self.assertFalse(match_property(property_b, {"key": None}))
|
||||
|
||||
property_c = self.property(key="key", value="no", operator="icontains")
|
||||
self.assertTrue(match_property(property_c, {"key": None}))
|
||||
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.assertTrue(match_property(property_d, {"key": None}))
|
||||
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.assertTrue(match_property(property_e, {"key": None}))
|
||||
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}))
|
||||
@@ -2002,15 +2056,13 @@ class TestMatchProperties(unittest.TestCase):
|
||||
self.assertFalse(match_property(property_g, {"key": None}))
|
||||
|
||||
property_h = self.property(key="key", value="Oo", operator="lte")
|
||||
self.assertTrue(match_property(property_h, {"key": None}))
|
||||
self.assertFalse(match_property(property_h, {"key": None}))
|
||||
|
||||
property_i = self.property(key="key", value="2022-05-01", operator="is_date_before")
|
||||
with self.assertRaises(InconclusiveMatchError):
|
||||
self.assertFalse(match_property(property_i, {"key": None}))
|
||||
self.assertFalse(match_property(property_i, {"key": None}))
|
||||
|
||||
property_j = self.property(key="key", value="2022-05-01", operator="is_date_after")
|
||||
with self.assertRaises(InconclusiveMatchError):
|
||||
self.assertFalse(match_property(property_j, {"key": None}))
|
||||
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):
|
||||
|
||||
@@ -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.3.0"
|
||||
VERSION = "3.6.2"
|
||||
|
||||
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", "pytest-timeout"],
|
||||
"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,
|
||||
|
||||
Reference in New Issue
Block a user