Compare commits

..
28 Commits
Author SHA1 Message Date
Sibin M SandGitHub 05932b3f13 [FEATURE]Add distinct_id to group_identify (#155)
* [FEATURE]Add distinct_id to group_identify

* [TESTS]Updated test cases for adding distinct_id to group_identify

* [LINT-FIX]client.py and test_client.py

* [CHORE]Verion bump and changelog update
2025-01-03 16:00:35 -05:00
Dylan MartinandGitHub 50c13563b2 fix: CI (#156)
* test CI

* heck it, upgrade python

* okay don't do anything silly with the cache hits i guess

* more CI upgrades :crossedfingers

* upgrade all CI to latest versions, then

* jk this is how python works

* whackamole

* what even

* yeesh

* this can't be it

* if this breaks ill kms

* dark magic dark MAGIC

* im giving up on my dreams
2025-01-03 15:50:21 -05:00
Dylan MartinandGitHub dca4af66ae Update CODEOWNERS (#154) 2025-01-02 12:52:51 -05:00
Dylan MartinandGitHub 9e1bb8c58a fix(flags): bump the version (#148) 2024-11-27 17:15:38 -05:00
fb57de2e12 fix(flags): correctly emit feature flag events with the FF response on get_feature_flag_payload calls (#143)
* this is the fix, needs tests

* fix test

* tests

* yeah

* please work

* ran the formatter

* code review feedback

* how'd this get here

* bump version add changelog

* Update CHANGELOG.md

Co-authored-by: David Newell <d.newell1@outlook.com>

---------

Co-authored-by: David Newell <d.newell1@outlook.com>
2024-11-25 14:51:06 -05:00
db565bc0fd fix(err): fix distinct_id, set personless and use a uuid (#144)
Co-authored-by: David Newell <david@posthog.com>
2024-11-25 12:09:57 +00:00
David NewellandGitHub 8ae3f2b623 chore: add type to stack (#142) 2024-11-19 12:46:52 +00:00
David NewellandGitHub 39f72a0070 chore: add lang to frames (#139) 2024-10-24 16:18:02 +01:00
David NewellandGitHub ee0305993d feat: add super properties (#138) 2024-10-03 17:07:47 +01:00
28c4802d9b Remove deprecated datetime.utcnow() in favour of datetime.now(tz=tzutc()) (#124)
Co-authored-by: Neil Kakkar <neilkakkar@gmail.com>
2024-09-24 11:09:15 +01:00
Neil KakkarandGitHub 67a343f242 fix(errors): Make sure project root exists to judge in app frames (#136)
* fix(errors): Make sure project root exists to judge in app frames

* prep release
2024-09-16 11:11:28 +01:00
Neil KakkarandGitHub 1521621d66 fix: Update Django integration for manual capture (#135) 2024-09-10 09:27:47 +01:00
Neil KakkarandGitHub 39070babfb feat(errors): Add manual exception capture (#134)
* feat(errors): Add manual exception capture

* prep release

* use backwards compatible helper

* add tests
2024-09-09 11:40:51 +01:00
Neil KakkarandGitHub 716eab0bc2 fix(setup): Make sure all packages are bundled (#133)
* fix(setup): Make sure all packages are bundled

* prep release

* black
2024-09-03 08:13:10 +01:00
Neil KakkarandGitHub 1c0a61d6b5 fix(setup): Make sure all packages are bundled (#132)
* fix(setup): Make sure all packages are bundled

* prep release
2024-09-03 07:23:54 +01:00
Neil KakkarandGitHub ffa35fa5cd feat(errors): Add django integration and in app frames (#131) 2024-09-03 06:10:36 +01:00
Neil KakkarandGitHub 24b7b918f7 feat(error-capture): Add basic exception autocapture (#128) 2024-08-28 06:59:41 +01:00
Phani RajandGitHub 16cbd10f1b bump version to 3.5.2 (#130) 2024-08-21 11:58:02 -05:00
Phani RajandGitHub b83d544931 fix(feature flags): Guard for None values when comparing person Properties (#129)
* Guard for None values when comparing person Properties
2024-08-21 11:18:20 -05:00
Frank HamandandGitHub 72c0ed1935 Switch us-api.i hosts to just us.i (#119)
We dropped the -api as it's a bit confusing (is capture really api?)
2024-08-14 08:41:10 +01:00
Neil KakkarandGitHub 5fdd6177ee Create CODEOWNERS (#122) 2024-05-02 18:02:13 +01:00
Neil KakkarandGitHub fc1da7d589 fix(flags): Add a shorter configurable timeout for flag requests (#120) 2024-03-04 14:25:11 +00:00
Brett HoernerandGitHub cba6e86537 Bump to 3.4.2 (#118) 2024-02-20 08:43:10 -07:00
Brett HoernerandGitHub 4e45255207 Add historical_migration option to toplevel Client (#117) 2024-02-15 06:42:43 -07:00
Neil KakkarandGitHub bc37351ab4 chore: Use ingestion hosts for event capture (#116) 2024-02-13 11:19:21 +00:00
Neil KakkarandGitHub a5e8b7d7fb fix(routing): Update hosts to point to right ingestion host (#115) 2024-02-05 12:14:14 +00:00
Neil KakkarandGitHub efb0ccf3c7 chore(flags): Update type hints for newer mypy versions (#114)
* chore(flags): Update type hints for newer mypy versions

* black
2024-01-30 16:06:10 +00:00
Neil KakkarandGitHub 8554b51a48 fix(flags): Update relative date op names (#113) 2024-01-26 15:52:46 +00:00
25 changed files with 2026 additions and 116 deletions
+5 -5
View File
@@ -18,7 +18,7 @@ jobs:
with:
python-version: 3.8
- uses: actions/cache@v1
- uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
@@ -47,14 +47,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v2
with:
fetch-depth: 1
- name: Set up Python 3.7
uses: actions/setup-python@v1
- name: Set up Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.7
python-version: 3.8
- name: Install requirements.txt dependencies with pip
run: |
+2 -1
View File
@@ -14,4 +14,5 @@ pylint.out
posthog-analytics
.idea
.python-version
.coverage
.coverage
pyrightconfig.json
+97 -6
View File
@@ -1,8 +1,95 @@
## 3.7.5 - 2025-01-03
1. Add `distinct_id` to group_identify
## 3.7.4 - 2024-11-25
1. Fix bug where this SDK incorrectly sent feature flag events with null values when calling `get_feature_flag_payload`.
## 3.7.3 - 2024-11-25
1. Use personless mode when sending an exception without a provided `distinct_id`.
## 3.7.2 - 2024-11-19
1. Add `type` property to exception stacks.
## 3.7.1 - 2024-10-24
1. Add `platform` property to each frame of exception stacks.
## 3.7.0 - 2024-10-03
1. Adds a new `super_properties` parameter on the client that are appended to every /capture call.
## 3.6.7 - 2024-09-24
1. Remove deprecated datetime.utcnow() in favour of datetime.now(tz=tzutc())
## 3.6.6 - 2024-09-16
1. Fix manual capture support for in app frames
## 3.6.5 - 2024-09-10
1. Fix django integration support for manual exception capture.
## 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.
@@ -28,6 +115,7 @@
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.
2. Add a `feature_flag_definitions()` debug option, which returns currently loaded feature flag definitions. You can use this to more cleverly decide when to request local evaluation of feature flags.
## 3.0.0 - 2023-04-14
Breaking change:
@@ -68,7 +156,6 @@ posthog = Posthog('api_key', disable_geoip=False)
1. Log instead of raise error on posthog personal api key errors
2. Remove upper bound on backoff dependency
## 2.3.0 - 2023-01-31
1. Add support for returning payloads of matched feature flags
@@ -84,6 +171,7 @@ Changes:
Changes:
1. Fixes issues with date comparison.
## 2.1.1 - 2022-09-14
Changes:
@@ -96,6 +184,7 @@ Changes:
1. Feature flag defaults have been removed
2. Setup logging only when debug mode is enabled.
## 2.0.1 - 2022-08-04
- Make poll_interval configurable
@@ -107,8 +196,8 @@ 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.
**Note: These are removed in 2.0.2**
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.
New Changes:
@@ -116,18 +205,20 @@ New Changes:
1. You can now evaluate feature flags locally (i.e. without sending a request to your PostHog servers) by setting a personal API key, and passing in groups and person properties to `is_feature_enabled` and `get_feature_flag` calls.
2. Introduces a `get_all_flags` method that returns all feature flags. This is useful for when you want to seed your frontend with some initial flags, given a user ID.
## 1.4.9 - 2022-06-13
- Support for sending feature flags with capture calls
## 1.4.8 - 2022-05-12
- Support multi variate feature flags
## 1.4.7 - 2022-04-25
- Allow feature flags usage without project_api_key
## 1.4.1 - 2021-05-28
- Fix packaging issues with Sentry integrations
## 1.4.0 - 2021-05-18
+1
View File
@@ -0,0 +1 @@
@PostHog/team-feature-flags
+1 -1
View File
@@ -1,7 +1,7 @@
# PostHog Python library example
# Import the library
import time
# import time
import posthog
+71 -7
View File
@@ -1,25 +1,33 @@
import datetime # noqa: F401
from typing import Callable, Dict, Optional, Tuple # 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
super_properties = None # type: Optional[Dict]
# Currently alpha, use at your own risk
enable_exception_autocapture = False # type: bool
exception_autocapture_integrations = [] # type: List[Integrations]
# Used to determine in app paths for exception autocapture. Defaults to the current working directory
project_root = None # type: Optional[str]
default_client = None
default_client = None # type: Optional[Client]
def capture(
@@ -246,6 +254,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,
properties=properties,
context=context,
timestamp=timestamp,
uuid=uuid,
groups=groups,
)
def feature_enabled(
key, # type: str
distinct_id, # type: str
@@ -405,6 +457,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 +504,13 @@ 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,
super_properties=super_properties,
# 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
+187 -24
View File
@@ -1,17 +1,21 @@
import atexit
import logging
import numbers
import os
import sys
from datetime import datetime, timedelta
from uuid import UUID
from uuid import UUID, uuid4
from dateutil.tz import tzutc
from six import string_types
from posthog.consumer import Consumer
from posthog.exception_capture import 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 +53,12 @@ class Client(object):
project_api_key=None,
disabled=False,
disable_geoip=True,
historical_migration=False,
feature_flags_request_timeout_seconds=3,
super_properties=None,
enable_exception_autocapture=False,
exception_autocapture_integrations=None,
project_root=None,
):
self.queue = queue.Queue(max_queue_size)
@@ -61,7 +71,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 +81,24 @@ 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.super_properties = super_properties
self.enable_exception_autocapture = enable_exception_autocapture
self.exception_autocapture_integrations = exception_autocapture_integrations
self.exception_capture = None
if project_root is None:
try:
project_root = os.getcwd()
except Exception:
project_root = None
self.project_root = project_root
# personal_api_key: This should be a generated Personal API Key, private
self.personal_api_key = personal_api_key
@@ -84,6 +110,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 +129,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)
@@ -143,6 +173,15 @@ class Client(object):
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
return resp_data["featureFlagPayloads"]
def get_feature_flags_and_payloads(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
):
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
return {
"featureFlags": resp_data["featureFlags"],
"featureFlagPayloads": resp_data["featureFlagPayloads"],
}
def get_decide(self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None):
require("distinct_id", distinct_id, ID_TYPES)
@@ -161,7 +200,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
@@ -265,6 +304,7 @@ class Client(object):
timestamp=None,
uuid=None,
disable_geoip=None,
distinct_id=None,
):
properties = properties or {}
context = context or {}
@@ -272,6 +312,11 @@ class Client(object):
require("group_key", group_key, ID_TYPES)
require("properties", properties, dict)
if distinct_id:
require("distinct_id", distinct_id, ID_TYPES)
else:
distinct_id = "${}_{}".format(group_type, group_key)
msg = {
"event": "$groupidentify",
"properties": {
@@ -279,7 +324,7 @@ class Client(object):
"$group_key": group_key,
"$group_set": properties,
},
"distinct_id": "${}_{}".format(group_type, group_key),
"distinct_id": distinct_id,
"timestamp": timestamp,
"context": context,
"uuid": uuid,
@@ -329,6 +374,65 @@ class Client(object):
return self._enqueue(msg, disable_geoip)
def capture_exception(
self,
exception=None,
distinct_id=None,
properties=None,
context=None,
timestamp=None,
uuid=None,
groups=None,
):
# 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 {}
# if there's no distinct_id, we'll generate one and set personless mode
# via $process_person_profile = false
if distinct_id is None:
properties["$process_person_profile"] = False
distinct_id = uuid4()
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
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 for cymbal
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,
},
},
project_root=self.project_root,
)
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)`"""
@@ -337,7 +441,7 @@ class Client(object):
timestamp = msg["timestamp"]
if timestamp is None:
timestamp = datetime.utcnow().replace(tzinfo=tzutc())
timestamp = datetime.now(tz=tzutc())
require("timestamp", timestamp, datetime)
require("context", msg["context"], dict)
@@ -363,6 +467,9 @@ class Client(object):
if disable_geoip:
msg["properties"]["$geoip_disable"] = True
if self.super_properties:
msg["properties"] = {**msg["properties"], **self.super_properties}
msg["distinct_id"] = stringify_id(msg.get("distinct_id", None))
msg = clean(msg)
@@ -374,7 +481,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
@@ -414,6 +528,9 @@ class Client(object):
self.flush()
self.join()
if self.exception_capture:
self.exception_capture.close()
def _load_feature_flags(self):
try:
response = get(
@@ -451,7 +568,7 @@ class Client(object):
)
self.log.warning(e)
self._last_feature_flag_poll = datetime.utcnow().replace(tzinfo=tzutc())
self._last_feature_flag_poll = datetime.now(tz=tzutc())
def load_feature_flags(self):
if not self.personal_api_key:
@@ -464,7 +581,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")
@@ -486,9 +612,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]
@@ -630,23 +761,52 @@ class Client(object):
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
send_feature_flag_events=send_feature_flag_events,
only_evaluate_locally=True,
send_feature_flag_events=False,
# Disable automatic sending of feature flag events because we're manually handling event dispatch.
# This prevents sending events with empty data when `get_feature_flag` cannot be evaluated locally.
only_evaluate_locally=True, # Enable local evaluation of feature flags to avoid making multiple requests to `/decide`.
disable_geoip=disable_geoip,
)
response = None
payload = None
if match_value is not None:
response = self._compute_payload_locally(key, match_value)
payload = self._compute_payload_locally(key, match_value)
if response is None and not only_evaluate_locally:
decide_payloads = self.get_feature_payloads(
distinct_id, groups, person_properties, group_properties, disable_geoip
flag_was_locally_evaluated = payload is not None
if not flag_was_locally_evaluated and not only_evaluate_locally:
try:
responses_and_payloads = self.get_feature_flags_and_payloads(
distinct_id, groups, person_properties, group_properties, disable_geoip
)
response = responses_and_payloads["featureFlags"].get(key, None)
payload = responses_and_payloads["featureFlagPayloads"].get(str(key).lower(), None)
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
feature_flag_reported_key = f"{key}_{str(response)}"
if (
feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]
and send_feature_flag_events # noqa: W503
):
self.capture(
distinct_id,
"$feature_flag_called",
{
"$feature_flag": key,
"$feature_flag_response": response,
"$feature_flag_payload": payload,
"locally_evaluated": flag_was_locally_evaluated,
f"$feature/{key}": response,
},
groups=groups,
disable_geoip=disable_geoip,
)
response = decide_payloads.get(str(key).lower(), None)
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
return response
return payload
def _compute_payload_locally(self, key, match_value):
payload = None
@@ -717,7 +877,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)
@@ -737,6 +899,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:
@@ -756,7 +919,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
View File
@@ -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()
+64
View File
@@ -0,0 +1,64 @@
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"
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:
distinct_id = metadata.get("distinct_id") if metadata else None
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`.
"""
+88
View File
@@ -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)
+873
View File
@@ -0,0 +1,873 @@
# 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 = {
"platform": "python",
"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, "type": "raw"}
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, "type": "raw"}
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 -8
View File
@@ -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:
+16 -2
View File
@@ -13,17 +13,31 @@ 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:
"""Post the `kwargs` to the API"""
log = logging.getLogger("posthog")
body = kwargs
body["sentAt"] = datetime.utcnow().replace(tzinfo=tzutc()).isoformat()
body["sentAt"] = datetime.now(tz=tzutc()).isoformat()
url = remove_trailing_slash(host or DEFAULT_HOST) + path
body["api_key"] = api_key
data = json.dumps(body, cls=DatetimeSerializer)
+3 -3
View File
@@ -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",
}
+256 -25
View File
@@ -84,6 +84,184 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
def test_basic_super_properties(self):
client = Client(FAKE_TEST_API_KEY, super_properties={"source": "repo-name"})
_, msg = client.capture("distinct_id", "python test event")
client.flush()
self.assertEqual(msg["event"], "python test event")
self.assertEqual(msg["properties"]["source"], "repo-name")
_, msg = client.identify("distinct_id", {"trait": "value"})
client.flush()
self.assertEqual(msg["$set"]["trait"], "value")
self.assertEqual(msg["properties"]["source"], "repo-name")
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, distinct_id="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_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(distinct_id="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")
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"]["type"],
"raw",
)
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"
)
self.assertEqual(capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["in_app"], True)
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"}}
@@ -314,8 +492,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,
@@ -330,7 +508,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()
@@ -351,8 +534,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,
@@ -532,6 +715,25 @@ class TestClient(unittest.TestCase):
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertIsNone(msg.get("uuid"))
def test_basic_group_identify_with_distinct_id(self):
success, msg = self.client.group_identify("organization", "id:5", distinct_id="distinct_id")
self.assertTrue(success)
self.assertEqual(msg["event"], "$groupidentify")
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(
msg["properties"],
{
"$group_type": "organization",
"$group_key": "id:5",
"$group_set": {},
"$lib": "posthog-python",
"$lib_version": VERSION,
"$geoip_disable": True,
},
)
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertIsNone(msg.get("uuid"))
def test_advanced_group_identify(self):
success, msg = self.client.group_identify(
"organization", "id:5", {"trait": "value"}, {"ip": "192.168.0.1"}, datetime(2014, 9, 3), "new-uuid"
@@ -554,6 +756,35 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
def test_advanced_group_identify_with_distinct_id(self):
success, msg = self.client.group_identify(
"organization",
"id:5",
{"trait": "value"},
{"ip": "192.168.0.1"},
datetime(2014, 9, 3),
"new-uuid",
distinct_id="distinct_id",
)
self.assertTrue(success)
self.assertEqual(msg["event"], "$groupidentify")
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(
msg["properties"],
{
"$group_type": "organization",
"$group_key": "id:5",
"$group_set": {"trait": "value"},
"$lib": "posthog-python",
"$lib_version": VERSION,
"$geoip_disable": True,
},
)
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
self.assertEqual(msg["context"]["ip"], "192.168.0.1")
def test_basic_alias(self):
client = self.client
success, msg = client.alias("previousId", "distinct_id")
@@ -779,11 +1010,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,
)
@@ -791,11 +1022,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,
)
@@ -803,11 +1034,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,
)
@@ -829,7 +1060,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",
@@ -839,11 +1070,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"},
@@ -856,7 +1087,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",
@@ -865,11 +1096,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"},
@@ -882,11 +1113,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,
)
+63
View File
@@ -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": [{"platform": "python", "filename": "app.py", "abs_path"'
in output
)
def test_trying_to_use_django_integration(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
from posthog import Posthog, Integrations
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, exception_autocapture_integrations=[Integrations.Django], debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
# frame_value = "LOL"
1/0
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output
assert b"ZeroDivisionError" in output
assert b"LOL" in output
assert b"DEBUG:posthog:data uploaded successfully" in output
assert (
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
in output
)
+165 -28
View File
@@ -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):
@@ -1578,9 +1632,10 @@ class TestLocalEvaluation(unittest.TestCase):
)
self.assertEqual(patch_decide.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_boolean_feature_flag_payload_decide(self, patch_decide):
patch_decide.return_value = {"featureFlagPayloads": {"person-flag": 300}}
def test_boolean_feature_flag_payload_decide(self, patch_decide, patch_capture):
patch_decide.return_value = {"featureFlags": {"person-flag": True}, "featureFlagPayloads": {"person-flag": 300}}
self.assertEqual(
self.client.get_feature_flag_payload(
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
@@ -1595,6 +1650,8 @@ class TestLocalEvaluation(unittest.TestCase):
300,
)
self.assertEqual(patch_decide.call_count, 2)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.reset_mock()
@mock.patch("posthog.client.decide")
def test_multivariate_feature_flag_payloads(self, patch_decide):
@@ -1714,7 +1771,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 +1930,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 +1955,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 +1967,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 +1986,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 +2037,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 +2059,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):
@@ -2282,6 +2337,88 @@ class TestCaptureCalls(unittest.TestCase):
disable_geoip=None,
)
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_capture_is_called_in_get_feature_flag_payload(self, patch_decide, patch_capture):
patch_decide.return_value = {
"featureFlags": {"person-flag": True},
"featureFlagPayloads": {"person-flag": 300},
}
client = Client(api_key=FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
{
"id": 1,
"name": "Beta Feature",
"key": "person-flag",
"is_simple_flag": False,
"active": True,
"filters": {
"groups": [
{
"properties": [{"key": "region", "value": "USA"}],
"rollout_percentage": 100,
}
],
},
}
]
# Call get_feature_flag_payload with match_value=None to trigger get_feature_flag
client.get_feature_flag_payload(
key="person-flag", distinct_id="some-distinct-id", person_properties={"region": "USA", "name": "Aloha"}
)
# Assert that capture was called once, with the correct parameters
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"$feature_flag_payload": 300,
"locally_evaluated": False,
"$feature/person-flag": True,
},
groups={},
disable_geoip=None,
)
# Reset mocks for further tests
patch_capture.reset_mock()
patch_decide.reset_mock()
# Call get_feature_flag_payload again for the same user; capture should not be called again because we've already reported an event for this distinct_id + flag
client.get_feature_flag_payload(
key="person-flag", distinct_id="some-distinct-id", person_properties={"region": "USA", "name": "Aloha"}
)
self.assertEqual(patch_capture.call_count, 0)
patch_capture.reset_mock()
# Call get_feature_flag_payload for a different user; capture should be called
client.get_feature_flag_payload(
key="person-flag", distinct_id="some-distinct-id2", person_properties={"region": "USA", "name": "Aloha"}
)
self.assertEqual(patch_capture.call_count, 1)
patch_capture.assert_called_with(
"some-distinct-id2",
"$feature_flag_called",
{
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"$feature_flag_payload": 300,
"locally_evaluated": False,
"$feature/person-flag": True,
},
groups={},
disable_geoip=None,
)
patch_capture.reset_mock()
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.decide")
def test_disable_geoip_get_flag_capture_call(self, patch_decide, patch_capture):
+25 -1
View File
@@ -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
View File
@@ -1,4 +1,4 @@
VERSION = "3.3.2"
VERSION = "3.7.5"
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
+2 -2
View File
@@ -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,
+6 -1
View File
@@ -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,