Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
190c628c7a | ||
|
|
78ab0ca8b5 | ||
|
|
c5bfc1377a | ||
|
|
6b1c0dc313 | ||
|
|
e51b883e7b | ||
|
|
66101c92bf | ||
|
|
05932b3f13 | ||
|
|
50c13563b2 | ||
|
|
dca4af66ae | ||
|
|
9e1bb8c58a | ||
|
|
fb57de2e12 | ||
|
|
db565bc0fd | ||
|
|
8ae3f2b623 | ||
|
|
39f72a0070 | ||
|
|
ee0305993d | ||
|
|
28c4802d9b | ||
|
|
67a343f242 | ||
|
|
1521621d66 | ||
|
|
39070babfb | ||
|
|
716eab0bc2 | ||
|
|
1c0a61d6b5 | ||
|
|
ffa35fa5cd | ||
|
|
24b7b918f7 | ||
|
|
16cbd10f1b | ||
|
|
b83d544931 | ||
|
|
72c0ed1935 | ||
|
|
5fdd6177ee | ||
|
|
fc1da7d589 | ||
|
|
cba6e86537 | ||
|
|
4e45255207 | ||
|
|
bc37351ab4 | ||
|
|
a5e8b7d7fb | ||
|
|
efb0ccf3c7 | ||
|
|
8554b51a48 | ||
|
|
d0d962a8ba | ||
|
|
e348106094 | ||
|
|
e60d52c199 | ||
|
|
a2c73d0536 | ||
|
|
33ba5d6843 | ||
|
|
3515c40483 | ||
|
|
139258cacb | ||
|
|
f75d924d4c | ||
|
|
617bb53501 | ||
|
|
4aa3499527 | ||
|
|
de7def97e2 | ||
|
|
dfefd0a1b6 | ||
|
|
477a688016 | ||
|
|
fa474a0fe6 | ||
|
|
f8bc3f17eb | ||
|
|
07277d35e7 | ||
|
|
15d0716744 | ||
|
|
b4103b3ae2 | ||
|
|
1aeffa990f | ||
|
|
5ae7feb4e9 | ||
|
|
6534afd8e3 | ||
|
|
a9d7bf3e0b | ||
|
|
d7be253ef8 | ||
|
|
592c0f362e | ||
|
|
c7fc5a83b4 | ||
|
|
ae8817b611 | ||
|
|
acad2b142e | ||
|
|
cb62570e69 | ||
|
|
33645ecd3c | ||
|
|
81debcef27 | ||
|
|
dac06bab18 | ||
|
|
2dc1298620 | ||
|
|
2c6b675be7 | ||
|
|
3a6fd07951 | ||
|
|
de0ccd29d3 | ||
|
|
addd2e3340 | ||
|
|
9d2fa72753 | ||
|
|
306fb2a1fa | ||
|
|
faffd1f88a | ||
|
|
ab1399d88f | ||
|
|
1777b7062e | ||
|
|
565bb8a0eb | ||
|
|
009cac8634 | ||
|
|
ec2425996c | ||
|
|
90fa0a0604 | ||
|
|
a97fe0a40a | ||
|
|
a474fcff93 | ||
|
|
a181ba718f | ||
|
|
b996f3a4e9 | ||
|
|
6c945a0624 | ||
|
|
ab8ccb4dff | ||
|
|
870f6f8b6b | ||
|
|
9e0aeaefe6 | ||
|
|
a8409960b9 | ||
|
|
deb078293a | ||
|
|
edb8b7891e | ||
|
|
727bdb2b1e | ||
|
|
0781a1280e | ||
|
|
8b2ed8bb12 | ||
|
|
a139795a74 | ||
|
|
11f1d06761 |
@@ -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') }}
|
||||
@@ -34,6 +34,10 @@ jobs:
|
||||
run: |
|
||||
black --check .
|
||||
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
flake8 posthog --ignore E501
|
||||
|
||||
- name: Check import order with isort
|
||||
run: |
|
||||
isort --check-only .
|
||||
@@ -43,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.9
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.7
|
||||
python-version: 3.9
|
||||
|
||||
- name: Install requirements.txt dependencies with pip
|
||||
run: |
|
||||
@@ -58,4 +62,4 @@ jobs:
|
||||
|
||||
- name: Run posthog tests
|
||||
run: |
|
||||
python setup.py test
|
||||
pytest --verbose --timeout=30
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: 'Release'
|
||||
|
||||
on:
|
||||
- workflow_dispatch
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Publish release
|
||||
runs-on: ubuntu-20.04
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
|
||||
- name: Detect version
|
||||
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare for building release
|
||||
run: pip install -U pip setuptools wheel twine
|
||||
|
||||
- name: Push release to PyPI
|
||||
run: make release && make release_analytics
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ env.REPO_VERSION }}
|
||||
release_name: ${{ env.REPO_VERSION }}
|
||||
+3
-1
@@ -14,4 +14,6 @@ pylint.out
|
||||
posthog-analytics
|
||||
.idea
|
||||
.python-version
|
||||
.coverage
|
||||
.coverage
|
||||
pyrightconfig.json
|
||||
.env
|
||||
|
||||
+232
@@ -1,4 +1,236 @@
|
||||
## 3.8.3 - 2025-01-14
|
||||
|
||||
1. Fix setuptools to include the `posthog.ai.openai` and `posthog.ai.langchain` packages for the `posthoganalytics` package.
|
||||
|
||||
## 3.8.2 - 2025-01-14
|
||||
|
||||
1. Fix setuptools to include the `posthog.ai.openai` and `posthog.ai.langchain` packages.
|
||||
|
||||
## 3.8.1 - 2025-01-14
|
||||
|
||||
1. Add LLM Observability with support for OpenAI and Langchain callbacks.
|
||||
|
||||
## 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.
|
||||
|
||||
## 3.3.0 - 2024-01-09
|
||||
|
||||
1. When local evaluation is enabled, we automatically add flag information to all events sent to PostHog, whenever possible. This makes it easier to use these events in experiments.
|
||||
|
||||
## 3.2.0 - 2024-01-09
|
||||
|
||||
1. Numeric property handling for feature flags now does the expected: When passed in a number, we do a numeric comparison. When passed in a string, we do a string comparison. Previously, we always did a string comparison.
|
||||
2. Add support for relative date operators for local evaluation.
|
||||
|
||||
## 3.1.0 - 2023-12-04
|
||||
|
||||
1. Increase maximum event size and batch size
|
||||
|
||||
## 3.0.2 - 2023-08-17
|
||||
|
||||
1. Returns the current flag property with $feature_flag_called events, to make it easier to use in experiments
|
||||
|
||||
## 3.0.1 - 2023-04-21
|
||||
|
||||
1. Restore how feature flags work when the client library is disabled: All requests return `None` and no events are sent when the client is disabled.
|
||||
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:
|
||||
|
||||
All events by default now send the `$geoip_disable` property to disable geoip lookup in app. This is because usually we don't
|
||||
want to update person properties to take the server's location.
|
||||
|
||||
The same now happens for feature flag requests, where we discard the IP address of the server for matching on geoip properties like city, country, continent.
|
||||
|
||||
To restore previous behaviour, you can set the default to False like so:
|
||||
|
||||
```python
|
||||
posthog.disable_geoip = False
|
||||
|
||||
# // and if using client instantiation:
|
||||
posthog = Posthog('api_key', disable_geoip=False)
|
||||
|
||||
```
|
||||
|
||||
## 2.5.0 - 2023-04-10
|
||||
|
||||
1. Add option for instantiating separate client object
|
||||
|
||||
## 2.4.2 - 2023-03-30
|
||||
|
||||
1. Update backoff dependency for posthoganalytics package to be the same as posthog package
|
||||
|
||||
## 2.4.1 - 2023-03-17
|
||||
|
||||
1. Removes accidental print call left in for decide response
|
||||
|
||||
## 2.4.0 - 2023-03-14
|
||||
|
||||
1. Support evaluating all cohorts in feature flags for local evaluation
|
||||
|
||||
## 2.3.1 - 2023-02-07
|
||||
|
||||
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
|
||||
|
||||
## 2.2.0 - 2022-11-14
|
||||
|
||||
Changes:
|
||||
|
||||
1. Add support for feature flag variant overrides with local evaluation
|
||||
|
||||
## 2.1.2 - 2022-09-15
|
||||
|
||||
Changes:
|
||||
|
||||
1. Fixes issues with date comparison.
|
||||
|
||||
## 2.1.1 - 2022-09-14
|
||||
|
||||
Changes:
|
||||
|
||||
1. Feature flags local evaluation now supports date property filters as well. Accepts both strings and datetime objects.
|
||||
|
||||
## 2.1.0 - 2022-08-11
|
||||
|
||||
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
|
||||
- Add `send_feature_flag_events` parameter to feature flag calls, which determine whether the `$feature_flag_called` event should be sent or not.
|
||||
- Add `only_evaluate_locally` parameter to feature flag calls, which determines whether the feature flag should only be evaluated locally or not.
|
||||
|
||||
## 2.0.0 - 2022-08-02
|
||||
|
||||
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**
|
||||
3. Feature flag remote evaluation doesn't require a personal API key.
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@PostHog/team-feature-flags
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2020 PostHog (part of Hiberly Inc)
|
||||
Copyright (c) 2023 PostHog (part of Hiberly Inc)
|
||||
|
||||
Copyright (c) 2013 Segment Inc. friends@segment.com
|
||||
|
||||
|
||||
@@ -2,28 +2,24 @@
|
||||
|
||||
[](https://pypi.org/project/posthog/)
|
||||
|
||||
Please see the main [PostHog docs](https://posthog.com/docs).
|
||||
|
||||
Specifically, the [Python integration](https://posthog.com/docs/integrations/python-integration) details.
|
||||
Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details.
|
||||
|
||||
## Questions?
|
||||
## Development
|
||||
|
||||
### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ)
|
||||
|
||||
# Local Development
|
||||
|
||||
## Testing Locally
|
||||
### Testing Locally
|
||||
|
||||
1. Run `python3 -m venv env` (creates virtual environment called "env")
|
||||
2. Run `source env/bin/activate` (activates the virtual environment)
|
||||
3. Run `python3 -m pip install -e ".[test]"` (installs the package in develop mode, along with test dependencies)
|
||||
4. Run `make test`
|
||||
1. To run a specific test do `pytest -k test_no_api_key`
|
||||
|
||||
## Running Locally
|
||||
### Running Locally
|
||||
|
||||
Assuming you have a [local version of PostHog](https://posthog.com/docs/developing-locally) running, you can run `python3 example.py` to see the library in action.
|
||||
|
||||
## Running the Django Sentry Integration Locally
|
||||
### Running the Django Sentry Integration Locally
|
||||
|
||||
There's a sample Django project included, called `sentry_django_example`, which explains how to use PostHog with Sentry.
|
||||
|
||||
@@ -40,3 +36,11 @@ There's 2 places of importance (Changes required are all marked with TODO in the
|
||||
To run things: `make django_example`. This installs the posthog-python library with the sentry-sdk add-on, and then runs the django app.
|
||||
Also start the PostHog app locally.
|
||||
Then navigate to `http://127.0.0.1:8080/sentry-debug/` and you should get an event in both Sentry and PostHog, with links to each other.
|
||||
|
||||
### Releasing Versions
|
||||
|
||||
Updated are released using GitHub Actions: after bumping `version.py` in `master` and adding to `CHANGELOG.md`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
|
||||
|
||||
## Questions?
|
||||
|
||||
### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ)
|
||||
|
||||
+64
-10
@@ -1,41 +1,67 @@
|
||||
# PostHog Python library example
|
||||
|
||||
# Import the library
|
||||
import time
|
||||
# import time
|
||||
|
||||
import posthog
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
# You can find this key on the /setup page in PostHog
|
||||
posthog.project_api_key = ""
|
||||
posthog.personal_api_key = ""
|
||||
posthog.project_api_key = "phc_gtWmTq3Pgl06u4sZY3TRcoQfp42yfuXHKoe8ZVSR6Kh"
|
||||
posthog.personal_api_key = "phx_fiRCOQkTA3o2ePSdLrFDAILLHjMu2Mv52vUi8MNruIm"
|
||||
|
||||
# Where you host PostHog, with no trailing /.
|
||||
# You can remove this line if you're using posthog.com
|
||||
posthog.host = "http://127.0.0.1:8000"
|
||||
posthog.host = "http://localhost:8000"
|
||||
posthog.poll_interval = 10
|
||||
|
||||
print(
|
||||
posthog.feature_enabled(
|
||||
"person-on-events-enabled",
|
||||
"12345",
|
||||
groups={"organization": str("0182ee91-8ef7-0000-4cb9-fedc5f00926a")},
|
||||
group_properties={
|
||||
"organization": {
|
||||
"id": "0182ee91-8ef7-0000-4cb9-fedc5f00926a",
|
||||
"created_at": "2022-06-30 11:44:52.984121+00:00",
|
||||
}
|
||||
},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Capture an event
|
||||
posthog.capture("distinct_id", "event", {"property1": "value", "property2": "value"})
|
||||
posthog.capture("distinct_id", "event", {"property1": "value", "property2": "value"}, send_feature_flags=True)
|
||||
|
||||
print(posthog.feature_enabled("beta-feature", "distinct_id"))
|
||||
|
||||
print("sleeping")
|
||||
time.sleep(5)
|
||||
print(posthog.feature_enabled("beta-feature-groups", "distinct_id", groups={"company": "id:5"}))
|
||||
|
||||
print(posthog.feature_enabled("beta-feature", "distinct_id"))
|
||||
|
||||
# get payload
|
||||
print(posthog.get_feature_flag_payload("beta-feature", "distinct_id"))
|
||||
print(posthog.get_all_flags_and_payloads("distinct_id"))
|
||||
exit()
|
||||
# # Alias a previous distinct id with a new one
|
||||
|
||||
posthog.alias("distinct_id", "new_distinct_id")
|
||||
|
||||
posthog.capture("new_distinct_id", "event2", {"property1": "value", "property2": "value"})
|
||||
posthog.capture(
|
||||
"new_distinct_id", "event-with-groups", {"property1": "value", "property2": "value"}, groups={"company": "id:5"}
|
||||
)
|
||||
|
||||
# # Add properties to the person
|
||||
posthog.identify("new_distinct_id", {"email": "something@something.com"})
|
||||
|
||||
# Add properties to a group
|
||||
posthog.group_identify("company", "id:5", {"employees": 11})
|
||||
|
||||
# properties set only once to the person
|
||||
posthog.set_once("new_distinct_id", {"self_serve_signup": True})
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
posthog.set_once(
|
||||
"new_distinct_id", {"self_serve_signup": False}
|
||||
@@ -44,4 +70,32 @@ posthog.set_once(
|
||||
posthog.set("new_distinct_id", {"current_browser": "Chrome"})
|
||||
posthog.set("new_distinct_id", {"current_browser": "Firefox"})
|
||||
|
||||
# posthog.shutdown()
|
||||
|
||||
# #############################################################################
|
||||
# Make sure you have a personal API key for the examples below
|
||||
|
||||
# Local Evaluation
|
||||
|
||||
# If flag has City=Sydney, this call doesn't go to `/decide`
|
||||
print(posthog.feature_enabled("test-flag", "distinct_id_random_22", person_properties={"$geoip_city_name": "Sydney"}))
|
||||
|
||||
print(
|
||||
posthog.feature_enabled(
|
||||
"test-flag",
|
||||
"distinct_id_random_22",
|
||||
person_properties={"$geoip_city_name": "Sydney"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
print(posthog.get_all_flags("distinct_id_random_22"))
|
||||
print(posthog.get_all_flags("distinct_id_random_22", only_evaluate_locally=True))
|
||||
print(
|
||||
posthog.get_all_flags(
|
||||
"distinct_id_random_22", person_properties={"$geoip_city_name": "Sydney"}, only_evaluate_locally=True
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
posthog.shutdown()
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import posthog
|
||||
from posthog.ai.openai import AsyncOpenAI, OpenAI
|
||||
|
||||
# Example credentials - replace these with your own or use environment variables
|
||||
posthog.project_api_key = os.getenv("POSTHOG_PROJECT_API_KEY", "your-project-api-key")
|
||||
posthog.personal_api_key = os.getenv("POSTHOG_PERSONAL_API_KEY", "your-personal-api-key")
|
||||
posthog.host = os.getenv("POSTHOG_HOST", "http://localhost:8000") # Or https://app.posthog.com
|
||||
posthog.debug = True
|
||||
|
||||
openai_client = OpenAI(
|
||||
api_key=os.getenv("OPENAI_API_KEY", "your-openai-api-key"),
|
||||
posthog_client=posthog,
|
||||
)
|
||||
|
||||
async_openai_client = AsyncOpenAI(
|
||||
api_key=os.getenv("OPENAI_API_KEY", "your-openai-api-key"),
|
||||
posthog_client=posthog,
|
||||
)
|
||||
|
||||
|
||||
def main_sync():
|
||||
trace_id = str(uuid.uuid4())
|
||||
print("Trace ID:", trace_id)
|
||||
distinct_id = "test2_distinct_id"
|
||||
properties = {"test_property": "test_value"}
|
||||
|
||||
try:
|
||||
basic_openai_call(distinct_id, trace_id, properties)
|
||||
streaming_openai_call(distinct_id, trace_id, properties)
|
||||
embedding_openai_call(distinct_id, trace_id, properties)
|
||||
image_openai_call()
|
||||
except Exception as e:
|
||||
print("Error during OpenAI call:", str(e))
|
||||
|
||||
|
||||
async def main_async():
|
||||
trace_id = str(uuid.uuid4())
|
||||
print("Trace ID:", trace_id)
|
||||
distinct_id = "test_distinct_id"
|
||||
properties = {"test_property": "test_value"}
|
||||
|
||||
try:
|
||||
await basic_async_openai_call(distinct_id, trace_id, properties)
|
||||
await streaming_async_openai_call(distinct_id, trace_id, properties)
|
||||
await embedding_async_openai_call(distinct_id, trace_id, properties)
|
||||
await image_async_openai_call()
|
||||
except Exception as e:
|
||||
print("Error during OpenAI call:", str(e))
|
||||
|
||||
|
||||
def basic_openai_call(distinct_id, trace_id, properties):
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a complex problem solver."},
|
||||
{"role": "user", "content": "Explain quantum computing in simple terms."},
|
||||
],
|
||||
max_tokens=100,
|
||||
temperature=0.7,
|
||||
posthog_distinct_id=distinct_id,
|
||||
posthog_trace_id=trace_id,
|
||||
posthog_properties=properties,
|
||||
)
|
||||
print(response)
|
||||
if response and response.choices:
|
||||
print("OpenAI response:", response.choices[0].message.content)
|
||||
else:
|
||||
print("No response or unexpected format returned.")
|
||||
return response
|
||||
|
||||
|
||||
async def basic_async_openai_call(distinct_id, trace_id, properties):
|
||||
response = await async_openai_client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a complex problem solver."},
|
||||
{"role": "user", "content": "Explain quantum computing in simple terms."},
|
||||
],
|
||||
max_tokens=100,
|
||||
temperature=0.7,
|
||||
posthog_distinct_id=distinct_id,
|
||||
posthog_trace_id=trace_id,
|
||||
posthog_properties=properties,
|
||||
)
|
||||
if response and hasattr(response, "choices"):
|
||||
print("OpenAI response:", response.choices[0].message.content)
|
||||
else:
|
||||
print("No response or unexpected format returned.")
|
||||
return response
|
||||
|
||||
|
||||
def streaming_openai_call(distinct_id, trace_id, properties):
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a complex problem solver."},
|
||||
{"role": "user", "content": "Explain quantum computing in simple terms."},
|
||||
],
|
||||
max_tokens=100,
|
||||
temperature=0.7,
|
||||
stream=True,
|
||||
posthog_distinct_id=distinct_id,
|
||||
posthog_trace_id=trace_id,
|
||||
posthog_properties=properties,
|
||||
)
|
||||
|
||||
for chunk in response:
|
||||
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
|
||||
print(chunk.choices[0].delta.content or "", end="")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def streaming_async_openai_call(distinct_id, trace_id, properties):
|
||||
response = await async_openai_client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a complex problem solver."},
|
||||
{"role": "user", "content": "Explain quantum computing in simple terms."},
|
||||
],
|
||||
max_tokens=100,
|
||||
temperature=0.7,
|
||||
stream=True,
|
||||
posthog_distinct_id=distinct_id,
|
||||
posthog_trace_id=trace_id,
|
||||
posthog_properties=properties,
|
||||
)
|
||||
|
||||
async for chunk in response:
|
||||
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
|
||||
print(chunk.choices[0].delta.content or "", end="")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# none instrumented
|
||||
def image_openai_call():
|
||||
response = openai_client.images.generate(model="dall-e-3", prompt="A cute baby hedgehog", n=1, size="1024x1024")
|
||||
print(response)
|
||||
return response
|
||||
|
||||
|
||||
# none instrumented
|
||||
async def image_async_openai_call():
|
||||
response = await async_openai_client.images.generate(
|
||||
model="dall-e-3", prompt="A cute baby hedgehog", n=1, size="1024x1024"
|
||||
)
|
||||
print(response)
|
||||
return response
|
||||
|
||||
|
||||
def embedding_openai_call(posthog_distinct_id, posthog_trace_id, posthog_properties):
|
||||
response = openai_client.embeddings.create(
|
||||
input="The hedgehog is cute",
|
||||
model="text-embedding-3-small",
|
||||
posthog_distinct_id=posthog_distinct_id,
|
||||
posthog_trace_id=posthog_trace_id,
|
||||
posthog_properties=posthog_properties,
|
||||
)
|
||||
print(response)
|
||||
return response
|
||||
|
||||
|
||||
async def embedding_async_openai_call(posthog_distinct_id, posthog_trace_id, posthog_properties):
|
||||
response = await async_openai_client.embeddings.create(
|
||||
input="The hedgehog is cute",
|
||||
model="text-embedding-3-small",
|
||||
posthog_distinct_id=posthog_distinct_id,
|
||||
posthog_trace_id=posthog_trace_id,
|
||||
posthog_properties=posthog_properties,
|
||||
)
|
||||
print(response)
|
||||
return response
|
||||
|
||||
|
||||
# HOW TO RUN:
|
||||
# comment out one of these to run the other
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_sync()
|
||||
|
||||
# asyncio.run(main_async())
|
||||
+306
-41
@@ -1,22 +1,33 @@
|
||||
from typing import Callable, Dict, Optional
|
||||
import datetime # 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(
|
||||
@@ -25,9 +36,12 @@ def capture(
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
uuid=None, # type: Optional[str]
|
||||
groups=None, # type: Optional[Dict]
|
||||
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.
|
||||
|
||||
@@ -38,31 +52,39 @@ def capture(
|
||||
|
||||
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
|
||||
posthog.capture('distinct id', 'opened app')
|
||||
posthog.capture('distinct id', 'movie played', {'movie_id': '123', 'category': 'romcom'})
|
||||
|
||||
posthog.capture('distinct id', 'purchase', groups={'company': 'id:5'})
|
||||
```
|
||||
"""
|
||||
_proxy(
|
||||
return _proxy(
|
||||
"capture",
|
||||
distinct_id=distinct_id,
|
||||
event=event,
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
message_id=message_id,
|
||||
uuid=uuid,
|
||||
groups=groups,
|
||||
send_feature_flags=send_feature_flags,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def identify(
|
||||
distinct_id, # type: str,
|
||||
distinct_id, # type: str
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
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.
|
||||
|
||||
@@ -78,24 +100,26 @@ def identify(
|
||||
})
|
||||
```
|
||||
"""
|
||||
_proxy(
|
||||
return _proxy(
|
||||
"identify",
|
||||
distinct_id=distinct_id,
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
message_id=message_id,
|
||||
uuid=uuid,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def set(
|
||||
distinct_id, # type: str,
|
||||
distinct_id, # type: str
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
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`.
|
||||
@@ -111,24 +135,26 @@ def set(
|
||||
})
|
||||
```
|
||||
"""
|
||||
_proxy(
|
||||
return _proxy(
|
||||
"set",
|
||||
distinct_id=distinct_id,
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
message_id=message_id,
|
||||
uuid=uuid,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def set_once(
|
||||
distinct_id, # type: str,
|
||||
distinct_id, # type: str
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
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`.
|
||||
@@ -144,29 +170,63 @@ def set_once(
|
||||
})
|
||||
```
|
||||
"""
|
||||
_proxy(
|
||||
return _proxy(
|
||||
"set_once",
|
||||
distinct_id=distinct_id,
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
message_id=message_id,
|
||||
uuid=uuid,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def group(*args, **kwargs):
|
||||
"""Send a group call."""
|
||||
_proxy("group", *args, **kwargs)
|
||||
def group_identify(
|
||||
group_type, # type: str
|
||||
group_key, # type: str
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> Tuple[bool, dict]
|
||||
"""
|
||||
Set properties on a group
|
||||
|
||||
A `group_identify` call requires
|
||||
- `group_type` type of your group
|
||||
- `group_key` unique identifier of the group
|
||||
- `properties` with a dict with any key: value pairs
|
||||
|
||||
For example:
|
||||
```python
|
||||
posthog.group_identify('company', 5, {
|
||||
'employees': 11,
|
||||
})
|
||||
```
|
||||
"""
|
||||
return _proxy(
|
||||
"group_identify",
|
||||
group_type=group_type,
|
||||
group_key=group_key,
|
||||
properties=properties,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def alias(
|
||||
previous_id, # type: str,
|
||||
distinct_id, # type: str,
|
||||
previous_id, # type: str
|
||||
distinct_id, # type: str
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
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?"
|
||||
|
||||
@@ -183,20 +243,70 @@ def alias(
|
||||
posthog.alias('anonymous session id', 'distinct id')
|
||||
```
|
||||
"""
|
||||
_proxy(
|
||||
return _proxy(
|
||||
"alias",
|
||||
previous_id=previous_id,
|
||||
distinct_id=distinct_id,
|
||||
context=context,
|
||||
timestamp=timestamp,
|
||||
message_id=message_id,
|
||||
uuid=uuid,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
default=False, # type: bool
|
||||
key, # type: str
|
||||
distinct_id, # type: str
|
||||
groups={}, # type: dict
|
||||
person_properties={}, # type: dict
|
||||
group_properties={}, # type: dict
|
||||
only_evaluate_locally=False, # type: bool
|
||||
send_feature_flag_events=True, # type: bool
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> bool
|
||||
"""
|
||||
@@ -206,11 +316,150 @@ def feature_enabled(
|
||||
```python
|
||||
if posthog.feature_enabled('beta feature', 'distinct id'):
|
||||
# do something
|
||||
if posthog.feature_enabled('groups feature', 'distinct id', groups={"organization": "5"}):
|
||||
# do something
|
||||
```
|
||||
|
||||
You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.
|
||||
"""
|
||||
return _proxy("feature_enabled", key=key, distinct_id=distinct_id, default=default)
|
||||
return _proxy(
|
||||
"feature_enabled",
|
||||
key=key,
|
||||
distinct_id=distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def get_feature_flag(
|
||||
key, # type: str
|
||||
distinct_id, # type: str
|
||||
groups={}, # type: dict
|
||||
person_properties={}, # type: dict
|
||||
group_properties={}, # type: dict
|
||||
only_evaluate_locally=False, # type: bool
|
||||
send_feature_flag_events=True, # type: bool
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
"""
|
||||
Get feature flag variant for users. Used with experiments.
|
||||
Example:
|
||||
```python
|
||||
if posthog.get_feature_flag('beta-feature', 'distinct_id') == 'test-variant':
|
||||
# do test variant code
|
||||
if posthog.get_feature_flag('beta-feature', 'distinct_id') == 'control':
|
||||
# do control code
|
||||
```
|
||||
|
||||
`groups` are a mapping from group type to group key. So, if you have a group type of "organization" and a group key of "5",
|
||||
you would pass groups={"organization": "5"}.
|
||||
|
||||
`group_properties` take the format: { group_type_name: { group_properties } }
|
||||
|
||||
So, for example, if you have the group type "organization" and the group key "5", with the properties name, and employee count,
|
||||
you'll send these as:
|
||||
|
||||
```python
|
||||
group_properties={"organization": {"name": "PostHog", "employees": 11}}
|
||||
```
|
||||
"""
|
||||
return _proxy(
|
||||
"get_feature_flag",
|
||||
key=key,
|
||||
distinct_id=distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def get_all_flags(
|
||||
distinct_id, # type: str
|
||||
groups={}, # type: dict
|
||||
person_properties={}, # type: dict
|
||||
group_properties={}, # type: dict
|
||||
only_evaluate_locally=False, # type: bool
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
"""
|
||||
Get all flags for a given user.
|
||||
Example:
|
||||
```python
|
||||
flags = posthog.get_all_flags('distinct_id')
|
||||
```
|
||||
|
||||
flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.
|
||||
"""
|
||||
return _proxy(
|
||||
"get_all_flags",
|
||||
distinct_id=distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def get_feature_flag_payload(
|
||||
key,
|
||||
distinct_id,
|
||||
match_value=None,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
return _proxy(
|
||||
"get_feature_flag_payload",
|
||||
key=key,
|
||||
distinct_id=distinct_id,
|
||||
match_value=match_value,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def get_all_flags_and_payloads(
|
||||
distinct_id,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
return _proxy(
|
||||
"get_all_flags_and_payloads",
|
||||
distinct_id=distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def feature_flag_definitions():
|
||||
"""Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded."""
|
||||
return _proxy("feature_flag_definitions")
|
||||
|
||||
|
||||
def load_feature_flags():
|
||||
"""Load feature flag definitions from PostHog."""
|
||||
return _proxy("load_feature_flags")
|
||||
|
||||
|
||||
def page(*args, **kwargs):
|
||||
@@ -242,8 +491,6 @@ def shutdown():
|
||||
def _proxy(method, *args, **kwargs):
|
||||
"""Create an analytics client if one doesn't exist and send to it."""
|
||||
global default_client
|
||||
if disabled:
|
||||
return None
|
||||
if not default_client:
|
||||
default_client = Client(
|
||||
api_key,
|
||||
@@ -254,7 +501,25 @@ def _proxy(method, *args, **kwargs):
|
||||
sync_mode=sync_mode,
|
||||
personal_api_key=personal_api_key,
|
||||
project_api_key=project_api_key,
|
||||
poll_interval=poll_interval,
|
||||
disabled=disabled,
|
||||
disable_geoip=disable_geoip,
|
||||
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
|
||||
default_client.disabled = disabled
|
||||
default_client.debug = debug
|
||||
|
||||
fn = getattr(default_client, method)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
|
||||
class Posthog(Client):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .callbacks import CallbackHandler
|
||||
|
||||
__all__ = ["CallbackHandler"]
|
||||
@@ -0,0 +1,413 @@
|
||||
try:
|
||||
import langchain # noqa: F401
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError("Please install LangChain to use this feature: 'pip install langchain'")
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
TypedDict,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from uuid import UUID
|
||||
|
||||
from langchain.callbacks.base import BaseCallbackHandler
|
||||
from langchain_core.messages import AIMessage, BaseMessage, FunctionMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
from langchain_core.outputs import ChatGeneration, LLMResult
|
||||
from pydantic import BaseModel
|
||||
|
||||
from posthog.ai.utils import get_model_params
|
||||
from posthog.client import Client
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
|
||||
class RunMetadata(TypedDict, total=False):
|
||||
messages: Union[List[Dict[str, Any]], List[str]]
|
||||
provider: str
|
||||
model: str
|
||||
model_params: Dict[str, Any]
|
||||
base_url: str
|
||||
start_time: float
|
||||
end_time: float
|
||||
|
||||
|
||||
RunStorage = Dict[UUID, RunMetadata]
|
||||
|
||||
|
||||
class CallbackHandler(BaseCallbackHandler):
|
||||
"""
|
||||
A callback handler for LangChain that sends events to PostHog LLM Observability.
|
||||
"""
|
||||
|
||||
_client: Client
|
||||
"""PostHog client instance."""
|
||||
_distinct_id: Optional[Union[str, int, float, UUID]]
|
||||
"""Distinct ID of the user to associate the trace with."""
|
||||
_trace_id: Optional[Union[str, int, float, UUID]]
|
||||
"""Global trace ID to be sent with every event. Otherwise, the top-level run ID is used."""
|
||||
_properties: Optional[Dict[str, Any]]
|
||||
"""Global properties to be sent with every event."""
|
||||
_runs: RunStorage
|
||||
"""Mapping of run IDs to run metadata as run metadata is only available on the start of generation."""
|
||||
_parent_tree: Dict[UUID, UUID]
|
||||
"""
|
||||
A dictionary that maps chain run IDs to their parent chain run IDs (parent pointer tree),
|
||||
so the top level can be found from a bottom-level run ID.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Client,
|
||||
distinct_id: Optional[Union[str, int, float, UUID]] = None,
|
||||
trace_id: Optional[Union[str, int, float, UUID]] = None,
|
||||
properties: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
client: PostHog client instance.
|
||||
distinct_id: Optional distinct ID of the user to associate the trace with.
|
||||
trace_id: Optional trace ID to use for the event.
|
||||
properties: Optional additional metadata to use for the trace.
|
||||
"""
|
||||
self._client = client
|
||||
self._distinct_id = distinct_id
|
||||
self._trace_id = trace_id
|
||||
self._properties = properties or {}
|
||||
self._runs = {}
|
||||
self._parent_tree = {}
|
||||
|
||||
def on_chain_start(
|
||||
self,
|
||||
serialized: Dict[str, Any],
|
||||
inputs: Dict[str, Any],
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
|
||||
def on_chat_model_start(
|
||||
self,
|
||||
serialized: Dict[str, Any],
|
||||
messages: List[List[BaseMessage]],
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
input = [_convert_message_to_dict(message) for row in messages for message in row]
|
||||
self._set_run_metadata(serialized, run_id, input, **kwargs)
|
||||
|
||||
def on_llm_start(
|
||||
self,
|
||||
serialized: Dict[str, Any],
|
||||
prompts: List[str],
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_run_metadata(serialized, run_id, prompts, **kwargs)
|
||||
|
||||
def on_chain_end(
|
||||
self,
|
||||
outputs: Dict[str, Any],
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._pop_parent_of_run(run_id)
|
||||
|
||||
def on_llm_end(
|
||||
self,
|
||||
response: LLMResult,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
The callback works for both streaming and non-streaming runs. For streaming runs, the chain must set `stream_usage=True` in the LLM.
|
||||
"""
|
||||
trace_id = self._get_trace_id(run_id)
|
||||
self._pop_parent_of_run(run_id)
|
||||
run = self._pop_run_metadata(run_id)
|
||||
if not run:
|
||||
return
|
||||
|
||||
latency = run.get("end_time", 0) - run.get("start_time", 0)
|
||||
input_tokens, output_tokens = _parse_usage(response)
|
||||
|
||||
generation_result = response.generations[-1]
|
||||
if isinstance(generation_result[-1], ChatGeneration):
|
||||
output = [
|
||||
_convert_message_to_dict(cast(ChatGeneration, generation).message) for generation in generation_result
|
||||
]
|
||||
else:
|
||||
output = [_extract_raw_esponse(generation) for generation in generation_result]
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": run.get("provider"),
|
||||
"$ai_model": run.get("model"),
|
||||
"$ai_model_parameters": run.get("model_params"),
|
||||
"$ai_input": run.get("messages"),
|
||||
"$ai_output": {"choices": output},
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": input_tokens,
|
||||
"$ai_output_tokens": output_tokens,
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_base_url": run.get("base_url"),
|
||||
**self._properties,
|
||||
}
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
self._client.capture(
|
||||
distinct_id=self._distinct_id or trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
)
|
||||
|
||||
def on_chain_error(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._pop_parent_of_run(run_id)
|
||||
|
||||
def on_llm_error(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
trace_id = self._get_trace_id(run_id)
|
||||
self._pop_parent_of_run(run_id)
|
||||
run = self._pop_run_metadata(run_id)
|
||||
if not run:
|
||||
return
|
||||
|
||||
latency = run.get("end_time", 0) - run.get("start_time", 0)
|
||||
event_properties = {
|
||||
"$ai_provider": run.get("provider"),
|
||||
"$ai_model": run.get("model"),
|
||||
"$ai_model_parameters": run.get("model_params"),
|
||||
"$ai_input": run.get("messages"),
|
||||
"$ai_http_status": _get_http_status(error),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_base_url": run.get("base_url"),
|
||||
**self._properties,
|
||||
}
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
self._client.capture(
|
||||
distinct_id=self._distinct_id or trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
)
|
||||
|
||||
def _set_parent_of_run(self, run_id: UUID, parent_run_id: Optional[UUID] = None):
|
||||
"""
|
||||
Set the parent run ID for a chain run. If there is no parent, the run is the root.
|
||||
"""
|
||||
if parent_run_id is not None:
|
||||
self._parent_tree[run_id] = parent_run_id
|
||||
|
||||
def _pop_parent_of_run(self, run_id: UUID):
|
||||
"""
|
||||
Remove the parent run ID for a chain run.
|
||||
"""
|
||||
try:
|
||||
self._parent_tree.pop(run_id)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _find_root_run(self, run_id: UUID) -> UUID:
|
||||
"""
|
||||
Finds the root ID of a chain run.
|
||||
"""
|
||||
id: UUID = run_id
|
||||
while id in self._parent_tree:
|
||||
id = self._parent_tree[id]
|
||||
return id
|
||||
|
||||
def _set_run_metadata(
|
||||
self,
|
||||
serialized: Dict[str, Any],
|
||||
run_id: UUID,
|
||||
messages: Union[List[Dict[str, Any]], List[str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
invocation_params: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
run: RunMetadata = {
|
||||
"messages": messages,
|
||||
"start_time": time.time(),
|
||||
}
|
||||
if isinstance(invocation_params, dict):
|
||||
run["model_params"] = get_model_params(invocation_params)
|
||||
if isinstance(metadata, dict):
|
||||
if model := metadata.get("ls_model_name"):
|
||||
run["model"] = model
|
||||
if provider := metadata.get("ls_provider"):
|
||||
run["provider"] = provider
|
||||
try:
|
||||
base_url = serialized["kwargs"]["openai_api_base"]
|
||||
if base_url is not None:
|
||||
run["base_url"] = base_url
|
||||
except KeyError:
|
||||
pass
|
||||
self._runs[run_id] = run
|
||||
|
||||
def _pop_run_metadata(self, run_id: UUID) -> Optional[RunMetadata]:
|
||||
end_time = time.time()
|
||||
try:
|
||||
run = self._runs.pop(run_id)
|
||||
except KeyError:
|
||||
log.warning(f"No run metadata found for run {run_id}")
|
||||
return None
|
||||
run["end_time"] = end_time
|
||||
return run
|
||||
|
||||
def _get_trace_id(self, run_id: UUID):
|
||||
trace_id = self._trace_id or self._find_root_run(run_id)
|
||||
if not trace_id:
|
||||
trace_id = uuid.uuid4()
|
||||
return trace_id
|
||||
|
||||
|
||||
def _extract_raw_esponse(last_response):
|
||||
"""Extract the response from the last response of the LLM call."""
|
||||
# We return the text of the response if not empty
|
||||
if last_response.text is not None and last_response.text.strip() != "":
|
||||
return last_response.text.strip()
|
||||
elif hasattr(last_response, "message"):
|
||||
# Additional kwargs contains the response in case of tool usage
|
||||
return last_response.message.additional_kwargs
|
||||
else:
|
||||
# Not tool usage, some LLM responses can be simply empty
|
||||
return ""
|
||||
|
||||
|
||||
def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
|
||||
# assistant message
|
||||
if isinstance(message, HumanMessage):
|
||||
message_dict = {"role": "user", "content": message.content}
|
||||
elif isinstance(message, AIMessage):
|
||||
message_dict = {"role": "assistant", "content": message.content}
|
||||
elif isinstance(message, SystemMessage):
|
||||
message_dict = {"role": "system", "content": message.content}
|
||||
elif isinstance(message, ToolMessage):
|
||||
message_dict = {"role": "tool", "content": message.content}
|
||||
elif isinstance(message, FunctionMessage):
|
||||
message_dict = {"role": "function", "content": message.content}
|
||||
else:
|
||||
message_dict = {"role": message.type, "content": str(message.content)}
|
||||
|
||||
if "name" in message.additional_kwargs:
|
||||
message_dict["name"] = message.additional_kwargs["name"]
|
||||
if message.additional_kwargs:
|
||||
message_dict["additional_kwargs"] = message.additional_kwargs
|
||||
|
||||
return message_dict
|
||||
|
||||
|
||||
def _parse_usage_model(usage: Union[BaseModel, Dict]) -> Tuple[Union[int, None], Union[int, None]]:
|
||||
if isinstance(usage, BaseModel):
|
||||
usage = usage.__dict__
|
||||
|
||||
conversion_list = [
|
||||
# https://pypi.org/project/langchain-anthropic/ (works also for Bedrock-Anthropic)
|
||||
("input_tokens", "input"),
|
||||
("output_tokens", "output"),
|
||||
# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/get-token-count
|
||||
("prompt_token_count", "input"),
|
||||
("candidates_token_count", "output"),
|
||||
# Bedrock: https://docs.aws.amazon.com/bedrock/latest/userguide/monitoring-cw.html#runtime-cloudwatch-metrics
|
||||
("inputTokenCount", "input"),
|
||||
("outputTokenCount", "output"),
|
||||
# langchain-ibm https://pypi.org/project/langchain-ibm/
|
||||
("input_token_count", "input"),
|
||||
("generated_token_count", "output"),
|
||||
]
|
||||
|
||||
parsed_usage = {}
|
||||
for model_key, type_key in conversion_list:
|
||||
if model_key in usage:
|
||||
captured_count = usage[model_key]
|
||||
final_count = (
|
||||
sum(captured_count) if isinstance(captured_count, list) else captured_count
|
||||
) # For Bedrock, the token count is a list when streamed
|
||||
|
||||
parsed_usage[type_key] = final_count
|
||||
|
||||
return parsed_usage.get("input"), parsed_usage.get("output")
|
||||
|
||||
|
||||
def _parse_usage(response: LLMResult):
|
||||
# langchain-anthropic uses the usage field
|
||||
llm_usage_keys = ["token_usage", "usage"]
|
||||
llm_usage: Tuple[Union[int, None], Union[int, None]] = (None, None)
|
||||
if response.llm_output is not None:
|
||||
for key in llm_usage_keys:
|
||||
if response.llm_output.get(key):
|
||||
llm_usage = _parse_usage_model(response.llm_output[key])
|
||||
break
|
||||
|
||||
if hasattr(response, "generations"):
|
||||
for generation in response.generations:
|
||||
for generation_chunk in generation:
|
||||
if generation_chunk.generation_info and ("usage_metadata" in generation_chunk.generation_info):
|
||||
llm_usage = _parse_usage_model(generation_chunk.generation_info["usage_metadata"])
|
||||
break
|
||||
|
||||
message_chunk = getattr(generation_chunk, "message", {})
|
||||
response_metadata = getattr(message_chunk, "response_metadata", {})
|
||||
|
||||
bedrock_anthropic_usage = (
|
||||
response_metadata.get("usage", None) # for Bedrock-Anthropic
|
||||
if isinstance(response_metadata, dict)
|
||||
else None
|
||||
)
|
||||
bedrock_titan_usage = (
|
||||
response_metadata.get("amazon-bedrock-invocationMetrics", None) # for Bedrock-Titan
|
||||
if isinstance(response_metadata, dict)
|
||||
else None
|
||||
)
|
||||
ollama_usage = getattr(message_chunk, "usage_metadata", None) # for Ollama
|
||||
|
||||
chunk_usage = bedrock_anthropic_usage or bedrock_titan_usage or ollama_usage
|
||||
if chunk_usage:
|
||||
llm_usage = _parse_usage_model(chunk_usage)
|
||||
break
|
||||
|
||||
return llm_usage
|
||||
|
||||
|
||||
def _get_http_status(error: BaseException) -> int:
|
||||
# OpenAI: https://github.com/openai/openai-python/blob/main/src/openai/_exceptions.py
|
||||
# Anthropic: https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/_exceptions.py
|
||||
# Google: https://github.com/googleapis/python-api-core/blob/main/google/api_core/exceptions.py
|
||||
status_code = getattr(error, "status_code", getattr(error, "code", 0))
|
||||
return status_code
|
||||
@@ -0,0 +1,4 @@
|
||||
from .openai import OpenAI
|
||||
from .openai_async import AsyncOpenAI
|
||||
|
||||
__all__ = ["OpenAI", "AsyncOpenAI"]
|
||||
@@ -0,0 +1,237 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
try:
|
||||
import openai
|
||||
import openai.resources
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError("Please install the OpenAI SDK to use this feature: 'pip install openai'")
|
||||
|
||||
from posthog.ai.utils import call_llm_and_track_usage, get_model_params
|
||||
from posthog.client import Client as PostHogClient
|
||||
|
||||
|
||||
class OpenAI(openai.OpenAI):
|
||||
"""
|
||||
A wrapper around the OpenAI SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: OpenAI API key.
|
||||
posthog_client: If provided, events will be captured via this client instead
|
||||
of the global posthog.
|
||||
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self.chat = WrappedChat(self)
|
||||
self.embeddings = WrappedEmbeddings(self)
|
||||
|
||||
|
||||
class WrappedChat(openai.resources.chat.Chat):
|
||||
_client: OpenAI
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedCompletions(self._client)
|
||||
|
||||
|
||||
class WrappedCompletions(openai.resources.chat.completions.Completions):
|
||||
_client: OpenAI
|
||||
|
||||
def create(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = uuid.uuid4()
|
||||
|
||||
if kwargs.get("stream", False):
|
||||
return self._create_streaming(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return call_llm_and_track_usage(
|
||||
posthog_distinct_id,
|
||||
self._client._ph_client,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
self._client.base_url,
|
||||
super().create,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _create_streaming(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
accumulated_content = []
|
||||
if "stream_options" not in kwargs:
|
||||
kwargs["stream_options"] = {}
|
||||
kwargs["stream_options"]["include_usage"] = True
|
||||
response = super().create(**kwargs)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content
|
||||
try:
|
||||
for chunk in response:
|
||||
if hasattr(chunk, "usage") and chunk.usage:
|
||||
usage_stats = {
|
||||
k: getattr(chunk.usage, k, 0)
|
||||
for k in [
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
]
|
||||
}
|
||||
|
||||
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
|
||||
content = chunk.choices[0].delta.content
|
||||
if content:
|
||||
accumulated_content.append(content)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
output = "".join(accumulated_content)
|
||||
self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
)
|
||||
|
||||
return generator()
|
||||
|
||||
def _capture_streaming_event(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
latency: float,
|
||||
output: str,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = uuid.uuid4()
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": kwargs.get("messages"),
|
||||
"$ai_output": {
|
||||
"choices": [
|
||||
{
|
||||
"content": output,
|
||||
"role": "assistant",
|
||||
}
|
||||
]
|
||||
},
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**posthog_properties,
|
||||
}
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
)
|
||||
|
||||
|
||||
class WrappedEmbeddings(openai.resources.embeddings.Embeddings):
|
||||
_client: OpenAI
|
||||
|
||||
def create(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Create an embedding using OpenAI's 'embeddings.create' method, but also track usage in PostHog.
|
||||
|
||||
Args:
|
||||
posthog_distinct_id: Optional ID to associate with the usage event.
|
||||
posthog_trace_id: Optional trace UUID for linking events.
|
||||
posthog_properties: Optional dictionary of extra properties to include in the event.
|
||||
**kwargs: Any additional parameters for the OpenAI Embeddings API.
|
||||
|
||||
Returns:
|
||||
The response from OpenAI's embeddings.create call.
|
||||
"""
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = uuid.uuid4()
|
||||
|
||||
start_time = time.time()
|
||||
response = super().create(**kwargs)
|
||||
end_time = time.time()
|
||||
|
||||
# Extract usage statistics if available
|
||||
usage_stats = {}
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
usage_stats = {
|
||||
"prompt_tokens": getattr(response.usage, "prompt_tokens", 0),
|
||||
"total_tokens": getattr(response.usage, "total_tokens", 0),
|
||||
}
|
||||
|
||||
latency = end_time - start_time
|
||||
|
||||
# Build the event properties
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_input": kwargs.get("input"),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**posthog_properties,
|
||||
}
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
# Send capture event for embeddings
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_embedding",
|
||||
properties=event_properties,
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,236 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
try:
|
||||
import openai
|
||||
import openai.resources
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError("Please install the OpenAI SDK to use this feature: 'pip install openai'")
|
||||
|
||||
from posthog.ai.utils import call_llm_and_track_usage_async, get_model_params
|
||||
from posthog.client import Client as PostHogClient
|
||||
|
||||
|
||||
class AsyncOpenAI(openai.AsyncOpenAI):
|
||||
"""
|
||||
An async wrapper around the OpenAI SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: OpenAI API key.
|
||||
posthog_client: If provided, events will be captured via this client instance.
|
||||
**openai_config: Additional keyword args (e.g. organization="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self.chat = WrappedChat(self)
|
||||
self.embeddings = WrappedEmbeddings(self)
|
||||
|
||||
|
||||
class WrappedChat(openai.resources.chat.AsyncChat):
|
||||
_client: AsyncOpenAI
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedCompletions(self._client)
|
||||
|
||||
|
||||
class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
|
||||
_client: AsyncOpenAI
|
||||
|
||||
async def create(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = uuid.uuid4()
|
||||
|
||||
# If streaming, handle streaming specifically
|
||||
if kwargs.get("stream", False):
|
||||
return await self._create_streaming(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
response = await call_llm_and_track_usage_async(
|
||||
posthog_distinct_id,
|
||||
self._client._ph_client,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
self._client.base_url,
|
||||
super().create,
|
||||
**kwargs,
|
||||
)
|
||||
return response
|
||||
|
||||
async def _create_streaming(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
accumulated_content = []
|
||||
if "stream_options" not in kwargs:
|
||||
kwargs["stream_options"] = {}
|
||||
kwargs["stream_options"]["include_usage"] = True
|
||||
response = await super().create(**kwargs)
|
||||
|
||||
async def async_generator():
|
||||
nonlocal usage_stats, accumulated_content
|
||||
try:
|
||||
async for chunk in response:
|
||||
if hasattr(chunk, "usage") and chunk.usage:
|
||||
usage_stats = {
|
||||
k: getattr(chunk.usage, k, 0)
|
||||
for k in [
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
]
|
||||
}
|
||||
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
|
||||
content = chunk.choices[0].delta.content
|
||||
if content:
|
||||
accumulated_content.append(content)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
output = "".join(accumulated_content)
|
||||
self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
)
|
||||
|
||||
return async_generator()
|
||||
|
||||
def _capture_streaming_event(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
latency: float,
|
||||
output: str,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = uuid.uuid4()
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": kwargs.get("messages"),
|
||||
"$ai_output": {
|
||||
"choices": [
|
||||
{
|
||||
"content": output,
|
||||
"role": "assistant",
|
||||
}
|
||||
]
|
||||
},
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**posthog_properties,
|
||||
}
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
)
|
||||
|
||||
|
||||
class WrappedEmbeddings(openai.resources.embeddings.AsyncEmbeddings):
|
||||
_client: AsyncOpenAI
|
||||
|
||||
async def create(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Create an embedding using OpenAI's 'embeddings.create' method, but also track usage in PostHog.
|
||||
|
||||
Args:
|
||||
posthog_distinct_id: Optional ID to associate with the usage event.
|
||||
posthog_trace_id: Optional trace UUID for linking events.
|
||||
posthog_properties: Optional dictionary of extra properties to include in the event.
|
||||
**kwargs: Any additional parameters for the OpenAI Embeddings API.
|
||||
|
||||
Returns:
|
||||
The response from OpenAI's embeddings.create call.
|
||||
"""
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = uuid.uuid4()
|
||||
|
||||
start_time = time.time()
|
||||
response = await super().create(**kwargs)
|
||||
end_time = time.time()
|
||||
|
||||
# Extract usage statistics if available
|
||||
usage_stats = {}
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
usage_stats = {
|
||||
"prompt_tokens": getattr(response.usage, "prompt_tokens", 0),
|
||||
"total_tokens": getattr(response.usage, "total_tokens", 0),
|
||||
}
|
||||
|
||||
latency = end_time - start_time
|
||||
|
||||
# Build the event properties
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_input": kwargs.get("input"),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**posthog_properties,
|
||||
}
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
# Send capture event for embeddings
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_embedding",
|
||||
properties=event_properties,
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,178 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from httpx import URL
|
||||
|
||||
from posthog.client import Client as PostHogClient
|
||||
|
||||
|
||||
def get_model_params(kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Extracts model parameters from the kwargs dictionary.
|
||||
"""
|
||||
model_params = {}
|
||||
for param in [
|
||||
"temperature",
|
||||
"max_tokens", # Deprecated field
|
||||
"max_completion_tokens",
|
||||
"top_p",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
"n",
|
||||
"stop",
|
||||
"stream",
|
||||
]:
|
||||
if param in kwargs and kwargs[param] is not None:
|
||||
model_params[param] = kwargs[param]
|
||||
return model_params
|
||||
|
||||
|
||||
def format_response(response):
|
||||
"""
|
||||
Format a regular (non-streaming) response.
|
||||
"""
|
||||
output = {"choices": []}
|
||||
if response is None:
|
||||
return output
|
||||
for choice in response.choices:
|
||||
if choice.message.content:
|
||||
output["choices"].append(
|
||||
{
|
||||
"content": choice.message.content,
|
||||
"role": choice.message.role,
|
||||
}
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def call_llm_and_track_usage(
|
||||
posthog_distinct_id: Optional[str],
|
||||
ph_client: PostHogClient,
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
base_url: URL,
|
||||
call_method: Callable[..., Any],
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Common usage-tracking logic for both sync and async calls.
|
||||
call_method: the llm call method (e.g. openai.chat.completions.create)
|
||||
"""
|
||||
start_time = time.time()
|
||||
response = None
|
||||
error = None
|
||||
http_status = 200
|
||||
usage: Dict[str, Any] = {}
|
||||
|
||||
try:
|
||||
response = call_method(**kwargs)
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
http_status = getattr(exc, "status_code", 0) # default to 0 becuase its likely an SDK error
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = uuid.uuid4()
|
||||
|
||||
if response and hasattr(response, "usage"):
|
||||
usage = response.usage.model_dump()
|
||||
|
||||
input_tokens = usage.get("prompt_tokens", 0)
|
||||
output_tokens = usage.get("completion_tokens", 0)
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": kwargs.get("messages"),
|
||||
"$ai_output": format_response(response),
|
||||
"$ai_http_status": http_status,
|
||||
"$ai_input_tokens": input_tokens,
|
||||
"$ai_output_tokens": output_tokens,
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(base_url),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
# send the event to posthog
|
||||
if hasattr(ph_client, "capture") and callable(ph_client.capture):
|
||||
ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise error
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def call_llm_and_track_usage_async(
|
||||
posthog_distinct_id: Optional[str],
|
||||
ph_client: PostHogClient,
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
base_url: URL,
|
||||
call_async_method: Callable[..., Any],
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
start_time = time.time()
|
||||
response = None
|
||||
error = None
|
||||
http_status = 200
|
||||
usage: Dict[str, Any] = {}
|
||||
|
||||
try:
|
||||
response = await call_async_method(**kwargs)
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
http_status = getattr(exc, "status_code", 0) # default to 0 because its likely an SDK error
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = uuid.uuid4()
|
||||
|
||||
if response and hasattr(response, "usage"):
|
||||
usage = response.usage.model_dump()
|
||||
|
||||
input_tokens = usage.get("prompt_tokens", 0)
|
||||
output_tokens = usage.get("completion_tokens", 0)
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": kwargs.get("messages"),
|
||||
"$ai_output": format_response(response),
|
||||
"$ai_http_status": http_status,
|
||||
"$ai_input_tokens": input_tokens,
|
||||
"$ai_output_tokens": output_tokens,
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(base_url),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
# send the event to posthog
|
||||
if hasattr(ph_client, "capture") and callable(ph_client.capture):
|
||||
ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise error
|
||||
|
||||
return response
|
||||
+642
-76
@@ -1,7 +1,8 @@
|
||||
import atexit
|
||||
import hashlib
|
||||
import logging
|
||||
import numbers
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -9,9 +10,12 @@ 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 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:
|
||||
@@ -21,7 +25,7 @@ except ImportError:
|
||||
|
||||
|
||||
ID_TYPES = (numbers.Number, string_types, UUID)
|
||||
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
|
||||
MAX_DICT_SIZE = 50_000
|
||||
|
||||
|
||||
class Client(object):
|
||||
@@ -47,8 +51,15 @@ class Client(object):
|
||||
poll_interval=30,
|
||||
personal_api_key=None,
|
||||
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)
|
||||
|
||||
# api_key: This should be the Team API Key (token), public
|
||||
@@ -60,18 +71,47 @@ 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
|
||||
self.feature_flags_by_key = None
|
||||
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
|
||||
|
||||
if debug:
|
||||
# Ensures that debug level messages are logged when debug mode is on.
|
||||
# Otherwise, defaults to WARNING level. See https://docs.python.org/3/howto/logging.html#what-happens-if-no-configuration-is-provided
|
||||
logging.basicConfig()
|
||||
self.log.setLevel(logging.DEBUG)
|
||||
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
|
||||
@@ -89,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)
|
||||
|
||||
@@ -103,7 +144,7 @@ class Client(object):
|
||||
if send:
|
||||
consumer.start()
|
||||
|
||||
def identify(self, distinct_id=None, properties=None, context=None, timestamp=None, message_id=None):
|
||||
def identify(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
@@ -115,12 +156,66 @@ class Client(object):
|
||||
"distinct_id": distinct_id,
|
||||
"$set": properties,
|
||||
"event": "$identify",
|
||||
"messageId": message_id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def capture(self, distinct_id=None, event=None, properties=None, context=None, timestamp=None, message_id=None):
|
||||
def get_feature_variants(
|
||||
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 resp_data["featureFlags"]
|
||||
|
||||
def get_feature_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 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)
|
||||
|
||||
if disable_geoip is None:
|
||||
disable_geoip = self.disable_geoip
|
||||
|
||||
if groups:
|
||||
require("groups", groups, dict)
|
||||
else:
|
||||
groups = {}
|
||||
|
||||
request_data = {
|
||||
"distinct_id": distinct_id,
|
||||
"groups": groups,
|
||||
"person_properties": person_properties,
|
||||
"group_properties": group_properties,
|
||||
"disable_geoip": disable_geoip,
|
||||
}
|
||||
resp_data = decide(self.api_key, self.host, timeout=self.feature_flags_request_timeout_seconds, **request_data)
|
||||
|
||||
return resp_data
|
||||
|
||||
def capture(
|
||||
self,
|
||||
distinct_id=None,
|
||||
event=None,
|
||||
properties=None,
|
||||
context=None,
|
||||
timestamp=None,
|
||||
uuid=None,
|
||||
groups=None,
|
||||
send_feature_flags=False,
|
||||
disable_geoip=None,
|
||||
):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
@@ -133,12 +228,40 @@ class Client(object):
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"event": event,
|
||||
"messageId": message_id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
if groups:
|
||||
require("groups", groups, dict)
|
||||
msg["properties"]["$groups"] = groups
|
||||
|
||||
def set(self, distinct_id=None, properties=None, context=None, timestamp=None, message_id=None):
|
||||
extra_properties = {}
|
||||
feature_variants = {}
|
||||
if send_feature_flags:
|
||||
try:
|
||||
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}")
|
||||
|
||||
elif self.feature_flags:
|
||||
# Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
|
||||
feature_variants = self.get_all_flags(
|
||||
distinct_id, groups=(groups or {}), disable_geoip=disable_geoip, only_evaluate_locally=True
|
||||
)
|
||||
|
||||
for feature, variant in feature_variants.items():
|
||||
extra_properties[f"$feature/{feature}"] = variant
|
||||
|
||||
active_feature_flags = [key for (key, value) in feature_variants.items() if value is not False]
|
||||
if active_feature_flags:
|
||||
extra_properties["$active_feature_flags"] = active_feature_flags
|
||||
|
||||
if extra_properties:
|
||||
msg["properties"] = {**extra_properties, **msg["properties"]}
|
||||
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def set(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
@@ -150,12 +273,12 @@ class Client(object):
|
||||
"distinct_id": distinct_id,
|
||||
"$set": properties,
|
||||
"event": "$set",
|
||||
"messageId": message_id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def set_once(self, distinct_id=None, properties=None, context=None, timestamp=None, message_id=None):
|
||||
def set_once(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
@@ -167,12 +290,49 @@ class Client(object):
|
||||
"distinct_id": distinct_id,
|
||||
"$set_once": properties,
|
||||
"event": "$set_once",
|
||||
"messageId": message_id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def alias(self, previous_id=None, distinct_id=None, context=None, timestamp=None, message_id=None):
|
||||
def group_identify(
|
||||
self,
|
||||
group_type=None,
|
||||
group_key=None,
|
||||
properties=None,
|
||||
context=None,
|
||||
timestamp=None,
|
||||
uuid=None,
|
||||
disable_geoip=None,
|
||||
distinct_id=None,
|
||||
):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
require("group_type", group_type, ID_TYPES)
|
||||
require("group_key", group_key, ID_TYPES)
|
||||
require("properties", properties, dict)
|
||||
|
||||
if distinct_id:
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
else:
|
||||
distinct_id = "${}_{}".format(group_type, group_key)
|
||||
|
||||
msg = {
|
||||
"event": "$groupidentify",
|
||||
"properties": {
|
||||
"$group_type": group_type,
|
||||
"$group_key": group_key,
|
||||
"$group_set": properties,
|
||||
},
|
||||
"distinct_id": distinct_id,
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def alias(self, previous_id=None, distinct_id=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||||
context = context or {}
|
||||
|
||||
require("previous_id", previous_id, ID_TYPES)
|
||||
@@ -189,9 +349,11 @@ class Client(object):
|
||||
"distinct_id": previous_id,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def page(self, distinct_id=None, url=None, properties=None, context=None, timestamp=None, message_id=None):
|
||||
def page(
|
||||
self, distinct_id=None, url=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None
|
||||
):
|
||||
properties = properties or {}
|
||||
context = context or {}
|
||||
|
||||
@@ -207,19 +369,79 @@ class Client(object):
|
||||
"timestamp": timestamp,
|
||||
"context": context,
|
||||
"distinct_id": distinct_id,
|
||||
"messageId": message_id,
|
||||
"uuid": uuid,
|
||||
}
|
||||
|
||||
return self._enqueue(msg)
|
||||
return self._enqueue(msg, disable_geoip)
|
||||
|
||||
def _enqueue(self, msg):
|
||||
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)`"""
|
||||
|
||||
if self.disabled:
|
||||
return False, "disabled"
|
||||
|
||||
timestamp = msg["timestamp"]
|
||||
if timestamp is None:
|
||||
timestamp = datetime.utcnow().replace(tzinfo=tzutc())
|
||||
message_id = msg.get("messageId")
|
||||
if message_id is None:
|
||||
message_id = uuid4()
|
||||
timestamp = datetime.now(tz=tzutc())
|
||||
|
||||
require("timestamp", timestamp, datetime)
|
||||
require("context", msg["context"], dict)
|
||||
@@ -227,12 +449,27 @@ class Client(object):
|
||||
# add common
|
||||
timestamp = guess_timezone(timestamp)
|
||||
msg["timestamp"] = timestamp.isoformat()
|
||||
msg["messageId"] = stringify_id(message_id)
|
||||
|
||||
# only send if "uuid" is truthy
|
||||
if "uuid" in msg:
|
||||
uuid = msg.pop("uuid")
|
||||
if uuid:
|
||||
msg["uuid"] = stringify_id(uuid)
|
||||
|
||||
if not msg.get("properties"):
|
||||
msg["properties"] = {}
|
||||
msg["properties"]["$lib"] = "posthog-python"
|
||||
msg["properties"]["$lib_version"] = VERSION
|
||||
|
||||
if disable_geoip is None:
|
||||
disable_geoip = self.disable_geoip
|
||||
|
||||
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)
|
||||
@@ -244,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
|
||||
|
||||
@@ -284,19 +528,39 @@ class Client(object):
|
||||
self.flush()
|
||||
self.join()
|
||||
|
||||
if self.exception_capture:
|
||||
self.exception_capture.close()
|
||||
|
||||
def _load_feature_flags(self):
|
||||
try:
|
||||
self.feature_flags = get(self.personal_api_key, "/api/feature_flag/", self.host)["results"]
|
||||
response = get(
|
||||
self.personal_api_key,
|
||||
f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
|
||||
self.host,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
self.feature_flags = response["flags"] or []
|
||||
self.feature_flags_by_key = {
|
||||
flag["key"]: flag for flag in self.feature_flags if flag.get("key") is not None
|
||||
}
|
||||
self.group_type_mapping = response["group_type_mapping"] or {}
|
||||
self.cohorts = response["cohorts"] or {}
|
||||
|
||||
except APIError as e:
|
||||
if e.status == 401:
|
||||
raise APIError(
|
||||
status=401,
|
||||
message="You are using a write-only key with feature flags. "
|
||||
"To use feature flags, please set a personal_api_key "
|
||||
"More information: https://posthog.com/docs/api/overview",
|
||||
self.log.error(
|
||||
"[FEATURE FLAGS] Error loading feature flags: To use feature flags, please set a valid personal_api_key. More information: https://posthog.com/docs/api/overview"
|
||||
)
|
||||
if self.debug:
|
||||
raise APIError(
|
||||
status=401,
|
||||
message="You are using a write-only key with feature flags. "
|
||||
"To use feature flags, please set a personal_api_key "
|
||||
"More information: https://posthog.com/docs/api/overview",
|
||||
)
|
||||
else:
|
||||
raise APIError(status=e.status, message=e.message)
|
||||
self.log.error(f"[FEATURE FLAGS] Error loading feature flags: {e}")
|
||||
except Exception as e:
|
||||
self.log.warning(
|
||||
"[FEATURE FLAGS] Fetching feature flags failed with following error. We will retry in %s seconds."
|
||||
@@ -304,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:
|
||||
@@ -317,53 +581,355 @@ class Client(object):
|
||||
self.poller = Poller(interval=timedelta(seconds=self.poll_interval), execute=self._load_feature_flags)
|
||||
self.poller.start()
|
||||
|
||||
def feature_enabled(self, key, distinct_id, default=False):
|
||||
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")
|
||||
|
||||
if not feature_flag.get("active"):
|
||||
return False
|
||||
|
||||
flag_filters = feature_flag.get("filters") or {}
|
||||
aggregation_group_type_index = flag_filters.get("aggregation_group_type_index")
|
||||
if aggregation_group_type_index is not None:
|
||||
group_name = self.group_type_mapping.get(str(aggregation_group_type_index))
|
||||
|
||||
if not group_name:
|
||||
self.log.warning(
|
||||
f"[FEATURE FLAGS] Unknown group type index {aggregation_group_type_index} for feature flag {feature_flag['key']}"
|
||||
)
|
||||
# failover to `/decide/`
|
||||
raise InconclusiveMatchError("Flag has unknown group type index")
|
||||
|
||||
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
|
||||
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]
|
||||
return match_feature_flag_properties(feature_flag, groups[group_name], focused_group_properties)
|
||||
else:
|
||||
return match_feature_flag_properties(feature_flag, distinct_id, person_properties, self.cohorts)
|
||||
|
||||
def feature_enabled(
|
||||
self,
|
||||
key,
|
||||
distinct_id,
|
||||
*,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None,
|
||||
):
|
||||
response = self.get_feature_flag(
|
||||
key,
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
if response is None:
|
||||
return None
|
||||
return bool(response)
|
||||
|
||||
def get_feature_flag(
|
||||
self,
|
||||
key,
|
||||
distinct_id,
|
||||
*,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None,
|
||||
):
|
||||
require("key", key, string_types)
|
||||
require("distinct_id", distinct_id, ID_TYPES)
|
||||
require("groups", groups, dict)
|
||||
|
||||
if not self.personal_api_key:
|
||||
self.log.warning("[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags.")
|
||||
if not self.feature_flags:
|
||||
if self.disabled:
|
||||
return None
|
||||
|
||||
person_properties, group_properties = self._add_local_person_and_group_properties(
|
||||
distinct_id, groups, person_properties, group_properties
|
||||
)
|
||||
|
||||
if self.feature_flags is None and self.personal_api_key:
|
||||
self.load_feature_flags()
|
||||
response = None
|
||||
|
||||
# If loading in previous line failed
|
||||
if not self.feature_flags:
|
||||
response = default
|
||||
else:
|
||||
if self.feature_flags:
|
||||
for flag in self.feature_flags:
|
||||
if flag["key"] == key:
|
||||
try:
|
||||
response = self._compute_flag_locally(
|
||||
flag,
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
)
|
||||
self.log.debug(f"Successfully computed flag locally: {key} -> {response}")
|
||||
except InconclusiveMatchError as e:
|
||||
self.log.debug(f"Failed to compute flag {key} locally: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Error while computing variant locally: {e}")
|
||||
continue
|
||||
|
||||
flag_was_locally_evaluated = response is not None
|
||||
if not flag_was_locally_evaluated and not only_evaluate_locally:
|
||||
try:
|
||||
feature_flag = [flag for flag in self.feature_flags if flag["key"] == key][0]
|
||||
except IndexError:
|
||||
return default
|
||||
feature_flags = self.get_feature_variants(
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
response = feature_flags.get(key)
|
||||
if response is None:
|
||||
response = False
|
||||
self.log.debug(f"Successfully computed flag remotely: #{key} -> #{response}")
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
|
||||
|
||||
if feature_flag.get("is_simple_flag"):
|
||||
response = _hash(key, distinct_id) <= ((feature_flag.get("rollout_percentage", 100) or 100) / 100)
|
||||
else:
|
||||
try:
|
||||
request_data = {
|
||||
"distinct_id": distinct_id,
|
||||
"personal_api_key": self.personal_api_key,
|
||||
}
|
||||
resp_data = decide(self.api_key, self.host, timeout=10, **request_data)
|
||||
response = key in resp_data["featureFlags"]
|
||||
except Exception as e:
|
||||
response = default
|
||||
self.log.warning(
|
||||
"[FEATURE FLAGS] Unable to get data for flag %s, because of the following error:" % key
|
||||
)
|
||||
self.log.warning(e)
|
||||
|
||||
self.capture(distinct_id, "$feature_flag_called", {"$feature_flag": key, "$feature_flag_response": response})
|
||||
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,
|
||||
"locally_evaluated": flag_was_locally_evaluated,
|
||||
f"$feature/{key}": response,
|
||||
},
|
||||
groups=groups,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
|
||||
return response
|
||||
|
||||
def get_feature_flag_payload(
|
||||
self,
|
||||
key,
|
||||
distinct_id,
|
||||
*,
|
||||
match_value=None,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None,
|
||||
):
|
||||
if self.disabled:
|
||||
return None
|
||||
|
||||
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
||||
# Given the same distinct_id and key, it'll always return the same float. These floats are
|
||||
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
||||
# we can do _hash(key, distinct_id) < 0.2
|
||||
def _hash(key, distinct_id):
|
||||
hash_key = "%s.%s" % (key, distinct_id)
|
||||
hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16)
|
||||
return hash_val / __LONG_SCALE__
|
||||
if match_value is None:
|
||||
match_value = self.get_feature_flag(
|
||||
key,
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
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:
|
||||
payload = self._compute_payload_locally(key, match_value)
|
||||
|
||||
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,
|
||||
)
|
||||
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
|
||||
|
||||
return payload
|
||||
|
||||
def _compute_payload_locally(self, key, match_value):
|
||||
payload = None
|
||||
|
||||
if self.feature_flags_by_key is None:
|
||||
return payload
|
||||
|
||||
flag_definition = self.feature_flags_by_key.get(key) or {}
|
||||
flag_filters = flag_definition.get("filters") or {}
|
||||
flag_payloads = flag_filters.get("payloads") or {}
|
||||
payload = flag_payloads.get(str(match_value).lower(), None)
|
||||
return payload
|
||||
|
||||
def get_all_flags(
|
||||
self,
|
||||
distinct_id,
|
||||
*,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
disable_geoip=None,
|
||||
):
|
||||
flags = self.get_all_flags_and_payloads(
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
return flags["featureFlags"]
|
||||
|
||||
def get_all_flags_and_payloads(
|
||||
self,
|
||||
distinct_id,
|
||||
*,
|
||||
groups={},
|
||||
person_properties={},
|
||||
group_properties={},
|
||||
only_evaluate_locally=False,
|
||||
disable_geoip=None,
|
||||
):
|
||||
if self.disabled:
|
||||
return {"featureFlags": None, "featureFlagPayloads": None}
|
||||
|
||||
person_properties, group_properties = self._add_local_person_and_group_properties(
|
||||
distinct_id, groups, person_properties, group_properties
|
||||
)
|
||||
|
||||
flags, payloads, fallback_to_decide = self._get_all_flags_and_payloads_locally(
|
||||
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
|
||||
)
|
||||
response = {"featureFlags": flags, "featureFlagPayloads": payloads}
|
||||
|
||||
if fallback_to_decide and not only_evaluate_locally:
|
||||
try:
|
||||
flags_and_payloads = self.get_decide(
|
||||
distinct_id,
|
||||
groups=groups,
|
||||
person_properties=person_properties,
|
||||
group_properties=group_properties,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
response = flags_and_payloads
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
|
||||
|
||||
return response
|
||||
|
||||
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)
|
||||
|
||||
if self.feature_flags is None and self.personal_api_key:
|
||||
self.load_feature_flags()
|
||||
|
||||
flags = {}
|
||||
payloads = {}
|
||||
fallback_to_decide = False
|
||||
# If loading in previous line failed
|
||||
if self.feature_flags:
|
||||
for flag in self.feature_flags:
|
||||
try:
|
||||
flags[flag["key"]] = self._compute_flag_locally(
|
||||
flag,
|
||||
distinct_id,
|
||||
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:
|
||||
payloads[flag["key"]] = matched_payload
|
||||
except InconclusiveMatchError:
|
||||
# No need to log this, since it's just telling us to fall back to `/decide`
|
||||
fallback_to_decide = True
|
||||
except Exception as e:
|
||||
self.log.exception(f"[FEATURE FLAGS] Error while computing variant and payload: {e}")
|
||||
fallback_to_decide = True
|
||||
else:
|
||||
fallback_to_decide = True
|
||||
|
||||
return flags, payloads, fallback_to_decide
|
||||
|
||||
def feature_flag_definitions(self):
|
||||
return self.feature_flags
|
||||
|
||||
def _add_local_person_and_group_properties(self, distinct_id, groups, person_properties, group_properties):
|
||||
all_person_properties = {"distinct_id": distinct_id, **(person_properties or {})}
|
||||
|
||||
all_group_properties = {}
|
||||
if groups:
|
||||
for group_name in groups:
|
||||
all_group_properties[group_name] = {
|
||||
"$group_key": groups[group_name],
|
||||
**(group_properties.get(group_name) or {}),
|
||||
}
|
||||
|
||||
return all_person_properties, all_group_properties
|
||||
|
||||
|
||||
def require(name, field, data_type):
|
||||
|
||||
+16
-6
@@ -12,11 +12,12 @@ try:
|
||||
except ImportError:
|
||||
from Queue import Empty
|
||||
|
||||
MAX_MSG_SIZE = 32 << 10
|
||||
|
||||
# Our servers only accept batches less than 500KB. Here limit is set slightly
|
||||
# lower to leave space for extra data that will be added later, eg. "sentAt".
|
||||
BATCH_SIZE_LIMIT = 475000
|
||||
MAX_MSG_SIZE = 900 * 1024 # 900KiB per event
|
||||
|
||||
# The maximum request body size is currently 20MiB, let's be conservative
|
||||
# in case we want to lower it in the future.
|
||||
BATCH_SIZE_LIMIT = 5 * 1024 * 1024
|
||||
|
||||
|
||||
class Consumer(Thread):
|
||||
@@ -35,6 +36,7 @@ class Consumer(Thread):
|
||||
gzip=False,
|
||||
retries=10,
|
||||
timeout=15,
|
||||
historical_migration=False,
|
||||
):
|
||||
"""Create a consumer thread."""
|
||||
Thread.__init__(self)
|
||||
@@ -54,6 +56,7 @@ class Consumer(Thread):
|
||||
self.running = True
|
||||
self.retries = retries
|
||||
self.timeout = timeout
|
||||
self.historical_migration = historical_migration
|
||||
|
||||
def run(self):
|
||||
"""Runs the consumer."""
|
||||
@@ -104,7 +107,7 @@ class Consumer(Thread):
|
||||
item = queue.get(block=True, timeout=self.flush_interval - elapsed)
|
||||
item_size = len(json.dumps(item, cls=DatetimeSerializer).encode())
|
||||
if item_size > MAX_MSG_SIZE:
|
||||
self.log.error("Item exceeds 32kb limit, dropping. (%s)", str(item))
|
||||
self.log.error("Item exceeds 900kib limit, dropping. (%s)", str(item))
|
||||
continue
|
||||
items.append(item)
|
||||
total_size += item_size
|
||||
@@ -133,6 +136,13 @@ class Consumer(Thread):
|
||||
|
||||
@backoff.on_exception(backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception)
|
||||
def send_request():
|
||||
batch_post(self.api_key, self.host, gzip=self.gzip, timeout=self.timeout, batch=batch)
|
||||
batch_post(
|
||||
self.api_key,
|
||||
self.host,
|
||||
gzip=self.gzip,
|
||||
timeout=self.timeout,
|
||||
batch=batch,
|
||||
historical_migration=self.historical_migration,
|
||||
)
|
||||
|
||||
send_request()
|
||||
|
||||
@@ -0,0 +1,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`.
|
||||
"""
|
||||
@@ -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,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]],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,336 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from dateutil import parser
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from posthog.utils import convert_to_datetime_aware, is_valid_regex
|
||||
|
||||
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
NONE_VALUES_ALLOWED_OPERATORS = ["is_not"]
|
||||
|
||||
|
||||
class InconclusiveMatchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
||||
# Given the same distinct_id and key, it'll always return the same float. These floats are
|
||||
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
||||
# we can do _hash(key, distinct_id) < 0.2
|
||||
def _hash(key, distinct_id, salt=""):
|
||||
hash_key = f"{key}.{distinct_id}{salt}"
|
||||
hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16)
|
||||
return hash_val / __LONG_SCALE__
|
||||
|
||||
|
||||
def get_matching_variant(flag, distinct_id):
|
||||
hash_value = _hash(flag["key"], distinct_id, salt="variant")
|
||||
for variant in variant_lookup_table(flag):
|
||||
if hash_value >= variant["value_min"] and hash_value < variant["value_max"]:
|
||||
return variant["key"]
|
||||
return None
|
||||
|
||||
|
||||
def variant_lookup_table(feature_flag):
|
||||
lookup_table = []
|
||||
value_min = 0
|
||||
multivariates = ((feature_flag.get("filters") or {}).get("multivariate") or {}).get("variants") or []
|
||||
for variant in multivariates:
|
||||
value_max = value_min + variant["rollout_percentage"] / 100
|
||||
lookup_table.append({"value_min": value_min, "value_max": value_max, "key": variant["key"]})
|
||||
value_min = value_max
|
||||
return lookup_table
|
||||
|
||||
|
||||
def match_feature_flag_properties(flag, distinct_id, properties, cohort_properties=None):
|
||||
flag_conditions = (flag.get("filters") or {}).get("groups") or []
|
||||
is_inconclusive = False
|
||||
cohort_properties = cohort_properties or {}
|
||||
|
||||
# Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
|
||||
# evaluated first, and the variant override is applied to the first matching condition.
|
||||
sorted_flag_conditions = sorted(
|
||||
flag_conditions,
|
||||
key=lambda condition: 0 if condition.get("variant") else 1,
|
||||
)
|
||||
|
||||
for condition in sorted_flag_conditions:
|
||||
try:
|
||||
# if any one condition resolves to True, we can shortcircuit and return
|
||||
# the matching variant
|
||||
if is_condition_match(flag, distinct_id, condition, properties, cohort_properties):
|
||||
variant_override = condition.get("variant")
|
||||
# Some filters can be explicitly set to null, which require accessing variants like so
|
||||
flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get("variants") or []
|
||||
if variant_override and variant_override in [variant["key"] for variant in flag_variants]:
|
||||
variant = variant_override
|
||||
else:
|
||||
variant = get_matching_variant(flag, distinct_id)
|
||||
return variant or True
|
||||
except InconclusiveMatchError:
|
||||
is_inconclusive = True
|
||||
|
||||
if is_inconclusive:
|
||||
raise InconclusiveMatchError("Can't determine if feature flag is enabled or not with given properties")
|
||||
|
||||
# We can only return False when either all conditions are False, or
|
||||
# no condition was inconclusive.
|
||||
return False
|
||||
|
||||
|
||||
def is_condition_match(feature_flag, distinct_id, condition, properties, cohort_properties):
|
||||
rollout_percentage = condition.get("rollout_percentage")
|
||||
if len(condition.get("properties") or []) > 0:
|
||||
for prop in condition.get("properties"):
|
||||
property_type = prop.get("type")
|
||||
if property_type == "cohort":
|
||||
matches = match_cohort(prop, properties, cohort_properties)
|
||||
else:
|
||||
matches = match_property(prop, properties)
|
||||
if not matches:
|
||||
return False
|
||||
|
||||
if rollout_percentage is None:
|
||||
return True
|
||||
|
||||
if rollout_percentage is not None and _hash(feature_flag["key"], distinct_id) > (rollout_percentage / 100):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def match_property(property, property_values) -> bool:
|
||||
# only looks for matches where key exists in override_property_values
|
||||
# doesn't support operator is_not_set
|
||||
key = property.get("key")
|
||||
operator = property.get("operator") or "exact"
|
||||
value = property.get("value")
|
||||
|
||||
if key not in property_values:
|
||||
raise InconclusiveMatchError("can't match properties without a given property value")
|
||||
|
||||
if operator == "is_not_set":
|
||||
raise InconclusiveMatchError("can't match properties with operator is_not_set")
|
||||
|
||||
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):
|
||||
if isinstance(value, list):
|
||||
return str(override_value).lower() in [str(val).lower() for val in value]
|
||||
return str(value).lower() == str(override_value).lower()
|
||||
|
||||
if operator == "exact":
|
||||
return compute_exact_match(value, override_value)
|
||||
else:
|
||||
return not compute_exact_match(value, override_value)
|
||||
|
||||
if operator == "is_set":
|
||||
return key in property_values
|
||||
|
||||
if operator == "icontains":
|
||||
return str(value).lower() in str(override_value).lower()
|
||||
|
||||
if operator == "not_icontains":
|
||||
return str(value).lower() not in str(override_value).lower()
|
||||
|
||||
if operator == "regex":
|
||||
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is not None
|
||||
|
||||
if operator == "not_regex":
|
||||
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is None
|
||||
|
||||
if operator in ("gt", "gte", "lt", "lte"):
|
||||
# :TRICKY: We adjust comparison based on the override value passed in,
|
||||
# to make sure we handle both numeric and string comparisons appropriately.
|
||||
def compare(lhs, rhs, operator):
|
||||
if operator == "gt":
|
||||
return lhs > rhs
|
||||
elif operator == "gte":
|
||||
return lhs >= rhs
|
||||
elif operator == "lt":
|
||||
return lhs < rhs
|
||||
elif operator == "lte":
|
||||
return lhs <= rhs
|
||||
else:
|
||||
raise ValueError(f"Invalid operator: {operator}")
|
||||
|
||||
parsed_value = None
|
||||
try:
|
||||
parsed_value = float(value) # type: ignore
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if parsed_value is not None and override_value is not None:
|
||||
if isinstance(override_value, str):
|
||||
return compare(override_value, str(value), operator)
|
||||
else:
|
||||
return compare(override_value, parsed_value, operator)
|
||||
else:
|
||||
return compare(str(override_value), str(value), operator)
|
||||
|
||||
if operator in ["is_date_before", "is_date_after"]:
|
||||
try:
|
||||
parsed_date = relative_date_parse_for_feature_flag_matching(str(value))
|
||||
|
||||
if not parsed_date:
|
||||
parsed_date = parser.parse(str(value))
|
||||
parsed_date = convert_to_datetime_aware(parsed_date)
|
||||
except Exception as e:
|
||||
raise InconclusiveMatchError("The date set on the flag is not a valid format") from e
|
||||
|
||||
if not parsed_date:
|
||||
raise InconclusiveMatchError("The date set on the flag is not a valid format")
|
||||
|
||||
if isinstance(override_value, datetime.datetime):
|
||||
override_date = convert_to_datetime_aware(override_value)
|
||||
if operator == "is_date_before":
|
||||
return override_date < parsed_date
|
||||
else:
|
||||
return override_date > parsed_date
|
||||
elif isinstance(override_value, datetime.date):
|
||||
if operator == "is_date_before":
|
||||
return override_value < parsed_date.date()
|
||||
else:
|
||||
return override_value > parsed_date.date()
|
||||
elif isinstance(override_value, str):
|
||||
try:
|
||||
override_date = parser.parse(override_value)
|
||||
override_date = convert_to_datetime_aware(override_date)
|
||||
if operator == "is_date_before":
|
||||
return override_date < parsed_date
|
||||
else:
|
||||
return override_date > parsed_date
|
||||
except Exception:
|
||||
raise InconclusiveMatchError("The date provided is not a valid format")
|
||||
else:
|
||||
raise InconclusiveMatchError("The date provided must be a string or date object")
|
||||
|
||||
# if we get here, we don't know how to handle the operator
|
||||
raise InconclusiveMatchError(f"Unknown operator {operator}")
|
||||
|
||||
|
||||
def match_cohort(property, property_values, cohort_properties) -> bool:
|
||||
# Cohort properties are in the form of property groups like this:
|
||||
# {
|
||||
# "cohort_id": {
|
||||
# "type": "AND|OR",
|
||||
# "values": [{
|
||||
# "key": "property_name", "value": "property_value"
|
||||
# }]
|
||||
# }
|
||||
# }
|
||||
cohort_id = str(property.get("value"))
|
||||
if cohort_id not in cohort_properties:
|
||||
raise InconclusiveMatchError("can't match cohort without a given cohort property value")
|
||||
|
||||
property_group = cohort_properties[cohort_id]
|
||||
return match_property_group(property_group, property_values, cohort_properties)
|
||||
|
||||
|
||||
def match_property_group(property_group, property_values, cohort_properties) -> bool:
|
||||
if not property_group:
|
||||
return True
|
||||
|
||||
property_group_type = property_group.get("type")
|
||||
properties = property_group.get("values")
|
||||
|
||||
if not properties or len(properties) == 0:
|
||||
# empty groups are no-ops, always match
|
||||
return True
|
||||
|
||||
error_matching_locally = False
|
||||
|
||||
if "values" in properties[0]:
|
||||
# a nested property group
|
||||
for prop in properties:
|
||||
try:
|
||||
matches = match_property_group(prop, property_values, cohort_properties)
|
||||
if property_group_type == "AND":
|
||||
if not matches:
|
||||
return False
|
||||
else:
|
||||
# OR group
|
||||
if matches:
|
||||
return True
|
||||
except InconclusiveMatchError as e:
|
||||
log.debug(f"Failed to compute property {prop} locally: {e}")
|
||||
error_matching_locally = True
|
||||
|
||||
if error_matching_locally:
|
||||
raise InconclusiveMatchError("Can't match cohort without a given cohort property value")
|
||||
# if we get here, all matched in AND case, or none matched in OR case
|
||||
return property_group_type == "AND"
|
||||
|
||||
else:
|
||||
for prop in properties:
|
||||
try:
|
||||
if prop.get("type") == "cohort":
|
||||
matches = match_cohort(prop, property_values, cohort_properties)
|
||||
else:
|
||||
matches = match_property(prop, property_values)
|
||||
|
||||
negation = prop.get("negation", False)
|
||||
|
||||
if property_group_type == "AND":
|
||||
# if negated property, do the inverse
|
||||
if not matches and not negation:
|
||||
return False
|
||||
if matches and negation:
|
||||
return False
|
||||
else:
|
||||
# OR group
|
||||
if matches and not negation:
|
||||
return True
|
||||
if not matches and negation:
|
||||
return True
|
||||
except InconclusiveMatchError as e:
|
||||
log.debug(f"Failed to compute property {prop} locally: {e}")
|
||||
error_matching_locally = True
|
||||
|
||||
if error_matching_locally:
|
||||
raise InconclusiveMatchError("can't match cohort without a given cohort property value")
|
||||
|
||||
# if we get here, all matched in AND case, or none matched in OR case
|
||||
return property_group_type == "AND"
|
||||
|
||||
|
||||
def relative_date_parse_for_feature_flag_matching(value: str) -> Optional[datetime.datetime]:
|
||||
regex = r"^-?(?P<number>[0-9]+)(?P<interval>[a-z])$"
|
||||
match = re.search(regex, value)
|
||||
parsed_dt = datetime.datetime.now(datetime.timezone.utc)
|
||||
if match:
|
||||
number = int(match.group("number"))
|
||||
|
||||
if number >= 10_000:
|
||||
# Guard against overflow, disallow numbers greater than 10_000
|
||||
return None
|
||||
|
||||
interval = match.group("interval")
|
||||
if interval == "h":
|
||||
parsed_dt = parsed_dt - relativedelta(hours=number)
|
||||
elif interval == "d":
|
||||
parsed_dt = parsed_dt - relativedelta(days=number)
|
||||
elif interval == "w":
|
||||
parsed_dt = parsed_dt - relativedelta(weeks=number)
|
||||
elif interval == "m":
|
||||
parsed_dt = parsed_dt - relativedelta(months=number)
|
||||
elif interval == "y":
|
||||
parsed_dt = parsed_dt - relativedelta(years=number)
|
||||
else:
|
||||
return None
|
||||
|
||||
return parsed_dt
|
||||
else:
|
||||
return None
|
||||
+18
-9
@@ -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)
|
||||
@@ -50,11 +64,6 @@ def _process_response(
|
||||
res: requests.Response, success_message: str, *, return_json: bool = True
|
||||
) -> Union[requests.Response, Any]:
|
||||
log = logging.getLogger("posthog")
|
||||
if not res:
|
||||
raise APIError(
|
||||
"N/A",
|
||||
"Error when fetching PostHog API, please make sure you are using your public project token/key and not a private API key.",
|
||||
)
|
||||
if res.status_code == 200:
|
||||
log.debug(success_message)
|
||||
return res.json() if return_json else res
|
||||
@@ -62,13 +71,13 @@ def _process_response(
|
||||
payload = res.json()
|
||||
log.debug("received response: %s", payload)
|
||||
raise APIError(res.status_code, payload["detail"])
|
||||
except ValueError:
|
||||
except (KeyError, ValueError):
|
||||
raise APIError(res.status_code, res.text)
|
||||
|
||||
|
||||
def decide(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any:
|
||||
"""Post the `kwargs to the decide API endpoint"""
|
||||
res = post(api_key, host, "/decide/", gzip, timeout, **kwargs)
|
||||
res = post(api_key, host, "/decide/?v=3", gzip, timeout, **kwargs)
|
||||
return _process_response(res, success_message="Feature flags decided successfully")
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ def get_distinct_id(request):
|
||||
return None
|
||||
try:
|
||||
return GET_DISTINCT_ID(request)
|
||||
except:
|
||||
except: # noqa: E722
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -2,22 +2,23 @@ from sentry_sdk._types import MYPY
|
||||
from sentry_sdk.hub import Hub
|
||||
from sentry_sdk.integrations import Integration
|
||||
from sentry_sdk.scope import add_global_event_processor
|
||||
from sentry_sdk.utils import Dsn
|
||||
|
||||
import posthog
|
||||
from posthog.request import DEFAULT_HOST
|
||||
from posthog.sentry import POSTHOG_ID_TAG
|
||||
|
||||
if MYPY:
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Optional # noqa: F401
|
||||
|
||||
from sentry_sdk._types import Event, Hint
|
||||
from sentry_sdk._types import Event, Hint # noqa: F401
|
||||
|
||||
|
||||
class PostHogIntegration(Integration):
|
||||
identifier = "posthog-python"
|
||||
organization = None # The Sentry organization, used to send a direct link from PostHog to Sentry
|
||||
project_id = None # The Sentry project id, used to send a direct link from PostHog to Sentry
|
||||
prefix = "https://sentry.io/organizations/" # Url of a self-hosted sentry instance (default: https://sentry.io/organizations/)
|
||||
prefix = "https://sentry.io/organizations/" # URL of a hosted sentry instance (default: https://sentry.io/organizations/)
|
||||
|
||||
@staticmethod
|
||||
def setup_once():
|
||||
@@ -37,10 +38,14 @@ class PostHogIntegration(Integration):
|
||||
"$sentry_exception": event["exception"],
|
||||
}
|
||||
|
||||
if PostHogIntegration.organization and PostHogIntegration.project_id:
|
||||
properties[
|
||||
"$sentry_url"
|
||||
] = f"{PostHogIntegration.prefix}{PostHogIntegration.organization}/issues/?project={PostHogIntegration.project_id}&query={event['event_id']}"
|
||||
if PostHogIntegration.organization:
|
||||
project_id = PostHogIntegration.project_id or (
|
||||
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']}"
|
||||
)
|
||||
|
||||
posthog.capture(posthog_distinct_id, "$exception", properties)
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("langchain")
|
||||
pytest.importorskip("langchain_community")
|
||||
@@ -0,0 +1,597 @@
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from langchain_community.chat_models.fake import FakeMessagesListChatModel
|
||||
from langchain_community.llms.fake import FakeListLLM, FakeStreamingListLLM
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_core.runnables import RunnableLambda
|
||||
from langchain_openai.chat_models import ChatOpenAI
|
||||
|
||||
from posthog.ai.langchain import CallbackHandler
|
||||
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def mock_client():
|
||||
with patch("posthog.client.Client") as mock_client:
|
||||
yield mock_client
|
||||
|
||||
|
||||
def test_parent_capture(mock_client):
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
parent_run_id = uuid.uuid4()
|
||||
run_id = uuid.uuid4()
|
||||
callbacks._set_parent_of_run(run_id, parent_run_id)
|
||||
assert callbacks._parent_tree == {run_id: parent_run_id}
|
||||
callbacks._pop_parent_of_run(run_id)
|
||||
assert callbacks._parent_tree == {}
|
||||
callbacks._pop_parent_of_run(parent_run_id) # should not raise
|
||||
|
||||
|
||||
def test_find_root_run(mock_client):
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
root_run_id = uuid.uuid4()
|
||||
parent_run_id = uuid.uuid4()
|
||||
run_id = uuid.uuid4()
|
||||
callbacks._set_parent_of_run(run_id, parent_run_id)
|
||||
callbacks._set_parent_of_run(parent_run_id, root_run_id)
|
||||
assert callbacks._find_root_run(run_id) == root_run_id
|
||||
new_run_id = uuid.uuid4()
|
||||
assert callbacks._find_root_run(new_run_id) == new_run_id
|
||||
|
||||
|
||||
def test_trace_id_generation(mock_client):
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
run_id = uuid.uuid4()
|
||||
with patch("uuid.uuid4", return_value=run_id):
|
||||
assert callbacks._get_trace_id(run_id) == run_id
|
||||
run_id = uuid.uuid4()
|
||||
callbacks = CallbackHandler(mock_client, trace_id=run_id)
|
||||
assert callbacks._get_trace_id(uuid.uuid4()) == run_id
|
||||
|
||||
|
||||
def test_metadata_capture(mock_client):
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
run_id = uuid.uuid4()
|
||||
with patch("time.time", return_value=1234567890):
|
||||
callbacks._set_run_metadata(
|
||||
{"kwargs": {"openai_api_base": "https://us.posthog.com"}},
|
||||
run_id,
|
||||
messages=[{"role": "user", "content": "Who won the world series in 2020?"}],
|
||||
invocation_params={"temperature": 0.5},
|
||||
metadata={"ls_model_name": "hog-mini", "ls_provider": "posthog"},
|
||||
)
|
||||
expected = {
|
||||
"model": "hog-mini",
|
||||
"messages": [{"role": "user", "content": "Who won the world series in 2020?"}],
|
||||
"start_time": 1234567890,
|
||||
"model_params": {"temperature": 0.5},
|
||||
"provider": "posthog",
|
||||
"base_url": "https://us.posthog.com",
|
||||
}
|
||||
assert callbacks._runs[run_id] == expected
|
||||
with patch("time.time", return_value=1234567891):
|
||||
run = callbacks._pop_run_metadata(run_id)
|
||||
assert run == {**expected, "end_time": 1234567891}
|
||||
assert callbacks._runs == {}
|
||||
callbacks._pop_run_metadata(uuid.uuid4()) # should not raise
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", [True, False])
|
||||
def test_basic_chat_chain(mock_client, stream):
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
("system", "You are a helpful assistant."),
|
||||
("user", "Who won the world series in 2020?"),
|
||||
]
|
||||
)
|
||||
model = FakeMessagesListChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="The Los Angeles Dodgers won the World Series in 2020.",
|
||||
usage_metadata={"input_tokens": 10, "output_tokens": 10, "total_tokens": 20},
|
||||
)
|
||||
]
|
||||
)
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = prompt | model
|
||||
if stream:
|
||||
result = [m for m in chain.stream({}, config={"callbacks": callbacks})][0]
|
||||
else:
|
||||
result = chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
assert result.content == "The Los Angeles Dodgers won the World Series in 2020."
|
||||
assert mock_client.capture.call_count == 1
|
||||
args = mock_client.capture.call_args[1]
|
||||
props = args["properties"]
|
||||
|
||||
assert args["event"] == "$ai_generation"
|
||||
assert "distinct_id" in args
|
||||
assert "$ai_model" in props
|
||||
assert "$ai_provider" in props
|
||||
assert props["$ai_input"] == [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Who won the world series in 2020?"},
|
||||
]
|
||||
assert props["$ai_output"] == {
|
||||
"choices": [{"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."}]
|
||||
}
|
||||
assert props["$ai_input_tokens"] == 10
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["$ai_trace_id"] is not None
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", [True, False])
|
||||
async def test_async_basic_chat_chain(mock_client, stream):
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
("system", "You are a helpful assistant."),
|
||||
("user", "Who won the world series in 2020?"),
|
||||
]
|
||||
)
|
||||
model = FakeMessagesListChatModel(
|
||||
responses=[
|
||||
AIMessage(
|
||||
content="The Los Angeles Dodgers won the World Series in 2020.",
|
||||
usage_metadata={"input_tokens": 10, "output_tokens": 10, "total_tokens": 20},
|
||||
)
|
||||
]
|
||||
)
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = prompt | model
|
||||
if stream:
|
||||
result = [m async for m in chain.astream({}, config={"callbacks": callbacks})][0]
|
||||
else:
|
||||
result = await chain.ainvoke({}, config={"callbacks": callbacks})
|
||||
assert result.content == "The Los Angeles Dodgers won the World Series in 2020."
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
args = mock_client.capture.call_args[1]
|
||||
props = args["properties"]
|
||||
assert args["event"] == "$ai_generation"
|
||||
assert "distinct_id" in args
|
||||
assert "$ai_model" in props
|
||||
assert "$ai_provider" in props
|
||||
assert props["$ai_input"] == [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Who won the world series in 2020?"},
|
||||
]
|
||||
assert props["$ai_output"] == {
|
||||
"choices": [{"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."}]
|
||||
}
|
||||
assert props["$ai_input_tokens"] == 10
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["$ai_trace_id"] is not None
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"Model,stream",
|
||||
[(FakeListLLM, True), (FakeListLLM, False), (FakeStreamingListLLM, True), (FakeStreamingListLLM, False)],
|
||||
)
|
||||
def test_basic_llm_chain(mock_client, Model, stream):
|
||||
model = Model(responses=["The Los Angeles Dodgers won the World Series in 2020."])
|
||||
callbacks: list[CallbackHandler] = [CallbackHandler(mock_client)]
|
||||
|
||||
if stream:
|
||||
result = "".join(
|
||||
[m for m in model.stream("Who won the world series in 2020?", config={"callbacks": callbacks})]
|
||||
)
|
||||
else:
|
||||
result = model.invoke("Who won the world series in 2020?", config={"callbacks": callbacks})
|
||||
assert result == "The Los Angeles Dodgers won the World Series in 2020."
|
||||
|
||||
assert mock_client.capture.call_count == 1
|
||||
args = mock_client.capture.call_args[1]
|
||||
props = args["properties"]
|
||||
|
||||
assert args["event"] == "$ai_generation"
|
||||
assert "distinct_id" in args
|
||||
assert "$ai_model" in props
|
||||
assert "$ai_provider" in props
|
||||
assert props["$ai_input"] == ["Who won the world series in 2020?"]
|
||||
assert props["$ai_output"] == {"choices": ["The Los Angeles Dodgers won the World Series in 2020."]}
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["$ai_trace_id"] is not None
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"Model,stream",
|
||||
[(FakeListLLM, True), (FakeListLLM, False), (FakeStreamingListLLM, True), (FakeStreamingListLLM, False)],
|
||||
)
|
||||
async def test_async_basic_llm_chain(mock_client, Model, stream):
|
||||
model = Model(responses=["The Los Angeles Dodgers won the World Series in 2020."])
|
||||
callbacks: list[CallbackHandler] = [CallbackHandler(mock_client)]
|
||||
|
||||
if stream:
|
||||
result = "".join(
|
||||
[m async for m in model.astream("Who won the world series in 2020?", config={"callbacks": callbacks})]
|
||||
)
|
||||
else:
|
||||
result = await model.ainvoke("Who won the world series in 2020?", config={"callbacks": callbacks})
|
||||
assert result == "The Los Angeles Dodgers won the World Series in 2020."
|
||||
|
||||
assert mock_client.capture.call_count == 1
|
||||
args = mock_client.capture.call_args[1]
|
||||
props = args["properties"]
|
||||
|
||||
assert args["event"] == "$ai_generation"
|
||||
assert "distinct_id" in args
|
||||
assert "$ai_model" in props
|
||||
assert "$ai_provider" in props
|
||||
assert props["$ai_input"] == ["Who won the world series in 2020?"]
|
||||
assert props["$ai_output"] == {"choices": ["The Los Angeles Dodgers won the World Series in 2020."]}
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["$ai_trace_id"] is not None
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
def test_trace_id_for_multiple_chains(mock_client):
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
("user", "Foo"),
|
||||
]
|
||||
)
|
||||
model = FakeMessagesListChatModel(responses=[AIMessage(content="Bar")])
|
||||
callbacks = [CallbackHandler(mock_client)]
|
||||
chain = prompt | model | RunnableLambda(lambda x: [x]) | model
|
||||
result = chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
assert result.content == "Bar"
|
||||
assert mock_client.capture.call_count == 2
|
||||
|
||||
first_call_args = mock_client.capture.call_args_list[0][1]
|
||||
first_call_props = first_call_args["properties"]
|
||||
assert first_call_args["event"] == "$ai_generation"
|
||||
assert "distinct_id" in first_call_args
|
||||
assert "$ai_model" in first_call_props
|
||||
assert "$ai_provider" in first_call_props
|
||||
assert first_call_props["$ai_input"] == [{"role": "user", "content": "Foo"}]
|
||||
assert first_call_props["$ai_output"] == {"choices": [{"role": "assistant", "content": "Bar"}]}
|
||||
assert first_call_props["$ai_http_status"] == 200
|
||||
assert first_call_props["$ai_trace_id"] is not None
|
||||
assert isinstance(first_call_props["$ai_latency"], float)
|
||||
|
||||
second_call_args = mock_client.capture.call_args_list[1][1]
|
||||
second_call_props = second_call_args["properties"]
|
||||
assert second_call_args["event"] == "$ai_generation"
|
||||
assert "distinct_id" in second_call_args
|
||||
assert "$ai_model" in second_call_props
|
||||
assert "$ai_provider" in second_call_props
|
||||
assert second_call_props["$ai_input"] == [{"role": "assistant", "content": "Bar"}]
|
||||
assert second_call_props["$ai_output"] == {"choices": [{"role": "assistant", "content": "Bar"}]}
|
||||
assert second_call_props["$ai_http_status"] == 200
|
||||
assert second_call_props["$ai_trace_id"] is not None
|
||||
assert isinstance(second_call_props["$ai_latency"], float)
|
||||
|
||||
# Check that the trace_id is the same as the first call
|
||||
assert first_call_props["$ai_trace_id"] == second_call_props["$ai_trace_id"]
|
||||
|
||||
|
||||
def test_personless_mode(mock_client):
|
||||
prompt = ChatPromptTemplate.from_messages([("user", "Foo")])
|
||||
chain = prompt | FakeMessagesListChatModel(responses=[AIMessage(content="Bar")])
|
||||
chain.invoke({}, config={"callbacks": [CallbackHandler(mock_client)]})
|
||||
assert mock_client.capture.call_count == 1
|
||||
args = mock_client.capture.call_args_list[0][1]
|
||||
assert args["properties"]["$process_person_profile"] is False
|
||||
|
||||
id = uuid.uuid4()
|
||||
chain.invoke({}, config={"callbacks": [CallbackHandler(mock_client, distinct_id=id)]})
|
||||
assert mock_client.capture.call_count == 2
|
||||
args = mock_client.capture.call_args_list[1][1]
|
||||
assert "$process_person_profile" not in args["properties"]
|
||||
assert args["distinct_id"] == id
|
||||
|
||||
|
||||
def test_personless_mode_exception(mock_client):
|
||||
prompt = ChatPromptTemplate.from_messages([("user", "Foo")])
|
||||
chain = prompt | ChatOpenAI(api_key="test", model="gpt-4o-mini")
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
with pytest.raises(Exception):
|
||||
chain.invoke({}, config={"callbacks": [callbacks]})
|
||||
assert mock_client.capture.call_count == 1
|
||||
args = mock_client.capture.call_args_list[0][1]
|
||||
assert args["properties"]["$process_person_profile"] is False
|
||||
|
||||
id = uuid.uuid4()
|
||||
with pytest.raises(Exception):
|
||||
chain.invoke({}, config={"callbacks": [CallbackHandler(mock_client, distinct_id=id)]})
|
||||
assert mock_client.capture.call_count == 2
|
||||
args = mock_client.capture.call_args_list[1][1]
|
||||
assert "$process_person_profile" not in args["properties"]
|
||||
assert args["distinct_id"] == id
|
||||
|
||||
|
||||
def test_metadata(mock_client):
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
("user", "Foo"),
|
||||
]
|
||||
)
|
||||
model = FakeMessagesListChatModel(responses=[AIMessage(content="Bar")])
|
||||
callbacks = [
|
||||
CallbackHandler(mock_client, trace_id="test-trace-id", distinct_id="test_id", properties={"foo": "bar"})
|
||||
]
|
||||
chain = prompt | model
|
||||
result = chain.invoke({}, config={"callbacks": callbacks})
|
||||
|
||||
assert result.content == "Bar"
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
first_call_args = mock_client.capture.call_args[1]
|
||||
assert first_call_args["distinct_id"] == "test_id"
|
||||
|
||||
first_call_props = first_call_args["properties"]
|
||||
assert first_call_args["event"] == "$ai_generation"
|
||||
assert first_call_props["$ai_trace_id"] == "test-trace-id"
|
||||
assert first_call_props["foo"] == "bar"
|
||||
assert first_call_props["$ai_input"] == [{"role": "user", "content": "Foo"}]
|
||||
assert first_call_props["$ai_output"] == {"choices": [{"role": "assistant", "content": "Bar"}]}
|
||||
assert first_call_props["$ai_http_status"] == 200
|
||||
assert isinstance(first_call_props["$ai_latency"], float)
|
||||
|
||||
|
||||
def test_callbacks_logic(mock_client):
|
||||
prompt = ChatPromptTemplate.from_messages([("user", "Foo")])
|
||||
model = FakeMessagesListChatModel(responses=[AIMessage(content="Bar")])
|
||||
callbacks = CallbackHandler(mock_client, trace_id="test-trace-id", distinct_id="test_id", properties={"foo": "bar"})
|
||||
chain = prompt | model
|
||||
|
||||
chain.invoke({}, config={"callbacks": [callbacks]})
|
||||
assert callbacks._runs == {}
|
||||
assert callbacks._parent_tree == {}
|
||||
|
||||
def assert_intermediary_run(m):
|
||||
assert callbacks._runs == {}
|
||||
assert len(callbacks._parent_tree.items()) == 1
|
||||
return [m]
|
||||
|
||||
(chain | RunnableLambda(assert_intermediary_run) | model).invoke({}, config={"callbacks": [callbacks]})
|
||||
assert callbacks._runs == {}
|
||||
assert callbacks._parent_tree == {}
|
||||
|
||||
|
||||
def test_exception_in_chain(mock_client):
|
||||
def runnable(_):
|
||||
raise ValueError("test")
|
||||
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
with pytest.raises(ValueError):
|
||||
RunnableLambda(runnable).invoke({}, config={"callbacks": [callbacks]})
|
||||
|
||||
assert callbacks._runs == {}
|
||||
assert callbacks._parent_tree == {}
|
||||
assert mock_client.capture.call_count == 0
|
||||
|
||||
|
||||
def test_openai_error(mock_client):
|
||||
prompt = ChatPromptTemplate.from_messages([("user", "Foo")])
|
||||
chain = prompt | ChatOpenAI(api_key="test", model="gpt-4o-mini")
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
|
||||
# 401
|
||||
with pytest.raises(Exception):
|
||||
chain.invoke({}, config={"callbacks": [callbacks]})
|
||||
|
||||
assert callbacks._runs == {}
|
||||
assert callbacks._parent_tree == {}
|
||||
assert mock_client.capture.call_count == 1
|
||||
args = mock_client.capture.call_args[1]
|
||||
props = args["properties"]
|
||||
assert props["$ai_http_status"] == 401
|
||||
assert props["$ai_input"] == [{"role": "user", "content": "Foo"}]
|
||||
assert "$ai_output" not in props
|
||||
|
||||
|
||||
@pytest.mark.skipif(not OPENAI_API_KEY, reason="OpenAI API key not set")
|
||||
def test_openai_chain(mock_client):
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
("system", 'You must always answer with "Bar".'),
|
||||
("user", "Foo"),
|
||||
]
|
||||
)
|
||||
chain = prompt | ChatOpenAI(
|
||||
api_key=OPENAI_API_KEY,
|
||||
model="gpt-4o-mini",
|
||||
temperature=0,
|
||||
max_tokens=1,
|
||||
)
|
||||
callbacks = CallbackHandler(mock_client, trace_id="test-trace-id", distinct_id="test_id", properties={"foo": "bar"})
|
||||
start_time = time.time()
|
||||
result = chain.invoke({}, config={"callbacks": [callbacks]})
|
||||
approximate_latency = math.floor(time.time() - start_time)
|
||||
|
||||
assert result.content == "Bar"
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
first_call_args = mock_client.capture.call_args[1]
|
||||
first_call_props = first_call_args["properties"]
|
||||
assert first_call_args["event"] == "$ai_generation"
|
||||
assert first_call_props["$ai_trace_id"] == "test-trace-id"
|
||||
assert first_call_props["$ai_provider"] == "openai"
|
||||
assert first_call_props["$ai_model"] == "gpt-4o-mini"
|
||||
assert first_call_props["foo"] == "bar"
|
||||
|
||||
# langchain-openai for langchain v3
|
||||
if "max_completion_tokens" in first_call_props["$ai_model_parameters"]:
|
||||
assert first_call_props["$ai_model_parameters"] == {
|
||||
"temperature": 0.0,
|
||||
"max_completion_tokens": 1,
|
||||
"stream": False,
|
||||
}
|
||||
else:
|
||||
assert first_call_props["$ai_model_parameters"] == {
|
||||
"temperature": 0.0,
|
||||
"max_tokens": 1,
|
||||
"n": 1,
|
||||
"stream": False,
|
||||
}
|
||||
assert first_call_props["$ai_input"] == [
|
||||
{"role": "system", "content": 'You must always answer with "Bar".'},
|
||||
{"role": "user", "content": "Foo"},
|
||||
]
|
||||
assert first_call_props["$ai_output"] == {
|
||||
"choices": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Bar",
|
||||
"additional_kwargs": {"refusal": None},
|
||||
}
|
||||
]
|
||||
}
|
||||
assert first_call_props["$ai_http_status"] == 200
|
||||
assert isinstance(first_call_props["$ai_latency"], float)
|
||||
assert min(approximate_latency - 1, 0) <= math.floor(first_call_props["$ai_latency"]) <= approximate_latency
|
||||
assert first_call_props["$ai_input_tokens"] == 20
|
||||
assert first_call_props["$ai_output_tokens"] == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(not OPENAI_API_KEY, reason="OpenAI API key not set")
|
||||
def test_openai_captures_multiple_generations(mock_client):
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
("system", 'You must always answer with "Bar".'),
|
||||
("user", "Foo"),
|
||||
]
|
||||
)
|
||||
chain = prompt | ChatOpenAI(
|
||||
api_key=OPENAI_API_KEY,
|
||||
model="gpt-4o-mini",
|
||||
temperature=0,
|
||||
max_tokens=1,
|
||||
n=2,
|
||||
)
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
result = chain.invoke({}, config={"callbacks": [callbacks]})
|
||||
|
||||
assert result.content == "Bar"
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
first_call_args = mock_client.capture.call_args[1]
|
||||
first_call_props = first_call_args["properties"]
|
||||
assert first_call_props["$ai_input"] == [
|
||||
{"role": "system", "content": 'You must always answer with "Bar".'},
|
||||
{"role": "user", "content": "Foo"},
|
||||
]
|
||||
assert first_call_props["$ai_output"] == {
|
||||
"choices": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Bar",
|
||||
"additional_kwargs": {"refusal": None},
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Bar",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
# langchain-openai for langchain v3
|
||||
if "max_completion_tokens" in first_call_props["$ai_model_parameters"]:
|
||||
assert first_call_props["$ai_model_parameters"] == {
|
||||
"temperature": 0.0,
|
||||
"max_completion_tokens": 1,
|
||||
"stream": False,
|
||||
"n": 2,
|
||||
}
|
||||
else:
|
||||
assert first_call_props["$ai_model_parameters"] == {
|
||||
"temperature": 0.0,
|
||||
"max_tokens": 1,
|
||||
"stream": False,
|
||||
"n": 2,
|
||||
}
|
||||
assert first_call_props["$ai_http_status"] == 200
|
||||
|
||||
|
||||
@pytest.mark.skipif(not OPENAI_API_KEY, reason="OpenAI API key not set")
|
||||
def test_openai_streaming(mock_client):
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
("system", 'You must always answer with "Bar".'),
|
||||
("user", "Foo"),
|
||||
]
|
||||
)
|
||||
chain = prompt | ChatOpenAI(
|
||||
api_key=OPENAI_API_KEY, model="gpt-4o-mini", temperature=0, max_tokens=1, stream=True, stream_usage=True
|
||||
)
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
result = [m for m in chain.stream({}, config={"callbacks": [callbacks]})]
|
||||
result = sum(result[1:], result[0])
|
||||
|
||||
assert result.content == "Bar"
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
first_call_args = mock_client.capture.call_args[1]
|
||||
first_call_props = first_call_args["properties"]
|
||||
|
||||
assert first_call_props["$ai_model_parameters"]["stream"]
|
||||
assert first_call_props["$ai_input"] == [
|
||||
{"role": "system", "content": 'You must always answer with "Bar".'},
|
||||
{"role": "user", "content": "Foo"},
|
||||
]
|
||||
assert first_call_props["$ai_output"] == {"choices": [{"role": "assistant", "content": "Bar"}]}
|
||||
assert first_call_props["$ai_http_status"] == 200
|
||||
assert first_call_props["$ai_input_tokens"] == 20
|
||||
assert first_call_props["$ai_output_tokens"] == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(not OPENAI_API_KEY, reason="OpenAI API key not set")
|
||||
async def test_async_openai_streaming(mock_client):
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
("system", 'You must always answer with "Bar".'),
|
||||
("user", "Foo"),
|
||||
]
|
||||
)
|
||||
chain = prompt | ChatOpenAI(
|
||||
api_key=OPENAI_API_KEY, model="gpt-4o-mini", temperature=0, max_tokens=1, stream=True, stream_usage=True
|
||||
)
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
result = [m async for m in chain.astream({}, config={"callbacks": [callbacks]})]
|
||||
result = sum(result[1:], result[0])
|
||||
|
||||
assert result.content == "Bar"
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
first_call_args = mock_client.capture.call_args[1]
|
||||
first_call_props = first_call_args["properties"]
|
||||
|
||||
assert first_call_props["$ai_model_parameters"]["stream"]
|
||||
assert first_call_props["$ai_input"] == [
|
||||
{"role": "system", "content": 'You must always answer with "Bar".'},
|
||||
{"role": "user", "content": "Foo"},
|
||||
]
|
||||
assert first_call_props["$ai_output"] == {"choices": [{"role": "assistant", "content": "Bar"}]}
|
||||
assert first_call_props["$ai_http_status"] == 200
|
||||
assert first_call_props["$ai_input_tokens"] == 20
|
||||
assert first_call_props["$ai_output_tokens"] == 1
|
||||
|
||||
|
||||
def test_base_url_retrieval(mock_client):
|
||||
prompt = ChatPromptTemplate.from_messages([("user", "Foo")])
|
||||
chain = prompt | ChatOpenAI(
|
||||
api_key="test",
|
||||
model="posthog-mini",
|
||||
base_url="https://test.posthog.com",
|
||||
)
|
||||
callbacks = CallbackHandler(mock_client)
|
||||
with pytest.raises(Exception):
|
||||
chain.invoke({}, config={"callbacks": [callbacks]})
|
||||
|
||||
assert mock_client.capture.call_count == 1
|
||||
call = mock_client.capture.call_args[1]
|
||||
assert call["properties"]["$ai_base_url"] == "https://test.posthog.com"
|
||||
@@ -0,0 +1,117 @@
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
from openai.types.create_embedding_response import CreateEmbeddingResponse, Usage
|
||||
from openai.types.embedding import Embedding
|
||||
|
||||
from posthog.ai.openai import OpenAI
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
with patch("posthog.client.Client") as mock_client:
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_response():
|
||||
return ChatCompletion(
|
||||
id="test",
|
||||
model="gpt-4",
|
||||
object="chat.completion",
|
||||
created=int(time.time()),
|
||||
choices=[
|
||||
Choice(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
content="Test response",
|
||||
role="assistant",
|
||||
),
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
completion_tokens=10,
|
||||
prompt_tokens=20,
|
||||
total_tokens=30,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_embedding_response():
|
||||
return CreateEmbeddingResponse(
|
||||
data=[
|
||||
Embedding(
|
||||
embedding=[0.1, 0.2, 0.3],
|
||||
index=0,
|
||||
object="embedding",
|
||||
)
|
||||
],
|
||||
model="text-embedding-3-small",
|
||||
object="list",
|
||||
usage=Usage(
|
||||
prompt_tokens=10,
|
||||
total_tokens=10,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_basic_completion(mock_client, mock_openai_response):
|
||||
with patch("openai.resources.chat.completions.Completions.create", return_value=mock_openai_response):
|
||||
client = OpenAI(api_key="test-key", posthog_client=mock_client)
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
assert response == mock_openai_response
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "test-id"
|
||||
assert call_args["event"] == "$ai_generation"
|
||||
assert props["$ai_provider"] == "openai"
|
||||
assert props["$ai_model"] == "gpt-4"
|
||||
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
|
||||
assert props["$ai_output"] == {"choices": [{"role": "assistant", "content": "Test response"}]}
|
||||
assert props["$ai_input_tokens"] == 20
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
def test_embeddings(mock_client, mock_embedding_response):
|
||||
with patch("openai.resources.embeddings.Embeddings.create", return_value=mock_embedding_response):
|
||||
client = OpenAI(api_key="test-key", posthog_client=mock_client)
|
||||
response = client.embeddings.create(
|
||||
model="text-embedding-3-small",
|
||||
input="Hello world",
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
assert response == mock_embedding_response
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "test-id"
|
||||
assert call_args["event"] == "$ai_embedding"
|
||||
assert props["$ai_provider"] == "openai"
|
||||
assert props["$ai_model"] == "text-embedding-3-small"
|
||||
assert props["$ai_input"] == "Hello world"
|
||||
assert props["$ai_input_tokens"] == 10
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
@@ -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",
|
||||
}
|
||||
+815
-106
File diff suppressed because it is too large
Load Diff
@@ -145,15 +145,20 @@ class TestConsumer(unittest.TestCase):
|
||||
def test_max_batch_size(self):
|
||||
q = Queue()
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=100000, flush_interval=3)
|
||||
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
|
||||
properties = {}
|
||||
for n in range(0, 500):
|
||||
properties[str(n)] = "one_long_property_value_to_build_a_big_event"
|
||||
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id", "properties": properties}
|
||||
msg_size = len(json.dumps(track).encode())
|
||||
# number of messages in a maximum-size batch
|
||||
n_msgs = int(475000 / msg_size)
|
||||
# Let's capture 8MB of data to trigger two batches
|
||||
n_msgs = int(8_000_000 / msg_size)
|
||||
|
||||
def mock_post_fn(_, data, **kwargs):
|
||||
res = mock.Mock()
|
||||
res.status_code = 200
|
||||
self.assertTrue(len(data.encode()) < 500000, "batch size (%d) exceeds 500KB limit" % len(data.encode()))
|
||||
request_size = len(data.encode())
|
||||
# Batches close after the first message bringing it bigger than BATCH_SIZE_LIMIT, let's add 10% of margin
|
||||
self.assertTrue(request_size < (5 * 1024 * 1024) * 1.1, "batch size (%d) higher than limit" % request_size)
|
||||
return res
|
||||
|
||||
with mock.patch("posthog.request._session.post", side_effect=mock_post_fn) as mock_post:
|
||||
@@ -161,4 +166,4 @@ class TestConsumer(unittest.TestCase):
|
||||
for _ in range(0, n_msgs + 2):
|
||||
q.put(track)
|
||||
q.join()
|
||||
self.assertEquals(mock_post.call_count, 2)
|
||||
self.assertEqual(mock_post.call_count, 2)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
+24
-16
@@ -1,40 +1,48 @@
|
||||
import unittest
|
||||
|
||||
import posthog
|
||||
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
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
posthog.api_key = "testsecret"
|
||||
posthog.on_error = self.failed
|
||||
self.posthog = Posthog("testsecret", host="http://localhost:8000", on_error=self.failed)
|
||||
|
||||
def test_no_api_key(self):
|
||||
posthog.api_key = None
|
||||
self.assertRaises(Exception, posthog.capture)
|
||||
self.posthog.api_key = None
|
||||
self.assertRaises(Exception, self.posthog.capture)
|
||||
|
||||
def test_no_host(self):
|
||||
posthog.host = None
|
||||
self.assertRaises(Exception, posthog.capture)
|
||||
self.posthog.host = None
|
||||
self.assertRaises(Exception, self.posthog.capture)
|
||||
|
||||
def test_track(self):
|
||||
posthog.capture("distinct_id", "python module event")
|
||||
posthog.flush()
|
||||
res = self.posthog.capture("distinct_id", "python module event")
|
||||
self._assert_enqueue_result(res)
|
||||
self.posthog.flush()
|
||||
|
||||
def test_identify(self):
|
||||
posthog.identify("distinct_id", {"email": "user@email.com"})
|
||||
posthog.flush()
|
||||
res = self.posthog.identify("distinct_id", {"email": "user@email.com"})
|
||||
self._assert_enqueue_result(res)
|
||||
self.posthog.flush()
|
||||
|
||||
def test_alias(self):
|
||||
posthog.alias("previousId", "distinct_id")
|
||||
posthog.flush()
|
||||
res = self.posthog.alias("previousId", "distinct_id")
|
||||
self._assert_enqueue_result(res)
|
||||
self.posthog.flush()
|
||||
|
||||
def test_page(self):
|
||||
posthog.page("distinct_id", "https://posthog.com/contact")
|
||||
posthog.flush()
|
||||
self.posthog.page("distinct_id", "https://posthog.com/contact")
|
||||
self.posthog.flush()
|
||||
|
||||
def test_flush(self):
|
||||
posthog.flush()
|
||||
self.posthog.flush()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ from dateutil.tz import tzutc
|
||||
from posthog import utils
|
||||
|
||||
TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4"
|
||||
FAKE_TEST_API_KEY = "random_key"
|
||||
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
@@ -79,3 +80,22 @@ class TestUtils(unittest.TestCase):
|
||||
def test_remove_slash(self):
|
||||
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io/"))
|
||||
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io"))
|
||||
|
||||
|
||||
class TestSizeLimitedDict(unittest.TestCase):
|
||||
def test_size_limited_dict(self):
|
||||
size = 10
|
||||
values = utils.SizeLimitedDict(size, lambda _: -1)
|
||||
|
||||
for i in range(100):
|
||||
values[i] = i
|
||||
|
||||
self.assertEqual(values[i], i)
|
||||
self.assertEqual(len(values), i % size + 1)
|
||||
|
||||
if i % size == 0:
|
||||
# old numbers should've been removed
|
||||
self.assertIsNone(values.get(i - 1))
|
||||
self.assertIsNone(values.get(i - 3))
|
||||
self.assertIsNone(values.get(i - 5))
|
||||
self.assertIsNone(values.get(i - 9))
|
||||
|
||||
+29
-1
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import numbers
|
||||
from datetime import date, datetime
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
@@ -87,3 +89,29 @@ def _coerce_unicode(cmplx):
|
||||
log.warning("Error decoding: %s", item)
|
||||
return None
|
||||
return item
|
||||
|
||||
|
||||
def is_valid_regex(value) -> bool:
|
||||
try:
|
||||
re.compile(value)
|
||||
return True
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
|
||||
class SizeLimitedDict(defaultdict):
|
||||
def __init__(self, max_size, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.max_size = max_size
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if len(self) >= self.max_size:
|
||||
self.clear()
|
||||
|
||||
super().__setitem__(key, value)
|
||||
|
||||
|
||||
def convert_to_datetime_aware(date_obj):
|
||||
if date_obj.tzinfo is None:
|
||||
date_obj = date_obj.replace(tzinfo=timezone.utc)
|
||||
return date_obj
|
||||
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
VERSION = "1.4.1"
|
||||
VERSION = "3.8.3"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
@@ -44,7 +44,6 @@ posthog.host = "http://127.0.0.1:8000"
|
||||
from posthog.sentry.posthog_integration import PostHogIntegration
|
||||
|
||||
PostHogIntegration.organization = "posthog" # TODO: your sentry organization
|
||||
PostHogIntegration.project_id = "5624115" # TODO: your sentry projectID
|
||||
# PostHogIntegration.prefix = # TODO: your self hosted Sentry url. (default: https://sentry.io/organizations/)
|
||||
|
||||
# Since Sentry doesn't allow Integrations configuration (see https://github.com/getsentry/sentry-python/blob/master/sentry_sdk/integrations/__init__.py#L171-L183)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
[bdist_wheel]
|
||||
universal = 1
|
||||
|
||||
[tool:pytest]
|
||||
asyncio_mode = auto
|
||||
|
||||
@@ -14,16 +14,37 @@ long_description = """
|
||||
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
|
||||
"""
|
||||
|
||||
install_requires = ["requests>=2.7,<3.0", "six>=1.5", "monotonic>=1.5", "backoff==1.10.0", "python-dateutil>2.1"]
|
||||
install_requires = [
|
||||
"requests>=2.7,<3.0",
|
||||
"six>=1.5",
|
||||
"monotonic>=1.5",
|
||||
"backoff>=1.10.0",
|
||||
"python-dateutil>2.1",
|
||||
]
|
||||
|
||||
extras_require = {
|
||||
"dev": [
|
||||
"black",
|
||||
"isort",
|
||||
"flake8",
|
||||
"flake8-print",
|
||||
"pre-commit",
|
||||
],
|
||||
"test": ["mock>=2.0.0", "freezegun==0.3.15", "pylint", "flake8", "coverage"],
|
||||
"test": [
|
||||
"mock>=2.0.0",
|
||||
"freezegun==0.3.15",
|
||||
"pylint",
|
||||
"flake8",
|
||||
"coverage",
|
||||
"pytest",
|
||||
"pytest-timeout",
|
||||
"pytest-asyncio",
|
||||
"django",
|
||||
"langchain-community>=0.2.0",
|
||||
"langchain-openai>=0.2.0",
|
||||
],
|
||||
"sentry": ["sentry-sdk", "django"],
|
||||
"langchain": ["langchain>=0.2.0"],
|
||||
}
|
||||
|
||||
setup(
|
||||
@@ -35,7 +56,15 @@ setup(
|
||||
maintainer="PostHog",
|
||||
maintainer_email="hey@posthog.com",
|
||||
test_suite="posthog.test.all",
|
||||
packages=["posthog", "posthog.test", "posthog.sentry"],
|
||||
packages=[
|
||||
"posthog",
|
||||
"posthog.ai",
|
||||
"posthog.ai.langchain",
|
||||
"posthog.ai.openai",
|
||||
"posthog.test",
|
||||
"posthog.sentry",
|
||||
"posthog.exception_integrations",
|
||||
],
|
||||
license="MIT License",
|
||||
install_requires=install_requires,
|
||||
extras_require=extras_require,
|
||||
@@ -58,5 +87,8 @@ setup(
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
],
|
||||
)
|
||||
|
||||
+13
-2
@@ -14,7 +14,7 @@ long_description = """
|
||||
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
|
||||
"""
|
||||
|
||||
install_requires = ["requests>=2.7,<3.0", "six>=1.5", "monotonic>=1.5", "backoff==1.6.0", "python-dateutil>2.1"]
|
||||
install_requires = ["requests>=2.7,<3.0", "six>=1.5", "monotonic>=1.5", "backoff>=1.10.0", "python-dateutil>2.1"]
|
||||
|
||||
tests_require = ["mock>=2.0.0"]
|
||||
|
||||
@@ -27,7 +27,15 @@ setup(
|
||||
maintainer="PostHog",
|
||||
maintainer_email="hey@posthog.com",
|
||||
test_suite="posthoganalytics.test.all",
|
||||
packages=["posthoganalytics", "posthoganalytics.test", "posthoganalytics.sentry"],
|
||||
packages=[
|
||||
"posthoganalytics",
|
||||
"posthoganalytics.ai",
|
||||
"posthoganalytics.ai.langchain",
|
||||
"posthoganalytics.ai.openai",
|
||||
"posthoganalytics.test",
|
||||
"posthoganalytics.sentry",
|
||||
"posthoganalytics.exception_integrations",
|
||||
],
|
||||
license="MIT License",
|
||||
install_requires=install_requires,
|
||||
tests_require=tests_require,
|
||||
@@ -53,5 +61,8 @@ setup(
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user