Compare commits

...
28 Commits
Author SHA1 Message Date
Oliver BrowneandGitHub e13c428ff6 feat(err): construct full trace if no traceback available (#266)
* construct full trace if no traceback available

* delete asserts

* bump version
2025-06-19 16:47:42 +03:00
Oliver BrowneandGitHub 77190c23e1 document prep_local (#265) 2025-06-18 19:50:32 +01:00
Oliver BrowneandGitHub b7753392f7 feat: session and identity integrate with context now (#264)
* session and identity in context

* bump version

* make django integration use context distinct id and session functions

* don't use self

* fix comments

* fix middleware tests

* clarify fresh and distinct id's

* Fix exported modules, add makefile command to test changes locally

* tiny fix
2025-06-18 19:27:27 +03:00
250bd424d0 feat(err): add django middleware (#263)
* fix exactly-once capture

* add middleware

* fix typing

* ignore unreacable

* Revert "ignore unreacable"

This reverts commit 0458f0efa6c8e52ecfb1eeb41c57a76d84578164.

* add unreachable ignore

* move unreachable ignore

* switch to use request.headers

* clarify comment

* Update posthog/integrations/django.py

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

* explain typle

* fix comment

* explain that tags become properties

* fix tests

---------

Co-authored-by: David Newell <d.newell1@outlook.com>
2025-06-17 17:54:32 +03:00
Oliver BrowneandGitHub 579cc56787 fix: delete sentry integration (#262)
* delete relevant files

* bump uv lock, bump major version as deprecation

* README.md

* try mypy sync

* Revert "try mypy sync"

This reverts commit e1b98b26e59132e52eff6389afd42cb1b07a6a0b.

* try looking at the github action
2025-06-16 18:38:53 +03:00
Oliver BrowneandGitHub 3778eaef7b fix(err): just check if the passed exceptions is a BaseException (#261)
* just check if it's an exception first

* version bump
2025-06-13 19:36:00 +00:00
Georgiy TarasovandGitHub 52df246a3e feat(ai): langchain cached and reasoning tokens (#258)
* fix: reasoning and cached tokens

* test: new flows

* fix: missing field

* chore: bump

* fix: make sure we send write/read/reasoning tokens
2025-06-13 15:02:06 +02:00
Phil HaackandGitHub f1f9ecf7a4 Add flags project board workflow (#259) 2025-06-12 16:33:42 +00:00
Oliver BrowneandGitHub 9db1b7e9f3 fix: change scoped export, add capturing param (#257)
* change export, add capturing param

* capturing -> capture_exceptions
2025-06-12 10:29:32 +01:00
Peter KirkhamandGitHub 01751d1205 feat: add support for parse via responses (#256) 2025-06-11 05:39:24 +01:00
David NewellandGitHub 4426dd9d27 remove 'import posthog' (#255) 2025-06-10 11:05:22 +01:00
David NewellandGitHub bf0d7efbfe fix: makefile import (#254) 2025-06-09 19:20:31 +01:00
David NewellandGitHub f17ebfa12b feat: more django context (#252) 2025-06-09 14:32:42 +01:00
Paul D'AmbraandGitHub 800527da43 feat: add before_send callback (#249) 2025-06-09 13:56:46 +01:00
Paul D'AmbraandGitHub 0d29fb7be3 fix changelog to match pypi (#253) 2025-06-09 12:09:15 +01:00
Paul D'AmbraandGitHub 24d89806cb chore: more fiddling to get release working (#251)
* chore: more fiddling to get release working

* fix

* fix
2025-06-09 11:50:30 +01:00
Paul D'AmbraandGitHub a2105f6e95 chore: use uv run when releasing (#250) 2025-06-09 10:11:12 +00:00
Paul D'AmbraandGitHub 3171193d75 fix: makefile for posthog_analytics release (#248) 2025-06-09 10:51:04 +01:00
Paul D'AmbraandGitHub 1db6e45258 chore: pyproject and CI update (#247) 2025-06-07 14:08:47 +03:00
Dylan MartinandGitHub 1daa8a8053 chore(flags): roll everyone onto /flags (#246) 2025-06-06 16:36:10 -07:00
Oliver BrowneandGitHub 5d58a53b36 fix: lets try again (#244)
* maybe

* bump version
2025-06-06 16:25:47 +02:00
7af8e886ee fix: python release attempt 3 (#242)
* fix: maybe the classifier is deprecated

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* ruff

* bump version for release

---------

Co-authored-by: Oliver Browne <oliver@posthog.com>
2025-06-06 15:16:31 +03:00
Oliver BrowneandGitHub 90d3fca27d fix: bump for release (#243)
* bump for release

* changelog

* changelog
2025-06-06 11:59:58 +00:00
Oliver BrowneGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>David Newell
243b98df11 feat(err): add context manager and tag functions (#239)
* add context maanager and tag functions

* Update posthog/scopes.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* ran black

* black locally disagrees with ci. python is awful

* bleh

* isort

* fix mypyp thing

* Revert "fix mypyp thing"

This reverts commit 21ad8733610967cad0bbf8451508ca1189be543c.

* update baseline

* lets try again

* alright lets try again

* revert to baseline

* try ignoring it i guess

* black

* try supporting async too

* black

* mypy

* formatting

* fix changelog

* fix comment

* we only support python 3.9+

* change decorator name

* fix example

* isort

* auto-capture in with blocks

* fix tests

* black

* inherit tags by default

* assert swap

* add tags to all events

* rm comment

* Update example.py

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

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: David Newell <d.newell1@outlook.com>
2025-06-06 14:49:15 +03:00
Paul D'AmbraandGitHub 7ab2080309 fix: release action failed (#241) 2025-06-05 15:50:27 +01:00
Paul D'AmbraandGitHub 23e1d8e2a3 fix: opinionated setup and clean fn fix (#240) 2025-06-05 14:25:56 +01:00
e2d8200cc6 pin actions versions (#210)
* pin actions versions
---------

Co-authored-by: Paweł Szczur <orian@users.noreply.github.com>
2025-05-27 08:25:50 +00:00
Paweł SzczurandGitHub da69b68f7d fix: feature flag request use geoip_disable (#235)
* make feature flag request use geoip_disable
2025-05-27 10:18:55 +02:00
69 changed files with 8258 additions and 1554 deletions
@@ -0,0 +1,17 @@
# This workflow is used to call the flags-project-board workflow when a pull request is opened, ready for review, review requested, synchronized, converted to draft, or reopened.
# It is used to update the feature flags project board with the pull request information.
name: Call Feature Flags Project Workflow
on:
pull_request:
types: [opened, ready_for_review, review_requested, synchronize, converted_to_draft, reopened]
jobs:
call-flags-project:
uses: PostHog/.github/.github/workflows/flags-project-board.yml@main
with:
pr_number: ${{ github.event.pull_request.number }}
pr_node_id: ${{ github.event.pull_request.node_id }}
is_draft: ${{ github.event.pull_request.draft }}
secrets: inherit
+21 -23
View File
@@ -9,38 +9,29 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
with:
fetch-depth: 1
- name: Set up Python 3.11
uses: actions/setup-python@v2
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
with:
python-version: 3.11.11
- uses: actions/cache@v3
- name: Install uv
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
enable-cache: true
pyproject-file: 'pyproject.toml'
- name: Install dev dependencies
shell: bash
run: |
python -m pip install -e .[dev]
if: steps.cache.outputs.cache-hit != 'true'
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra dev
- name: Check formatting with black
- name: Check formatting with ruff
run: |
black --check .
- name: Lint with flake8
run: |
flake8 posthog --ignore E501,W503
- name: Check import order with isort
run: |
isort --check-only .
ruff format --check .
- name: Check types with mypy
run: |
@@ -54,18 +45,25 @@ jobs:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
with:
fetch-depth: 1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
with:
python-version: ${{ matrix.python-version }}
- name: Install requirements.txt dependencies with pip
- name: Install uv
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
with:
enable-cache: true
pyproject-file: 'pyproject.toml'
- name: Install test dependencies
shell: bash
run: |
python -m pip install -e .[test]
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra test
- name: Run posthog tests
run: |
+14 -6
View File
@@ -17,25 +17,33 @@ jobs:
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
steps:
- name: Checkout the repository
uses: actions/checkout@v2
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
with:
fetch-depth: 0
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v2
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
with:
python-version: 3.11.11
- name: Install uv
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
with:
enable-cache: true
pyproject-file: 'pyproject.toml'
- 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
run: uv sync --extra dev
- name: Push release to PyPI
run: make release && make release_analytics
- name: Push releases to PyPI
run: uv run make release && uv run make release_analytics
- name: Create GitHub release
uses: actions/create-release@v1
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
env:
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
with:
+1
View File
@@ -17,3 +17,4 @@ posthog-analytics
.coverage
pyrightconfig.json
.env
.DS_Store
+9 -8
View File
@@ -1,9 +1,10 @@
repos:
- repo: https://github.com/psf/black
rev: stable
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.7.0
hooks:
- id: isort
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.11.12
hooks:
# Run the linter.
- id: ruff-check
args: [ --fix ]
# Run the formatter.
- id: ruff-format
+237
View File
@@ -0,0 +1,237 @@
# Before Send Hook
The `before_send` parameter allows you to modify or filter events before they are sent to PostHog. This is useful for:
- **Privacy**: Removing or masking sensitive data (PII)
- **Filtering**: Dropping unwanted events (test events, internal users, etc.)
- **Enhancement**: Adding custom properties to all events
- **Transformation**: Modifying event names or property formats
## Basic Usage
```python
import posthog
from typing import Optional, Dict, Any
def my_before_send(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Process event before sending to PostHog.
Args:
event: The event dictionary containing 'event', 'distinct_id', 'properties', etc.
Returns:
Modified event dictionary to send, or None to drop the event
"""
# Your processing logic here
return event
# Initialize client with before_send hook
client = posthog.Client(
api_key="your-project-api-key",
before_send=my_before_send
)
```
## Common Use Cases
### 1. Filter Out Events
```python
from typing import Optional, Any
def filter_events_by_property_or_event_name(event: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Drop events from internal users or test environments."""
properties = event.get("properties", {})
# Choose some property from your events
event_source = properties.get("event_source", "")
if event_source.endswith("internal"):
return None # Drop the event
# Filter out test events
if event.get("event") == "test_event":
return None
return event
```
### 2. Remove/Mask PII Data
```python
from typing import Optional, Any
def scrub_pii(event: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Remove or mask personally identifiable information."""
properties = event.get("properties", {})
# Mask email but keep domain for analytics
if "email" in properties:
email = properties["email"]
if "@" in email:
domain = email.split("@")[1]
properties["email"] = f"***@{domain}"
else:
properties["email"] = "***"
# Remove sensitive fields entirely
sensitive_fields = ["my_business_info", "secret_things"]
for field in sensitive_fields:
properties.pop(field, None)
return event
```
### 3. Add Custom Properties
```python
from typing import Optional, Any
from datetime import datetime
from typing import Optional, Any
def add_context(event: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Add custom properties to all events."""
if "properties" not in event:
event["properties"] = {}
event["properties"].update({
"app_version": "2.1.0",
"environment": "production",
"processed_at": datetime.now().isoformat()
})
return event
```
### 4. Transform Event Names
```python
from typing import Optional, Any
def normalize_event_names(event: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Convert event names to a consistent format."""
original_event = event.get("event")
if original_event:
# Convert to snake_case
normalized = original_event.lower().replace(" ", "_").replace("-", "_")
event["event"] = f"app_{normalized}"
return event
```
### 5. Log and drop in "dev" mode
When running in local dev often, you want to log but drop all events
```python
from typing import Optional, Any
def log_and_drop_all(event: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Convert event names to a consistent format."""
print(event)
return None
```
### 6. Combined Processing
```python
from typing import Optional, Any
def comprehensive_processor(event: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Apply multiple transformations in sequence."""
# Step 1: Filter unwanted events
if should_drop_event(event):
return None
# Step 2: Scrub PII
event = scrub_pii(event)
# Step 3: Add context
event = add_context(event)
# Step 4: Normalize names
event = normalize_event_names(event)
return event
def should_drop_event(event: dict[str, Any]) -> bool:
"""Determine if event should be dropped."""
# Your filtering logic
return False
```
## Error Handling
If your `before_send` function raises an exception, PostHog will:
1. Log the error
2. Continue with the original, unmodified event
3. Not crash your application
```python
from typing import Optional, Any
def risky_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
# If this raises an exception, the original event will be sent
risky_operation()
return event
```
## Complete Example
```python
import posthog
from typing import Optional, Any
import re
def production_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
try:
properties = event.get("properties", {})
# 1. Filter out bot traffic
user_agent = properties.get("$user_agent", "")
if re.search(r'bot|crawler|spider', user_agent, re.I):
return None
# 2. Filter out internal traffic
ip = properties.get("$ip", "")
if ip.startswith("192.168.") or ip.startswith("10."):
return None
# 3. Scrub email PII but keep domain
if "email" in properties:
email = properties["email"]
if "@" in email:
domain = email.split("@")[1]
properties["email"] = f"***@{domain}"
# 4. Add custom context
properties.update({
"app_version": "1.0.0",
"build_number": "123"
})
# 5. Normalize event name
if event.get("event"):
event["event"] = event["event"].lower().replace(" ", "_")
return event
except Exception as e:
# Log error but don't crash
print(f"Error in before_send: {e}")
return event # Return original event on error
# Usage
client = posthog.Client(
api_key="your-api-key",
before_send=production_before_send
)
# All events will now be processed by your before_send function
client.capture("user_123", "Page View", {"url": "/home"})
```
+74
View File
@@ -1,3 +1,77 @@
# 5.2.0 - 2025-06-19
- feat: construct artificial stack traces if no traceback is available on a captured exception
## 5.1.0 - 2025-06-18
- feat: session and distinct ID's can now be associated with contexts, and are used as such
- feat: django http request middleware
## 5.0.0 - 2025-06-16
- fix: removed deprecated sentry integration
## 4.10.0 - 2025-06-13
- fix: no longer fail in autocapture.
## 4.9.0 - 2025-06-13
- feat(ai): track reasoning and cache tokens in the LangChain callback
## 4.8.0 - 2025-06-10
- fix: export scoped, rather than tracked, decorator
- feat: allow use of contexts without error tracking
## 4.7.0 - 2025-06-10
- feat: add support for parse endpoint in responses API (no longer beta)
## 4.6.2 - 2025-06-09
- fix: replace `import posthog` with direct method imports
## 4.6.1 - 2025-06-09
- fix: replace `import posthog` in `posthoganalytics` package
## 4.6.0 - 2025-06-09
- feat: add additional user and request context to captured exceptions via the Django integration
- feat: Add `setup()` function to initialise default client
## 4.5.0 - 2025-06-09
- feat: add before_send callback (#249)
## 4.4.2- 2025-06-09
- empty point release to fix release automation
## 4.4.1 2025-06-09
- empty point release to fix release automation
## 4.4.0 - 2025-06-09
- Use the new `/flags` endpoint for all feature flag evaluations (don't fall back to `/decide` at all)
## 4.3.2 - 2025-06-06
1. Add context management:
- New context manager with `posthog.new_context()`
- Tag functions: `posthog.tag()`, `posthog.get_tags()`, `posthog.clear_tags()`
- Function decorator:
- `@posthog.scoped` - Creates context and captures exceptions thrown within the function
- Automatic deduplication of exceptions to ensure each exception is only captured once
2. fix: feature flag request use geoip_disable (#235)
3. chore: pin actions versions (#210)
4. fix: opinionated setup and clean fn fix (#240)
5. fix: release action failed (#241)
## 4.2.0 - 2025-05-22
Add support for google gemini
+29 -10
View File
@@ -1,6 +1,5 @@
lint:
pylint --rcfile=.pylintrc --reports=y --exit-zero analytics | tee pylint.out
flake8 --max-complexity=10 --statistics analytics > flake8.out || true
uvx ruff format
test:
coverage run -m pytest
@@ -17,22 +16,42 @@ release_analytics:
rm -rf posthoganalytics
mkdir posthoganalytics
cp -r posthog/* posthoganalytics/
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthog /from posthoganalytics /g' {} \;
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthog\./from posthoganalytics\./g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \;
find ./posthoganalytics -name "*.bak" -delete
rm -rf posthog
python setup_analytics.py sdist bdist_wheel
twine upload dist/*
mkdir posthog
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthoganalytics /from posthog /g' {} \;
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthoganalytics\./from posthog\./g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics /from posthog /g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics\./from posthog\./g' {} \;
find ./posthoganalytics -name "*.bak" -delete
cp -r posthoganalytics/* posthog/
rm -rf posthoganalytics
rm -f pyproject.toml
cp pyproject.toml.backup pyproject.toml
rm -f pyproject.toml.backup
e2e_test:
.buildscripts/e2e.sh
django_example:
python -m pip install -e ".[sentry]"
cd sentry_django_example && python manage.py runserver 8080
prep_local:
rm -rf ../posthog-python-local
mkdir ../posthog-python-local
cp -r . ../posthog-python-local/
cd ../posthog-python-local && rm -rf dist build posthoganalytics .git
cd ../posthog-python-local && mkdir posthoganalytics
cd ../posthog-python-local && cp -r posthog/* posthoganalytics/
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \;
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \;
cd ../posthog-python-local && find ./posthoganalytics -name "*.bak" -delete
cd ../posthog-python-local && rm -rf posthog
cd ../posthog-python-local && sed -i.bak 's/from version import VERSION/from posthoganalytics.version import VERSION/' setup_analytics.py
cd ../posthog-python-local && rm setup_analytics.py.bak
cd ../posthog-python-local && sed -i.bak 's/"posthog"/"posthoganalytics"/' setup.py
cd ../posthog-python-local && rm setup.py.bak
cd ../posthog-python-local && python -c "import setup_analytics" 2>/dev/null || true
@echo "Local copy created at ../posthog-python-local"
@echo "Install with: pip install -e ../posthog-python-local"
.PHONY: test lint release e2e_test
.PHONY: test lint release e2e_test prep_local
+44 -28
View File
@@ -1,7 +1,14 @@
# PostHog Python
[![PyPI](https://img.shields.io/pypi/v/posthog)](https://pypi.org/project/posthog/)
<p align="center">
<img alt="posthoglogo" src="https://user-images.githubusercontent.com/65415371/205059737-c8a4f836-4889-4654-902e-f302b187b6a0.png">
</p>
<p align="center">
<a href="https://pypi.org/project/posthog/"><img alt="pypi installs" src="https://img.shields.io/pypi/v/posthog"/></a>
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/posthog/posthog-python">
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/posthog/posthog-python"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/posthog/posthog-python"/>
</p>
Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details.
@@ -9,40 +16,49 @@ Please see the [Python integration docs](https://posthog.com/docs/integrations/p
### Testing Locally
1. Run `python3 -m venv env` (creates virtual environment called "env")
* or `uv venv env`
We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
1. Run `uv venv env` (creates virtual environment called "env")
* or `python3 -m venv 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)
* or `uv pip install -e ".[test]"`
4. Run `make test`
3. Run `uv sync --extra dev --extra test` (installs the package in develop mode, along with test dependencies)
* or `pip install -e ".[dev,test]"`
4. you have to run `pre-commit install` to have auto linting pre commit
5. Run `make test`
1. To run a specific test do `pytest -k test_no_api_key`
## PostHog recommends `uv` so...
```bash
uv python install 3.9.19
uv python pin 3.9.19
uv venv env
source env/bin/activate
uv sync --extra dev --extra test
pre-commit install
make test
```
### 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
There's a sample Django project included, called `sentry_django_example`, which explains how to use PostHog with Sentry.
There's 2 places of importance (Changes required are all marked with TODO in the sample project directory)
1. Settings.py
1. Input your Sentry DSN
2. Input your Sentry Org and ProjectID details into `PosthogIntegration()`
3. Add `POSTHOG_DJANGO` to settings.py. This allows the `PosthogDistinctIdMiddleware` to get the distinct_ids
2. urls.py
1. This includes the `sentry-debug/` endpoint, which generates an exception
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`.
Updates are released using GitHub Actions: after bumping `version.py` in `master` and adding to `CHANGELOG.md`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
## Questions?
### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ)
### Testing changes locally with the PostHog app
You can run `make prep_local`, and it'll create a new folder alongside the SDK repo one called `posthog-python-local`, which you can then import into the posthog project by changing pyproject.toml to look like this:
```toml
dependencies = [
...
"posthoganalytics" #NOTE: no version number
...
]
...
[tools.uv.sources]
posthoganalytics = { path = "../posthog-python-local" }
```
This'll let you build and test SDK changes fully locally, incorporating them into your local posthog app stack. It mainly takes care of the `posthog -> posthoganalytics` module renaming. You'll need to re-run `make prep_local` each time you make a change, and re-run `uv sync --active` in the posthog app project.
+71 -7
View File
@@ -6,7 +6,9 @@ import posthog
# Add argument parsing
parser = argparse.ArgumentParser(description="PostHog Python library example")
parser.add_argument(
"--flag", default="person-on-events-enabled", help="Feature flag key to check (default: person-on-events-enabled)"
"--flag",
default="person-on-events-enabled",
help="Feature flag key to check (default: person-on-events-enabled)",
)
args = parser.parse_args()
@@ -38,10 +40,19 @@ print(
# Capture an event
posthog.capture("distinct_id", "event", {"property1": "value", "property2": "value"}, send_feature_flags=True)
posthog.capture(
"distinct_id",
"event",
{"property1": "value", "property2": "value"},
send_feature_flags=True,
)
print(posthog.feature_enabled("beta-feature", "distinct_id"))
print(posthog.feature_enabled("beta-feature-groups", "distinct_id", groups={"company": "id:5"}))
print(
posthog.feature_enabled(
"beta-feature-groups", "distinct_id", groups={"company": "id:5"}
)
)
print(posthog.feature_enabled("beta-feature", "distinct_id"))
@@ -53,9 +64,14 @@ exit()
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"}
"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
@@ -82,7 +98,13 @@ posthog.set("new_distinct_id", {"current_browser": "Firefox"})
# 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"},
)
)
print(
posthog.feature_enabled(
@@ -98,10 +120,52 @@ 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
"distinct_id_random_22",
person_properties={"$geoip_city_name": "Sydney"},
only_evaluate_locally=True,
)
)
print(posthog.get_remote_config_payload("encrypted_payload_flag_key"))
# You can add tags to a context, and these are automatically added to any events (including exceptions) captured
# within that context.
# You can enter a new context using a with statement. Any exceptions thrown in the context will be captured,
# and tagged with the context tags. Other events captured will also be tagged with the context tags. By default,
# the new context inherits tags from the parent context.
with posthog.new_context():
posthog.tag("transaction_id", "abc123")
posthog.tag("some_arbitrary_value", {"tags": "can be dicts"})
# This event will be captured with the tags set above
posthog.capture("order_processed")
# This exception will be captured with the tags set above
raise Exception("Order processing failed")
# Use fresh=True to start with a clean context (no inherited tags)
with posthog.new_context(fresh=True):
posthog.tag("session_id", "xyz789")
# Only session_id tag will be present, no inherited tags
raise Exception("Session handling failed")
# You can also use the `@posthog.scoped()` decorator to enter a new context.
# By default, it inherits tags from the parent context
@posthog.scoped()
def process_order(order_id):
posthog.tag("order_id", order_id)
# Exception will be captured and tagged automatically
raise Exception("Order processing failed")
# Use fresh=True to start with a clean context (no inherited tags)
@posthog.scoped(fresh=True)
def process_payment(payment_id):
posthog.tag("payment_id", payment_id)
# Only payment_id tag will be present, no inherited tags
raise Exception("Payment processing failed")
posthog.shutdown()
-4
View File
@@ -1,7 +1,6 @@
posthog/utils.py:0: error: Library stubs not installed for "six" [import-untyped]
posthog/utils.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
posthog/utils.py:0: error: Statement is unreachable [unreachable]
posthog/utils.py:0: error: Argument 1 to "join" of "str" has incompatible type "AttributeError"; expected "Iterable[str]" [arg-type]
posthog/request.py:0: error: Library stubs not installed for "requests" [import-untyped]
posthog/request.py:0: note: Hint: "python3 -m pip install types-requests"
posthog/request.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
@@ -31,11 +30,8 @@ posthog/__init__.py:0: note: "identify" defined here
simulator.py:0: error: Unexpected keyword argument "traits" for "identify" [call-arg]
posthog/__init__.py:0: note: "identify" defined here
example.py:0: error: Statement is unreachable [unreachable]
posthog/sentry/posthog_integration.py:0: error: Statement is unreachable [unreachable]
posthog/ai/utils.py:0: error: Need type annotation for "output" (hint: "output: list[<type>] = ...") [var-annotated]
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
sentry_django_example/sentry_django_example/settings.py:0: error: Need type annotation for "ALLOWED_HOSTS" (hint: "ALLOWED_HOSTS: list[<type>] = ...") [var-annotated]
sentry_django_example/sentry_django_example/settings.py:0: error: Incompatible types in assignment (expression has type "str", variable has type "None") [assignment]
+1 -1
View File
@@ -9,7 +9,7 @@ check_untyped_defs = True
warn_unreachable = True
strict_equality = True
ignore_missing_imports = True
exclude = env/.*|venv/.*
exclude = env/.*|venv/.*|build/.*
[mypy-django.*]
ignore_missing_imports = True
+27 -4
View File
@@ -4,11 +4,30 @@ 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.scopes import (
clear_tags,
get_tags,
new_context,
scoped,
tag,
set_context_session,
identify_context,
)
from posthog.types import FeatureFlag, FlagsAndPayloads
from posthog.version import VERSION
__version__ = VERSION
"""Context management."""
new_context = new_context
tag = tag
get_tags = get_tags
clear_tags = clear_tags
scoped = scoped
identify_context = identify_context
set_context_session = set_context_session
"""Settings."""
api_key = None # type: Optional[str]
host = None # type: Optional[str]
@@ -315,7 +334,7 @@ def capture_exception(
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
groups=None, # type: Optional[Dict]
**kwargs
**kwargs,
):
# type: (...) -> Tuple[bool, dict]
"""
@@ -358,7 +377,7 @@ def capture_exception(
timestamp=timestamp,
uuid=uuid,
groups=groups,
**kwargs
**kwargs,
)
@@ -572,8 +591,7 @@ def shutdown():
_proxy("join")
def _proxy(method, *args, **kwargs):
"""Create an analytics client if one doesn't exist and send to it."""
def setup():
global default_client
if not default_client:
default_client = Client(
@@ -602,6 +620,11 @@ def _proxy(method, *args, **kwargs):
default_client.disabled = disabled
default_client.debug = debug
def _proxy(method, *args, **kwargs):
"""Create an analytics client if one doesn't exist and send to it."""
setup()
fn = getattr(default_client, method)
return fn(*args, **kwargs)
+6 -1
View File
@@ -1,6 +1,11 @@
from .anthropic import Anthropic
from .anthropic_async import AsyncAnthropic
from .anthropic_providers import AnthropicBedrock, AnthropicVertex, AsyncAnthropicBedrock, AsyncAnthropicVertex
from .anthropic_providers import (
AnthropicBedrock,
AnthropicVertex,
AsyncAnthropicBedrock,
AsyncAnthropicVertex,
)
__all__ = [
"Anthropic",
+15 -4
View File
@@ -2,13 +2,20 @@ try:
import anthropic
from anthropic.resources import Messages
except ImportError:
raise ModuleNotFoundError("Please install the Anthropic SDK to use this feature: 'pip install anthropic'")
raise ModuleNotFoundError(
"Please install the Anthropic SDK to use this feature: 'pip install anthropic'"
)
import time
import uuid
from typing import Any, Dict, Optional
from posthog.ai.utils import call_llm_and_track_usage, get_model_params, merge_system_prompt, with_privacy_mode
from posthog.ai.utils import (
call_llm_and_track_usage,
get_model_params,
merge_system_prompt,
with_privacy_mode,
)
from posthog.client import Client as PostHogClient
@@ -186,8 +193,12 @@ class WrappedMessages(Messages):
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_cache_creation_input_tokens": usage_stats.get("cache_creation_input_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get(
"cache_read_input_tokens", 0
),
"$ai_cache_creation_input_tokens": usage_stats.get(
"cache_creation_input_tokens", 0
),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
+15 -4
View File
@@ -2,13 +2,20 @@ try:
import anthropic
from anthropic.resources import AsyncMessages
except ImportError:
raise ModuleNotFoundError("Please install the Anthropic SDK to use this feature: 'pip install anthropic'")
raise ModuleNotFoundError(
"Please install the Anthropic SDK to use this feature: 'pip install anthropic'"
)
import time
import uuid
from typing import Any, Dict, Optional
from posthog.ai.utils import call_llm_and_track_usage_async, get_model_params, merge_system_prompt, with_privacy_mode
from posthog.ai.utils import (
call_llm_and_track_usage_async,
get_model_params,
merge_system_prompt,
with_privacy_mode,
)
from posthog.client import Client as PostHogClient
@@ -186,8 +193,12 @@ class AsyncWrappedMessages(AsyncMessages):
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_cache_creation_input_tokens": usage_stats.get("cache_creation_input_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get(
"cache_read_input_tokens", 0
),
"$ai_cache_creation_input_tokens": usage_stats.get(
"cache_creation_input_tokens", 0
),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
+3 -1
View File
@@ -1,7 +1,9 @@
try:
import anthropic
except ImportError:
raise ModuleNotFoundError("Please install the Anthropic SDK to use this feature: 'pip install anthropic'")
raise ModuleNotFoundError(
"Please install the Anthropic SDK to use this feature: 'pip install anthropic'"
)
from posthog.ai.anthropic.anthropic import WrappedMessages
from posthog.ai.anthropic.anthropic_async import AsyncWrappedMessages
+40 -10
View File
@@ -6,9 +6,15 @@ from typing import Any, Dict, Optional
try:
from google import genai
except ImportError:
raise ModuleNotFoundError("Please install the Google Gemini SDK to use this feature: 'pip install google-genai'")
raise ModuleNotFoundError(
"Please install the Google Gemini SDK to use this feature: 'pip install google-genai'"
)
from posthog.ai.utils import call_llm_and_track_usage, get_model_params, with_privacy_mode
from posthog.ai.utils import (
call_llm_and_track_usage,
get_model_params,
with_privacy_mode,
)
from posthog.client import Client as PostHogClient
@@ -124,8 +130,16 @@ class Models:
):
"""Merge call-level PostHog parameters with client defaults."""
# Use call-level values if provided, otherwise fall back to defaults
distinct_id = call_distinct_id if call_distinct_id is not None else self._default_distinct_id
privacy_mode = call_privacy_mode if call_privacy_mode is not None else self._default_privacy_mode
distinct_id = (
call_distinct_id
if call_distinct_id is not None
else self._default_distinct_id
)
privacy_mode = (
call_privacy_mode
if call_privacy_mode is not None
else self._default_privacy_mode
)
groups = call_groups if call_groups is not None else self._default_groups
# Merge properties: default properties + call properties (call properties override)
@@ -166,8 +180,14 @@ class Models:
**kwargs: Arguments passed to Gemini's generate_content
"""
# Merge PostHog parameters
distinct_id, trace_id, properties, privacy_mode, groups = self._merge_posthog_params(
posthog_distinct_id, posthog_trace_id, posthog_properties, posthog_privacy_mode, posthog_groups
distinct_id, trace_id, properties, privacy_mode, groups = (
self._merge_posthog_params(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
)
)
kwargs_with_contents = {"model": model, "contents": contents, **kwargs}
@@ -210,8 +230,12 @@ class Models:
for chunk in response:
if hasattr(chunk, "usage_metadata") and chunk.usage_metadata:
usage_stats = {
"input_tokens": getattr(chunk.usage_metadata, "prompt_token_count", 0),
"output_tokens": getattr(chunk.usage_metadata, "candidates_token_count", 0),
"input_tokens": getattr(
chunk.usage_metadata, "prompt_token_count", 0
),
"output_tokens": getattr(
chunk.usage_metadata, "candidates_token_count", 0
),
}
if hasattr(chunk, "text") and chunk.text:
@@ -320,8 +344,14 @@ class Models:
**kwargs: Any,
):
# Merge PostHog parameters
distinct_id, trace_id, properties, privacy_mode, groups = self._merge_posthog_params(
posthog_distinct_id, posthog_trace_id, posthog_properties, posthog_privacy_mode, posthog_groups
distinct_id, trace_id, properties, privacy_mode, groups = (
self._merge_posthog_params(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
)
)
return self._generate_content_streaming(
+148 -36
View File
@@ -1,7 +1,9 @@
try:
import langchain # noqa: F401
except ImportError:
raise ModuleNotFoundError("Please install LangChain to use this feature: 'pip install langchain'")
raise ModuleNotFoundError(
"Please install LangChain to use this feature: 'pip install langchain'"
)
import logging
import time
@@ -12,7 +14,6 @@ from typing import (
List,
Optional,
Sequence,
Tuple,
Union,
cast,
)
@@ -21,7 +22,14 @@ from uuid import UUID
from langchain.callbacks.base import BaseCallbackHandler
from langchain.schema.agent import AgentAction, AgentFinish
from langchain_core.documents import Document
from langchain_core.messages import AIMessage, BaseMessage, FunctionMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_core.messages import (
AIMessage,
BaseMessage,
FunctionMessage,
HumanMessage,
SystemMessage,
ToolMessage,
)
from langchain_core.outputs import ChatGeneration, LLMResult
from pydantic import BaseModel
@@ -143,7 +151,9 @@ class CallbackHandler(BaseCallbackHandler):
):
self._log_debug_event("on_chain_start", run_id, parent_run_id, inputs=inputs)
self._set_parent_of_run(run_id, parent_run_id)
self._set_trace_or_span_metadata(serialized, inputs, run_id, parent_run_id, **kwargs)
self._set_trace_or_span_metadata(
serialized, inputs, run_id, parent_run_id, **kwargs
)
def on_chain_end(
self,
@@ -176,9 +186,13 @@ class CallbackHandler(BaseCallbackHandler):
parent_run_id: Optional[UUID] = None,
**kwargs,
):
self._log_debug_event("on_chat_model_start", run_id, parent_run_id, messages=messages)
self._log_debug_event(
"on_chat_model_start", run_id, parent_run_id, messages=messages
)
self._set_parent_of_run(run_id, parent_run_id)
input = [_convert_message_to_dict(message) for row in messages for message in row]
input = [
_convert_message_to_dict(message) for row in messages for message in row
]
self._set_llm_metadata(serialized, run_id, input, **kwargs)
def on_llm_start(
@@ -216,7 +230,9 @@ class CallbackHandler(BaseCallbackHandler):
"""
The callback works for both streaming and non-streaming runs. For streaming runs, the chain must set `stream_usage=True` in the LLM.
"""
self._log_debug_event("on_llm_end", run_id, parent_run_id, response=response, kwargs=kwargs)
self._log_debug_event(
"on_llm_end", run_id, parent_run_id, response=response, kwargs=kwargs
)
self._pop_run_and_capture_generation(run_id, parent_run_id, response)
def on_llm_error(
@@ -240,9 +256,13 @@ class CallbackHandler(BaseCallbackHandler):
metadata: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> Any:
self._log_debug_event("on_tool_start", run_id, parent_run_id, input_str=input_str)
self._log_debug_event(
"on_tool_start", run_id, parent_run_id, input_str=input_str
)
self._set_parent_of_run(run_id, parent_run_id)
self._set_trace_or_span_metadata(serialized, input_str, run_id, parent_run_id, **kwargs)
self._set_trace_or_span_metadata(
serialized, input_str, run_id, parent_run_id, **kwargs
)
def on_tool_end(
self,
@@ -279,7 +299,9 @@ class CallbackHandler(BaseCallbackHandler):
) -> Any:
self._log_debug_event("on_retriever_start", run_id, parent_run_id, query=query)
self._set_parent_of_run(run_id, parent_run_id)
self._set_trace_or_span_metadata(serialized, query, run_id, parent_run_id, **kwargs)
self._set_trace_or_span_metadata(
serialized, query, run_id, parent_run_id, **kwargs
)
def on_retriever_end(
self,
@@ -289,7 +311,9 @@ class CallbackHandler(BaseCallbackHandler):
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
):
self._log_debug_event("on_retriever_end", run_id, parent_run_id, documents=documents)
self._log_debug_event(
"on_retriever_end", run_id, parent_run_id, documents=documents
)
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, documents)
def on_retriever_error(
@@ -364,7 +388,9 @@ class CallbackHandler(BaseCallbackHandler):
):
default_name = "trace" if parent_run_id is None else "span"
run_name = _get_langchain_run_name(serialized, **kwargs) or default_name
self._runs[run_id] = SpanMetadata(name=run_name, input=input, start_time=time.time(), end_time=None)
self._runs[run_id] = SpanMetadata(
name=run_name, input=input, start_time=time.time(), end_time=None
)
def _set_llm_metadata(
self,
@@ -376,7 +402,9 @@ class CallbackHandler(BaseCallbackHandler):
**kwargs,
):
run_name = _get_langchain_run_name(serialized, **kwargs) or "generation"
generation = GenerationMetadata(name=run_name, input=messages, start_time=time.time(), end_time=None)
generation = GenerationMetadata(
name=run_name, input=messages, start_time=time.time(), end_time=None
)
if isinstance(invocation_params, dict):
generation.model_params = get_model_params(invocation_params)
if tools := invocation_params.get("tools"):
@@ -410,7 +438,9 @@ class CallbackHandler(BaseCallbackHandler):
return run_id
return trace_id
def _get_parent_run_id(self, trace_id: Any, run_id: UUID, parent_run_id: Optional[UUID]):
def _get_parent_run_id(
self, trace_id: Any, run_id: UUID, parent_run_id: Optional[UUID]
):
"""
Replace the parent run ID with the trace ID for second level runs when a custom trace ID is set.
"""
@@ -418,14 +448,18 @@ class CallbackHandler(BaseCallbackHandler):
return trace_id
return parent_run_id
def _pop_run_and_capture_trace_or_span(self, run_id: UUID, parent_run_id: Optional[UUID], outputs: Any):
def _pop_run_and_capture_trace_or_span(
self, run_id: UUID, parent_run_id: Optional[UUID], outputs: 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
if isinstance(run, GenerationMetadata):
log.warning(f"Run {run_id} is a generation, but attempted to be captured as a trace or span.")
log.warning(
f"Run {run_id} is a generation, but attempted to be captured as a trace or span."
)
return
self._capture_trace_or_span(
trace_id,
@@ -446,7 +480,9 @@ class CallbackHandler(BaseCallbackHandler):
event_name = "$ai_trace" if parent_run_id is None else "$ai_span"
event_properties = {
"$ai_trace_id": trace_id,
"$ai_input_state": with_privacy_mode(self._client, self._privacy_mode, run.input),
"$ai_input_state": with_privacy_mode(
self._client, self._privacy_mode, run.input
),
"$ai_latency": run.latency,
"$ai_span_name": run.name,
"$ai_span_id": run_id,
@@ -460,7 +496,9 @@ class CallbackHandler(BaseCallbackHandler):
event_properties["$ai_error"] = _stringify_exception(outputs)
event_properties["$ai_is_error"] = True
elif outputs is not None:
event_properties["$ai_output_state"] = with_privacy_mode(self._client, self._privacy_mode, outputs)
event_properties["$ai_output_state"] = with_privacy_mode(
self._client, self._privacy_mode, outputs
)
if self._distinct_id is None:
event_properties["$process_person_profile"] = False
@@ -484,7 +522,9 @@ class CallbackHandler(BaseCallbackHandler):
if not run:
return
if not isinstance(run, GenerationMetadata):
log.warning(f"Run {run_id} is not a generation, but attempted to be captured as a generation.")
log.warning(
f"Run {run_id} is not a generation, but attempted to be captured as a generation."
)
return
self._capture_generation(
trace_id,
@@ -528,9 +568,14 @@ class CallbackHandler(BaseCallbackHandler):
event_properties["$ai_is_error"] = True
else:
# Add usage
input_tokens, output_tokens = _parse_usage(output)
event_properties["$ai_input_tokens"] = input_tokens
event_properties["$ai_output_tokens"] = output_tokens
usage = _parse_usage(output)
event_properties["$ai_input_tokens"] = usage.input_tokens
event_properties["$ai_output_tokens"] = usage.output_tokens
event_properties["$ai_cache_creation_input_tokens"] = (
usage.cache_write_tokens
)
event_properties["$ai_cache_read_input_tokens"] = usage.cache_read_tokens
event_properties["$ai_reasoning_tokens"] = usage.reasoning_tokens
# Generation results
generation_result = output.generations[-1]
@@ -540,8 +585,12 @@ class CallbackHandler(BaseCallbackHandler):
for generation in generation_result
]
else:
completions = [_extract_raw_esponse(generation) for generation in generation_result]
event_properties["$ai_output_choices"] = with_privacy_mode(self._client, self._privacy_mode, completions)
completions = [
_extract_raw_esponse(generation) for generation in generation_result
]
event_properties["$ai_output_choices"] = with_privacy_mode(
self._client, self._privacy_mode, completions
)
if self._properties:
event_properties.update(self._properties)
@@ -602,9 +651,18 @@ def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
return message_dict
@dataclass
class ModelUsage:
input_tokens: Optional[int]
output_tokens: Optional[int]
cache_write_tokens: Optional[int]
cache_read_tokens: Optional[int]
reasoning_tokens: Optional[int]
def _parse_usage_model(
usage: Union[BaseModel, Dict],
) -> Tuple[Union[int, None], Union[int, None]]:
usage: Union[BaseModel, dict],
) -> ModelUsage:
if isinstance(usage, BaseModel):
usage = usage.__dict__
@@ -612,15 +670,23 @@ def _parse_usage_model(
# https://pypi.org/project/langchain-anthropic/ (works also for Bedrock-Anthropic)
("input_tokens", "input"),
("output_tokens", "output"),
("cache_creation_input_tokens", "cache_write"),
("cache_read_input_tokens", "cache_read"),
# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/get-token-count
("prompt_token_count", "input"),
("candidates_token_count", "output"),
("cached_content_token_count", "cache_read"),
("thoughts_token_count", "reasoning"),
# Bedrock: https://docs.aws.amazon.com/bedrock/latest/userguide/monitoring-cw.html#runtime-cloudwatch-metrics
("inputTokenCount", "input"),
("outputTokenCount", "output"),
("cacheCreationInputTokenCount", "cache_write"),
("cacheReadInputTokenCount", "cache_read"),
# Bedrock Anthropic
("prompt_tokens", "input"),
("completion_tokens", "output"),
("cache_creation_input_tokens", "cache_write"),
("cache_read_input_tokens", "cache_read"),
# langchain-ibm https://pypi.org/project/langchain-ibm/
("input_token_count", "input"),
("generated_token_count", "output"),
@@ -631,18 +697,52 @@ def _parse_usage_model(
if model_key in usage:
captured_count = usage[model_key]
final_count = (
sum(captured_count) if isinstance(captured_count, list) else captured_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")
# Caching (OpenAI & langchain 0.3.9+)
if "input_token_details" in usage and isinstance(
usage["input_token_details"], dict
):
parsed_usage["cache_write"] = usage["input_token_details"].get("cache_creation")
parsed_usage["cache_read"] = usage["input_token_details"].get("cache_read")
# Reasoning (OpenAI & langchain 0.3.9+)
if "output_token_details" in usage and isinstance(
usage["output_token_details"], dict
):
parsed_usage["reasoning"] = usage["output_token_details"].get("reasoning")
field_mapping = {
"input": "input_tokens",
"output": "output_tokens",
"cache_write": "cache_write_tokens",
"cache_read": "cache_read_tokens",
"reasoning": "reasoning_tokens",
}
return ModelUsage(
**{
dataclass_key: parsed_usage.get(mapped_key) or 0
for mapped_key, dataclass_key in field_mapping.items()
},
)
def _parse_usage(response: LLMResult):
def _parse_usage(response: LLMResult) -> ModelUsage:
# langchain-anthropic uses the usage field
llm_usage_keys = ["token_usage", "usage"]
llm_usage: Tuple[Union[int, None], Union[int, None]] = (None, None)
llm_usage: ModelUsage = ModelUsage(
input_tokens=None,
output_tokens=None,
cache_write_tokens=None,
cache_read_tokens=None,
reasoning_tokens=None,
)
if response.llm_output is not None:
for key in llm_usage_keys:
if response.llm_output.get(key):
@@ -656,8 +756,12 @@ def _parse_usage(response: LLMResult):
break
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"])
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", {})
@@ -669,13 +773,19 @@ def _parse_usage(response: LLMResult):
else None
)
bedrock_titan_usage = (
response_metadata.get("amazon-bedrock-invocationMetrics", None) # for Bedrock-Titan
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
ollama_usage = getattr(
message_chunk, "usage_metadata", None
) # for Ollama
chunk_usage = bedrock_anthropic_usage or bedrock_titan_usage or ollama_usage
chunk_usage = (
bedrock_anthropic_usage or bedrock_titan_usage or ollama_usage
)
if chunk_usage:
llm_usage = _parse_usage_model(chunk_usage)
break
@@ -691,7 +801,9 @@ def _get_http_status(error: BaseException) -> int:
return status_code
def _get_langchain_run_name(serialized: Optional[Dict[str, Any]], **kwargs: Any) -> Optional[str]:
def _get_langchain_run_name(
serialized: Optional[Dict[str, Any]], **kwargs: Any
) -> Optional[str]:
"""Retrieve the name of a serialized LangChain runnable.
The prioritization for the determination of the run name is as follows:
+84 -14
View File
@@ -5,9 +5,15 @@ from typing import Any, Dict, List, Optional
try:
import openai
except ImportError:
raise ModuleNotFoundError("Please install the OpenAI SDK to use this feature: 'pip install openai'")
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, with_privacy_mode
from posthog.ai.utils import (
call_llm_and_track_usage,
get_model_params,
with_privacy_mode,
)
from posthog.client import Client as PostHogClient
@@ -134,12 +140,16 @@ class WrappedResponses:
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
chunk.usage.output_tokens_details, "reasoning_tokens"
):
usage_stats["reasoning_tokens"] = chunk.usage.output_tokens_details.reasoning_tokens
usage_stats["reasoning_tokens"] = (
chunk.usage.output_tokens_details.reasoning_tokens
)
if hasattr(chunk.usage, "input_tokens_details") and hasattr(
chunk.usage.input_tokens_details, "cached_tokens"
):
usage_stats["cache_read_input_tokens"] = chunk.usage.input_tokens_details.cached_tokens
usage_stats["cache_read_input_tokens"] = (
chunk.usage.input_tokens_details.cached_tokens
)
yield chunk
@@ -181,7 +191,9 @@ class WrappedResponses:
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("input")),
"$ai_input": with_privacy_mode(
self._client._ph_client, posthog_privacy_mode, kwargs.get("input")
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
@@ -190,7 +202,9 @@ class WrappedResponses:
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get(
"cache_read_input_tokens", 0
),
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
@@ -216,6 +230,42 @@ class WrappedResponses:
groups=posthog_groups,
)
def parse(
self,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Parse structured output using OpenAI's 'responses.parse' 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.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
Returns:
The response from OpenAI's responses.parse call.
"""
return call_llm_and_track_usage(
posthog_distinct_id,
self._client._ph_client,
"openai",
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.parse,
**kwargs,
)
class WrappedChat:
"""Wrapper for OpenAI chat that tracks usage in PostHog."""
@@ -318,14 +368,22 @@ class WrappedCompletions:
if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
chunk.usage.prompt_tokens_details, "cached_tokens"
):
usage_stats["cache_read_input_tokens"] = chunk.usage.prompt_tokens_details.cached_tokens
usage_stats["cache_read_input_tokens"] = (
chunk.usage.prompt_tokens_details.cached_tokens
)
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
chunk.usage.output_tokens_details, "reasoning_tokens"
):
usage_stats["reasoning_tokens"] = chunk.usage.output_tokens_details.reasoning_tokens
usage_stats["reasoning_tokens"] = (
chunk.usage.output_tokens_details.reasoning_tokens
)
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
if (
hasattr(chunk, "choices")
and chunk.choices
and len(chunk.choices) > 0
):
if chunk.choices[0].delta and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
if content:
@@ -340,8 +398,14 @@ class WrappedCompletions:
accumulated_tools[index] = tool_call
else:
# Append arguments for existing tool calls
if hasattr(tool_call, "function") and hasattr(tool_call.function, "arguments"):
accumulated_tools[index].function.arguments += tool_call.function.arguments
if hasattr(tool_call, "function") and hasattr(
tool_call.function, "arguments"
):
accumulated_tools[
index
].function.arguments += (
tool_call.function.arguments
)
yield chunk
@@ -385,7 +449,9 @@ class WrappedCompletions:
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("messages")),
"$ai_input": with_privacy_mode(
self._client._ph_client, posthog_privacy_mode, kwargs.get("messages")
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
@@ -394,7 +460,9 @@ class WrappedCompletions:
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get(
"cache_read_input_tokens", 0
),
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
@@ -476,7 +544,9 @@ class WrappedEmbeddings:
event_properties = {
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("input")),
"$ai_input": with_privacy_mode(
self._client._ph_client, posthog_privacy_mode, kwargs.get("input")
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_latency": latency,
+84 -14
View File
@@ -5,9 +5,15 @@ from typing import Any, Dict, List, Optional
try:
import openai
except ImportError:
raise ModuleNotFoundError("Please install the OpenAI SDK to use this feature: 'pip install openai'")
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, with_privacy_mode
from posthog.ai.utils import (
call_llm_and_track_usage_async,
get_model_params,
with_privacy_mode,
)
from posthog.client import Client as PostHogClient
@@ -134,12 +140,16 @@ class WrappedResponses:
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
chunk.usage.output_tokens_details, "reasoning_tokens"
):
usage_stats["reasoning_tokens"] = chunk.usage.output_tokens_details.reasoning_tokens
usage_stats["reasoning_tokens"] = (
chunk.usage.output_tokens_details.reasoning_tokens
)
if hasattr(chunk.usage, "input_tokens_details") and hasattr(
chunk.usage.input_tokens_details, "cached_tokens"
):
usage_stats["cache_read_input_tokens"] = chunk.usage.input_tokens_details.cached_tokens
usage_stats["cache_read_input_tokens"] = (
chunk.usage.input_tokens_details.cached_tokens
)
yield chunk
@@ -181,7 +191,9 @@ class WrappedResponses:
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("input")),
"$ai_input": with_privacy_mode(
self._client._ph_client, posthog_privacy_mode, kwargs.get("input")
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
@@ -190,7 +202,9 @@ class WrappedResponses:
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get(
"cache_read_input_tokens", 0
),
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
@@ -216,6 +230,42 @@ class WrappedResponses:
groups=posthog_groups,
)
async def parse(
self,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Parse structured output using OpenAI's 'responses.parse' 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.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
Returns:
The response from OpenAI's responses.parse call.
"""
return await call_llm_and_track_usage_async(
posthog_distinct_id,
self._client._ph_client,
"openai",
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.parse,
**kwargs,
)
class WrappedChat:
"""Async wrapper for OpenAI chat that tracks usage in PostHog."""
@@ -321,14 +371,22 @@ class WrappedCompletions:
if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
chunk.usage.prompt_tokens_details, "cached_tokens"
):
usage_stats["cache_read_input_tokens"] = chunk.usage.prompt_tokens_details.cached_tokens
usage_stats["cache_read_input_tokens"] = (
chunk.usage.prompt_tokens_details.cached_tokens
)
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
chunk.usage.output_tokens_details, "reasoning_tokens"
):
usage_stats["reasoning_tokens"] = chunk.usage.output_tokens_details.reasoning_tokens
usage_stats["reasoning_tokens"] = (
chunk.usage.output_tokens_details.reasoning_tokens
)
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
if (
hasattr(chunk, "choices")
and chunk.choices
and len(chunk.choices) > 0
):
if chunk.choices[0].delta and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
if content:
@@ -343,8 +401,14 @@ class WrappedCompletions:
accumulated_tools[index] = tool_call
else:
# Append arguments for existing tool calls
if hasattr(tool_call, "function") and hasattr(tool_call.function, "arguments"):
accumulated_tools[index].function.arguments += tool_call.function.arguments
if hasattr(tool_call, "function") and hasattr(
tool_call.function, "arguments"
):
accumulated_tools[
index
].function.arguments += (
tool_call.function.arguments
)
yield chunk
@@ -388,7 +452,9 @@ class WrappedCompletions:
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("messages")),
"$ai_input": with_privacy_mode(
self._client._ph_client, posthog_privacy_mode, kwargs.get("messages")
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
@@ -397,7 +463,9 @@ class WrappedCompletions:
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get(
"cache_read_input_tokens", 0
),
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
@@ -479,7 +547,9 @@ class WrappedEmbeddings:
event_properties = {
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("input")),
"$ai_input": with_privacy_mode(
self._client._ph_client, posthog_privacy_mode, kwargs.get("input")
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_latency": latency,
+9 -2
View File
@@ -1,9 +1,16 @@
try:
import openai
except ImportError:
raise ModuleNotFoundError("Please install the Open AI SDK to use this feature: 'pip install openai'")
raise ModuleNotFoundError(
"Please install the Open AI SDK to use this feature: 'pip install openai'"
)
from posthog.ai.openai.openai import WrappedBeta, WrappedChat, WrappedEmbeddings, WrappedResponses
from posthog.ai.openai.openai import (
WrappedBeta,
WrappedChat,
WrappedEmbeddings,
WrappedResponses,
)
from posthog.ai.openai.openai_async import WrappedBeta as AsyncWrappedBeta
from posthog.ai.openai.openai_async import WrappedChat as AsyncWrappedChat
from posthog.ai.openai.openai_async import WrappedEmbeddings as AsyncWrappedEmbeddings
+68 -20
View File
@@ -79,7 +79,9 @@ def get_usage(response, provider: str) -> Dict[str, Any]:
if hasattr(response, "usage_metadata") and response.usage_metadata:
input_tokens = getattr(response.usage_metadata, "prompt_token_count", 0)
output_tokens = getattr(response.usage_metadata, "candidates_token_count", 0)
output_tokens = getattr(
response.usage_metadata, "candidates_token_count", 0
)
return {
"input_tokens": input_tokens,
@@ -237,7 +239,10 @@ def format_tool_calls(response, provider: str):
return response.choices[0].message.tool_calls
# Check for tool_calls directly in response (Responses API format)
if hasattr(response.choices[0], "tool_calls") and response.choices[0].tool_calls:
if (
hasattr(response.choices[0], "tool_calls")
and response.choices[0].tool_calls
):
return response.choices[0].tool_calls
return None
@@ -286,15 +291,21 @@ def merge_system_prompt(kwargs: Dict[str, Any], provider: str):
# For Responses API, add instructions to the system prompt if provided
if kwargs.get("instructions") is not None:
# Find the system message if it exists
system_idx = next((i for i, msg in enumerate(messages) if msg.get("role") == "system"), None)
system_idx = next(
(i for i, msg in enumerate(messages) if msg.get("role") == "system"), None
)
if system_idx is not None:
# Append instructions to existing system message
system_content = messages[system_idx].get("content", "")
messages[system_idx]["content"] = f"{system_content}\n\n{kwargs.get('instructions')}"
messages[system_idx]["content"] = (
f"{system_content}\n\n{kwargs.get('instructions')}"
)
else:
# Create a new system message with instructions
messages = [{"role": "system", "content": kwargs.get("instructions")}] + messages
messages = [
{"role": "system", "content": kwargs.get("instructions")}
] + messages
return messages
@@ -326,7 +337,9 @@ def call_llm_and_track_usage(
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
http_status = getattr(
exc, "status_code", 0
) # default to 0 becuase its likely an SDK error
error_params = {
"$ai_is_error": True,
"$ai_error": exc.__str__(),
@@ -338,7 +351,10 @@ def call_llm_and_track_usage(
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if response and (hasattr(response, "usage") or (provider == "gemini" and hasattr(response, "usage_metadata"))):
if response and (
hasattr(response, "usage")
or (provider == "gemini" and hasattr(response, "usage_metadata"))
):
usage = get_usage(response, provider)
messages = merge_system_prompt(kwargs, provider)
@@ -363,15 +379,30 @@ def call_llm_and_track_usage(
tool_calls = format_tool_calls(response, provider)
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(ph_client, posthog_privacy_mode, tool_calls)
event_properties["$ai_tools"] = with_privacy_mode(
ph_client, posthog_privacy_mode, tool_calls
)
if usage.get("cache_read_input_tokens") is not None and usage.get("cache_read_input_tokens", 0) > 0:
event_properties["$ai_cache_read_input_tokens"] = usage.get("cache_read_input_tokens", 0)
if (
usage.get("cache_read_input_tokens") is not None
and usage.get("cache_read_input_tokens", 0) > 0
):
event_properties["$ai_cache_read_input_tokens"] = usage.get(
"cache_read_input_tokens", 0
)
if usage.get("cache_creation_input_tokens") is not None and usage.get("cache_creation_input_tokens", 0) > 0:
event_properties["$ai_cache_creation_input_tokens"] = usage.get("cache_creation_input_tokens", 0)
if (
usage.get("cache_creation_input_tokens") is not None
and usage.get("cache_creation_input_tokens", 0) > 0
):
event_properties["$ai_cache_creation_input_tokens"] = usage.get(
"cache_creation_input_tokens", 0
)
if usage.get("reasoning_tokens") is not None and usage.get("reasoning_tokens", 0) > 0:
if (
usage.get("reasoning_tokens") is not None
and usage.get("reasoning_tokens", 0) > 0
):
event_properties["$ai_reasoning_tokens"] = usage.get("reasoning_tokens", 0)
if posthog_distinct_id is None:
@@ -421,7 +452,9 @@ async def call_llm_and_track_usage_async(
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
http_status = getattr(
exc, "status_code", 0
) # default to 0 because its likely an SDK error
error_params = {
"$ai_is_error": True,
"$ai_error": exc.__str__(),
@@ -433,7 +466,10 @@ async def call_llm_and_track_usage_async(
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if response and (hasattr(response, "usage") or (provider == "gemini" and hasattr(response, "usage_metadata"))):
if response and (
hasattr(response, "usage")
or (provider == "gemini" and hasattr(response, "usage_metadata"))
):
usage = get_usage(response, provider)
messages = merge_system_prompt(kwargs, provider)
@@ -458,13 +494,25 @@ async def call_llm_and_track_usage_async(
tool_calls = format_tool_calls(response, provider)
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(ph_client, posthog_privacy_mode, tool_calls)
event_properties["$ai_tools"] = with_privacy_mode(
ph_client, posthog_privacy_mode, tool_calls
)
if usage.get("cache_read_input_tokens") is not None and usage.get("cache_read_input_tokens", 0) > 0:
event_properties["$ai_cache_read_input_tokens"] = usage.get("cache_read_input_tokens", 0)
if (
usage.get("cache_read_input_tokens") is not None
and usage.get("cache_read_input_tokens", 0) > 0
):
event_properties["$ai_cache_read_input_tokens"] = usage.get(
"cache_read_input_tokens", 0
)
if usage.get("cache_creation_input_tokens") is not None and usage.get("cache_creation_input_tokens", 0) > 0:
event_properties["$ai_cache_creation_input_tokens"] = usage.get("cache_creation_input_tokens", 0)
if (
usage.get("cache_creation_input_tokens") is not None
and usage.get("cache_creation_input_tokens", 0) > 0
):
event_properties["$ai_cache_creation_input_tokens"] = usage.get(
"cache_creation_input_tokens", 0
)
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
+312 -181
View File
@@ -1,5 +1,4 @@
import atexit
import hashlib
import logging
import numbers
import os
@@ -16,19 +15,29 @@ 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.exception_utils import (
exc_info_from_error,
exceptions_from_error_tuple,
handle_in_app,
exception_is_already_captured,
mark_exception_as_captured,
)
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
from posthog.poller import Poller
from posthog.request import (
DEFAULT_HOST,
APIError,
batch_post,
decide,
determine_server_host,
flags,
get,
remote_config,
)
from posthog.scopes import (
_get_current_context,
get_context_distinct_id,
get_context_session_id,
)
from posthog.types import (
FeatureFlag,
FeatureFlagResult,
@@ -53,76 +62,6 @@ except ImportError:
ID_TYPES = (numbers.Number, string_types, UUID)
MAX_DICT_SIZE = 50_000
# TODO: Get rid of these when you're done rolling out `/flags` to all customers
ROLLOUT_PERCENTAGE = 1
INCLUDED_HASHES = set({"bc94e67150c97dbcbf52549d50a7b80814841dbf"}) # this is PostHog's API key
# Explicitly excluding all the API tokens associated with the top 10 customers; we'll get to them soon, but don't want to rollout to them just yet
EXCLUDED_HASHES = set(
{
"03005596796f9ee626e9596b8062972cb6a556a0",
"05620a20b287e0d5cb1d4a0dd492797f36b952c5",
"0f95b5ca12878693c01c6420e727904f1737caa7",
"1212b6287a6e7e5ff6be5cb30ec563f35c2139d6",
"171ec1bb2caf762e06b1fde2e36a38c4638691a8",
"171faa9fc754b1aa42252a4eedb948b7c805d5cb",
"178ddde3f628fb0030321387acf939e4e6946d35",
"1790085d7e9aa136e8b73c180dd6a6060e2ef949",
"1895a3349c2371559c886f19ef1bf60617a934e0",
"1f01267d4f0295f88e8943bc963d816ee4abc84b",
"213df54990a34e62e3570b430f7ee36ec0928743",
"23d235537d988ab98ad259853eab02b07d828c2b",
"27135f7ae8f936222a5fcfcdc75c139b27dd3254",
"2817396d80fafc86c0816af8e73880f8b3e54320",
"29d3235e63db42056858ef04c6a5488c2a459eaa",
"2a76d9b5eb9307e540de9d516aa80f6cb5a0292f",
"2a92965a1344ab8a1f7dac2507e858f579a88ac2",
"2d5823818261512d616161de2bb8a161d48f1e35",
"32942f6a879dbfa8011cc68288c098e4a76e6cc0",
"3db6c17ab65827ceadf77d9a8462fabd94170ca6",
"4975b24f9ced9b2c06b604ddc9612f663f9452d5",
"497c7b017b13cd6cdbfe641c71f0dfb660a4c518",
"49c79e1dbce4a7b9394d6c14bf0421e04cecb445",
"4d63e1c5cd3a80972eac4e7526f03357ac538043",
"4da0f42a6f8f116822411152e5cda3c65ed2561f",
"4e494675ecd2b841784d6f29b658b38a0877a62e",
"4e852d8422130cec991eca2d6416dbe321d0a689",
"5120bfd92c9c6731074a89e4a82f49e947d34369",
"512cd72f9aa7ab11dfd012cc2e19394a020bd9a8",
"5b175d4064cc62f01118a2c6818c2c02fc8f27e1",
"5ba4bba3979e97d2c84df2aba394ca29c6c43187",
"639014946463614353ca640b268dc6592f62b652",
"643b9be9d50104e2b4ba94bc56688adba69c80fe",
"658f92992af9fc6a360143d72d93a36f63bbccb0",
"673a59c99739dfcee35202e428dd020b94866d52",
"67a9829b4997f5c6f3ab8173ad299f634adcfa53",
"6d686043e914ae8275df65e1ad890bd32a3b6fdd",
"6e4b5e1d649ad006d78f1f1617a9a0f35fc73078",
"6f1fc3a8fa9df54d00cbc1ef9ad5f24640589fd0",
"764e5fec2c7899cfee620fae8450fcc62cd72bf0",
"80ea6d6ed9a5895633c7bee7aba4323eeacdc90e",
"872e420156f583bc97351f3d83c02dae734a85df",
"8a24844cbeae31e74b4372964cdea74e99d9c0e2",
"975ae7330506d4583b000f96ad87abb41a0141ce",
"9e3d71378b340def3080e0a3a785a1b964cf43ef",
"9ede7b21365661331d024d92915de6e69749892b",
"a1ed1b4216ef4cec542c6b3b676507770be24ddc",
"a4f66a70a9647b3b89fc59f7642af8ffab073ba1",
"a7adb80be9e90948ab6bb726cc6e8e52694aec74",
"bca4b14ac8de49cccc02306c7bb6e5ae2acc0f72",
"bde5fe49f61e13629c5498d7428a7f6215e482a6",
"c54a7074c323aa7c5cb7b24bf826751b2a58f5d8",
"c552d20da0c87fb4ebe2da97c7f95c05eef2bca1",
"d7682f2d268f3064d433309af34f2935810989d2",
"d794ac43d8be26bf99f369ea79501eb774fe1b16",
"e0963e2552af77d46bb24d5b5806b5b456c64c5f",
"e6f14b2100cb0598925958b097ace82486037a25",
"e79ec399ad45f44a4295a5bb1322e2f14600ae39",
"eecf29f73f9c31009e5737a6c5ec3f87ec5b8ea6",
"f2c01f3cc770c7788257ee60910e2530f92eefc3",
"f7bbc58f4122b1e2812c0f1962c584cb404a1ac3",
}
)
def get_os_info():
"""
@@ -178,43 +117,6 @@ def system_context() -> dict[str, Any]:
}
def is_token_in_rollout(
token: str,
percentage: float = 0,
included_hashes: Optional[set[str]] = None,
excluded_hashes: Optional[set[str]] = None,
) -> bool:
"""
Determines if a token should be included in a rollout based on:
1. If its hash matches any included_hashes provided
2. If its hash falls within the percentage rollout
Args:
token: String to hash (usually API key)
percentage: Float between 0 and 1 representing rollout percentage
included_hashes: Optional set of specific SHA1 hashes to match against
excluded_hashes: Optional set of specific SHA1 hashes to exclude from rollout
Returns:
bool: True if token should be included in rollout
"""
# First generate SHA1 hash of token
token_hash = hashlib.sha1(token.encode("utf-8")).hexdigest()
# Check if hash matches any included hashes
if included_hashes and token_hash in included_hashes:
return True
# Check if hash matches any excluded hashes
if excluded_hashes and token_hash in excluded_hashes:
return False
# Convert first 8 chars of hash to int and divide by max value to get number between 0-1
hash_int = int(token_hash[:8], 16)
hash_float = hash_int / 0xFFFFFFFF
return hash_float < percentage
class Client(object):
"""Create a new PostHog client."""
@@ -248,6 +150,7 @@ class Client(object):
exception_autocapture_integrations=None,
project_root=None,
privacy_mode=False,
before_send=None,
):
self.queue = queue.Queue(max_queue_size)
@@ -270,7 +173,9 @@ class Client(object):
self.group_type_mapping = None
self.cohorts = None
self.poll_interval = poll_interval
self.feature_flags_request_timeout_seconds = feature_flags_request_timeout_seconds
self.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
@@ -301,8 +206,19 @@ class Client(object):
else:
self.log.setLevel(logging.WARNING)
if before_send is not None:
if callable(before_send):
self.before_send = before_send
else:
self.log.warning("before_send is not callable, it will be ignored")
self.before_send = None
else:
self.before_send = None
if self.enable_exception_autocapture:
self.exception_capture = ExceptionCapture(self, integrations=self.exception_autocapture_integrations)
self.exception_capture = ExceptionCapture(
self, integrations=self.exception_autocapture_integrations
)
if sync_mode:
self.consumers = None
@@ -348,12 +264,24 @@ class Client(object):
Set the local evaluation feature flags.
"""
self._feature_flags = flags or []
self.feature_flags_by_key = {flag["key"]: flag for flag in self._feature_flags if flag.get("key") is not None}
assert (
self.feature_flags_by_key is not None
), "feature_flags_by_key should be initialized when feature_flags is set"
self.feature_flags_by_key = {
flag["key"]: flag
for flag in self._feature_flags
if flag.get("key") is not None
}
assert self.feature_flags_by_key is not None, (
"feature_flags_by_key should be initialized when feature_flags is set"
)
def identify(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
def identify(
self,
distinct_id=None,
properties=None,
context=None,
timestamp=None,
uuid=None,
disable_geoip=None,
):
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
@@ -361,10 +289,17 @@ class Client(object):
stacklevel=2,
)
if distinct_id is None:
distinct_id = get_context_distinct_id()
properties = properties or {}
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
if "$session_id" not in properties and get_context_session_id():
properties["$session_id"] = get_context_session_id()
msg = {
"timestamp": timestamp,
"distinct_id": distinct_id,
@@ -376,38 +311,67 @@ class Client(object):
return self._enqueue(msg, disable_geoip)
def get_feature_variants(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
self,
distinct_id,
groups=None,
person_properties=None,
group_properties=None,
disable_geoip=None,
) -> dict[str, Union[bool, str]]:
"""
Get feature flag variants for a distinct_id by calling decide.
"""
resp_data = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
resp_data = self.get_flags_decision(
distinct_id, groups, person_properties, group_properties, disable_geoip
)
return to_values(resp_data) or {}
def get_feature_payloads(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
self,
distinct_id,
groups=None,
person_properties=None,
group_properties=None,
disable_geoip=None,
) -> dict[str, str]:
"""
Get feature flag payloads for a distinct_id by calling decide.
"""
resp_data = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
resp_data = self.get_flags_decision(
distinct_id, groups, person_properties, group_properties, disable_geoip
)
return to_payloads(resp_data) or {}
def get_feature_flags_and_payloads(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
self,
distinct_id,
groups=None,
person_properties=None,
group_properties=None,
disable_geoip=None,
) -> FlagsAndPayloads:
"""
Get feature flags and payloads for a distinct_id by calling decide.
"""
resp = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
resp = self.get_flags_decision(
distinct_id, groups, person_properties, group_properties, disable_geoip
)
return to_flags_and_payloads(resp)
def get_flags_decision(
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
self,
distinct_id,
groups=None,
person_properties=None,
group_properties=None,
disable_geoip=None,
) -> FlagsResponse:
"""
Get feature flags decision, using either flags() or decide() API based on rollout.
"""
if distinct_id is None:
distinct_id = get_context_distinct_id()
require("distinct_id", distinct_id, ID_TYPES)
if disable_geoip is None:
@@ -423,22 +387,16 @@ class Client(object):
"groups": groups,
"person_properties": person_properties,
"group_properties": group_properties,
"disable_geoip": disable_geoip,
"geoip_disable": disable_geoip,
}
use_flags = is_token_in_rollout(
self.api_key, ROLLOUT_PERCENTAGE, included_hashes=INCLUDED_HASHES, excluded_hashes=EXCLUDED_HASHES
resp_data = flags(
self.api_key,
self.host,
timeout=self.feature_flags_request_timeout_seconds,
**request_data,
)
if use_flags:
resp_data = flags(
self.api_key, self.host, timeout=self.feature_flags_request_timeout_seconds, **request_data
)
else:
resp_data = decide(
self.api_key, self.host, timeout=self.feature_flags_request_timeout_seconds, **request_data
)
return normalize_flags_response(resp_data)
def capture(
@@ -462,10 +420,23 @@ class Client(object):
properties = {**(properties or {}), **system_context()}
if "$session_id" not in properties and get_context_session_id():
properties["$session_id"] = get_context_session_id()
if distinct_id is None:
distinct_id = get_context_distinct_id()
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
require("event", event, string_types)
current_context = _get_current_context()
if current_context:
context_tags = current_context.collect_tags()
# We want explicitly passed properties to override context tags
context_tags.update(properties)
properties = context_tags
msg = {
"properties": properties,
"timestamp": timestamp,
@@ -482,20 +453,31 @@ class Client(object):
feature_variants: Optional[dict[str, Union[bool, str]]] = {}
if send_feature_flags:
try:
feature_variants = self.get_feature_variants(distinct_id, groups, disable_geoip=disable_geoip)
feature_variants = self.get_feature_variants(
distinct_id, groups, disable_geoip=disable_geoip
)
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get feature variants: {e}")
self.log.exception(
f"[FEATURE FLAGS] Unable to get feature variants: {e}"
)
elif self.feature_flags and event != "$feature_flag_called":
# 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
distinct_id,
groups=(groups or {}),
disable_geoip=disable_geoip,
only_evaluate_locally=True,
)
for feature, variant in (feature_variants or {}).items():
extra_properties[f"$feature/{feature}"] = variant
active_feature_flags = [key for (key, value) in (feature_variants or {}).items() if value is not False]
active_feature_flags = [
key
for (key, value) in (feature_variants or {}).items()
if value is not False
]
if active_feature_flags:
extra_properties["$active_feature_flags"] = active_feature_flags
@@ -504,7 +486,15 @@ class Client(object):
return self._enqueue(msg, disable_geoip)
def set(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
def set(
self,
distinct_id=None,
properties=None,
context=None,
timestamp=None,
uuid=None,
disable_geoip=None,
):
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
@@ -512,6 +502,9 @@ class Client(object):
stacklevel=2,
)
if distinct_id is None:
distinct_id = get_context_distinct_id()
properties = properties or {}
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
@@ -526,7 +519,15 @@ class Client(object):
return self._enqueue(msg, disable_geoip)
def set_once(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
def set_once(
self,
distinct_id=None,
properties=None,
context=None,
timestamp=None,
uuid=None,
disable_geoip=None,
):
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
@@ -534,6 +535,9 @@ class Client(object):
stacklevel=2,
)
if distinct_id is None:
distinct_id = get_context_distinct_id()
properties = properties or {}
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
@@ -589,7 +593,15 @@ class Client(object):
return self._enqueue(msg, disable_geoip)
def alias(self, previous_id=None, distinct_id=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
def alias(
self,
previous_id=None,
distinct_id=None,
context=None,
timestamp=None,
uuid=None,
disable_geoip=None,
):
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
@@ -597,6 +609,9 @@ class Client(object):
stacklevel=2,
)
if distinct_id is None:
distinct_id = get_context_distinct_id()
require("previous_id", previous_id, ID_TYPES)
require("distinct_id", distinct_id, ID_TYPES)
@@ -613,7 +628,14 @@ class Client(object):
return self._enqueue(msg, disable_geoip)
def page(
self, distinct_id=None, url=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None
self,
distinct_id=None,
url=None,
properties=None,
context=None,
timestamp=None,
uuid=None,
disable_geoip=None,
):
if context is not None:
warnings.warn(
@@ -622,6 +644,9 @@ class Client(object):
stacklevel=2,
)
if distinct_id is None:
distinct_id = get_context_distinct_id()
properties = properties or {}
require("distinct_id", distinct_id, ID_TYPES)
require("properties", properties, dict)
@@ -657,11 +682,19 @@ class Client(object):
stacklevel=2,
)
if distinct_id is None:
distinct_id = get_context_distinct_id()
# 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 {}
# Check if this exception has already been captured
if exception is not None and exception_is_already_captured(exception):
self.log.debug("Exception already captured, skipping")
return
# if there's no distinct_id, we'll generate one and set personless mode
# via $process_person_profile = false
if distinct_id is None:
@@ -696,7 +729,9 @@ class Client(object):
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_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,
@@ -705,7 +740,15 @@ class Client(object):
if self.log_captured_exceptions:
self.log.exception(exception, extra=kwargs)
return self.capture(distinct_id, "$exception", properties, context, timestamp, uuid, groups)
res = self.capture(
distinct_id, "$exception", properties, context, timestamp, uuid, groups
)
# Mark the exception as captured to prevent duplicate captures
if exception is not None:
mark_exception_as_captured(exception)
return res
except Exception as e:
self.log.exception(f"Failed to capture exception: {e}")
@@ -748,6 +791,18 @@ class Client(object):
msg["distinct_id"] = stringify_id(msg.get("distinct_id", None))
msg = clean(msg)
if self.before_send:
try:
modified_msg = self.before_send(msg)
if modified_msg is None:
self.log.debug("Event dropped by before_send callback")
return True, None
msg = modified_msg
except Exception as e:
self.log.exception(f"Error in before_send callback: {e}")
# Continue with the original message if callback fails
self.log.debug("queueing: %s", msg)
# if send is False, return msg as if it was successfully queued
@@ -858,13 +913,18 @@ class Client(object):
def load_feature_flags(self):
if not self.personal_api_key:
self.log.warning("[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags.")
self.log.warning(
"[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags."
)
self.feature_flags = []
return
self._load_feature_flags()
if not (self.poller and self.poller.is_alive()):
self.poller = Poller(interval=timedelta(seconds=self.poll_interval), execute=self._load_feature_flags)
self.poller = Poller(
interval=timedelta(seconds=self.poll_interval),
execute=self._load_feature_flags,
)
self.poller.start()
def _compute_flag_locally(
@@ -909,9 +969,13 @@ class Client(object):
return False
focused_group_properties = group_properties[group_name]
return match_feature_flag_properties(feature_flag, groups[group_name], focused_group_properties)
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)
return match_feature_flag_properties(
feature_flag, distinct_id, person_properties, self.cohorts
)
def feature_enabled(
self,
@@ -960,28 +1024,47 @@ class Client(object):
if self.disabled:
return None
person_properties, group_properties = self._add_local_person_and_group_properties(
distinct_id, groups, person_properties, group_properties
person_properties, group_properties = (
self._add_local_person_and_group_properties(
distinct_id, groups, person_properties, group_properties
)
)
flag_result = None
flag_details = None
request_id = None
flag_value = self._locally_evaluate_flag(key, distinct_id, groups, person_properties, group_properties)
flag_value = self._locally_evaluate_flag(
key, distinct_id, groups, person_properties, group_properties
)
flag_was_locally_evaluated = flag_value is not None
if flag_was_locally_evaluated:
lookup_match_value = override_match_value or flag_value
payload = self._compute_payload_locally(key, lookup_match_value) if lookup_match_value else None
flag_result = FeatureFlagResult.from_value_and_payload(key, lookup_match_value, payload)
payload = (
self._compute_payload_locally(key, lookup_match_value)
if lookup_match_value
else None
)
flag_result = FeatureFlagResult.from_value_and_payload(
key, lookup_match_value, payload
)
elif not only_evaluate_locally:
try:
flag_details, request_id = self._get_feature_flag_details_from_decide(
key, distinct_id, groups, person_properties, group_properties, disable_geoip
key,
distinct_id,
groups,
person_properties,
group_properties,
disable_geoip,
)
flag_result = FeatureFlagResult.from_flag_details(
flag_details, override_match_value
)
self.log.debug(
f"Successfully computed flag remotely: #{key} -> #{flag_result}"
)
flag_result = FeatureFlagResult.from_flag_details(flag_details, override_match_value)
self.log.debug(f"Successfully computed flag remotely: #{key} -> #{flag_result}")
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
@@ -1073,9 +1156,9 @@ class Client(object):
response = None
if self.feature_flags:
assert (
self.feature_flags_by_key is not None
), "feature_flags_by_key should be initialized when feature_flags is set"
assert self.feature_flags_by_key is not None, (
"feature_flags_by_key should be initialized when feature_flags is set"
)
# Local evaluation
flag = self.feature_flags_by_key.get(key)
if flag:
@@ -1087,11 +1170,15 @@ class Client(object):
person_properties=person_properties,
group_properties=group_properties,
)
self.log.debug(f"Successfully computed flag locally: {key} -> {response}")
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}")
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Error while computing variant locally: {e}")
self.log.exception(
f"[FEATURE FLAGS] Error while computing variant locally: {e}"
)
return response
def get_feature_flag_payload(
@@ -1132,7 +1219,9 @@ class Client(object):
"""
Calls /decide and returns the flag details and request id
"""
resp_data = self.get_flags_decision(distinct_id, groups, person_properties, group_properties, disable_geoip)
resp_data = self.get_flags_decision(
distinct_id, groups, person_properties, group_properties, disable_geoip
)
request_id = resp_data.get("requestId")
flags = resp_data.get("flags")
flag_details = flags.get(key) if flags else None
@@ -1150,9 +1239,14 @@ class Client(object):
request_id: Optional[str],
flag_details: Optional[FeatureFlag],
):
feature_flag_reported_key = f"{key}_{'::null::' if response is None else str(response)}"
feature_flag_reported_key = (
f"{key}_{'::null::' if response is None else str(response)}"
)
if feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]:
if (
feature_flag_reported_key
not in self.distinct_ids_feature_flags_reported[distinct_id]
):
properties: dict[str, Any] = {
"$feature_flag": key,
"$feature_flag_response": response,
@@ -1171,7 +1265,9 @@ class Client(object):
properties["$feature_flag_reason"] = flag_details.reason.description
if isinstance(flag_details.metadata, FlagMetadata):
if flag_details.metadata.version:
properties["$feature_flag_version"] = flag_details.metadata.version
properties["$feature_flag_version"] = (
flag_details.metadata.version
)
if flag_details.metadata.id:
properties["$feature_flag_id"] = flag_details.metadata.id
@@ -1182,7 +1278,9 @@ class Client(object):
groups=groups,
disable_geoip=disable_geoip,
)
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
self.distinct_ids_feature_flags_reported[distinct_id].add(
feature_flag_reported_key
)
def get_remote_config_payload(self, key: str):
if self.disabled:
@@ -1202,9 +1300,13 @@ class Client(object):
timeout=self.feature_flags_request_timeout_seconds,
)
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get decrypted feature flag payload: {e}")
self.log.exception(
f"[FEATURE FLAGS] Unable to get decrypted feature flag payload: {e}"
)
def _compute_payload_locally(self, key: str, match_value: FlagValue) -> Optional[str]:
def _compute_payload_locally(
self, key: str, match_value: FlagValue
) -> Optional[str]:
payload = None
if self.feature_flags_by_key is None:
@@ -1216,7 +1318,11 @@ class Client(object):
flag_payloads = flag_filters.get("payloads") or {}
# For boolean flags, convert True to "true"
# For multivariate flags, use the variant string as-is
lookup_value = "true" if isinstance(match_value, bool) and match_value else str(match_value)
lookup_value = (
"true"
if isinstance(match_value, bool) and match_value
else str(match_value)
)
payload = flag_payloads.get(lookup_value, None)
return payload
@@ -1254,12 +1360,17 @@ class Client(object):
if self.disabled:
return {"featureFlags": None, "featureFlagPayloads": None}
person_properties, group_properties = self._add_local_person_and_group_properties(
distinct_id, groups, person_properties, group_properties
person_properties, group_properties = (
self._add_local_person_and_group_properties(
distinct_id, groups, person_properties, group_properties
)
)
response, fallback_to_decide = self._get_all_flags_and_payloads_locally(
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
)
if fallback_to_decide and not only_evaluate_locally:
@@ -1273,12 +1384,20 @@ class Client(object):
)
return to_flags_and_payloads(decide_response)
except Exception as e:
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {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
self,
distinct_id,
*,
groups={},
person_properties={},
group_properties={},
warn_on_unknown_groups=False,
) -> tuple[FlagsAndPayloads, bool]:
require("distinct_id", distinct_id, ID_TYPES)
require("groups", groups, dict)
@@ -1301,25 +1420,37 @@ class Client(object):
group_properties=group_properties,
warn_on_unknown_groups=warn_on_unknown_groups,
)
matched_payload = self._compute_payload_locally(flag["key"], flags[flag["key"]])
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}")
self.log.exception(
f"[FEATURE FLAGS] Error while computing variant and payload: {e}"
)
fallback_to_decide = True
else:
fallback_to_decide = True
return {"featureFlags": flags, "featureFlagPayloads": payloads}, fallback_to_decide
return {
"featureFlags": flags,
"featureFlagPayloads": 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 {})}
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:
+6 -2
View File
@@ -107,7 +107,9 @@ 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 900kib limit, dropping. (%s)", str(item))
self.log.error(
"Item exceeds 900kib limit, dropping. (%s)", str(item)
)
continue
items.append(item)
total_size += item_size
@@ -134,7 +136,9 @@ class Consumer(Thread):
# retry on all other errors (eg. network)
return False
@backoff.on_exception(backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception)
@backoff.on_exception(
backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception
)
def send_request():
batch_post(
self.api_key,
+3 -1
View File
@@ -22,7 +22,9 @@ class ExceptionCapture:
log = logging.getLogger("posthog")
def __init__(self, client: "Client", integrations: Optional[List[Integrations]] = None):
def __init__(
self, client: "Client", integrations: Optional[List[Integrations]] = None
):
self.client = client
self.original_excepthook = sys.excepthook
sys.excepthook = self.exception_handler
+28 -4
View File
@@ -32,7 +32,6 @@ class DjangoIntegration:
identifier = "django"
def __init__(self, capture_exception_fn=None):
if DJANGO_VERSION < (4, 2):
raise IntegrationEnablingError("Django 4.2 or newer is required.")
@@ -60,7 +59,6 @@ class DjangoIntegration:
class DjangoRequestExtractor:
def __init__(self, request):
# type: (Any) -> None
self.request = request
@@ -69,8 +67,8 @@ class DjangoRequestExtractor:
headers = self.headers()
# Extract traceparent and tracestate headers
traceparent = headers.get("traceparent")
tracestate = headers.get("tracestate")
traceparent = headers.get("Traceparent")
tracestate = headers.get("Tracestate")
# Extract the distinct_id from tracestate
distinct_id = None
@@ -82,12 +80,38 @@ class DjangoRequestExtractor:
distinct_id = match.group(1)
return {
**self.user(),
"distinct_id": distinct_id,
"ip": headers.get("X-Forwarded-For"),
"user_agent": headers.get("User-Agent"),
"traceparent": traceparent,
"$request_path": self.request.path,
}
def user(self):
user_data: dict[str, str] = {}
user = getattr(self.request, "user", None)
if user is None or not user.is_authenticated:
return user_data
try:
user_id = str(user.pk)
if user_id:
user_data.setdefault("$user_id", user_id)
except Exception:
pass
try:
email = str(user.email)
if email:
user_data.setdefault("email", email)
except Exception:
pass
return user_data
def headers(self):
# type: () -> Dict[str, str]
return dict(self.request.headers)
+128 -37
View File
@@ -9,6 +9,7 @@ import linecache
import os
import re
import sys
import types
from datetime import datetime
from typing import TYPE_CHECKING
@@ -24,7 +25,6 @@ DEFAULT_MAX_VALUE_LENGTH = 1024
if TYPE_CHECKING:
from types import FrameType, TracebackType
from typing import ( # noqa: F401
Any,
@@ -52,7 +52,9 @@ if TYPE_CHECKING:
Event = TypedDict(
"Event",
{
"breadcrumbs": Dict[Literal["values"], List[Dict[str, Any]]], # TODO: We can expand on this type
"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,
@@ -60,7 +62,9 @@ if TYPE_CHECKING:
"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
"exception": Dict[
Literal["values"], List[Dict[str, Any]]
], # TODO: We can expand on this type
# "extra": MutableMapping[str, object],
# "fingerprint": List[str],
"level": LogLevelStr,
@@ -72,19 +76,23 @@ if TYPE_CHECKING:
# "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
"profile": object,
"release": str,
"request": Dict[str, object],
# "sdk": Mapping[str, object],
"server_name": str,
"spans": List[Dict[str, object]],
"stacktrace": Dict[str, object], # We access this key in the code, but I am unsure whether we ever set it
"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
"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
@@ -129,9 +137,6 @@ def event_hint_with_exc_info(exc_info=None):
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")
@@ -273,7 +278,10 @@ def get_lines_from_file(
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]]
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)
@@ -305,7 +313,9 @@ def get_source_context(
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 get_lines_from_file(
abs_path, lineno, max_value_length, loader=loader, module=module
)
return [], None, []
@@ -342,7 +352,9 @@ def filename_for_module(module, abs_path):
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)
return abs_path.split(base_module_path.rsplit(os.sep, 2)[0], 1)[-1].lstrip(
os.sep
)
except Exception:
return abs_path
@@ -386,12 +398,7 @@ def serialize_frame(
)
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
# )
# TODO - we don't support local variables, yet
pass
return rv
@@ -431,7 +438,11 @@ def get_errno(exc_value):
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)
return (
getattr(exc_value, "message", "")
or getattr(exc_value, "detail", "")
or safe_str(exc_value)
)
def single_exception_from_error_tuple(
@@ -446,13 +457,12 @@ def single_exception_from_error_tuple(
):
# 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/
Creates a dict that goes into the events `exception.values` list
"""
exception_value = {} # type: Dict[str, Any]
exception_value["mechanism"] = mechanism.copy() if mechanism else {"type": "generic", "handled": True}
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
@@ -462,7 +472,9 @@ def single_exception_from_error_tuple(
errno = None
if errno is not None:
exception_value["mechanism"].setdefault("meta", {}).setdefault("errno", {}).setdefault("number", errno)
exception_value["mechanism"].setdefault("meta", {}).setdefault(
"errno", {}
).setdefault("number", errno)
if source is not None:
exception_value["mechanism"]["source"] = source
@@ -475,7 +487,9 @@ def single_exception_from_error_tuple(
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)
is_exception_group = BaseExceptionGroup is not None and isinstance(
exc_value, BaseExceptionGroup
)
if is_exception_group:
exception_value["mechanism"]["is_exception_group"] = True
@@ -523,7 +537,11 @@ if HAS_CHAINED_EXCEPTIONS:
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:
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
@@ -563,9 +581,6 @@ def exceptions_from_error(
"""
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(
@@ -583,11 +598,17 @@ def exceptions_from_error(
parent_id = exception_id
exception_id += 1
should_supress_context = hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore
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
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(
@@ -604,7 +625,11 @@ def exceptions_from_error(
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
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(
@@ -645,7 +670,9 @@ def exceptions_from_error_tuple(
# type: (...) -> List[Dict[str, Any]]
exc_type, exc_value, tb = exc_info
is_exception_group = BaseExceptionGroup is not None and isinstance(exc_value, BaseExceptionGroup)
is_exception_group = BaseExceptionGroup is not None and isinstance(
exc_value, BaseExceptionGroup
)
if is_exception_group:
(_, exceptions) = exceptions_from_error(
@@ -661,7 +688,11 @@ def exceptions_from_error_tuple(
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.append(
single_exception_from_error_tuple(
exc_type, exc_value, tb, client_options, mechanism
)
)
exceptions.reverse()
@@ -749,11 +780,40 @@ def set_in_app_in_frames(frames, in_app_exclude, in_app_include, project_root=No
return frames
def exception_is_already_captured(error):
# type: (Union[BaseException, ExcInfo]) -> bool
if isinstance(error, BaseException):
return hasattr(error, "__posthog_exception_captured")
# Autocaptured exceptions are passed as a tuple from our system hooks,
# the second item is the exception value (the first is the exception type)
elif isinstance(error, tuple) and len(error) > 1:
return error[1] is not None and hasattr(
error[1], "__posthog_exception_captured"
)
else:
return False # type: ignore[unreachable]
def mark_exception_as_captured(error):
# type: (Union[BaseException, ExcInfo]) -> None
if isinstance(error, BaseException):
setattr(error, "__posthog_exception_captured", True)
# Autocaptured exceptions are passed as a tuple from our system hooks,
# the second item is the exception value (the first is the exception type)
elif isinstance(error, tuple) and len(error) > 1:
if error[1] is not None:
setattr(error[1], "__posthog_exception_captured", True)
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):
try:
construct_artificial_traceback(error)
except Exception:
pass
tb = getattr(error, "__traceback__", None)
if tb is not None:
exc_type = type(error)
@@ -778,6 +838,31 @@ def exc_info_from_error(error):
return exc_info
def construct_artificial_traceback(e):
# type: (BaseException) -> None
if getattr(e, "__traceback__", None) is not None:
return
depth = 0
frames = []
while True:
try:
frame = sys._getframe(depth)
depth += 1
except ValueError:
break
frames.append(frame)
frames.reverse()
tb = None
for frame in frames:
tb = types.TracebackType(tb, frame, frame.f_lasti, frame.f_lineno)
setattr(e, "__traceback__", tb)
def event_from_exception(
exc_info, # type: Union[BaseException, ExcInfo]
client_options=None, # type: Optional[Dict[str, Any]]
@@ -789,7 +874,11 @@ def event_from_exception(
return (
{
"level": "error",
"exception": {"values": exceptions_from_error_tuple(exc_info, client_options, mechanism)},
"exception": {
"values": exceptions_from_error_tuple(
exc_info, client_options, mechanism
)
},
},
hint,
)
@@ -813,7 +902,9 @@ def _module_in_list(name, items):
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
external_source = (
re.search(r"[\\/](?:dist|site)-packages[\\/]", abs_path) is not None
)
return external_source
+59 -19
View File
@@ -43,20 +43,28 @@ def get_matching_variant(flag, distinct_id):
def variant_lookup_table(feature_flag):
lookup_table = []
value_min = 0
multivariates = ((feature_flag.get("filters") or {}).get("multivariate") or {}).get("variants") or []
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"]})
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) -> FlagValue:
def match_feature_flag_properties(
flag, distinct_id, properties, cohort_properties=None
) -> FlagValue:
flag_conditions = (flag.get("filters") or {}).get("groups") or []
is_inconclusive = False
cohort_properties = cohort_properties or {}
# 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 []
flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get(
"variants"
) or []
valid_variant_keys = [variant["key"] for variant in flag_variants]
# Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
@@ -70,7 +78,9 @@ def match_feature_flag_properties(flag, distinct_id, properties, cohort_properti
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):
if is_condition_match(
flag, distinct_id, condition, properties, cohort_properties
):
variant_override = condition.get("variant")
if variant_override and variant_override in valid_variant_keys:
variant = variant_override
@@ -81,14 +91,18 @@ def match_feature_flag_properties(flag, distinct_id, properties, cohort_properti
is_inconclusive = True
if is_inconclusive:
raise InconclusiveMatchError("Can't determine if feature flag is enabled or not with given properties")
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) -> bool:
def is_condition_match(
feature_flag, distinct_id, condition, properties, cohort_properties
) -> bool:
rollout_percentage = condition.get("rollout_percentage")
if len(condition.get("properties") or []) > 0:
for prop in condition.get("properties"):
@@ -103,7 +117,9 @@ def is_condition_match(feature_flag, distinct_id, condition, properties, cohort_
if rollout_percentage is None:
return True
if rollout_percentage is not None and _hash(feature_flag["key"], distinct_id) > (rollout_percentage / 100):
if rollout_percentage is not None and _hash(feature_flag["key"], distinct_id) > (
rollout_percentage / 100
):
return False
return True
@@ -117,7 +133,9 @@ def match_property(property, property_values) -> bool:
value = property.get("value")
if key not in property_values:
raise InconclusiveMatchError("can't match properties without a given property value")
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")
@@ -131,7 +149,9 @@ def match_property(property, property_values) -> bool:
def compute_exact_match(value, override_value):
if isinstance(value, list):
return str(override_value).casefold() in [str(val).casefold() for val in value]
return str(override_value).casefold() in [
str(val).casefold() for val in value
]
return utils.str_iequals(value, override_value)
if operator == "exact":
@@ -149,10 +169,16 @@ def match_property(property, property_values) -> bool:
return not utils.str_icontains(override_value, value)
if operator == "regex":
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is not None
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
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,
@@ -191,10 +217,14 @@ def match_property(property, property_values) -> bool:
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
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")
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)
@@ -218,7 +248,9 @@ def match_property(property, property_values) -> bool:
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")
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}")
@@ -236,7 +268,9 @@ def match_cohort(property, property_values, cohort_properties) -> bool:
# }
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")
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)
@@ -272,7 +306,9 @@ def match_property_group(property_group, property_values, cohort_properties) ->
error_matching_locally = True
if error_matching_locally:
raise InconclusiveMatchError("Can't match cohort without a given cohort property value")
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"
@@ -303,13 +339,17 @@ def match_property_group(property_group, property_values, cohort_properties) ->
error_matching_locally = True
if error_matching_locally:
raise InconclusiveMatchError("can't match cohort without a given cohort property value")
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]:
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)
+121
View File
@@ -0,0 +1,121 @@
from typing import TYPE_CHECKING, cast
from posthog import scopes
if TYPE_CHECKING:
from django.http import HttpRequest, HttpResponse # noqa: F401
from typing import Callable, Dict, Any, Optional # noqa: F401
class PosthogContextMiddleware:
"""Middleware to automatically track Django requests.
This middleware wraps all calls with a posthog context. It attempts to extract the following from the request headers:
- Session ID, (extracted from `X-POSTHOG-SESSION-ID`)
- Distinct ID, (extracted from `X-POSTHOG-DISTINCT-ID`)
- Request URL as $current_url
- Request Method as $request_method
The context will also auto-capture exceptions and send them to PostHog, unless you disable it by setting
`POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings.
The middleware behaviour is customisable through 3 additional functions:
- `POSTHOG_MW_EXTRA_TAGS`, which is a Callable[[HttpRequest], Dict[str, Any]] expected to return a dictionary of additional tags to be added to the context.
- `POSTHOG_MW_REQUEST_FILTER`, which is a Callable[[HttpRequest], bool] expected to return `False` if the request should not be tracked.
- `POSTHOG_MW_TAG_MAP`, which is a Callable[[Dict[str, Any]], Dict[str, Any]], which you can use to modify the tags before they're added to the context.
You can use the `POSTHOG_MW_TAG_MAP` function to remove any default tags you don't want to capture, or override them with your own values.
Context tags are automatically included as properties on all events captured within a context, including exceptions.
See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to
associate all events captured in the middleware context with the same distinct ID and session as currently active on the
frontend. See the documentation for `set_context_session` and `identify_context` for more details.
"""
def __init__(self, get_response):
# type: (Callable[[HttpRequest], HttpResponse]) -> None
self.get_response = get_response
from django.conf import settings
if hasattr(settings, "POSTHOG_MW_EXTRA_TAGS") and callable(
settings.POSTHOG_MW_EXTRA_TAGS
):
self.extra_tags = cast(
"Optional[Callable[[HttpRequest], Dict[str, Any]]]",
settings.POSTHOG_MW_EXTRA_TAGS,
)
else:
self.extra_tags = None
if hasattr(settings, "POSTHOG_MW_REQUEST_FILTER") and callable(
settings.POSTHOG_MW_REQUEST_FILTER
):
self.request_filter = cast(
"Optional[Callable[[HttpRequest], bool]]",
settings.POSTHOG_MW_REQUEST_FILTER,
)
else:
self.request_filter = None
if hasattr(settings, "POSTHOG_MW_TAG_MAP") and callable(
settings.POSTHOG_MW_TAG_MAP
):
self.tag_map = cast(
"Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]",
settings.POSTHOG_MW_TAG_MAP,
)
else:
self.tag_map = None
if hasattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS") and isinstance(
settings.POSTHOG_MW_CAPTURE_EXCEPTIONS, bool
):
self.capture_exceptions = settings.POSTHOG_MW_CAPTURE_EXCEPTIONS
else:
self.capture_exceptions = True
def extract_tags(self, request):
# type: (HttpRequest) -> Dict[str, Any]
tags = {}
# Extract session ID from X-POSTHOG-SESSION-ID header
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
if session_id:
scopes.set_context_session(session_id)
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header
distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID")
if distinct_id:
scopes.identify_context(distinct_id)
# Extract current URL
absolute_url = request.build_absolute_uri()
if absolute_url:
tags["$current_url"] = absolute_url
# Extract request method
if request.method:
tags["$request_method"] = request.method
# Apply extra tags if configured
if self.extra_tags:
extra = self.extra_tags(request)
if extra:
tags.update(extra)
# Apply tag mapping if configured
if self.tag_map:
tags = self.tag_map(tags)
return tags
def __call__(self, request):
# type: (HttpRequest) -> HttpResponse
if self.request_filter and not self.request_filter(request):
return self.get_response(request)
with scopes.new_context(self.capture_exceptions):
for k, v in self.extract_tags(request).items():
scopes.tag(k, v)
return self.get_response(request)
+48 -10
View File
@@ -43,7 +43,12 @@ def determine_server_host(host: Optional[str]) -> str:
def post(
api_key: str, host: Optional[str] = None, path=None, gzip: bool = False, timeout: int = 15, **kwargs
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")
@@ -100,34 +105,67 @@ def _process_response(
raise APIError(res.status_code, res.text)
def decide(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any:
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/?v=4", gzip, timeout, **kwargs)
return _process_response(res, success_message="Feature flags decided successfully")
def flags(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any:
def flags(
api_key: str,
host: Optional[str] = None,
gzip: bool = False,
timeout: int = 15,
**kwargs,
) -> Any:
"""Post the `kwargs to the flags API endpoint"""
res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
return _process_response(res, success_message="Feature flags evaluated successfully")
return _process_response(
res, success_message="Feature flags evaluated successfully"
)
def remote_config(personal_api_key: str, host: Optional[str] = None, key: str = "", timeout: int = 15) -> Any:
def remote_config(
personal_api_key: str, host: Optional[str] = None, key: str = "", timeout: int = 15
) -> Any:
"""Get remote config flag value from remote_config API endpoint"""
return get(personal_api_key, f"/api/projects/@current/feature_flags/{key}/remote_config/", host, timeout)
return get(
personal_api_key,
f"/api/projects/@current/feature_flags/{key}/remote_config/",
host,
timeout,
)
def batch_post(
api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs
api_key: str,
host: Optional[str] = None,
gzip: bool = False,
timeout: int = 15,
**kwargs,
) -> requests.Response:
"""Post the `kwargs` to the batch API endpoint for events"""
res = post(api_key, host, "/batch/", gzip, timeout, **kwargs)
return _process_response(res, success_message="data uploaded successfully", return_json=False)
return _process_response(
res, success_message="data uploaded successfully", return_json=False
)
def get(api_key: str, url: str, host: Optional[str] = None, timeout: Optional[int] = None) -> requests.Response:
def get(
api_key: str, url: str, host: Optional[str] = None, timeout: Optional[int] = None
) -> requests.Response:
url = remove_trailing_slash(host or DEFAULT_HOST) + url
res = requests.get(url, headers={"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}, timeout=timeout)
res = requests.get(
url,
headers={"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT},
timeout=timeout,
)
return _process_response(res, success_message=f"GET {url} completed successfully")
+245
View File
@@ -0,0 +1,245 @@
import contextvars
from contextlib import contextmanager
from typing import Optional, Any, Callable, Dict, TypeVar, cast
class ContextScope:
def __init__(
self,
parent=None,
fresh: bool = False,
capture_exceptions: bool = True,
):
self.parent = parent
self.fresh = fresh
self.capture_exceptions = capture_exceptions
self.session_id: Optional[str] = None
self.distinct_id: Optional[str] = None
self.tags: Dict[str, Any] = {}
def set_session_id(self, session_id: str):
self.session_id = session_id
def set_distinct_id(self, distinct_id: str):
self.distinct_id = distinct_id
def add_tag(self, key: str, value: Any):
self.tags[key] = value
def get_parent(self):
return self.parent
def get_session_id(self) -> Optional[str]:
if self.session_id is not None:
return self.session_id
if self.parent is not None and not self.fresh:
return self.parent.get_session_id()
return None
def get_distinct_id(self) -> Optional[str]:
if self.distinct_id is not None:
return self.distinct_id
if self.parent is not None and not self.fresh:
return self.parent.get_distinct_id()
return None
def collect_tags(self) -> Dict[str, Any]:
tags = self.tags.copy()
if self.parent and not self.fresh:
# We want child tags to take precedence over parent tags,
# so we can't use a simple update here, instead collecting
# the parent tags and then updating with the child tags.
new_tags = self.parent.collect_tags()
tags.update(new_tags)
return tags
_context_stack: contextvars.ContextVar[Optional[ContextScope]] = contextvars.ContextVar(
"posthog_context_stack", default=None
)
def _get_current_context() -> Optional[ContextScope]:
return _context_stack.get()
@contextmanager
def new_context(fresh=False, capture_exceptions=True):
"""
Create a new context scope that will be active for the duration of the with block.
Any tags set within this scope will be isolated to this context. Any exceptions raised
or events captured within the context will be tagged with the context tags.
Args:
fresh: Whether to start with a fresh context (default: False).
If False, inherits tags, identity and session id's from parent context.
If True, starts with no state
capture_exceptions: Whether to capture exceptions raised within the context (default: True).
If True, captures exceptions and tags them with the context tags before propagating them.
If False, exceptions will propagate without being tagged or captured.
Examples:
# Inherit parent context tags
with posthog.new_context():
posthog.tag("request_id", "123")
# Both this event and the exception will be tagged with the context tags
posthog.capture("event_name", {"property": "value"})
raise ValueError("Something went wrong")
# Start with fresh context (no inherited tags)
with posthog.new_context(fresh=True):
posthog.tag("request_id", "123")
# Both this event and the exception will be tagged with the context tags
posthog.capture("event_name", {"property": "value"})
raise ValueError("Something went wrong")
"""
from posthog import capture_exception
current_context = _get_current_context()
new_context = ContextScope(current_context, fresh, capture_exceptions)
_context_stack.set(new_context)
try:
yield
except Exception as e:
if new_context.capture_exceptions:
capture_exception(e)
raise
finally:
_context_stack.set(new_context.get_parent())
def tag(key: str, value: Any) -> None:
"""
Add a tag to the current context.
Args:
key: The tag key
value: The tag value
Example:
posthog.tag("user_id", "123")
"""
current_context = _get_current_context()
if current_context:
current_context.add_tag(key, value)
# NOTE: we should probably also remove this - there's no reason for the user to ever
# need to manually interact with the current tag set
def get_tags() -> Dict[str, Any]:
"""
Get all tags from the current context. Note, modifying
the returned dictionary will not affect the current context.
Returns:
Dict of all tags in the current context
"""
current_context = _get_current_context()
if current_context:
return current_context.collect_tags()
return {}
# NOTE: We should probably remove this function - the way to clear scope context
# is by entering a new, fresh context, rather than by clearing the tags or other
# scope data directly.
def clear_tags() -> None:
"""Clear all tags in the current context. Does not clear parent tags"""
current_context = _get_current_context()
if current_context:
current_context.tags.clear()
def identify_context(distinct_id: str) -> None:
"""
Identify the current context with a distinct ID, associating all events captured in this or
child contexts with the given distinct ID (unless identify_context is called again). This is overridden by
distinct id's passed directly to posthog.capture and related methods (identify, set etc). Entering a
fresh context will clear the context-level distinct ID.
Args:
distinct_id: The distinct ID to associate with the current context and its children.
"""
current_context = _get_current_context()
if current_context:
current_context.set_distinct_id(distinct_id)
def set_context_session(session_id: str) -> None:
"""
Set the session ID for the current context, associating all events captured in this or
child contexts with the given session ID (unless set_context_session is called again).
Entering a fresh context will clear the context-level session ID.
Args:
session_id: The session ID to associate with the current context and its children. See https://posthog.com/docs/data/sessions
"""
current_context = _get_current_context()
if current_context:
current_context.set_session_id(session_id)
def get_context_session_id() -> Optional[str]:
"""
Get the session ID for the current context.
Returns:
The session ID if set, None otherwise
"""
current_context = _get_current_context()
if current_context:
return current_context.get_session_id()
return None
def get_context_distinct_id() -> Optional[str]:
"""
Get the distinct ID for the current context.
Returns:
The distinct ID if set, None otherwise
"""
current_context = _get_current_context()
if current_context:
return current_context.get_distinct_id()
return None
F = TypeVar("F", bound=Callable[..., Any])
def scoped(fresh=False, capture_exceptions=True):
"""
Decorator that creates a new context for the function. Simply wraps
the function in a with posthog.new_context(): block.
Args:
fresh: Whether to start with a fresh context (default: False)
capture_exceptions: Whether to capture and track exceptions with posthog error tracking (default: True)
Example:
@posthog.scoped()
def process_payment(payment_id):
posthog.tag("payment_id", payment_id)
posthog.tag("payment_method", "credit_card")
# This event will be captured with tags
posthog.capture("payment_started")
# If this raises an exception, it will be captured with tags
# and then re-raised
some_risky_function()
"""
def decorator(func: F) -> F:
from functools import wraps
@wraps(func)
def wrapper(*args, **kwargs):
with new_context(fresh=fresh, capture_exceptions=capture_exceptions):
return func(*args, **kwargs)
return cast(F, wrapper)
return decorator
-1
View File
@@ -1 +0,0 @@
POSTHOG_ID_TAG = "posthog_distinct_id"
-28
View File
@@ -1,28 +0,0 @@
from django.conf import settings
from sentry_sdk import configure_scope
from posthog.sentry import POSTHOG_ID_TAG
GET_DISTINCT_ID = getattr(settings, "POSTHOG_DJANGO", {}).get("distinct_id")
def get_distinct_id(request):
if not GET_DISTINCT_ID:
return None
try:
return GET_DISTINCT_ID(request)
except: # noqa: E722
return None
class PosthogDistinctIdMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
with configure_scope() as scope:
distinct_id = get_distinct_id(request)
if distinct_id:
scope.set_tag(POSTHOG_ID_TAG, distinct_id)
response = self.get_response(request)
return response
-52
View File
@@ -1,52 +0,0 @@
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 Optional # noqa: F401
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 hosted sentry instance (default: https://sentry.io/organizations/)
@staticmethod
def setup_once():
@add_global_event_processor
def processor(event, hint):
# type: (Event, Optional[Hint]) -> Optional[Event]
if Hub.current.get_integration(PostHogIntegration) is not None:
if event.get("level") != "error":
return event
if event.get("tags", {}).get(POSTHOG_ID_TAG):
posthog_distinct_id = event["tags"][POSTHOG_ID_TAG]
event["tags"]["PostHog URL"] = f"{posthog.host or DEFAULT_HOST}/person/{posthog_distinct_id}"
properties = {
"$sentry_event_id": event["event_id"],
"$sentry_exception": event["exception"],
}
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)
return event
+48 -15
View File
@@ -16,7 +16,9 @@ except ImportError:
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
# Skip all tests if Anthropic is not available
pytestmark = pytest.mark.skipif(not ANTHROPIC_AVAILABLE, reason="Anthropic package is not available")
pytestmark = pytest.mark.skipif(
not ANTHROPIC_AVAILABLE, reason="Anthropic package is not available"
)
@pytest.fixture
@@ -87,7 +89,9 @@ def mock_anthropic_response_with_cached_tokens():
def test_basic_completion(mock_client, mock_anthropic_response):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
@@ -107,7 +111,9 @@ def test_basic_completion(mock_client, mock_anthropic_response):
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "Test response"}]
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
@@ -116,7 +122,9 @@ def test_basic_completion(mock_client, mock_anthropic_response):
def test_streaming(mock_client, mock_anthropic_stream):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_stream):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_stream
):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
@@ -153,7 +161,9 @@ def test_streaming(mock_client, mock_anthropic_stream):
def test_streaming_with_stream_endpoint(mock_client, mock_anthropic_stream):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_stream):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_stream
):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.stream(
model="claude-3-opus-20240229",
@@ -189,7 +199,9 @@ def test_streaming_with_stream_endpoint(mock_client, mock_anthropic_stream):
def test_groups(mock_client, mock_anthropic_response):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
@@ -206,7 +218,9 @@ def test_groups(mock_client, mock_anthropic_response):
def test_privacy_mode_local(mock_client, mock_anthropic_response):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
@@ -225,7 +239,9 @@ def test_privacy_mode_local(mock_client, mock_anthropic_response):
def test_privacy_mode_global(mock_client, mock_anthropic_response):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
mock_client.privacy_mode = True
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
@@ -299,7 +315,9 @@ async def test_basic_async_integration(mock_client):
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [{"role": "user", "content": "You must always answer with 'Bar'."}]
assert props["$ai_input"] == [
{"role": "user", "content": "You must always answer with 'Bar'."}
]
assert props["$ai_output_choices"][0]["role"] == "assistant"
assert props["$ai_input_tokens"] == 16
assert props["$ai_output_tokens"] == 1
@@ -309,7 +327,9 @@ async def test_basic_async_integration(mock_client):
def test_streaming_system_prompt(mock_client, mock_anthropic_stream):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_stream):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_stream
):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
@@ -328,7 +348,10 @@ def test_streaming_system_prompt(mock_client, mock_anthropic_stream):
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] == [{"role": "system", "content": "Foo"}, {"role": "user", "content": "Bar"}]
assert props["$ai_input"] == [
{"role": "system", "content": "Foo"},
{"role": "user", "content": "Bar"},
]
@pytest.mark.skipif(not ANTHROPIC_API_KEY, reason="ANTHROPIC_API_KEY is not set")
@@ -359,10 +382,15 @@ async def test_async_streaming_system_prompt(mock_client, mock_anthropic_stream)
def test_error(mock_client, mock_anthropic_response):
with patch("anthropic.resources.Messages.create", side_effect=Exception("Test error")):
with patch(
"anthropic.resources.Messages.create", side_effect=Exception("Test error")
):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
with pytest.raises(Exception):
client.messages.create(model="claude-3-opus-20240229", messages=[{"role": "user", "content": "Hello"}])
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
)
assert mock_client.capture.call_count == 1
@@ -373,7 +401,10 @@ def test_error(mock_client, mock_anthropic_response):
def test_cached_tokens(mock_client, mock_anthropic_response_with_cached_tokens):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_response_with_cached_tokens):
with patch(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response_with_cached_tokens,
):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
@@ -393,7 +424,9 @@ def test_cached_tokens(mock_client, mock_anthropic_response_with_cached_tokens):
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "Test response"}]
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_cache_read_input_tokens"] == 15
+36 -12
View File
@@ -11,7 +11,9 @@ try:
except ImportError:
GEMINI_AVAILABLE = False
pytestmark = pytest.mark.skipif(not GEMINI_AVAILABLE, reason="Google Gemini package is not available")
pytestmark = pytest.mark.skipif(
not GEMINI_AVAILABLE, reason="Google Gemini package is not available"
)
@pytest.fixture
@@ -54,7 +56,9 @@ def mock_google_genai_client():
yield mock_client_instance
def test_new_client_basic_generation(mock_client, mock_google_genai_client, mock_gemini_response):
def test_new_client_basic_generation(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test the new Client/Models API structure"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
@@ -84,7 +88,9 @@ def test_new_client_basic_generation(mock_client, mock_google_genai_client, mock
assert props["$ai_latency"] > 0
def test_new_client_streaming_with_generate_content_stream(mock_client, mock_google_genai_client):
def test_new_client_streaming_with_generate_content_stream(
mock_client, mock_google_genai_client
):
"""Test the new generate_content_stream method"""
def mock_streaming_response():
@@ -106,7 +112,9 @@ def test_new_client_streaming_with_generate_content_stream(mock_client, mock_goo
yield mock_chunk2
# Mock the generate_content_stream method
mock_google_genai_client.models.generate_content_stream.return_value = mock_streaming_response()
mock_google_genai_client.models.generate_content_stream.return_value = (
mock_streaming_response()
)
client = Client(api_key="test-key", posthog_client=mock_client)
@@ -154,7 +162,9 @@ def test_new_client_groups(mock_client, mock_google_genai_client, mock_gemini_re
assert call_args["groups"] == {"company": "company_123"}
def test_new_client_privacy_mode_local(mock_client, mock_google_genai_client, mock_gemini_response):
def test_new_client_privacy_mode_local(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test local privacy mode with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
@@ -173,7 +183,9 @@ def test_new_client_privacy_mode_local(mock_client, mock_google_genai_client, mo
assert props["$ai_output_choices"] is None
def test_new_client_privacy_mode_global(mock_client, mock_google_genai_client, mock_gemini_response):
def test_new_client_privacy_mode_global(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test global privacy mode with new Client API"""
mock_client.privacy_mode = True
@@ -193,14 +205,18 @@ def test_new_client_privacy_mode_global(mock_client, mock_google_genai_client, m
assert props["$ai_output_choices"] is None
def test_new_client_different_input_formats(mock_client, mock_google_genai_client, mock_gemini_response):
def test_new_client_different_input_formats(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test different input formats with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", posthog_client=mock_client)
# Test string input
client.models.generate_content(model="gemini-2.0-flash", contents="Hello", posthog_distinct_id="test-id")
client.models.generate_content(
model="gemini-2.0-flash", contents="Hello", posthog_distinct_id="test-id"
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
@@ -209,13 +225,17 @@ def test_new_client_different_input_formats(mock_client, mock_google_genai_clien
mock_client.capture.reset_mock()
mock_part = MagicMock()
mock_part.text = "List item"
client.models.generate_content(model="gemini-2.0-flash", contents=[mock_part], posthog_distinct_id="test-id")
client.models.generate_content(
model="gemini-2.0-flash", contents=[mock_part], posthog_distinct_id="test-id"
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] == [{"role": "user", "content": "List item"}]
def test_new_client_model_parameters(mock_client, mock_google_genai_client, mock_gemini_response):
def test_new_client_model_parameters(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test model parameters with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
@@ -235,7 +255,9 @@ def test_new_client_model_parameters(mock_client, mock_google_genai_client, mock
assert props["$ai_model_parameters"]["max_tokens"] == 100
def test_new_client_default_settings(mock_client, mock_google_genai_client, mock_gemini_response):
def test_new_client_default_settings(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test client with default PostHog settings"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
@@ -259,7 +281,9 @@ def test_new_client_default_settings(mock_client, mock_google_genai_client, mock
assert props["team"] == "ai"
def test_new_client_override_defaults(mock_client, mock_google_genai_client, mock_gemini_response):
def test_new_client_override_defaults(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test overriding client defaults per call"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
+375 -39
View File
@@ -43,7 +43,9 @@ except ImportError:
# Skip all tests if LangChain is not available
pytestmark = pytest.mark.skipif(not LANGCHAIN_AVAILABLE, reason="LangChain package is not available")
pytestmark = pytest.mark.skipif(
not LANGCHAIN_AVAILABLE, reason="LangChain package is not available"
)
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
@@ -252,7 +254,9 @@ async def test_async_basic_chat_chain(mock_client, stream):
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
if stream:
result = [m async for m in chain.astream({}, config={"callbacks": callbacks})][0]
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."
@@ -316,10 +320,17 @@ def test_basic_llm_chain(mock_client, Model, stream):
if stream:
result = "".join(
[m for m in model.stream("Who won the world series in 2020?", config={"callbacks": callbacks})]
[
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})
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
@@ -331,7 +342,9 @@ def test_basic_llm_chain(mock_client, Model, stream):
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_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)
@@ -352,10 +365,17 @@ async def test_async_basic_llm_chain(mock_client, Model, stream):
if stream:
result = "".join(
[m async for m in model.astream("Who won the world series in 2020?", config={"callbacks": callbacks})]
[
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})
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
@@ -367,7 +387,9 @@ async def test_async_basic_llm_chain(mock_client, Model, stream):
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_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)
@@ -418,8 +440,12 @@ def test_trace_id_and_inputs_for_multiple_chains(mock_client):
assert "distinct_id" in first_generation_args
assert "$ai_model" in first_generation_props
assert "$ai_provider" in first_generation_props
assert first_generation_props["$ai_input"] == [{"role": "user", "content": "Foo bar"}]
assert first_generation_props["$ai_output_choices"] == [{"role": "assistant", "content": "Bar"}]
assert first_generation_props["$ai_input"] == [
{"role": "user", "content": "Foo bar"}
]
assert first_generation_props["$ai_output_choices"] == [
{"role": "assistant", "content": "Bar"}
]
assert first_generation_props["$ai_http_status"] == 200
assert isinstance(first_generation_props["$ai_latency"], float)
assert "$ai_span_id" in first_generation_props
@@ -439,8 +465,12 @@ def test_trace_id_and_inputs_for_multiple_chains(mock_client):
assert "distinct_id" in second_generation_args
assert "$ai_model" in second_generation_props
assert "$ai_provider" in second_generation_props
assert second_generation_props["$ai_input"] == [{"role": "assistant", "content": "Bar"}]
assert second_generation_props["$ai_output_choices"] == [{"role": "assistant", "content": "Bar"}]
assert second_generation_props["$ai_input"] == [
{"role": "assistant", "content": "Bar"}
]
assert second_generation_props["$ai_output_choices"] == [
{"role": "assistant", "content": "Bar"}
]
assert second_generation_props["$ai_http_status"] == 200
assert second_generation_props["$ai_trace_id"] is not None
assert isinstance(second_generation_props["$ai_latency"], float)
@@ -475,7 +505,9 @@ def test_personless_mode(mock_client):
assert trace_args["properties"]["$process_person_profile"] is False
id = uuid.uuid4()
chain.invoke({}, config={"callbacks": [CallbackHandler(mock_client, distinct_id=id)]})
chain.invoke(
{}, config={"callbacks": [CallbackHandler(mock_client, distinct_id=id)]}
)
assert mock_client.capture.call_count == 6
span_args = mock_client.capture.call_args_list[3][1]
generation_args = mock_client.capture.call_args_list[4][1]
@@ -515,7 +547,9 @@ def test_personless_mode_exception(mock_client):
id = uuid.uuid4()
with pytest.raises(Exception):
chain.invoke({}, config={"callbacks": [CallbackHandler(mock_client, distinct_id=id)]})
chain.invoke(
{}, config={"callbacks": [CallbackHandler(mock_client, distinct_id=id)]}
)
assert mock_client.capture.call_count == 6
span_args = mock_client.capture.call_args_list[3][1]
generation_args = mock_client.capture.call_args_list[4][1]
@@ -574,7 +608,9 @@ def test_metadata(mock_client):
assert generation_call_props["$ai_trace_id"] == "test-trace-id"
assert generation_call_props["foo"] == "bar"
assert generation_call_props["$ai_input"] == [{"role": "user", "content": "Foo"}]
assert generation_call_props["$ai_output_choices"] == [{"role": "assistant", "content": "Bar"}]
assert generation_call_props["$ai_output_choices"] == [
{"role": "assistant", "content": "Bar"}
]
assert generation_call_props["$ai_http_status"] == 200
assert isinstance(generation_call_props["$ai_latency"], float)
@@ -655,7 +691,10 @@ def test_graph_state(mock_client):
# 1. Span, finish initialization
second_state = {
"messages": [HumanMessage(content="What's a bar?"), AIMessage(content="Let's explore bar.")],
"messages": [
HumanMessage(content="What's a bar?"),
AIMessage(content="Let's explore bar."),
],
"xyz": "abc",
}
@@ -669,19 +708,25 @@ def test_graph_state(mock_client):
# 2. Span - the ChatPromptTemplate within fake_llm's FakeMessagesListChatModel
assert calls[1]["event"] == "$ai_span"
assert calls[1]["properties"]["$ai_parent_id"] == calls[3]["properties"]["$ai_span_id"]
assert (
calls[1]["properties"]["$ai_parent_id"] == calls[3]["properties"]["$ai_span_id"]
)
assert "$ai_span_id" in calls[1]["properties"]
assert calls[1]["properties"]["$ai_span_name"] == "ChatPromptTemplate"
# 3. Generation - the FakeMessagesListChatModel within fake_llm's RunnableSequence
assert calls[2]["event"] == "$ai_generation"
assert calls[2]["properties"]["$ai_parent_id"] == calls[3]["properties"]["$ai_span_id"]
assert (
calls[2]["properties"]["$ai_parent_id"] == calls[3]["properties"]["$ai_span_id"]
)
assert "$ai_span_id" in calls[2]["properties"]
assert calls[2]["properties"]["$ai_span_name"] == "FakeMessagesListChatModel"
# 4. Span - RunnableSequence within fake_llm
assert calls[3]["event"] == "$ai_span"
assert calls[3]["properties"]["$ai_parent_id"] == calls[4]["properties"]["$ai_span_id"]
assert (
calls[3]["properties"]["$ai_parent_id"] == calls[4]["properties"]["$ai_span_id"]
)
assert "$ai_span_id" in calls[3]["properties"]
assert calls[3]["properties"]["$ai_span_name"] == "RunnableSequence"
@@ -705,9 +750,14 @@ def test_graph_state(mock_client):
assert isinstance(trace_props["$ai_output_state"]["messages"][0], HumanMessage)
assert trace_props["$ai_output_state"]["messages"][0].content == "What's a bar?"
assert isinstance(trace_props["$ai_output_state"]["messages"][1], AIMessage)
assert trace_props["$ai_output_state"]["messages"][1].content == "Let's explore bar."
assert (
trace_props["$ai_output_state"]["messages"][1].content == "Let's explore bar."
)
assert isinstance(trace_props["$ai_output_state"]["messages"][2], AIMessage)
assert trace_props["$ai_output_state"]["messages"][2].content == "It's a type of greeble."
assert (
trace_props["$ai_output_state"]["messages"][2].content
== "It's a type of greeble."
)
assert trace_args["properties"]["$ai_output_state"]["xyz"] == "abc"
@@ -735,7 +785,9 @@ def test_callbacks_logic(mock_client):
assert len(callbacks._parent_tree.items()) == 1
return [m]
(chain | RunnableLambda(assert_intermediary_run) | model).invoke({}, config={"callbacks": [callbacks]})
(chain | RunnableLambda(assert_intermediary_run) | model).invoke(
{}, config={"callbacks": [callbacks]}
)
assert callbacks._runs == {}
assert callbacks._parent_tree == {}
@@ -828,10 +880,16 @@ def test_openai_chain(mock_client):
{"role": "system", "content": 'You must always answer with "Bar".'},
{"role": "user", "content": "Foo"},
]
assert gen_props["$ai_output_choices"] == [{"role": "assistant", "content": "Bar", "refusal": None}]
assert gen_props["$ai_output_choices"] == [
{"role": "assistant", "content": "Bar", "refusal": None}
]
assert gen_props["$ai_http_status"] == 200
assert isinstance(gen_props["$ai_latency"], float)
assert min(approximate_latency - 1, 0) <= math.floor(gen_props["$ai_latency"]) <= approximate_latency
assert (
min(approximate_latency - 1, 0)
<= math.floor(gen_props["$ai_latency"])
<= approximate_latency
)
assert gen_props["$ai_input_tokens"] == 20
assert gen_props["$ai_output_tokens"] == 1
@@ -1105,7 +1163,11 @@ def test_anthropic_chain(mock_client):
assert gen_props["$ai_output_choices"] == [{"role": "assistant", "content": "Bar"}]
assert gen_props["$ai_http_status"] == 200
assert isinstance(gen_props["$ai_latency"], float)
assert min(approximate_latency - 1, 0) <= math.floor(gen_props["$ai_latency"]) <= approximate_latency
assert (
min(approximate_latency - 1, 0)
<= math.floor(gen_props["$ai_latency"])
<= approximate_latency
)
assert gen_props["$ai_input_tokens"] == 17
assert gen_props["$ai_output_tokens"] == 1
@@ -1252,7 +1314,10 @@ def test_tool_calls(mock_client):
},
}
]
assert "additional_kwargs" not in generation_call["properties"]["$ai_output_choices"][0]
assert (
"additional_kwargs"
not in generation_call["properties"]["$ai_output_choices"][0]
)
async def test_async_traces(mock_client):
@@ -1274,7 +1339,9 @@ async def test_async_traces(mock_client):
approximate_latency = math.floor(time.time() - start_time)
assert mock_client.capture.call_count == 4
first_call, second_call, third_call, fourth_call = mock_client.capture.call_args_list
first_call, second_call, third_call, fourth_call = (
mock_client.capture.call_args_list
)
assert first_call[1]["event"] == "$ai_span"
assert second_call[1]["event"] == "$ai_generation"
assert third_call[1]["event"] == "$ai_trace"
@@ -1282,7 +1349,9 @@ async def test_async_traces(mock_client):
assert fourth_call[1]["event"] == "$ai_trace"
assert fourth_call[1]["properties"]["$ai_span_name"] == "sleep"
assert (
min(approximate_latency - 1, 0) <= math.floor(third_call[1]["properties"]["$ai_latency"]) <= approximate_latency
min(approximate_latency - 1, 0)
<= math.floor(third_call[1]["properties"]["$ai_latency"])
<= approximate_latency
)
@@ -1304,14 +1373,16 @@ def test_langgraph_agent(mock_client):
model = ChatOpenAI(api_key=OPENAI_API_KEY, model="gpt-4o-mini", temperature=0)
graph = create_react_agent(model, tools=tools)
inputs = {"messages": [("user", "what is the weather in sf")]}
cb = CallbackHandler(mock_client, trace_id="test-trace-id", distinct_id="test-distinct-id")
cb = CallbackHandler(
mock_client, trace_id="test-trace-id", distinct_id="test-distinct-id"
)
graph.invoke(inputs, config={"callbacks": [cb]})
calls = [call[1] for call in mock_client.capture.call_args_list]
assert len(calls) == 21
assert len(calls) == 15
for call in calls:
assert call["properties"]["$ai_trace_id"] == "test-trace-id"
assert len([call for call in calls if call["event"] == "$ai_generation"]) == 2
assert len([call for call in calls if call["event"] == "$ai_span"]) == 18
assert len([call for call in calls if call["event"] == "$ai_span"]) == 12
assert len([call for call in calls if call["event"] == "$ai_trace"]) == 1
@@ -1324,7 +1395,9 @@ def test_span_set_parent_ids(mock_client, trace_id):
]
)
model = FakeMessagesListChatModel(
responses=[AIMessage(content="The Los Angeles Dodgers won the World Series in 2020.")]
responses=[
AIMessage(content="The Los Angeles Dodgers won the World Series in 2020.")
]
)
callbacks = [CallbackHandler(mock_client, trace_id=trace_id)]
chain = prompt | model
@@ -1333,10 +1406,16 @@ def test_span_set_parent_ids(mock_client, trace_id):
assert mock_client.capture.call_count == 3
span_props = mock_client.capture.call_args_list[0][1]
assert span_props["properties"]["$ai_trace_id"] == span_props["properties"]["$ai_parent_id"]
assert (
span_props["properties"]["$ai_trace_id"]
== span_props["properties"]["$ai_parent_id"]
)
generation_props = mock_client.capture.call_args_list[1][1]
assert generation_props["properties"]["$ai_trace_id"] == generation_props["properties"]["$ai_parent_id"]
assert (
generation_props["properties"]["$ai_trace_id"]
== generation_props["properties"]["$ai_parent_id"]
)
@pytest.mark.parametrize("trace_id", ["test-trace-id", None])
@@ -1356,9 +1435,13 @@ def test_span_set_parent_ids_for_third_level_run(mock_client, trace_id):
assert mock_client.capture.call_count == 3
span2, span1, trace = [call[1]["properties"] for call in mock_client.capture.call_args_list]
assert span2["$ai_parent_id"] == span1["$ai_span_id"]
assert span1["$ai_parent_id"] == trace["$ai_trace_id"]
calls = mock_client.capture.call_args_list
span_props_2 = calls[0][1]["properties"]
span_props_1 = calls[1][1]["properties"]
trace_props = calls[2][1]["properties"]
assert span_props_2["$ai_parent_id"] == span_props_1["$ai_span_id"]
assert span_props_1["$ai_parent_id"] == trace_props["$ai_trace_id"]
def test_captures_error_with_details_in_span(mock_client):
@@ -1373,7 +1456,10 @@ def test_captures_error_with_details_in_span(mock_client):
pass
assert mock_client.capture.call_count == 2
assert mock_client.capture.call_args_list[1][1]["properties"]["$ai_error"] == "ValueError: test"
assert (
mock_client.capture.call_args_list[1][1]["properties"]["$ai_error"]
== "ValueError: test"
)
assert mock_client.capture.call_args_list[1][1]["properties"]["$ai_is_error"]
@@ -1389,5 +1475,255 @@ def test_captures_error_without_details_in_span(mock_client):
pass
assert mock_client.capture.call_count == 2
assert mock_client.capture.call_args_list[1][1]["properties"]["$ai_error"] == "ValueError"
assert (
mock_client.capture.call_args_list[1][1]["properties"]["$ai_error"]
== "ValueError"
)
assert mock_client.capture.call_args_list[1][1]["properties"]["$ai_is_error"]
def test_openai_reasoning_tokens(mock_client):
"""Test that OpenAI reasoning tokens are captured correctly."""
prompt = ChatPromptTemplate.from_messages(
[("user", "Think step by step about this problem")]
)
# Mock response with reasoning tokens in output_token_details
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Let me think through this step by step...",
usage_metadata={
"input_tokens": 10,
"output_tokens": 25,
"total_tokens": 35,
"output_token_details": {"reasoning": 15}, # 15 reasoning tokens
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Let me think through this step by step..."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 10
assert generation_props["$ai_output_tokens"] == 25
assert generation_props["$ai_reasoning_tokens"] == 15
def test_anthropic_cache_write_and_read_tokens(mock_client):
"""Test that Anthropic cache creation and read tokens are captured correctly."""
prompt = ChatPromptTemplate.from_messages([("user", "Analyze this large document")])
# First call with cache creation
model_write = FakeMessagesListChatModel(
responses=[
AIMessage(
content="I've analyzed the document and cached the context.",
usage_metadata={
"total_tokens": 1050,
"input_tokens": 1000,
"output_tokens": 50,
"cache_creation_input_tokens": 800, # Anthropic cache write
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model_write
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "I've analyzed the document and cached the context."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 1000
assert generation_props["$ai_output_tokens"] == 50
assert generation_props["$ai_cache_creation_input_tokens"] == 800
assert generation_props["$ai_cache_read_input_tokens"] == 0
assert generation_props["$ai_reasoning_tokens"] == 0
# Reset mock for second call
mock_client.reset_mock()
# Second call with cache read
model_read = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Using cached analysis to provide quick response.",
usage_metadata={
"input_tokens": 200,
"output_tokens": 30,
"total_tokens": 1030,
"cache_read_input_tokens": 800, # Anthropic cache read
},
)
]
)
chain = prompt | model_read
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Using cached analysis to provide quick response."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 200
assert generation_props["$ai_output_tokens"] == 30
assert generation_props["$ai_cache_creation_input_tokens"] == 0
assert generation_props["$ai_cache_read_input_tokens"] == 800
assert generation_props["$ai_reasoning_tokens"] == 0
def test_openai_cache_read_tokens(mock_client):
"""Test that OpenAI cache read tokens are captured correctly."""
prompt = ChatPromptTemplate.from_messages(
[("user", "Use the cached prompt for this request")]
)
# Mock response with cache read tokens in input_token_details
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Response using cached prompt context.",
usage_metadata={
"input_tokens": 150,
"output_tokens": 40,
"total_tokens": 190,
"input_token_details": {
"cache_read": 100, # 100 tokens read from cache
"cache_creation": 0,
},
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Response using cached prompt context."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 150
assert generation_props["$ai_output_tokens"] == 40
assert generation_props["$ai_cache_read_input_tokens"] == 100
assert generation_props["$ai_cache_creation_input_tokens"] == 0
assert generation_props["$ai_reasoning_tokens"] == 0
def test_openai_cache_creation_tokens(mock_client):
"""Test that OpenAI cache creation tokens are captured correctly."""
prompt = ChatPromptTemplate.from_messages(
[("user", "Create a cache for this large prompt context")]
)
# Mock response with cache creation tokens in input_token_details
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Created cache for the prompt context.",
usage_metadata={
"input_tokens": 2000,
"output_tokens": 25,
"total_tokens": 2025,
"input_token_details": {
"cache_creation": 1500, # 1500 tokens written to cache
"cache_read": 0,
},
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Created cache for the prompt context."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 2000
assert generation_props["$ai_output_tokens"] == 25
assert generation_props["$ai_cache_creation_input_tokens"] == 1500
assert generation_props["$ai_cache_read_input_tokens"] == 0
assert generation_props["$ai_reasoning_tokens"] == 0
def test_combined_reasoning_and_cache_tokens(mock_client):
"""Test that both reasoning tokens and cache tokens can be captured together."""
prompt = ChatPromptTemplate.from_messages(
[("user", "Think through this cached problem")]
)
# Mock response with both reasoning and cache tokens
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Let me reason through this using cached context...",
usage_metadata={
"input_tokens": 500,
"output_tokens": 100,
"total_tokens": 600,
"input_token_details": {"cache_read": 300, "cache_creation": 0},
"output_token_details": {"reasoning": 60}, # 60 reasoning tokens
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Let me reason through this using cached context..."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 500
assert generation_props["$ai_output_tokens"] == 100
assert generation_props["$ai_cache_read_input_tokens"] == 300
assert generation_props["$ai_cache_creation_input_tokens"] == 0
assert generation_props["$ai_reasoning_tokens"] == 60
@pytest.mark.skipif(not OPENAI_API_KEY, reason="OPENAI_API_KEY is not set")
def test_openai_reasoning_tokens(mock_client):
model = ChatOpenAI(
api_key=OPENAI_API_KEY, model="o4-mini", max_completion_tokens=10
)
cb = CallbackHandler(
mock_client, trace_id="test-trace-id", distinct_id="test-distinct-id"
)
model.invoke("what is the weather in sf", config={"callbacks": [cb]})
call = mock_client.capture.call_args_list[0][1]
assert call["properties"]["$ai_reasoning_tokens"] is not None
assert call["properties"]["$ai_input_tokens"] is not None
assert call["properties"]["$ai_output_tokens"] is not None
+216 -24
View File
@@ -9,12 +9,29 @@ try:
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice as ChoiceChunk
from openai.types.chat.chat_completion_chunk import ChoiceDelta, ChoiceDeltaToolCall, ChoiceDeltaToolCallFunction
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall, Function
from openai.types.chat.chat_completion_chunk import (
ChoiceDelta,
ChoiceDeltaToolCall,
ChoiceDeltaToolCallFunction,
)
from openai.types.chat.chat_completion_message_tool_call import (
ChatCompletionMessageToolCall,
Function,
)
from openai.types.completion_usage import CompletionUsage
from openai.types.create_embedding_response import CreateEmbeddingResponse, Usage
from openai.types.embedding import Embedding
from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText, ResponseUsage
from openai.types.responses import (
Response,
ResponseOutputMessage,
ResponseOutputText,
ResponseUsage,
ParsedResponse,
)
from openai.types.responses.parsed_response import (
ParsedResponseOutputMessage,
ParsedResponseOutputText,
)
from posthog.ai.openai import OpenAI
@@ -23,7 +40,9 @@ except ImportError:
OPENAI_AVAILABLE = False
# Skip all tests if OpenAI is not available
pytestmark = pytest.mark.skipif(not OPENAI_AVAILABLE, reason="OpenAI package is not available")
pytestmark = pytest.mark.skipif(
not OPENAI_AVAILABLE, reason="OpenAI package is not available"
)
@pytest.fixture
@@ -101,6 +120,59 @@ def mock_openai_response_with_responses_api():
)
@pytest.fixture
def mock_parsed_response():
return ParsedResponse(
id="test",
model="gpt-4o-2024-08-06",
object="response",
created_at=1741476542,
status="completed",
error=None,
incomplete_details=None,
instructions=None,
max_output_tokens=None,
tools=[],
tool_choice="auto",
output=[
ParsedResponseOutputMessage(
id="msg_123",
type="message",
role="assistant",
status="completed",
content=[
ParsedResponseOutputText(
type="output_text",
text='{"name": "Science Fair", "date": "Friday", "participants": ["Alice", "Bob"]}',
annotations=[],
parsed={
"name": "Science Fair",
"date": "Friday",
"participants": ["Alice", "Bob"],
},
)
],
)
],
output_parsed={
"name": "Science Fair",
"date": "Friday",
"participants": ["Alice", "Bob"],
},
parallel_tool_calls=True,
previous_response_id=None,
usage=ResponseUsage(
input_tokens=15,
output_tokens=20,
input_tokens_details={"prompt_tokens": 15, "cached_tokens": 0},
output_tokens_details={"reasoning_tokens": 5},
total_tokens=35,
),
user=None,
metadata={},
)
@pytest.fixture
def mock_embedding_response():
return CreateEmbeddingResponse(
@@ -182,7 +254,10 @@ def mock_openai_response_with_tool_calls():
def test_basic_completion(mock_client, mock_openai_response):
with patch("openai.resources.chat.completions.Completions.create", return_value=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",
@@ -202,7 +277,9 @@ def test_basic_completion(mock_client, mock_openai_response):
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_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
@@ -211,7 +288,10 @@ def test_basic_completion(mock_client, mock_openai_response):
def test_embeddings(mock_client, mock_embedding_response):
with patch("openai.resources.embeddings.Embeddings.create", return_value=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",
@@ -238,7 +318,10 @@ def test_embeddings(mock_client, mock_embedding_response):
def test_groups(mock_client, mock_openai_response):
with patch("openai.resources.chat.completions.Completions.create", return_value=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",
@@ -256,7 +339,10 @@ def test_groups(mock_client, mock_openai_response):
def test_privacy_mode_local(mock_client, mock_openai_response):
with patch("openai.resources.chat.completions.Completions.create", return_value=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",
@@ -275,7 +361,10 @@ def test_privacy_mode_local(mock_client, mock_openai_response):
def test_privacy_mode_global(mock_client, mock_openai_response):
with patch("openai.resources.chat.completions.Completions.create", return_value=mock_openai_response):
with patch(
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response,
):
mock_client.privacy_mode = True
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
@@ -295,10 +384,15 @@ def test_privacy_mode_global(mock_client, mock_openai_response):
def test_error(mock_client, mock_openai_response):
with patch("openai.resources.chat.completions.Completions.create", side_effect=Exception("Test error")):
with patch(
"openai.resources.chat.completions.Completions.create",
side_effect=Exception("Test error"),
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
with pytest.raises(Exception):
client.chat.completions.create(model="gpt-4", messages=[{"role": "user", "content": "Hello"}])
client.chat.completions.create(
model="gpt-4", messages=[{"role": "user", "content": "Hello"}]
)
assert mock_client.capture.call_count == 1
@@ -310,7 +404,8 @@ def test_error(mock_client, mock_openai_response):
def test_cached_tokens(mock_client, mock_openai_response_with_cached_tokens):
with patch(
"openai.resources.chat.completions.Completions.create", return_value=mock_openai_response_with_cached_tokens
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response_with_cached_tokens,
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
@@ -331,7 +426,9 @@ def test_cached_tokens(mock_client, mock_openai_response_with_cached_tokens):
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_output_choices"] == [
{"role": "assistant", "content": "Test response"}
]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_cache_read_input_tokens"] == 15
@@ -342,16 +439,23 @@ def test_cached_tokens(mock_client, mock_openai_response_with_cached_tokens):
def test_tool_calls(mock_client, mock_openai_response_with_tool_calls):
with patch(
"openai.resources.chat.completions.Completions.create", return_value=mock_openai_response_with_tool_calls
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response_with_tool_calls,
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
messages=[
{"role": "user", "content": "What's the weather in San Francisco?"}
],
tools=[
{
"type": "function",
"function": {"name": "get_weather", "description": "Get weather", "parameters": {}},
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {},
},
}
],
posthog_distinct_id="test-id",
@@ -367,8 +471,12 @@ def test_tool_calls(mock_client, mock_openai_response_with_tool_calls):
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4"
assert props["$ai_input"] == [{"role": "user", "content": "What's the weather in San Francisco?"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "I'll check the weather for you."}]
assert props["$ai_input"] == [
{"role": "user", "content": "What's the weather in San Francisco?"}
]
assert props["$ai_output_choices"] == [
{"role": "assistant", "content": "I'll check the weather for you."}
]
# Check that tool calls are properly captured
assert "$ai_tools" in props
@@ -501,11 +609,17 @@ def test_streaming_with_tool_calls(mock_client):
# Call the streaming method
response_generator = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
messages=[
{"role": "user", "content": "What's the weather in San Francisco?"}
],
tools=[
{
"type": "function",
"function": {"name": "get_weather", "description": "Get weather", "parameters": {}},
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {},
},
}
],
stream=True,
@@ -547,7 +661,10 @@ def test_streaming_with_tool_calls(mock_client):
assert parsed_args == {"location": "San Francisco", "unit": "celsius"}
# Check that the content was also accumulated
assert props["$ai_output_choices"][0]["content"] == "The weather in San Francisco is 15°C."
assert (
props["$ai_output_choices"][0]["content"]
== "The weather in San Francisco is 15°C."
)
# Check token usage
assert props["$ai_input_tokens"] == 20
@@ -556,7 +673,10 @@ def test_streaming_with_tool_calls(mock_client):
# test responses api
def test_responses_api(mock_client, mock_openai_response_with_responses_api):
with patch("openai.resources.responses.Responses.create", return_value=mock_openai_response_with_responses_api):
with patch(
"openai.resources.responses.Responses.create",
return_value=mock_openai_response_with_responses_api,
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.responses.create(
model="gpt-4o-mini",
@@ -575,10 +695,82 @@ def test_responses_api(mock_client, mock_openai_response_with_responses_api):
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4o-mini"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "Test response"}]
assert props["$ai_output_choices"] == [
{"role": "assistant", "content": "Test response"}
]
assert props["$ai_input_tokens"] == 10
assert props["$ai_output_tokens"] == 10
assert props["$ai_reasoning_tokens"] == 15
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
def test_responses_parse(mock_client, mock_parsed_response):
with patch(
"openai.resources.responses.Responses.parse",
return_value=mock_parsed_response,
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.responses.parse(
model="gpt-4o-2024-08-06",
input=[
{"role": "system", "content": "Extract the event information."},
{
"role": "user",
"content": "Alice and Bob are going to a science fair on Friday.",
},
],
text={
"format": {
"type": "json_schema",
"json_schema": {
"name": "event",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"date": {"type": "string"},
"participants": {
"type": "array",
"items": {"type": "string"},
},
},
"required": ["name", "date", "participants"],
},
},
}
},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_parsed_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-4o-2024-08-06"
assert props["$ai_input"] == [
{"role": "system", "content": "Extract the event information."},
{
"role": "user",
"content": "Alice and Bob are going to a science fair on Friday.",
},
]
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": '{"name": "Science Fair", "date": "Friday", "participants": ["Alice", "Bob"]}',
}
]
assert props["$ai_input_tokens"] == 15
assert props["$ai_output_tokens"] == 20
assert props["$ai_reasoning_tokens"] == 5
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
@@ -1,22 +1,44 @@
from posthog.exception_integrations.django import DjangoRequestExtractor
from django.test import RequestFactory
from django.conf import settings
from django.core.management import call_command
import django
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
)
DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
# setup a test app
if not settings.configured:
settings.configure(
SECRET_KEY="test",
DEFAULT_CHARSET="utf-8",
INSTALLED_APPS=[
"django.contrib.auth",
"django.contrib.contenttypes",
],
DATABASES={
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
},
)
django.setup()
call_command("migrate", verbosity=0, interactive=False)
def mock_request_factory(override_headers):
class Request:
META = {}
# TRICKY: Actual django request dict object has case insensitive matching, and strips http from the names
headers = {
factory = RequestFactory(
headers={
"User-Agent": DEFAULT_USER_AGENT,
"Referrer": "http://example.com",
"X-Forwarded-For": "193.4.5.12",
**(override_headers or {}),
}
)
return Request()
request = factory.get("/api/endpoint")
return request
def test_request_extractor_with_no_trace():
@@ -27,17 +49,22 @@ def test_request_extractor_with_no_trace():
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": None,
"$request_path": "/api/endpoint",
}
def test_request_extractor_with_trace():
request = mock_request_factory({"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"})
request = mock_request_factory(
{"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"}
)
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"distinct_id": None,
"$request_path": "/api/endpoint",
}
@@ -54,15 +81,41 @@ def test_request_extractor_with_tracestate():
"user_agent": DEFAULT_USER_AGENT,
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"distinct_id": "1234",
"$request_path": "/api/endpoint",
}
def test_request_extractor_with_complicated_tracestate():
request = mock_request_factory({"tracestate": "posthog-distinct-id=alohaMountainsXUYZ,rojo=00f067aa0ba902b7"})
request = mock_request_factory(
{"tracestate": "posthog-distinct-id=alohaMountainsXUYZ,rojo=00f067aa0ba902b7"}
)
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": "alohaMountainsXUYZ",
"$request_path": "/api/endpoint",
}
def test_request_extractor_with_request_user():
from django.contrib.auth.models import User
user = User.objects.create_user(
username="test", email="test@posthog.com", password="top_secret"
)
request = mock_request_factory(None)
request.user = user
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": None,
"$request_path": "/api/endpoint",
"email": "test@posthog.com",
"$user_id": "1",
}
@@ -0,0 +1,169 @@
from posthog.scopes import new_context, get_context_session_id, get_context_distinct_id
import unittest
from unittest.mock import Mock
from posthog.integrations.django import PosthogContextMiddleware
class MockRequest:
"""Mock Django HttpRequest object"""
def __init__(
self,
headers=None,
method="GET",
path="/test",
host="example.com",
is_secure=False,
):
self.headers = headers or {}
self.method = method
self.path = path
self._host = host
self._is_secure = is_secure
def build_absolute_uri(self):
scheme = "https" if self._is_secure else "http"
return f"{scheme}://{self._host}{self.path}"
class TestPosthogContextMiddleware(unittest.TestCase):
def create_middleware(
self,
extra_tags=None,
request_filter=None,
tag_map=None,
capture_exceptions=True,
):
"""Helper to create middleware instance without calling __init__"""
middleware = PosthogContextMiddleware.__new__(PosthogContextMiddleware)
middleware.get_response = Mock()
middleware.extra_tags = extra_tags
middleware.request_filter = request_filter
middleware.tag_map = tag_map
middleware.capture_exceptions = capture_exceptions
return middleware
def test_extract_tags_basic(self):
with new_context():
"""Test basic tag extraction from request"""
middleware = self.create_middleware()
request = MockRequest(
headers={
"X-POSTHOG-SESSION-ID": "session-123",
"X-POSTHOG-DISTINCT-ID": "user-456",
},
method="POST",
path="/api/test",
host="example.com",
is_secure=True,
)
tags = middleware.extract_tags(request)
self.assertEqual(get_context_session_id(), "session-123")
self.assertEqual(get_context_distinct_id(), "user-456")
self.assertEqual(tags["$current_url"], "https://example.com/api/test")
self.assertEqual(tags["$request_method"], "POST")
def test_extract_tags_missing_headers(self):
"""Test tag extraction when PostHog headers are missing"""
with new_context():
middleware = self.create_middleware()
request = MockRequest(headers={}, method="GET", path="/home")
tags = middleware.extract_tags(request)
self.assertIsNone(get_context_session_id())
self.assertIsNone(get_context_distinct_id())
self.assertEqual(tags["$current_url"], "http://example.com/home")
self.assertEqual(tags["$request_method"], "GET")
def test_extract_tags_partial_headers(self):
"""Test tag extraction with only some PostHog headers present"""
with new_context():
middleware = self.create_middleware()
request = MockRequest(
headers={"X-POSTHOG-SESSION-ID": "session-only"}, method="PUT"
)
tags = middleware.extract_tags(request)
self.assertEqual(get_context_session_id(), "session-only")
self.assertIsNone(get_context_distinct_id())
self.assertEqual(tags["$request_method"], "PUT")
def test_extract_tags_with_extra_tags(self):
"""Test tag extraction with extra_tags function"""
def extra_tags_func(request):
return {"custom_tag": "custom_value", "user_id": "789"}
with new_context():
middleware = self.create_middleware(extra_tags=extra_tags_func)
request = MockRequest(
headers={"X-POSTHOG-SESSION-ID": "session-123"}, method="GET"
)
tags = middleware.extract_tags(request)
self.assertEqual(get_context_session_id(), "session-123")
self.assertEqual(tags["custom_tag"], "custom_value")
self.assertEqual(tags["user_id"], "789")
def test_extract_tags_with_tag_map(self):
"""Test tag extraction with tag_map function"""
def extra_tags_func(request):
return {"custom_tag": "custom_value", "user_id": "789"}
def tag_map_func(tags):
if "custom_tag" in tags:
tags["mapped_custom_tag"] = f"mapped_{tags['custom_tag']}"
del tags["custom_tag"]
return tags
with new_context():
middleware = self.create_middleware(
tag_map=tag_map_func, extra_tags=extra_tags_func
)
request = MockRequest(
headers={"X-POSTHOG-SESSION-ID": "session-123"}, method="GET"
)
tags = middleware.extract_tags(request)
self.assertEqual(tags["mapped_custom_tag"], "mapped_custom_value")
def test_extract_tags_extra_tags_returns_none(self):
"""Test tag extraction when extra_tags returns None"""
def extra_tags_func(request):
return None
middleware = self.create_middleware(extra_tags=extra_tags_func)
request = MockRequest(method="GET")
tags = middleware.extract_tags(request)
self.assertEqual(tags["$request_method"], "GET")
# Should not crash when extra_tags returns None
def test_extract_tags_extra_tags_returns_empty_dict(self):
"""Test tag extraction when extra_tags returns empty dict"""
def extra_tags_func(request):
return {}
middleware = self.create_middleware(extra_tags=extra_tags_func)
request = MockRequest(method="PATCH")
tags = middleware.extract_tags(request)
self.assertEqual(tags["$request_method"], "PATCH")
if __name__ == "__main__":
unittest.main()
+171
View File
@@ -0,0 +1,171 @@
import unittest
import mock
from posthog.client import Client
from posthog.test.test_utils import FAKE_TEST_API_KEY
class TestClient(unittest.TestCase):
@classmethod
def setUpClass(cls):
# This ensures no real HTTP POST requests are made
cls.client_post_patcher = mock.patch("posthog.client.batch_post")
cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post")
cls.client_post_patcher.start()
cls.consumer_post_patcher.start()
@classmethod
def tearDownClass(cls):
cls.client_post_patcher.stop()
cls.consumer_post_patcher.stop()
def set_fail(self, e, batch):
"""Mark the failure handler"""
print("FAIL", e, batch) # noqa: T201
self.failed = True
def setUp(self):
self.failed = False
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
def test_before_send_callback_modifies_event(self):
"""Test that before_send callback can modify events."""
processed_events = []
def my_before_send(event):
processed_events.append(event.copy())
if "properties" not in event:
event["properties"] = {}
event["properties"]["processed_by_before_send"] = True
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=my_before_send
)
success, msg = client.capture("user1", "test_event", {"original": "value"})
self.assertTrue(success)
self.assertEqual(msg["properties"]["processed_by_before_send"], True)
self.assertEqual(msg["properties"]["original"], "value")
self.assertEqual(len(processed_events), 1)
self.assertEqual(processed_events[0]["event"], "test_event")
def test_before_send_callback_drops_event(self):
"""Test that before_send callback can drop events by returning None."""
def drop_test_events(event):
if event.get("event") == "test_drop_me":
return None
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=drop_test_events
)
# Event should be dropped
success, msg = client.capture("user1", "test_drop_me")
self.assertTrue(success)
self.assertIsNone(msg)
# Event should go through
success, msg = client.capture("user1", "keep_me")
self.assertTrue(success)
self.assertIsNotNone(msg)
self.assertEqual(msg["event"], "keep_me")
def test_before_send_callback_handles_exceptions(self):
"""Test that exceptions in before_send don't crash the client."""
def buggy_before_send(event):
raise ValueError("Oops!")
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=buggy_before_send
)
success, msg = client.capture("user1", "robust_event")
# Event should still be sent despite the exception
self.assertTrue(success)
self.assertIsNotNone(msg)
self.assertEqual(msg["event"], "robust_event")
def test_before_send_callback_works_with_all_event_types(self):
"""Test that before_send works with capture, identify, set, etc."""
def add_marker(event):
if "properties" not in event:
event["properties"] = {}
event["properties"]["marked"] = True
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=add_marker
)
# Test capture
success, msg = client.capture("user1", "event")
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Test identify
success, msg = client.identify("user1", {"trait": "value"})
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Test set
success, msg = client.set("user1", {"prop": "value"})
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
# Test page
success, msg = client.page("user1", "https://example.com")
self.assertTrue(success)
self.assertTrue(msg["properties"]["marked"])
def test_before_send_callback_disabled_when_none(self):
"""Test that client works normally when before_send is None."""
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=None)
success, msg = client.capture("user1", "normal_event")
self.assertTrue(success)
self.assertIsNotNone(msg)
self.assertEqual(msg["event"], "normal_event")
def test_before_send_callback_pii_scrubbing_example(self):
"""Test a realistic PII scrubbing use case."""
def scrub_pii(event):
properties = event.get("properties", {})
# Mask email but keep domain
if "email" in properties:
email = properties["email"]
if "@" in email:
domain = email.split("@")[1]
properties["email"] = f"***@{domain}"
else:
properties["email"] = "***"
# Remove credit card
properties.pop("credit_card", None)
return event
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, before_send=scrub_pii
)
success, msg = client.capture(
"user1",
"form_submit",
{
"email": "user@example.com",
"credit_card": "1234-5678-9012-3456",
"form_name": "contact",
},
)
self.assertTrue(success)
self.assertEqual(msg["properties"]["email"], "***@example.com")
self.assertNotIn("credit_card", msg["properties"])
self.assertEqual(msg["properties"]["form_name"], "contact")
+264 -196
View File
@@ -1,4 +1,3 @@
import hashlib
import time
import unittest
from datetime import datetime
@@ -8,7 +7,7 @@ import mock
import six
from parameterized import parameterized
from posthog.client import EXCLUDED_HASHES, INCLUDED_HASHES, Client, is_token_in_rollout
from posthog.client import Client
from posthog.request import APIError
from posthog.test.test_utils import FAKE_TEST_API_KEY
from posthog.types import FeatureFlag, LegacyFlagMetadata
@@ -118,22 +117,6 @@ class TestClient(unittest.TestCase):
capture_call = patch_capture.call_args[0]
self.assertEqual(capture_call[0], "distinct_id")
self.assertEqual(capture_call[1], "$exception")
self.assertEqual(
capture_call[2],
{
"$exception_type": "Exception",
"$exception_message": "test exception",
"$exception_list": [
{
"mechanism": {"type": "generic", "handled": True},
"module": None,
"type": "Exception",
"value": "test exception",
}
],
"$exception_personURL": "https://us.i.posthog.com/project/random_key/person/distinct_id",
},
)
def test_basic_capture_exception_with_distinct_id(self):
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
@@ -145,26 +128,12 @@ class TestClient(unittest.TestCase):
capture_call = patch_capture.call_args[0]
self.assertEqual(capture_call[0], "distinct_id")
self.assertEqual(capture_call[1], "$exception")
self.assertEqual(
capture_call[2],
{
"$exception_type": "Exception",
"$exception_message": "test exception",
"$exception_list": [
{
"mechanism": {"type": "generic", "handled": True},
"module": None,
"type": "Exception",
"value": "test exception",
}
],
"$exception_personURL": "https://us.i.posthog.com/project/random_key/person/distinct_id",
},
)
def test_basic_capture_exception_with_correct_host_generation(self):
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://aloha.com")
client = Client(
FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://aloha.com"
)
exception = Exception("test exception")
client.capture_exception(exception, "distinct_id")
@@ -172,26 +141,16 @@ class TestClient(unittest.TestCase):
capture_call = patch_capture.call_args[0]
self.assertEqual(capture_call[0], "distinct_id")
self.assertEqual(capture_call[1], "$exception")
self.assertEqual(
capture_call[2],
{
"$exception_type": "Exception",
"$exception_message": "test exception",
"$exception_list": [
{
"mechanism": {"type": "generic", "handled": True},
"module": None,
"type": "Exception",
"value": "test exception",
}
],
"$exception_personURL": "https://aloha.com/project/random_key/person/distinct_id",
},
)
def test_basic_capture_exception_with_correct_host_generation_for_server_hosts(self):
def test_basic_capture_exception_with_correct_host_generation_for_server_hosts(
self,
):
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, host="https://app.posthog.com")
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
host="https://app.posthog.com",
)
exception = Exception("test exception")
client.capture_exception(exception, "distinct_id")
@@ -199,22 +158,6 @@ class TestClient(unittest.TestCase):
capture_call = patch_capture.call_args[0]
self.assertEqual(capture_call[0], "distinct_id")
self.assertEqual(capture_call[1], "$exception")
self.assertEqual(
capture_call[2],
{
"$exception_type": "Exception",
"$exception_message": "test exception",
"$exception_list": [
{
"mechanism": {"type": "generic", "handled": True},
"module": None,
"type": "Exception",
"value": "test exception",
}
],
"$exception_personURL": "https://app.posthog.com/project/random_key/person/distinct_id",
},
)
def test_basic_capture_exception_with_no_exception_given(self):
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
@@ -230,27 +173,45 @@ class TestClient(unittest.TestCase):
self.assertEqual(capture_call[1], "$exception")
self.assertEqual(capture_call[2]["$exception_type"], "Exception")
self.assertEqual(capture_call[2]["$exception_message"], "test exception")
self.assertEqual(capture_call[2]["$exception_list"][0]["mechanism"]["type"], "generic")
self.assertEqual(capture_call[2]["$exception_list"][0]["mechanism"]["handled"], True)
self.assertEqual(
capture_call[2]["$exception_list"][0]["mechanism"]["type"], "generic"
)
self.assertEqual(
capture_call[2]["$exception_list"][0]["mechanism"]["handled"], True
)
self.assertEqual(capture_call[2]["$exception_list"][0]["module"], None)
self.assertEqual(capture_call[2]["$exception_list"][0]["type"], "Exception")
self.assertEqual(capture_call[2]["$exception_list"][0]["value"], "test exception")
self.assertEqual(
capture_call[2]["$exception_list"][0]["value"], "test exception"
)
self.assertEqual(
capture_call[2]["$exception_list"][0]["stacktrace"]["type"],
"raw",
)
self.assertEqual(
capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["filename"],
capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0][
"filename"
],
"posthog/test/test_client.py",
)
self.assertEqual(
capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["function"],
capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0][
"function"
],
"test_basic_capture_exception_with_no_exception_given",
)
self.assertEqual(
capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["module"], "posthog.test.test_client"
capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0][
"module"
],
"posthog.test.test_client",
)
self.assertEqual(
capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0][
"in_app"
],
True,
)
self.assertEqual(capture_call[2]["$exception_list"][0]["stacktrace"]["frames"][0]["in_app"], True)
def test_basic_capture_exception_with_no_exception_happening(self):
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
@@ -267,16 +228,26 @@ class TestClient(unittest.TestCase):
def test_capture_exception_logs_when_enabled(self):
client = Client(FAKE_TEST_API_KEY, log_captured_exceptions=True)
with self.assertLogs("posthog", level="ERROR") as logs:
client.capture_exception(Exception("test exception"), "distinct_id", path="one/two/three")
self.assertEqual(logs.output[0], "ERROR:posthog:test exception\nNoneType: None")
client.capture_exception(
Exception("test exception"), "distinct_id", path="one/two/three"
)
self.assertEqual(
logs.output[0], "ERROR:posthog:test exception\nNoneType: None"
)
self.assertEqual(getattr(logs.records[0], "path"), "one/two/three")
@mock.patch("posthog.client.flags")
def test_basic_capture_with_feature_flags(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
success, msg = client.capture("distinct_id", "python test event", send_feature_flags=True)
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
personal_api_key=FAKE_TEST_API_KEY,
)
success, msg = client.capture(
"distinct_id", "python test event", send_feature_flags=True
)
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
@@ -295,7 +266,11 @@ class TestClient(unittest.TestCase):
@mock.patch("posthog.client.flags")
def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
personal_api_key=FAKE_TEST_API_KEY,
)
multivariate_flag = {
"id": 1,
@@ -307,7 +282,12 @@ class TestClient(unittest.TestCase):
"groups": [
{
"properties": [
{"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"}
{
"key": "email",
"type": "person",
"value": "test@posthog.com",
"operator": "exact",
}
],
"rollout_percentage": 100,
},
@@ -317,12 +297,27 @@ class TestClient(unittest.TestCase):
],
"multivariate": {
"variants": [
{"key": "first-variant", "name": "First Variant", "rollout_percentage": 50},
{"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25},
{"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25},
{
"key": "first-variant",
"name": "First Variant",
"rollout_percentage": 50,
},
{
"key": "second-variant",
"name": "Second Variant",
"rollout_percentage": 25,
},
{
"key": "third-variant",
"name": "Third Variant",
"rollout_percentage": 25,
},
]
},
"payloads": {"first-variant": "some-payload", "third-variant": {"a": "json"}},
"payloads": {
"first-variant": "some-payload",
"third-variant": {"a": "json"},
},
},
}
basic_flag = {
@@ -375,9 +370,13 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(msg["properties"]["$feature/beta-feature-local"], "third-variant")
self.assertEqual(
msg["properties"]["$feature/beta-feature-local"], "third-variant"
)
self.assertEqual(msg["properties"]["$feature/false-flag"], False)
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature-local"])
self.assertEqual(
msg["properties"]["$active_feature_flags"], ["beta-feature-local"]
)
assert "$feature/beta-feature" not in msg["properties"]
self.assertEqual(patch_flags.call_count, 0)
@@ -415,7 +414,11 @@ class TestClient(unittest.TestCase):
@mock.patch("posthog.client.flags")
def test_dont_override_capture_with_local_flags(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
personal_api_key=FAKE_TEST_API_KEY,
)
multivariate_flag = {
"id": 1,
@@ -427,7 +430,12 @@ class TestClient(unittest.TestCase):
"groups": [
{
"properties": [
{"key": "email", "type": "person", "value": "test@posthog.com", "operator": "exact"}
{
"key": "email",
"type": "person",
"value": "test@posthog.com",
"operator": "exact",
}
],
"rollout_percentage": 100,
},
@@ -437,12 +445,27 @@ class TestClient(unittest.TestCase):
],
"multivariate": {
"variants": [
{"key": "first-variant", "name": "First Variant", "rollout_percentage": 50},
{"key": "second-variant", "name": "Second Variant", "rollout_percentage": 25},
{"key": "third-variant", "name": "Third Variant", "rollout_percentage": 25},
{
"key": "first-variant",
"name": "First Variant",
"rollout_percentage": 50,
},
{
"key": "second-variant",
"name": "Second Variant",
"rollout_percentage": 25,
},
{
"key": "third-variant",
"name": "Third Variant",
"rollout_percentage": 25,
},
]
},
"payloads": {"first-variant": "some-payload", "third-variant": {"a": "json"}},
"payloads": {
"first-variant": "some-payload",
"third-variant": {"a": "json"},
},
},
}
basic_flag = {
@@ -470,7 +493,9 @@ class TestClient(unittest.TestCase):
client.feature_flags = [multivariate_flag, basic_flag]
success, msg = client.capture(
"distinct_id", "python test event", {"$feature/beta-feature-local": "my-custom-variant"}
"distinct_id",
"python test event",
{"$feature/beta-feature-local": "my-custom-variant"},
)
client.flush()
self.assertTrue(success)
@@ -482,8 +507,12 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(msg["properties"]["$feature/beta-feature-local"], "my-custom-variant")
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature-local"])
self.assertEqual(
msg["properties"]["$feature/beta-feature-local"], "my-custom-variant"
)
self.assertEqual(
msg["properties"]["$active_feature_flags"], ["beta-feature-local"]
)
assert "$feature/beta-feature" not in msg["properties"]
assert "$feature/person-flag" not in msg["properties"]
@@ -492,11 +521,21 @@ class TestClient(unittest.TestCase):
@mock.patch("posthog.client.flags")
def test_basic_capture_with_feature_flags_returns_active_only(self, patch_flags):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
"featureFlags": {
"beta-feature": "random-variant",
"alpha-feature": True,
"off-feature": False,
}
}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
success, msg = client.capture("distinct_id", "python test event", send_feature_flags=True)
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
personal_api_key=FAKE_TEST_API_KEY,
)
success, msg = client.capture(
"distinct_id", "python test event", send_feature_flags=True
)
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
@@ -510,7 +549,10 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(msg["properties"]["$feature/beta-feature"], "random-variant")
self.assertEqual(msg["properties"]["$feature/alpha-feature"], True)
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature", "alpha-feature"])
self.assertEqual(
msg["properties"]["$active_feature_flags"],
["beta-feature", "alpha-feature"],
)
self.assertEqual(patch_flags.call_count, 1)
patch_flags.assert_called_with(
@@ -521,13 +563,19 @@ class TestClient(unittest.TestCase):
groups={},
person_properties=None,
group_properties=None,
disable_geoip=True,
geoip_disable=True,
)
@mock.patch("posthog.client.flags")
def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly(self, patch_flags):
def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly(
self, patch_flags
):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
"featureFlags": {
"beta-feature": "random-variant",
"alpha-feature": True,
"off-feature": False,
}
}
client = Client(
@@ -538,7 +586,12 @@ class TestClient(unittest.TestCase):
disable_geoip=True,
feature_flags_request_timeout_seconds=12,
)
success, msg = client.capture("distinct_id", "python test event", send_feature_flags=True, disable_geoip=False)
success, msg = client.capture(
"distinct_id",
"python test event",
send_feature_flags=True,
disable_geoip=False,
)
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
@@ -552,7 +605,10 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(msg["properties"]["$feature/beta-feature"], "random-variant")
self.assertEqual(msg["properties"]["$feature/alpha-feature"], True)
self.assertEqual(msg["properties"]["$active_feature_flags"], ["beta-feature", "alpha-feature"])
self.assertEqual(
msg["properties"]["$active_feature_flags"],
["beta-feature", "alpha-feature"],
)
self.assertEqual(patch_flags.call_count, 1)
patch_flags.assert_called_with(
@@ -563,15 +619,23 @@ class TestClient(unittest.TestCase):
groups={},
person_properties=None,
group_properties=None,
disable_geoip=False,
geoip_disable=False,
)
@mock.patch("posthog.client.flags")
def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them(self, patch_flags):
def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them(
self, patch_flags
):
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, personal_api_key=FAKE_TEST_API_KEY)
success, msg = client.capture("distinct_id", "python test event", send_feature_flags=False)
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
personal_api_key=FAKE_TEST_API_KEY,
)
success, msg = client.capture(
"distinct_id", "python test event", send_feature_flags=False
)
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
@@ -591,7 +655,9 @@ class TestClient(unittest.TestCase):
# A large number that loses precision in node:
# node -e "console.log(157963456373623802 + 1)" > 157963456373623800
client = self.client
success, msg = client.capture(distinct_id=157963456373623802, event="python test event")
success, msg = client.capture(
distinct_id=157963456373623802, event="python test event"
)
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
@@ -627,7 +693,10 @@ class TestClient(unittest.TestCase):
)
self.assertTrue(success)
self.assertEqual(msg["properties"]["$groups"], {"company": "id:5", "instance": "app.posthog.com"})
self.assertEqual(
msg["properties"]["$groups"],
{"company": "id:5", "instance": "app.posthog.com"},
)
def test_basic_identify(self):
client = self.client
@@ -644,7 +713,10 @@ class TestClient(unittest.TestCase):
def test_advanced_identify(self):
client = self.client
success, msg = client.identify(
"distinct_id", {"trait": "value"}, timestamp=datetime(2014, 9, 3), uuid="new-uuid"
"distinct_id",
{"trait": "value"},
timestamp=datetime(2014, 9, 3),
uuid="new-uuid",
)
self.assertTrue(success)
@@ -671,7 +743,12 @@ class TestClient(unittest.TestCase):
def test_advanced_set(self):
client = self.client
success, msg = client.set("distinct_id", {"trait": "value"}, timestamp=datetime(2014, 9, 3), uuid="new-uuid")
success, msg = client.set(
"distinct_id",
{"trait": "value"},
timestamp=datetime(2014, 9, 3),
uuid="new-uuid",
)
self.assertTrue(success)
@@ -698,7 +775,10 @@ class TestClient(unittest.TestCase):
def test_advanced_set_once(self):
client = self.client
success, msg = client.set_once(
"distinct_id", {"trait": "value"}, timestamp=datetime(2014, 9, 3), uuid="new-uuid"
"distinct_id",
{"trait": "value"},
timestamp=datetime(2014, 9, 3),
uuid="new-uuid",
)
self.assertTrue(success)
@@ -732,7 +812,9 @@ class TestClient(unittest.TestCase):
self.assertIsNone(msg.get("uuid"))
def test_basic_group_identify_with_distinct_id(self):
success, msg = self.client.group_identify("organization", "id:5", distinct_id="distinct_id")
success, msg = self.client.group_identify(
"organization", "id:5", distinct_id="distinct_id"
)
self.assertTrue(success)
self.assertEqual(msg["event"], "$groupidentify")
self.assertEqual(msg["distinct_id"], "distinct_id")
@@ -752,7 +834,11 @@ class TestClient(unittest.TestCase):
def test_advanced_group_identify(self):
success, msg = self.client.group_identify(
"organization", "id:5", {"trait": "value"}, timestamp=datetime(2014, 9, 3), uuid="new-uuid"
"organization",
"id:5",
{"trait": "value"},
timestamp=datetime(2014, 9, 3),
uuid="new-uuid",
)
self.assertTrue(success)
@@ -814,7 +900,9 @@ class TestClient(unittest.TestCase):
client.flush()
self.assertTrue(success)
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$current_url"], "https://posthog.com/contact")
self.assertEqual(
msg["properties"]["$current_url"], "https://posthog.com/contact"
)
def test_basic_page_distinct_uuid(self):
client = self.client
@@ -824,7 +912,9 @@ class TestClient(unittest.TestCase):
client.flush()
self.assertTrue(success)
self.assertEqual(msg["distinct_id"], str(distinct_id))
self.assertEqual(msg["properties"]["$current_url"], "https://posthog.com/contact")
self.assertEqual(
msg["properties"]["$current_url"], "https://posthog.com/contact"
)
def test_advanced_page(self):
client = self.client
@@ -839,7 +929,9 @@ class TestClient(unittest.TestCase):
self.assertTrue(success)
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
self.assertEqual(msg["properties"]["$current_url"], "https://posthog.com/contact")
self.assertEqual(
msg["properties"]["$current_url"], "https://posthog.com/contact"
)
self.assertEqual(msg["properties"]["property"], "value")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
@@ -910,14 +1002,18 @@ class TestClient(unittest.TestCase):
self.assertFalse(self.failed)
def test_user_defined_flush_at(self):
client = Client(FAKE_TEST_API_KEY, on_error=self.fail, flush_at=10, flush_interval=3)
client = Client(
FAKE_TEST_API_KEY, on_error=self.fail, flush_at=10, flush_interval=3
)
def mock_post_fn(*args, **kwargs):
self.assertEqual(len(kwargs["batch"]), 10)
# the post function should be called 2 times, with a batch size of 10
# each time.
with mock.patch("posthog.consumer.batch_post", side_effect=mock_post_fn) as mock_post:
with mock.patch(
"posthog.consumer.batch_post", side_effect=mock_post_fn
) as mock_post:
for _ in range(20):
client.identify("distinct_id", {"trait": "value"})
time.sleep(1)
@@ -998,11 +1094,15 @@ class TestClient(unittest.TestCase):
def test_disable_geoip_override_on_events(self):
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disable_geoip=False)
_, capture_msg = client.set("distinct_id", {"a": "b", "c": "d"}, disable_geoip=True)
_, capture_msg = client.set(
"distinct_id", {"a": "b", "c": "d"}, disable_geoip=True
)
client.flush()
self.assertEqual(capture_msg["properties"]["$geoip_disable"], True)
_, identify_msg = client.page("distinct_id", "http://a.com", {"trait": "value"}, disable_geoip=False)
_, identify_msg = client.page(
"distinct_id", "http://a.com", {"trait": "value"}, disable_geoip=False
)
client.flush()
self.assertEqual("$geoip_disable" not in identify_msg["properties"], True)
@@ -1015,7 +1115,11 @@ class TestClient(unittest.TestCase):
@mock.patch("posthog.client.flags")
def test_disable_geoip_default_on_decide(self, patch_flags):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
"featureFlags": {
"beta-feature": "random-variant",
"alpha-feature": True,
"off-feature": False,
}
}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disable_geoip=False)
client.get_feature_flag("random_key", "some_id", disable_geoip=True)
@@ -1027,10 +1131,12 @@ class TestClient(unittest.TestCase):
groups={},
person_properties={"distinct_id": "some_id"},
group_properties={},
disable_geoip=True,
geoip_disable=True,
)
patch_flags.reset_mock()
client.feature_enabled("random_key", "feature_enabled_distinct_id", disable_geoip=True)
client.feature_enabled(
"random_key", "feature_enabled_distinct_id", disable_geoip=True
)
patch_flags.assert_called_with(
"random_key",
"https://us.i.posthog.com",
@@ -1039,7 +1145,7 @@ class TestClient(unittest.TestCase):
groups={},
person_properties={"distinct_id": "feature_enabled_distinct_id"},
group_properties={},
disable_geoip=True,
geoip_disable=True,
)
patch_flags.reset_mock()
client.get_all_flags_and_payloads("all_flags_payloads_id")
@@ -1051,7 +1157,7 @@ class TestClient(unittest.TestCase):
groups={},
person_properties={"distinct_id": "all_flags_payloads_id"},
group_properties={},
disable_geoip=False,
geoip_disable=False,
)
@mock.patch("posthog.client.Poller")
@@ -1069,9 +1175,18 @@ class TestClient(unittest.TestCase):
@mock.patch("posthog.client.flags")
def test_default_properties_get_added_properly(self, patch_flags):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False}
"featureFlags": {
"beta-feature": "random-variant",
"alpha-feature": True,
"off-feature": False,
}
}
client = Client(FAKE_TEST_API_KEY, host="http://app2.posthog.com", on_error=self.set_fail, disable_geoip=False)
client = Client(
FAKE_TEST_API_KEY,
host="http://app2.posthog.com",
on_error=self.set_fail,
disable_geoip=False,
)
client.get_feature_flag(
"random_key",
"some_id",
@@ -1090,7 +1205,7 @@ class TestClient(unittest.TestCase):
"company": {"$group_key": "id:5", "x": "y"},
"instance": {"$group_key": "app.posthog.com"},
},
disable_geoip=False,
geoip_disable=False,
)
patch_flags.reset_mock()
@@ -1116,12 +1231,14 @@ class TestClient(unittest.TestCase):
"company": {"$group_key": "group_override"},
"instance": {"$group_key": "app.posthog.com"},
},
disable_geoip=False,
geoip_disable=False,
)
patch_flags.reset_mock()
# test nones
client.get_all_flags_and_payloads("some_id", groups={}, person_properties=None, group_properties=None)
client.get_all_flags_and_payloads(
"some_id", groups={}, person_properties=None, group_properties=None
)
patch_flags.assert_called_with(
"random_key",
"http://app2.posthog.com",
@@ -1130,7 +1247,7 @@ class TestClient(unittest.TestCase):
groups={},
person_properties={"distinct_id": "some_id"},
group_properties={},
disable_geoip=False,
geoip_disable=False,
)
@parameterized.expand(
@@ -1197,12 +1314,17 @@ class TestClient(unittest.TestCase):
# Set up platform-specific mocks
if platform_method:
getattr(mock_platform, platform_method).return_value = platform_return
getattr(
mock_platform, platform_method
).return_value = platform_return
# Special handling for Linux which uses distro module
if sys_platform == "linux":
# Directly patch the get_os_info function to return our expected values
with mock.patch("posthog.client.get_os_info", return_value=(expected_os, expected_os_version)):
with mock.patch(
"posthog.client.get_os_info",
return_value=(expected_os, expected_os_version),
):
from posthog.client import system_context
context = system_context()
@@ -1225,7 +1347,11 @@ class TestClient(unittest.TestCase):
@mock.patch("posthog.client.flags")
def test_get_decide_returns_normalized_decide_response(self, patch_flags):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "random-variant", "alpha-feature": True, "off-feature": False},
"featureFlags": {
"beta-feature": "random-variant",
"alpha-feature": True,
"off-feature": False,
},
"featureFlagPayloads": {"beta-feature": '{"some": "data"}'},
"errorsWhileComputingFlags": False,
"requestId": "test-id",
@@ -1271,61 +1397,3 @@ class TestClient(unittest.TestCase):
"errorsWhileComputingFlags": False,
"requestId": "test-id",
}
@mock.patch("posthog.client.decide")
@mock.patch("posthog.client.flags")
def test_get_flags_decision_rollout(self, patch_flags, patch_decide):
# Set up mock responses
decide_response = {
"featureFlags": {"flag1": True},
"featureFlagPayloads": {},
"errorsWhileComputingFlags": False,
}
flags_response = {
"featureFlags": {"flag2": True},
"featureFlagPayloads": {},
"errorsWhileComputingFlags": False,
}
patch_decide.return_value = decide_response
patch_flags.return_value = flags_response
client = Client(FAKE_TEST_API_KEY)
# Test 100% rollout - should use flags
with mock.patch("posthog.client.is_token_in_rollout", return_value=True) as mock_rollout:
client.get_flags_decision("distinct_id")
mock_rollout.assert_called_with(
FAKE_TEST_API_KEY, 1, included_hashes=INCLUDED_HASHES, excluded_hashes=EXCLUDED_HASHES
)
patch_flags.assert_called_once()
patch_decide.assert_not_called()
def test_token_rollout_calculation(self):
# Test specific hash inclusion
token = "test_token"
token_hash = hashlib.sha1(token.encode("utf-8")).hexdigest()
included_hashes = {token_hash}
# Should be included due to specific hash, even with 0% rollout
self.assertTrue(expr=is_token_in_rollout(token, percentage=0.0, included_hashes=included_hashes))
# Should not be included with 0% rollout and no specific hash
self.assertFalse(is_token_in_rollout(token, percentage=0.0))
# Should be included with 100% rollout regardless of specific hash
self.assertTrue(is_token_in_rollout(token, percentage=1.0))
self.assertTrue(is_token_in_rollout(token, percentage=1.0, included_hashes=included_hashes))
# Test deterministic behavior - same token should always give same result
hash_float = int(token_hash[:8], 16) / 0xFFFFFFFF
percentage = hash_float + 0.1 # Just above the hash value
self.assertTrue(is_token_in_rollout(token, percentage))
self.assertFalse(is_token_in_rollout(token, percentage - 0.2)) # Just below the hash value
# Test that the token exclusion works correctly
self.assertFalse(is_token_in_rollout(token, percentage=1.0, excluded_hashes={token_hash}))
# Should work for other specific token hashes
# Include our API key
self.assertTrue(is_token_in_rollout("sTMFPsFhdP1Ssg", percentage=0.1, included_hashes=INCLUDED_HASHES))
+36 -9
View File
@@ -58,7 +58,11 @@ class TestConsumer(unittest.TestCase):
with mock.patch("posthog.consumer.batch_post") as mock_post:
consumer.start()
for i in range(0, 3):
track = {"type": "track", "event": "python event %d" % i, "distinct_id": "distinct_id"}
track = {
"type": "track",
"event": "python event %d" % i,
"distinct_id": "distinct_id",
}
q.put(track)
time.sleep(flush_interval * 1.1)
self.assertEqual(mock_post.call_count, 3)
@@ -69,11 +73,17 @@ class TestConsumer(unittest.TestCase):
q = Queue()
flush_interval = 0.5
flush_at = 10
consumer = Consumer(q, TEST_API_KEY, flush_at=flush_at, flush_interval=flush_interval)
consumer = Consumer(
q, TEST_API_KEY, flush_at=flush_at, flush_interval=flush_interval
)
with mock.patch("posthog.consumer.batch_post") as mock_post:
consumer.start()
for i in range(0, flush_at * 2):
track = {"type": "track", "event": "python event %d" % i, "distinct_id": "distinct_id"}
track = {
"type": "track",
"event": "python event %d" % i,
"distinct_id": "distinct_id",
}
q.put(track)
time.sleep(flush_interval * 1.1)
self.assertEqual(mock_post.call_count, 2)
@@ -91,8 +101,14 @@ class TestConsumer(unittest.TestCase):
mock_post.call_count = 0
with mock.patch("posthog.consumer.batch_post", mock.Mock(side_effect=mock_post)):
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
with mock.patch(
"posthog.consumer.batch_post", mock.Mock(side_effect=mock_post)
):
track = {
"type": "track",
"event": "python event",
"distinct_id": "distinct_id",
}
# request() should succeed if the number of exceptions raised is
# less than the retries paramater.
if exception_count <= consumer.retries:
@@ -107,7 +123,8 @@ class TestConsumer(unittest.TestCase):
self.assertEqual(exc, expected_exception)
else:
self.fail(
"request() should raise an exception if still failing after %d retries" % consumer.retries
"request() should raise an exception if still failing after %d retries"
% consumer.retries
)
def test_request_retry(self):
@@ -148,7 +165,12 @@ class TestConsumer(unittest.TestCase):
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}
track = {
"type": "track",
"event": "python event",
"distinct_id": "distinct_id",
"properties": properties,
}
msg_size = len(json.dumps(track).encode())
# Let's capture 8MB of data to trigger two batches
n_msgs = int(8_000_000 / msg_size)
@@ -158,10 +180,15 @@ class TestConsumer(unittest.TestCase):
res.status_code = 200
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)
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:
with mock.patch(
"posthog.request._session.post", side_effect=mock_post_fn
) as mock_post:
consumer.start()
for _ in range(0, n_msgs + 2):
q.put(track)
+57 -10
View File
@@ -10,8 +10,17 @@ class TestFeatureFlag(unittest.TestCase):
"key": "test-flag",
"enabled": True,
"variant": "test-variant",
"reason": {"code": "matched_condition", "condition_index": 0, "description": "Matched condition set 1"},
"metadata": {"id": 1, "payload": '{"some": "json"}', "version": 2, "description": "test-description"},
"reason": {
"code": "matched_condition",
"condition_index": 0,
"description": "Matched condition set 1",
},
"metadata": {
"id": 1,
"payload": '{"some": "json"}',
"version": 2,
"description": "test-description",
},
}
flag = FeatureFlag.from_json(resp)
@@ -20,10 +29,21 @@ class TestFeatureFlag(unittest.TestCase):
self.assertEqual(flag.variant, "test-variant")
self.assertEqual(flag.get_value(), "test-variant")
self.assertEqual(
flag.reason, FlagReason(code="matched_condition", condition_index=0, description="Matched condition set 1")
flag.reason,
FlagReason(
code="matched_condition",
condition_index=0,
description="Matched condition set 1",
),
)
self.assertEqual(
flag.metadata, FlagMetadata(id=1, payload='{"some": "json"}', version=2, description="test-description")
flag.metadata,
FlagMetadata(
id=1,
payload='{"some": "json"}',
version=2,
description="test-description",
),
)
def test_feature_flag_from_json_minimal(self):
@@ -44,7 +64,11 @@ class TestFeatureFlag(unittest.TestCase):
"key": "test-flag",
"enabled": True,
"variant": "test-variant",
"reason": {"code": "matched_condition", "condition_index": 0, "description": "Matched condition set 1"},
"reason": {
"code": "matched_condition",
"condition_index": 0,
"description": "Matched condition set 1",
},
}
flag = FeatureFlag.from_json(resp)
@@ -53,13 +77,22 @@ class TestFeatureFlag(unittest.TestCase):
self.assertEqual(flag.variant, "test-variant")
self.assertEqual(flag.get_value(), "test-variant")
self.assertEqual(
flag.reason, FlagReason(code="matched_condition", condition_index=0, description="Matched condition set 1")
flag.reason,
FlagReason(
code="matched_condition",
condition_index=0,
description="Matched condition set 1",
),
)
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None))
def test_flag_reason_from_json(self):
# Test with complete data
resp = {"code": "user_in_segment", "condition_index": 1, "description": "User is in segment 'beta_users'"}
resp = {
"code": "user_in_segment",
"condition_index": 1,
"description": "User is in segment 'beta_users'",
}
reason = FlagReason.from_json(resp)
self.assertEqual(reason.code, "user_in_segment")
self.assertEqual(reason.condition_index, 1)
@@ -77,7 +110,12 @@ class TestFeatureFlag(unittest.TestCase):
def test_flag_metadata_from_json(self):
# Test with complete data
resp = {"id": 123, "payload": {"key": "value"}, "version": 1, "description": "Test flag"}
resp = {
"id": 123,
"payload": {"key": "value"},
"version": 1,
"description": "Test flag",
}
metadata = FlagMetadata.from_json(resp)
self.assertEqual(metadata.id, 123)
self.assertEqual(metadata.payload, {"key": "value"})
@@ -106,7 +144,12 @@ class TestFeatureFlag(unittest.TestCase):
"condition_index": 1,
"description": "User is in segment 'beta_users'",
},
"metadata": {"id": 123, "payload": {"key": "value"}, "version": 1, "description": "Test flag"},
"metadata": {
"id": 123,
"payload": {"key": "value"},
"version": 1,
"description": "Test flag",
},
}
flag = FeatureFlag.from_json(resp)
self.assertEqual(flag.key, "test-flag")
@@ -131,7 +174,11 @@ class TestFeatureFlag(unittest.TestCase):
def test_feature_flag_from_json_with_reason(self):
# Test with reason but no metadata
resp = {"key": "test-flag", "enabled": True, "reason": {"code": "user_in_segment"}}
resp = {
"key": "test-flag",
"enabled": True,
"reason": {"code": "user_in_segment"},
}
flag = FeatureFlag.from_json(resp)
self.assertEqual(flag.key, "test-flag")
self.assertTrue(flag.enabled)
+63 -21
View File
@@ -9,7 +9,9 @@ from posthog.types import FeatureFlag, FeatureFlagResult, FlagMetadata, FlagReas
class TestFeatureFlagResult(unittest.TestCase):
def test_from_bool_value_and_payload(self):
result = FeatureFlagResult.from_value_and_payload("test-flag", True, "[1, 2, 3]")
result = FeatureFlagResult.from_value_and_payload(
"test-flag", True, "[1, 2, 3]"
)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
@@ -17,7 +19,9 @@ class TestFeatureFlagResult(unittest.TestCase):
self.assertEqual(result.payload, [1, 2, 3])
def test_from_false_value_and_payload(self):
result = FeatureFlagResult.from_value_and_payload("test-flag", False, '{"some": "value"}')
result = FeatureFlagResult.from_value_and_payload(
"test-flag", False, '{"some": "value"}'
)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, False)
@@ -25,7 +29,9 @@ class TestFeatureFlagResult(unittest.TestCase):
self.assertEqual(result.payload, {"some": "value"})
def test_from_variant_value_and_payload(self):
result = FeatureFlagResult.from_value_and_payload("test-flag", "control", "true")
result = FeatureFlagResult.from_value_and_payload(
"test-flag", "control", "true"
)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
@@ -33,7 +39,9 @@ class TestFeatureFlagResult(unittest.TestCase):
self.assertEqual(result.payload, True)
def test_from_none_value_and_payload(self):
result = FeatureFlagResult.from_value_and_payload("test-flag", None, '{"some": "value"}')
result = FeatureFlagResult.from_value_and_payload(
"test-flag", None, '{"some": "value"}'
)
self.assertIsNone(result)
def test_from_boolean_flag_details(self):
@@ -41,8 +49,12 @@ class TestFeatureFlagResult(unittest.TestCase):
key="test-flag",
enabled=True,
variant=None,
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='"Some string"'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
metadata=FlagMetadata(
id=1, version=1, description="test-flag", payload='"Some string"'
),
reason=FlagReason(
code="test-reason", description="test-reason", condition_index=0
),
)
result = FeatureFlagResult.from_flag_details(flag_details)
@@ -57,11 +69,17 @@ class TestFeatureFlagResult(unittest.TestCase):
key="test-flag",
enabled=True,
variant=None,
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='"Some string"'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
metadata=FlagMetadata(
id=1, version=1, description="test-flag", payload='"Some string"'
),
reason=FlagReason(
code="test-reason", description="test-reason", condition_index=0
),
)
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value="control")
result = FeatureFlagResult.from_flag_details(
flag_details, override_match_value="control"
)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
@@ -73,11 +91,17 @@ class TestFeatureFlagResult(unittest.TestCase):
key="test-flag",
enabled=True,
variant="control",
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
metadata=FlagMetadata(
id=1, version=1, description="test-flag", payload='{"some": "value"}'
),
reason=FlagReason(
code="test-reason", description="test-reason", condition_index=0
),
)
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value=True)
result = FeatureFlagResult.from_flag_details(
flag_details, override_match_value=True
)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
@@ -89,11 +113,17 @@ class TestFeatureFlagResult(unittest.TestCase):
key="test-flag",
enabled=True,
variant="control",
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
metadata=FlagMetadata(
id=1, version=1, description="test-flag", payload='{"some": "value"}'
),
reason=FlagReason(
code="test-reason", description="test-reason", condition_index=0
),
)
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value=False)
result = FeatureFlagResult.from_flag_details(
flag_details, override_match_value=False
)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, False)
@@ -105,8 +135,12 @@ class TestFeatureFlagResult(unittest.TestCase):
key="test-flag",
enabled=True,
variant="control",
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
metadata=FlagMetadata(
id=1, version=1, description="test-flag", payload='{"some": "value"}'
),
reason=FlagReason(
code="test-reason", description="test-reason", condition_index=0
),
)
result = FeatureFlagResult.from_flag_details(flag_details)
@@ -126,8 +160,12 @@ class TestFeatureFlagResult(unittest.TestCase):
key="test-flag",
enabled=True,
variant=None,
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload=None),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
metadata=FlagMetadata(
id=1, version=1, description="test-flag", payload=None
),
reason=FlagReason(
code="test-reason", description="test-reason", condition_index=0
),
)
result = FeatureFlagResult.from_flag_details(flag_details)
@@ -300,7 +338,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
},
}
flag_result = self.client.get_feature_flag_result("person-flag", "some-distinct-id")
flag_result = self.client.get_feature_flag_result(
"person-flag", "some-distinct-id"
)
self.assertEqual(flag_result.enabled, True)
self.assertEqual(flag_result.variant, None)
self.assertEqual(flag_result.payload, 300)
@@ -385,7 +425,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
},
}
flag_result = self.client.get_feature_flag_result("no-person-flag", "some-distinct-id")
flag_result = self.client.get_feature_flag_result(
"no-person-flag", "some-distinct-id"
)
self.assertIsNone(flag_result)
patch_capture.assert_called_with(
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -15,7 +15,9 @@ class TestModule(unittest.TestCase):
def setUp(self):
self.failed = False
self.posthog = Posthog("testsecret", host="http://localhost:8000", on_error=self.failed)
self.posthog = Posthog(
"testsecret", host="http://localhost:8000", on_error=self.failed
)
def test_no_api_key(self):
self.posthog.api_key = None
+38 -7
View File
@@ -6,20 +6,35 @@ import mock
import pytest
import requests
from posthog.request import DatetimeSerializer, QuotaLimitError, batch_post, decide, determine_server_host
from posthog.request import (
DatetimeSerializer,
QuotaLimitError,
batch_post,
decide,
determine_server_host,
)
from posthog.test.test_utils import TEST_API_KEY
class TestRequests(unittest.TestCase):
def test_valid_request(self):
res = batch_post(TEST_API_KEY, batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}])
res = batch_post(
TEST_API_KEY,
batch=[
{"distinct_id": "distinct_id", "event": "python event", "type": "track"}
],
)
self.assertEqual(res.status_code, 200)
def test_invalid_request_error(self):
self.assertRaises(Exception, batch_post, "testsecret", "https://t.posthog.com", False, "[{]")
self.assertRaises(
Exception, batch_post, "testsecret", "https://t.posthog.com", False, "[{]"
)
def test_invalid_host(self):
self.assertRaises(Exception, batch_post, "testsecret", "t.posthog.com/", batch=[])
self.assertRaises(
Exception, batch_post, "testsecret", "t.posthog.com/", batch=[]
)
def test_datetime_serialization(self):
data = {"created": datetime(2012, 3, 4, 5, 6, 7, 891011)}
@@ -35,14 +50,26 @@ class TestRequests(unittest.TestCase):
def test_should_not_timeout(self):
res = batch_post(
TEST_API_KEY, batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=15
TEST_API_KEY,
batch=[
{"distinct_id": "distinct_id", "event": "python event", "type": "track"}
],
timeout=15,
)
self.assertEqual(res.status_code, 200)
def test_should_timeout(self):
with self.assertRaises(requests.ReadTimeout):
batch_post(
"key", batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=0.0001
"key",
batch=[
{
"distinct_id": "distinct_id",
"event": "python event",
"type": "track",
}
],
timeout=0.0001,
)
def test_quota_limited_response(self):
@@ -68,7 +95,11 @@ class TestRequests(unittest.TestCase):
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = json.dumps(
{"featureFlags": {"flag1": True}, "featureFlagPayloads": {}, "errorsWhileComputingFlags": False}
{
"featureFlags": {"flag1": True},
"featureFlagPayloads": {},
"errorsWhileComputingFlags": False,
}
).encode("utf-8")
with mock.patch("posthog.request._session.post", return_value=mock_response):
+220
View File
@@ -0,0 +1,220 @@
import unittest
from unittest.mock import patch
from posthog.scopes import (
clear_tags,
get_tags,
new_context,
scoped,
tag,
identify_context,
set_context_session,
get_context_session_id,
get_context_distinct_id,
)
class TestScopes(unittest.TestCase):
def setUp(self):
# Reset any context between tests
clear_tags()
def test_tag_and_get_tags(self):
with new_context(fresh=True):
tag("key1", "value1")
tag("key2", 2)
tags = get_tags()
assert tags["key1"] == "value1"
assert tags["key2"] == 2
def test_clear_tags(self):
with new_context(fresh=True):
tag("key1", "value1")
assert get_tags()["key1"] == "value1"
clear_tags()
assert get_tags() == {}
def test_new_context_isolation(self):
with new_context(fresh=True):
# Set tag in outer context
tag("outer", "value")
with new_context(fresh=True):
# Inner context should start empty
assert get_tags() == {}
# Set tag in inner context
tag("inner", "value")
assert get_tags()["inner"] == "value"
# Outer tag should not be visible
self.assertNotIn("outer", get_tags())
with new_context(fresh=False):
# Inner context should inherit outer tag
assert get_tags() == {"outer": "value"}
# After exiting context, inner tag should be gone
self.assertNotIn("inner", get_tags())
# Outer tag should still be there
assert get_tags()["outer"] == "value"
def test_nested_contexts(self):
with new_context(fresh=True):
tag("level1", "value1")
with new_context(fresh=True):
tag("level2", "value2")
with new_context(fresh=True):
tag("level3", "value3")
assert get_tags() == {"level3": "value3"}
# Back to level 2
assert get_tags() == {"level2": "value2"}
# Back to level 1
assert get_tags() == {"level1": "value1"}
@patch("posthog.capture_exception")
def test_scoped_decorator_success(self, mock_capture):
@scoped()
def successful_function(x, y):
tag("x", x)
tag("y", y)
return x + y
result = successful_function(1, 2)
# Function should execute normally
assert result == 3
# No exception should be captured
mock_capture.assert_not_called()
# Context should be cleared after function execution
assert get_tags() == {}
@patch("posthog.capture_exception")
def test_scoped_decorator_exception(self, mock_capture):
test_exception = ValueError("Test exception")
def check_context_on_capture(exception, **kwargs):
# Assert tags are available when capture_exception is called
current_tags = get_tags()
assert current_tags.get("important_context") == "value"
mock_capture.side_effect = check_context_on_capture
@scoped()
def failing_function():
tag("important_context", "value")
raise test_exception
# Function should raise the exception
with self.assertRaises(ValueError):
failing_function()
# Verify capture_exception was called
mock_capture.assert_called_once_with(test_exception)
# Context should be cleared after function execution
assert get_tags() == {}
@patch("posthog.capture_exception")
def test_new_context_exception_handling(self, mock_capture):
test_exception = RuntimeError("Context exception")
def check_context_on_capture(exception, **kwargs):
# Assert inner context tags are available when capture_exception is called
current_tags = get_tags()
assert current_tags.get("inner_context") == "inner_value"
mock_capture.side_effect = check_context_on_capture
# Set up outer context
with new_context():
tag("outer_context", "outer_value")
try:
with new_context():
tag("inner_context", "inner_value")
raise test_exception
except RuntimeError:
pass # Expected exception
# Outer context should still be intact
assert get_tags()["outer_context"] == "outer_value"
# Verify capture_exception was called
mock_capture.assert_called_once_with(test_exception)
def test_identify_context(self):
with new_context(fresh=True):
# Initially no distinct ID
assert get_context_distinct_id() is None
# Set distinct ID
identify_context("user123")
assert get_context_distinct_id() == "user123"
def test_set_context_session(self):
with new_context(fresh=True):
# Initially no session ID
assert get_context_session_id() is None
# Set session ID
set_context_session("session456")
assert get_context_session_id() == "session456"
def test_context_inheritance_fresh_context(self):
with new_context(fresh=True):
identify_context("user123")
set_context_session("session456")
with new_context(fresh=True):
# Fresh context should not inherit
assert get_context_distinct_id() is None
assert get_context_session_id() is None
# Original context should still have values
assert get_context_distinct_id() == "user123"
assert get_context_session_id() == "session456"
def test_context_inheritance_non_fresh_context(self):
with new_context(fresh=True):
identify_context("user123")
set_context_session("session456")
with new_context(fresh=False):
# Non-fresh context should inherit
assert get_context_distinct_id() == "user123"
assert get_context_session_id() == "session456"
# Override in child context
identify_context("user789")
set_context_session("session999")
assert get_context_distinct_id() == "user789"
assert get_context_session_id() == "session999"
# Original context should still have original values
assert get_context_distinct_id() == "user123"
assert get_context_session_id() == "session456"
def test_scoped_decorator_with_context_ids(self):
@scoped()
def function_with_context():
identify_context("user456")
set_context_session("session789")
return get_context_distinct_id(), get_context_session_id()
distinct_id, session_id = function_with_context()
assert distinct_id == "user456"
assert session_id == "session789"
# Context should be cleared after function execution
assert get_context_distinct_id() is None
assert get_context_session_id() is None
+24
View File
@@ -0,0 +1,24 @@
import unittest
from parameterized import parameterized
from posthog import utils
class TestSizeLimitedDict(unittest.TestCase):
@parameterized.expand([(10, 100), (5, 20), (20, 200)])
def test_size_limited_dict(self, size: int, iterations: int) -> None:
values = utils.SizeLimitedDict(size, lambda _: -1)
for i in range(iterations):
values[i] = i
assert values[i] == i
assert 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))
+43 -10
View File
@@ -22,9 +22,16 @@ class TestTypes(unittest.TestCase):
enabled=True,
variant="test-variant",
reason=FlagReason(
code="matched_condition", condition_index=0, description="Matched condition set 1"
code="matched_condition",
condition_index=0,
description="Matched condition set 1",
),
metadata=FlagMetadata(
id=1,
payload='{"some": "json"}',
version=2,
description="test-description",
),
metadata=FlagMetadata(id=1, payload='{"some": "json"}', version=2, description="test-description"),
)
},
"errorsWhileComputingFlags": has_errors,
@@ -39,10 +46,21 @@ class TestTypes(unittest.TestCase):
self.assertEqual(flag.variant, "test-variant")
self.assertEqual(flag.get_value(), "test-variant")
self.assertEqual(
flag.reason, FlagReason(code="matched_condition", condition_index=0, description="Matched condition set 1")
flag.reason,
FlagReason(
code="matched_condition",
condition_index=0,
description="Matched condition set 1",
),
)
self.assertEqual(
flag.metadata, FlagMetadata(id=1, payload='{"some": "json"}', version=2, description="test-description")
flag.metadata,
FlagMetadata(
id=1,
payload='{"some": "json"}',
version=2,
description="test-description",
),
)
self.assertEqual(result["errorsWhileComputingFlags"], has_errors)
self.assertEqual(result["requestId"], "test-id")
@@ -64,7 +82,9 @@ class TestTypes(unittest.TestCase):
self.assertEqual(flag.variant, "test-variant")
self.assertEqual(flag.get_value(), "test-variant")
self.assertIsNone(flag.reason)
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload='{"some": "json-payload"}'))
self.assertEqual(
flag.metadata, LegacyFlagMetadata(payload='{"some": "json-payload"}')
)
self.assertFalse(result["errorsWhileComputingFlags"])
self.assertEqual(result["requestId"], "test-id")
# Verify legacy fields are removed
@@ -99,18 +119,29 @@ class TestTypes(unittest.TestCase):
enabled=True,
variant="test-variant",
reason=FlagReason(
code="matched_condition", condition_index=0, description="Matched condition set 1"
code="matched_condition",
condition_index=0,
description="Matched condition set 1",
),
metadata=FlagMetadata(
id=1,
payload='{"some": "json"}',
version=2,
description="test-description",
),
metadata=FlagMetadata(id=1, payload='{"some": "json"}', version=2, description="test-description"),
),
"my-boolean-flag": FeatureFlag(
key="my-boolean-flag",
enabled=True,
variant=None,
reason=FlagReason(
code="matched_condition", condition_index=0, description="Matched condition set 1"
code="matched_condition",
condition_index=0,
description="Matched condition set 1",
),
metadata=FlagMetadata(
id=1, payload=None, version=2, description="test-description"
),
metadata=FlagMetadata(id=1, payload=None, version=2, description="test-description"),
),
"disabled-flag": FeatureFlag(
key="disabled-flag",
@@ -129,7 +160,9 @@ class TestTypes(unittest.TestCase):
self.assertEqual(result["featureFlags"]["my-variant-flag"], "test-variant")
self.assertEqual(result["featureFlags"]["my-boolean-flag"], True)
self.assertEqual(result["featureFlags"]["disabled-flag"], False)
self.assertEqual(result["featureFlagPayloads"]["my-variant-flag"], '{"some": "json"}')
self.assertEqual(
result["featureFlagPayloads"]["my-variant-flag"], '{"some": "json"}'
)
self.assertNotIn("my-boolean-flag", result["featureFlagPayloads"])
self.assertNotIn("disabled-flag", result["featureFlagPayloads"])
+73 -74
View File
@@ -7,6 +7,7 @@ from uuid import UUID
import six
from dateutil.tz import tzutc
from parameterized import parameterized
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
@@ -17,17 +18,29 @@ FAKE_TEST_API_KEY = "random_key"
class TestUtils(unittest.TestCase):
@parameterized.expand(
[
("naive datetime should be naive", True),
("timezone-aware datetime should not be naive", False),
]
)
def test_is_naive(self, _name: str, expected_naive: bool):
if expected_naive:
dt = datetime.now() # naive datetime
else:
dt = datetime.now(tz=tzutc()) # timezone-aware datetime
assert utils.is_naive(dt) is expected_naive
def test_timezone_utils(self):
now = datetime.now()
utcnow = datetime.now(tz=tzutc())
self.assertTrue(utils.is_naive(now))
self.assertFalse(utils.is_naive(utcnow))
fixed = utils.guess_timezone(now)
self.assertFalse(utils.is_naive(fixed))
assert utils.is_naive(fixed) is False
shouldnt_be_edited = utils.guess_timezone(utcnow)
self.assertEqual(utcnow, shouldnt_be_edited)
assert utcnow == shouldnt_be_edited
def test_clean(self):
simple = {
@@ -54,12 +67,12 @@ class TestUtils(unittest.TestCase):
pre_clean_keys = combined.keys()
utils.clean(combined)
self.assertEqual(combined.keys(), pre_clean_keys)
assert combined.keys() == pre_clean_keys
# test UUID separately, as the UUID object doesn't equal its string representation according to Python
self.assertEqual(
utils.clean(UUID("12345678123456781234567812345678")),
"12345678-1234-5678-1234-567812345678",
assert (
utils.clean(UUID("12345678123456781234567812345678"))
== "12345678-1234-5678-1234-567812345678"
)
def test_clean_with_dates(self):
@@ -67,26 +80,27 @@ class TestUtils(unittest.TestCase):
"birthdate": date(1980, 1, 1),
"registration": datetime.now(tz=tzutc()),
}
self.assertEqual(dict_with_dates, utils.clean(dict_with_dates))
assert dict_with_dates == utils.clean(dict_with_dates)
def test_bytes(self):
if six.PY3:
item = bytes(10)
else:
item = bytearray(10)
item = bytes(10)
utils.clean(item)
assert utils.clean(item) == "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
def test_clean_fn(self):
cleaned = utils.clean({"fn": lambda x: x, "number": 4})
self.assertEqual(cleaned["number"], 4)
# TODO: fixme, different behavior on python 2 and 3
if "fn" in cleaned:
self.assertEqual(cleaned["fn"], None)
assert cleaned == {"fn": None, "number": 4}
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"))
@parameterized.expand(
[
("http://posthog.io/", "http://posthog.io"),
("http://posthog.io", "http://posthog.io"),
("https://example.com/path/", "https://example.com/path"),
("https://example.com/path", "https://example.com/path"),
]
)
def test_remove_slash(self, input_url, expected_url):
assert expected_url == utils.remove_trailing_slash(input_url)
def test_clean_pydantic(self):
class ModelV2(BaseModel):
@@ -101,19 +115,26 @@ class TestUtils(unittest.TestCase):
class NestedModel(BaseModel):
foo: ModelV2
self.assertEqual(utils.clean(ModelV2(foo="1", bar=2)), {"foo": "1", "bar": 2, "baz": None})
self.assertEqual(utils.clean(ModelV1(foo=1, bar="2")), {"foo": 1, "bar": "2"})
self.assertEqual(
utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))),
{"foo": {"foo": "1", "bar": 2, "baz": "3"}},
)
assert utils.clean(ModelV2(foo="1", bar=2)) == {
"foo": "1",
"bar": 2,
"baz": None,
}
assert utils.clean(ModelV1(foo=1, bar="2")) == {"foo": 1, "bar": "2"}
assert utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))) == {
"foo": {"foo": "1", "bar": 2, "baz": "3"}
}
def test_clean_pydantic_like_class(self) -> None:
class Dummy:
def model_dump(self, required_param):
pass
def model_dump(self, required_param: str) -> dict:
return {}
# Skips a class with a defined non-Pydantic `model_dump` method.
self.assertEqual(utils.clean({"test": Dummy()}), {})
# previously python 2 code would cause an error while cleaning,
# and this entire object would be None, and we would log an error
# let's allow ourselves to clean `Dummy` as None,
# without blatting the `test` key
assert utils.clean({"test": Dummy()}) == {"test": None}
def test_clean_dataclass(self):
@dataclass
@@ -130,47 +151,25 @@ class TestUtils(unittest.TestCase):
bar: int
nested: InnerDataClass
self.assertEqual(
utils.clean(
TestDataClass(
foo="1",
bar=2,
nested=InnerDataClass(
inner_foo="3",
inner_bar=4,
inner_uuid=UUID("12345678123456781234567812345678"),
inner_date=datetime(2025, 1, 1),
),
)
),
{
"foo": "1",
"bar": 2,
"nested": {
"inner_foo": "3",
"inner_bar": 4,
"inner_uuid": "12345678-1234-5678-1234-567812345678",
"inner_date": datetime(2025, 1, 1),
"inner_optional": None,
},
assert utils.clean(
TestDataClass(
foo="1",
bar=2,
nested=InnerDataClass(
inner_foo="3",
inner_bar=4,
inner_uuid=UUID("12345678123456781234567812345678"),
inner_date=datetime(2025, 1, 1),
),
)
) == {
"foo": "1",
"bar": 2,
"nested": {
"inner_foo": "3",
"inner_bar": 4,
"inner_uuid": "12345678-1234-5678-1234-567812345678",
"inner_date": datetime(2025, 1, 1),
"inner_optional": None,
},
)
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))
}
+22 -6
View File
@@ -1,9 +1,13 @@
import json
from dataclasses import dataclass
from typing import Any, List, Optional, TypedDict, Union, cast
from typing import Any, Callable, List, Optional, TypedDict, Union, cast
FlagValue = Union[bool, str]
# Type alias for the before_send callback function
# Takes an event dictionary and returns the modified event or None to drop it
BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]]
@dataclass(frozen=True)
class FlagReason:
@@ -78,7 +82,9 @@ class FeatureFlag:
)
@classmethod
def from_value_and_payload(cls, key: str, value: FlagValue, payload: Any) -> "FeatureFlag":
def from_value_and_payload(
cls, key: str, value: FlagValue, payload: Any
) -> "FeatureFlag":
enabled, variant = (True, value) if isinstance(value, str) else (value, None)
return cls(
key=key,
@@ -160,7 +166,9 @@ class FeatureFlagResult:
@classmethod
def from_flag_details(
cls, details: Union[FeatureFlag, None], override_match_value: Optional[FlagValue] = None
cls,
details: Union[FeatureFlag, None],
override_match_value: Optional[FlagValue] = None,
) -> "FeatureFlagResult | None":
"""
Create a FeatureFlagResult from a FeatureFlag object.
@@ -179,7 +187,9 @@ class FeatureFlagResult:
if override_match_value is not None:
enabled, variant = (
(True, override_match_value) if isinstance(override_match_value, str) else (override_match_value, None)
(True, override_match_value)
if isinstance(override_match_value, str)
else (override_match_value, None)
)
else:
enabled, variant = (details.enabled, details.variant)
@@ -226,7 +236,9 @@ def normalize_flags_response(resp: Any) -> FlagsResponse:
# look at each key in featureFlags and create a FeatureFlag object
flags = {}
for key, value in featureFlags.items():
flags[key] = FeatureFlag.from_value_and_payload(key, value, featureFlagPayloads.get(key, None))
flags[key] = FeatureFlag.from_value_and_payload(
key, value, featureFlagPayloads.get(key, None)
)
resp["flags"] = flags
return cast(FlagsResponse, resp)
@@ -252,7 +264,11 @@ def to_values(response: FlagsResponse) -> Optional[dict[str, FlagValue]]:
return None
flags = response.get("flags", {})
return {key: value.get_value() for key, value in flags.items() if isinstance(value, FeatureFlag)}
return {
key: value.get_value()
for key, value in flags.items()
if isinstance(value, FeatureFlag)
}
def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]:
+30 -6
View File
@@ -5,6 +5,7 @@ from collections import defaultdict
from dataclasses import asdict, is_dataclass
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Any, Optional
from uuid import UUID
import six
@@ -52,7 +53,9 @@ def clean(item):
return float(item)
if isinstance(item, UUID):
return str(item)
if isinstance(item, (six.string_types, bool, numbers.Number, datetime, date, type(None))):
if isinstance(
item, (six.string_types, bool, numbers.Number, datetime, date, type(None))
):
return item
if isinstance(item, (set, list, tuple)):
return _clean_list(item)
@@ -99,14 +102,35 @@ def _clean_dataclass(dataclass_):
return data
def _coerce_unicode(cmplx):
def _coerce_unicode(cmplx: Any) -> Optional[str]:
"""
In theory, this method is only called
after many isinstance checks are carried out in `utils.clean`.
When we supported Python 2 it was safe to call `decode` on a `str`
but in Python 3 that will throw.
So, we check if the input is bytes and only call `decode` in that case.
Previously we would always call `decode` on the input
That would throw an error.
Then we would call `decode` on the stringified error
That would throw an error.
And then we would return `None`
To avoid a breaking change, we can maintain the behavior
that anything which did not have `decode` in Python 2
returns None.
"""
item = None
try:
item = cmplx.decode("utf-8", "strict")
except AttributeError as exception:
item = ":".join(exception)
item.decode("utf-8", "strict")
if isinstance(cmplx, bytes):
item = cmplx.decode("utf-8", "strict")
elif isinstance(cmplx, str):
item = cmplx
except Exception as exception:
item = ":".join(map(str, exception.args))
log.warning("Error decoding: %s", item)
return None
return item
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "4.2.0"
VERSION = "5.2.0"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
+97 -9
View File
@@ -1,10 +1,98 @@
[tool.black]
line-length = 120
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.isort]
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 8
ensure_newline_before_comments = true
line_length = 120
virtual_env = "env"
[project]
name = "posthog"
dynamic = ["version"]
description = "Integrate PostHog into any python application."
authors = [{ name = "PostHog", email = "hey@posthog.com" }]
maintainers = [{ name = "PostHog", email = "hey@posthog.com" }]
license = { text = "MIT" }
readme = "README.md"
requires-python = ">=3.9"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"requests>=2.7,<3.0",
"six>=1.5",
"python-dateutil>=2.2",
"backoff>=1.10.0",
"distro>=1.5.0",
]
[project.urls]
Homepage = "https://github.com/posthog/posthog-python"
Repository = "https://github.com/posthog/posthog-python"
[project.optional-dependencies]
langchain = ["langchain>=0.2.0"]
dev = [
"django-stubs",
"lxml",
"mypy",
"mypy-baseline",
"types-mock",
"types-python-dateutil",
"types-requests",
"types-setuptools",
"types-six",
"pre-commit",
"pydantic",
"ruff",
"setuptools",
"packaging",
"wheel",
"twine",
"tomli",
"tomli_w",
]
test = [
"mock>=2.0.0",
"freezegun==1.5.1",
"coverage",
"pytest",
"pytest-timeout",
"pytest-asyncio",
"django",
"openai",
"anthropic",
"langgraph>=0.4.8",
"langchain-core>=0.3.65",
"langchain-community>=0.3.25",
"langchain-openai>=0.3.22",
"langchain-anthropic>=0.3.15",
"google-genai",
"pydantic",
"parameterized>=0.8.1",
]
[tool.setuptools]
packages = [
"posthog",
"posthog.ai",
"posthog.ai.langchain",
"posthog.ai.openai",
"posthog.ai.anthropic",
"posthog.ai.gemini",
"posthog.test",
"posthog.exception_integrations",
"posthog.integrations",
]
[tool.setuptools.dynamic]
version = { attr = "posthog.version.VERSION" }
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
View File
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
@@ -1,16 +0,0 @@
"""
ASGI config for sentry_django_example project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
application = get_asgi_application()
@@ -1,167 +0,0 @@
"""
Django settings for sentry_django_example project.
Generated by 'django-admin startproject' using Django 3.2.2.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/
"""
from pathlib import Path
from uuid import uuid4
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-4kzfiq7vb(t0+jbl#vq)u=%06ouf)n*=l%730c8=tk(wkm9i9o"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# PostHog Setup (can be a separate app)
import posthog
# You can find this key on the /setup page in PostHog
posthog.api_key = "LXP6nQXvo-2TCqGVrWvPah8uJIyVykoMmhnEkEBi5PA" # TODO: replace with your api key
posthog.personal_api_key = ""
# 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"
from posthog.sentry.posthog_integration import PostHogIntegration
PostHogIntegration.organization = "posthog" # TODO: your sentry organization
# 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)
# we work around this by setting static class variables beforehand
# Sentry Setup
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
sentry_sdk.init(
dsn="https://27ac54f7f4cf484abf1335436b0c52e5@o344752.ingest.sentry.io/5624115", # TODO: your Sentry DSN here
integrations=[DjangoIntegration(), PostHogIntegration()],
# Set traces_sample_rate to 1.0 to capture 100%
# of transactions for performance monitoring.
# We recommend adjusting this value in production.
traces_sample_rate=1.0,
# If you wish to associate users to errors (assuming you are using
# django.contrib.auth) you may enable sending PII data.
send_default_pii=True,
)
POSTHOG_DJANGO = {
"distinct_id": lambda request: str(uuid4()) # TODO: your logic for generating unique ID, given the request object
}
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"posthog.sentry.django.PosthogDistinctIdMiddleware",
]
ROOT_URLCONF = "sentry_django_example.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "sentry_django_example.wsgi.application"
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/
STATIC_URL = "/static/"
# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
@@ -1,28 +0,0 @@
"""sentry_django_example URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
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
def trigger_error(request):
division_by_zero = 1 / 0
urlpatterns = [
path("admin/", admin.site.urls),
path("sentry-debug/", trigger_error),
]
@@ -1,16 +0,0 @@
"""
WSGI config for sentry_django_example project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
application = get_wsgi_application()
-6
View File
@@ -1,6 +0,0 @@
[bdist_wheel]
universal = 1
[tool:pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
+7 -78
View File
@@ -8,99 +8,28 @@ except ImportError:
# Don't import analytics-python module here, since deps may not be installed
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthog"))
from version import VERSION
from version import VERSION # noqa: E402
long_description = """
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
PostHog is developer-friendly, self-hosted product analytics.
posthog-python is the python package.
This package requires Python 3.9 or higher.
"""
install_requires = [
"requests>=2.7,<3.0",
"six>=1.5",
"python-dateutil>=2.2",
"backoff>=1.10.0",
"distro>=1.5.0", # Required for Linux OS detection in Python 3.9+
]
extras_require = {
"dev": [
"black",
"django-stubs",
"isort",
"flake8",
"flake8-print",
"lxml",
"mypy",
"mypy-baseline",
"types-mock",
"types-python-dateutil",
"types-requests",
"types-setuptools",
"types-six",
"pre-commit",
"pydantic",
],
"test": [
"mock>=2.0.0",
"freezegun==1.5.1",
"pylint",
"flake8",
"coverage",
"pytest",
"pytest-timeout",
"pytest-asyncio",
"django",
"openai",
"anthropic",
"langgraph",
"langchain-community>=0.2.0",
"langchain-openai>=0.2.0",
"langchain-anthropic>=0.2.0",
"google-genai",
"pydantic",
"parameterized>=0.8.1",
],
"sentry": ["sentry-sdk", "django"],
"langchain": ["langchain>=0.2.0"],
}
# Minimal setup.py for backward compatibility
# Most configuration is now in pyproject.toml
setup(
name="posthog",
version=VERSION,
# Basic fields for backward compatibility
url="https://github.com/posthog/posthog-python",
author="Posthog",
author_email="hey@posthog.com",
maintainer="PostHog",
maintainer_email="hey@posthog.com",
test_suite="posthog.test.all",
packages=[
"posthog",
"posthog.ai",
"posthog.ai.langchain",
"posthog.ai.openai",
"posthog.ai.anthropic",
"posthog.ai.gemini",
"posthog.test",
"posthog.sentry",
"posthog.exception_integrations",
],
license="MIT License",
install_requires=install_requires,
extras_require=extras_require,
description="Integrate PostHog into any python application.",
long_description=long_description,
classifiers=[
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
],
# This will fallback to pyproject.toml for detailed configuration
)
+41 -42
View File
@@ -1,69 +1,68 @@
import os
import sys
import tomli
import tomli_w
import shutil
try:
from setuptools import setup
except ImportError:
from distutils.core import setup
# Don't import module here, since deps may not be installed
# Don't import analytics-python module here, since deps may not be installed
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthoganalytics"))
from version import VERSION
from version import VERSION # noqa: E402
# Copy the original pyproject.toml as backup
shutil.copy("pyproject.toml", "pyproject.toml.backup")
# Read the original pyproject.toml
with open("pyproject.toml", "rb") as f:
config = tomli.load(f)
# Override specific values
config["project"]["name"] = "posthoganalytics"
config["tool"]["setuptools"]["dynamic"]["version"] = {
"attr": "posthoganalytics.version.VERSION"
}
# Rename packages from posthog.* to posthoganalytics.*
if "packages" in config["tool"]["setuptools"]:
new_packages = []
for package in config["tool"]["setuptools"]["packages"]:
if package == "posthog":
new_packages.append("posthoganalytics")
elif package.startswith("posthog."):
new_packages.append(package.replace("posthog.", "posthoganalytics.", 1))
else:
new_packages.append(package)
config["tool"]["setuptools"]["packages"] = new_packages
# Overwrite the original pyproject.toml
with open("pyproject.toml", "wb") as f:
tomli_w.dump(config, f)
long_description = """
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
PostHog is developer-friendly, self-hosted product analytics.
posthog-python is the python package.
This package requires Python 3.9 or higher.
"""
install_requires = [
"requests>=2.7,<3.0",
"six>=1.5",
"python-dateutil>=2.2",
"backoff>=1.10.0",
"distro>=1.5.0", # Required for Linux OS detection in Python 3.9+
]
tests_require = ["mock>=2.0.0"]
# Minimal setup.py for backward compatibility
# Most configuration is now in pyproject.toml
setup(
name="posthoganalytics",
version=VERSION,
# Basic fields for backward compatibility
url="https://github.com/posthog/posthog-python",
author="Posthog",
author_email="hey@posthog.com",
maintainer="PostHog",
maintainer_email="hey@posthog.com",
test_suite="posthoganalytics.test.all",
packages=[
"posthoganalytics",
"posthoganalytics.ai",
"posthoganalytics.ai.langchain",
"posthoganalytics.ai.openai",
"posthoganalytics.ai.anthropic",
"posthoganalytics.ai.gemini",
"posthoganalytics.test",
"posthoganalytics.sentry",
"posthoganalytics.exception_integrations",
],
license="MIT License",
install_requires=install_requires,
tests_require=tests_require,
extras_require={
"sentry": ["sentry-sdk", "django"],
},
description="Integrate PostHog into any python application.",
long_description=long_description,
classifiers=[
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
],
# This will fallback to pyproject.toml for detailed configuration
)
+10 -2
View File
@@ -28,7 +28,9 @@ parser.add_argument("--anonymousId", help="the anonymous user id to send the eve
parser.add_argument("--event", help="the event name to send with the event")
parser.add_argument("--properties", help="the event properties to send (JSON-encoded)")
parser.add_argument("--name", help="name of the screen or page to send with the message")
parser.add_argument(
"--name", help="name of the screen or page to send with the message"
)
parser.add_argument("--traits", help="the identify/group traits to send (JSON-encoded)")
@@ -94,7 +96,13 @@ ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
log.addHandler(ch)
switcher = {"capture": capture, "page": page, "identify": identify, "set_once": set_once, "set": set}
switcher = {
"capture": capture,
"page": page,
"identify": identify,
"set_once": set_once,
"set": set,
}
func = switcher.get(options.type)
if func:
Generated
+3497
View File
File diff suppressed because it is too large Load Diff