Compare commits
276
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
662a6c752b | ||
|
|
fe7b5b1381 | ||
|
|
1505e3fe05 | ||
|
|
ebc9633386 | ||
|
|
9f370675d4 | ||
|
|
6e00d573f3 | ||
|
|
a91a20876e | ||
|
|
10472e721d | ||
|
|
fb38447869 | ||
|
|
ae97131107 | ||
|
|
675dea16a6 | ||
|
|
6a3e7ef3ad | ||
|
|
20b8825bd2 | ||
|
|
818edc2811 | ||
|
|
05074351a3 | ||
|
|
d25fae383c | ||
|
|
68e78c877d | ||
|
|
07cf32bb04 | ||
|
|
0076b66b75 | ||
|
|
09dad8117f | ||
|
|
09b9b5dc88 | ||
|
|
5a52af66a9 | ||
|
|
722c88701b | ||
|
|
6ab2856f8d | ||
|
|
7a8b09123c | ||
|
|
da09639428 | ||
|
|
6a271026d1 | ||
|
|
6d9247960f | ||
|
|
c4e09cdd40 | ||
|
|
c61236b26a | ||
|
|
b965332698 | ||
|
|
4739945a82 | ||
|
|
50ab10c858 | ||
|
|
37bd30194e | ||
|
|
b41dc8568e | ||
|
|
f0e1cdf870 | ||
|
|
e23ca94296 | ||
|
|
5a7f324a61 | ||
|
|
e13c428ff6 | ||
|
|
77190c23e1 | ||
|
|
b7753392f7 | ||
|
|
250bd424d0 | ||
|
|
579cc56787 | ||
|
|
3778eaef7b | ||
|
|
52df246a3e | ||
|
|
f1f9ecf7a4 | ||
|
|
9db1b7e9f3 | ||
|
|
01751d1205 | ||
|
|
4426dd9d27 | ||
|
|
bf0d7efbfe | ||
|
|
f17ebfa12b | ||
|
|
800527da43 | ||
|
|
0d29fb7be3 | ||
|
|
24d89806cb | ||
|
|
a2105f6e95 | ||
|
|
3171193d75 | ||
|
|
1db6e45258 | ||
|
|
1daa8a8053 | ||
|
|
5d58a53b36 | ||
|
|
7af8e886ee | ||
|
|
90d3fca27d | ||
|
|
243b98df11 | ||
|
|
7ab2080309 | ||
|
|
23e1d8e2a3 | ||
|
|
e2d8200cc6 | ||
|
|
da69b68f7d | ||
|
|
57c3cba200 | ||
|
|
9f4ef4f24f | ||
|
|
7aea6b72d3 | ||
|
|
7bb7c90a49 | ||
|
|
c1f668e8bb | ||
|
|
a1b81ee3d9 | ||
|
|
a6fb39902d | ||
|
|
a1583f6627 | ||
|
|
dfa7f70a04 | ||
|
|
d00d69e448 | ||
|
|
a833955ee0 | ||
|
|
58fbe05cb0 | ||
|
|
7a6e185902 | ||
|
|
e9c72e7f8c | ||
|
|
51380ac207 | ||
|
|
53ed80366b | ||
|
|
18729e33b8 | ||
|
|
334394bed2 | ||
|
|
14a2f80c6d | ||
|
|
2779ad194c | ||
|
|
5a4167d5ce | ||
|
|
332a6fffb6 | ||
|
|
28a7d351ba | ||
|
|
8331af7a42 | ||
|
|
f4c99714c3 | ||
|
|
7dc4cbb16b | ||
|
|
4cda646f03 | ||
|
|
ea4e7fa16d | ||
|
|
57a3e7470f | ||
|
|
5e0f9e35c1 | ||
|
|
337f7da7c5 | ||
|
|
31652d5ec3 | ||
|
|
6764c786a4 | ||
|
|
1b57a96509 | ||
|
|
38683e8550 | ||
|
|
a5c8f62a63 | ||
|
|
e480b88dce | ||
|
|
3ff2a8599d | ||
|
|
a3cf4ad5fb | ||
|
|
cec532f241 | ||
|
|
415508087f | ||
|
|
994003fc42 | ||
|
|
319b3807f3 | ||
|
|
5e7314f89d | ||
|
|
8f43bbc613 | ||
|
|
eb07aafaa3 | ||
|
|
0f8b10bb09 | ||
|
|
45dc933b9c | ||
|
|
2835af49cb | ||
|
|
54506e5a7c | ||
|
|
bcf5b27083 | ||
|
|
0b6ff2e8d3 | ||
|
|
80f0b3e52e | ||
|
|
d1e22188ec | ||
|
|
9b423495ed | ||
|
|
7870ccd3d8 | ||
|
|
190c628c7a | ||
|
|
78ab0ca8b5 | ||
|
|
c5bfc1377a | ||
|
|
6b1c0dc313 | ||
|
|
e51b883e7b | ||
|
|
66101c92bf | ||
|
|
05932b3f13 | ||
|
|
50c13563b2 | ||
|
|
dca4af66ae | ||
|
|
9e1bb8c58a | ||
|
|
fb57de2e12 | ||
|
|
db565bc0fd | ||
|
|
8ae3f2b623 | ||
|
|
39f72a0070 | ||
|
|
ee0305993d | ||
|
|
28c4802d9b | ||
|
|
67a343f242 | ||
|
|
1521621d66 | ||
|
|
39070babfb | ||
|
|
716eab0bc2 | ||
|
|
1c0a61d6b5 | ||
|
|
ffa35fa5cd | ||
|
|
24b7b918f7 | ||
|
|
16cbd10f1b | ||
|
|
b83d544931 | ||
|
|
72c0ed1935 | ||
|
|
5fdd6177ee | ||
|
|
fc1da7d589 | ||
|
|
cba6e86537 | ||
|
|
4e45255207 | ||
|
|
bc37351ab4 | ||
|
|
a5e8b7d7fb | ||
|
|
efb0ccf3c7 | ||
|
|
8554b51a48 | ||
|
|
d0d962a8ba | ||
|
|
e348106094 | ||
|
|
e60d52c199 | ||
|
|
a2c73d0536 | ||
|
|
33ba5d6843 | ||
|
|
3515c40483 | ||
|
|
139258cacb | ||
|
|
f75d924d4c | ||
|
|
617bb53501 | ||
|
|
4aa3499527 | ||
|
|
de7def97e2 | ||
|
|
dfefd0a1b6 | ||
|
|
477a688016 | ||
|
|
fa474a0fe6 | ||
|
|
f8bc3f17eb | ||
|
|
07277d35e7 | ||
|
|
15d0716744 | ||
|
|
b4103b3ae2 | ||
|
|
1aeffa990f | ||
|
|
5ae7feb4e9 | ||
|
|
6534afd8e3 | ||
|
|
a9d7bf3e0b | ||
|
|
d7be253ef8 | ||
|
|
592c0f362e | ||
|
|
c7fc5a83b4 | ||
|
|
ae8817b611 | ||
|
|
acad2b142e | ||
|
|
cb62570e69 | ||
|
|
33645ecd3c | ||
|
|
81debcef27 | ||
|
|
dac06bab18 | ||
|
|
2dc1298620 | ||
|
|
2c6b675be7 | ||
|
|
3a6fd07951 | ||
|
|
de0ccd29d3 | ||
|
|
addd2e3340 | ||
|
|
9d2fa72753 | ||
|
|
306fb2a1fa | ||
|
|
faffd1f88a | ||
|
|
ab1399d88f | ||
|
|
1777b7062e | ||
|
|
565bb8a0eb | ||
|
|
009cac8634 | ||
|
|
ec2425996c | ||
|
|
90fa0a0604 | ||
|
|
a97fe0a40a | ||
|
|
a474fcff93 | ||
|
|
a181ba718f | ||
|
|
b996f3a4e9 | ||
|
|
6c945a0624 | ||
|
|
ab8ccb4dff | ||
|
|
870f6f8b6b | ||
|
|
9e0aeaefe6 | ||
|
|
a8409960b9 | ||
|
|
deb078293a | ||
|
|
edb8b7891e | ||
|
|
727bdb2b1e | ||
|
|
0781a1280e | ||
|
|
8b2ed8bb12 | ||
|
|
a139795a74 | ||
|
|
11f1d06761 | ||
|
|
fd321566ed | ||
|
|
83737f2477 | ||
|
|
cc5649368f | ||
|
|
7fe5045da1 | ||
|
|
2c9ad238ec | ||
|
|
de6e60f12f | ||
|
|
fd92502d99 | ||
|
|
fe6d0dc1ec | ||
|
|
fbde5cafc4 | ||
|
|
2e99081cb1 | ||
|
|
372fb74637 | ||
|
|
ba11548089 | ||
|
|
49d0821e27 | ||
|
|
b4489f1dca | ||
|
|
8040964761 | ||
|
|
4f853403b9 | ||
|
|
ac61fb0e01 | ||
|
|
7196dc6048 | ||
|
|
45303b899e | ||
|
|
7e463ccad6 | ||
|
|
41dec34929 | ||
|
|
0bf9db0108 | ||
|
|
e8308360bb | ||
|
|
15ebe85a78 | ||
|
|
dbc22d2f9f | ||
|
|
f3ee238823 | ||
|
|
71d81b2da9 | ||
|
|
5493029577 | ||
|
|
d66f944571 | ||
|
|
9d620967f8 | ||
|
|
563404f914 | ||
|
|
d15aac41a9 | ||
|
|
8a3e28b949 | ||
|
|
1aa0d6335c | ||
|
|
3b46c60cf1 | ||
|
|
4ad8cbfa58 | ||
|
|
984a679b19 | ||
|
|
dd1bad6175 | ||
|
|
e6f71e4cc3 | ||
|
|
17874cb131 | ||
|
|
8366e09df9 | ||
|
|
e28b237ff1 | ||
|
|
05fde2a51e | ||
|
|
2be04f3b8b | ||
|
|
d52b605742 | ||
|
|
7ee3002c6f | ||
|
|
d1bc9135c7 | ||
|
|
111813296c | ||
|
|
31acda73a3 | ||
|
|
888457387b | ||
|
|
fe45ff2ab0 | ||
|
|
221d7f09f3 | ||
|
|
a5f2e030b5 | ||
|
|
98d2d4cc05 | ||
|
|
b7c1572c32 | ||
|
|
16acf2e278 | ||
|
|
df9ae05202 | ||
|
|
b05ee3884a | ||
|
|
144a7744e4 |
@@ -0,0 +1,11 @@
|
||||
# PostHog API Configuration
|
||||
# Copy this file to .env and update with your actual values
|
||||
|
||||
# Your project API key (found on the /setup page in PostHog)
|
||||
POSTHOG_PROJECT_API_KEY=phc_your_project_api_key_here
|
||||
|
||||
# Your personal API key (for local evaluation and other advanced features)
|
||||
POSTHOG_PERSONAL_API_KEY=phx_your_personal_api_key_here
|
||||
|
||||
# PostHog host URL (remove this line if using posthog.com)
|
||||
POSTHOG_HOST=http://localhost:8000
|
||||
@@ -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
|
||||
@@ -0,0 +1,70 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
code-quality:
|
||||
name: Code quality checks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.11
|
||||
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: Install dev dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra dev
|
||||
|
||||
- name: Check formatting with ruff
|
||||
run: |
|
||||
ruff format --check .
|
||||
|
||||
- name: Check types with mypy
|
||||
run: |
|
||||
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
|
||||
|
||||
tests:
|
||||
name: Python ${{ matrix.python-version }} tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- 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: |
|
||||
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra test
|
||||
|
||||
- name: Run posthog tests
|
||||
run: |
|
||||
pytest --verbose --timeout=30
|
||||
@@ -0,0 +1,51 @@
|
||||
name: "Release"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "posthog/version.py"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Publish release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
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: uv sync --extra dev
|
||||
|
||||
- name: Push releases to PyPI
|
||||
run: uv run make release && uv run make release_analytics
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ env.REPO_VERSION }}
|
||||
release_name: ${{ env.REPO_VERSION }}
|
||||
@@ -1,27 +0,0 @@
|
||||
name: Backend CI
|
||||
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Python tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.7
|
||||
|
||||
- name: Install requirements.txt dependencies with pip
|
||||
run: |
|
||||
python -m pip install -e .
|
||||
|
||||
- name: Run posthog tests
|
||||
run: |
|
||||
python setup.py test
|
||||
+12
-5
@@ -1,14 +1,21 @@
|
||||
**sublime**
|
||||
*.pyc
|
||||
dist
|
||||
dist/
|
||||
*.egg-info
|
||||
dist
|
||||
MANIFEST
|
||||
build
|
||||
.eggs
|
||||
build/
|
||||
.eggs/
|
||||
.coverage
|
||||
.vscode/
|
||||
env
|
||||
env/
|
||||
venv/
|
||||
flake8.out
|
||||
pylint.out
|
||||
posthog-analytics
|
||||
.idea
|
||||
.python-version
|
||||
.coverage
|
||||
pyrightconfig.json
|
||||
.env
|
||||
.DS_Store
|
||||
posthog-python-references.json
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
repos:
|
||||
- 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
@@ -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"})
|
||||
```
|
||||
+606
@@ -0,0 +1,606 @@
|
||||
# 6.7.1 - 2025-09-01
|
||||
|
||||
- fix: Add base64 inline image sanitization
|
||||
|
||||
# 6.7.0 - 2025-08-26
|
||||
|
||||
- feat: Add support for feature flag dependencies
|
||||
|
||||
# 6.6.1 - 2025-08-21
|
||||
|
||||
- fix: Prevent `NoneType` error when `group_properties` is `None`
|
||||
|
||||
# 6.6.0 - 2025-08-15
|
||||
|
||||
- feat: Add `flag_keys_to_evaluate` parameter to optimize feature flag evaluation performance by only evaluating specified flags
|
||||
- feat: Add `flag_keys_filter` option to `send_feature_flags` for selective flag evaluation in capture events
|
||||
|
||||
# 6.5.0 - 2025-08-08
|
||||
|
||||
- feat: Add `$context_tags` to an event to know which properties were included as tags
|
||||
|
||||
# 6.4.1 - 2025-08-06
|
||||
|
||||
- fix: Always pass project API key in `remote_config` requests for deterministic project routing
|
||||
|
||||
# 6.4.0 - 2025-08-05
|
||||
|
||||
- feat: support Vertex AI for Gemini
|
||||
|
||||
# 6.3.4 - 2025-08-04
|
||||
|
||||
- fix: set `$ai_tools` for all providers and `$ai_output_choices` for all non-streaming provider flows properly
|
||||
|
||||
# 6.3.3 - 2025-08-01
|
||||
|
||||
- fix: `get_feature_flag_result` now correctly returns FeatureFlagResult when payload is empty string instead of None
|
||||
|
||||
# 6.3.2 - 2025-07-31
|
||||
|
||||
- fix: Anthropic's tool calls are now handled properly
|
||||
|
||||
# 6.3.0 - 2025-07-22
|
||||
|
||||
- feat: Enhanced `send_feature_flags` parameter to accept `SendFeatureFlagsOptions` object for declarative control over local/remote evaluation and custom properties
|
||||
|
||||
# 6.2.1 - 2025-07-21
|
||||
|
||||
- feat: make `posthog_client` an optional argument in PostHog AI providers wrappers (`posthog.ai.*`), intuitively using the default client as the default
|
||||
|
||||
# 6.1.1 - 2025-07-16
|
||||
|
||||
- fix: correctly capture exceptions processed by Django from views or middleware
|
||||
|
||||
# 6.1.0 - 2025-07-10
|
||||
|
||||
- feat: decouple feature flag local evaluation from personal API keys; support decrypting remote config payloads without relying on the feature flags poller
|
||||
|
||||
# 6.0.4 - 2025-07-09
|
||||
|
||||
- fix: add POSTHOG_MW_CLIENT setting to django middleware, to support custom clients for exception capture.
|
||||
|
||||
# 6.0.3 - 2025-07-07
|
||||
|
||||
- feat: add a feature flag evaluation cache (local storage or redis) to support returning flag evaluations when the service is down
|
||||
|
||||
# 6.0.2 - 2025-07-02
|
||||
|
||||
- fix: send_feature_flags changed to default to false in `Client::capture_exception`
|
||||
|
||||
# 6.0.1
|
||||
|
||||
- fix: response `$process_person_profile` property when passed to capture
|
||||
|
||||
# 6.0.0
|
||||
|
||||
This release contains a number of major breaking changes:
|
||||
|
||||
- feat: make distinct_id an optional parameter in posthog.capture and related functions
|
||||
- feat: make capture and related functions return `Optional[str]`, which is the UUID of the sent event, if it was sent
|
||||
- fix: remove `identify` (prefer `posthog.set()`), and `page` and `screen` (prefer `posthog.capture()`)
|
||||
- fix: delete exception-capture specific integrations module. Prefer the general-purpose django middleware as a replacement for the django `Integration`.
|
||||
|
||||
To migrate to this version, you'll mostly just need to switch to using named keyword arguments, rather than positional ones. For example:
|
||||
|
||||
```python
|
||||
# Old calling convention
|
||||
posthog.capture("user123", "button_clicked", {"button_id": "123"})
|
||||
# New calling convention
|
||||
posthog.capture(distinct_id="user123", event="button_clicked", properties={"button_id": "123"})
|
||||
|
||||
# Better pattern
|
||||
with posthog.new_context():
|
||||
posthog.identify_context("user123")
|
||||
|
||||
# The event name is the first argument, and can be passed positionally, or as a keyword argument in a later position
|
||||
posthog.capture("button_pressed")
|
||||
```
|
||||
|
||||
Generally, arguments are now appropriately typed, and docstrings have been updated. If something is unclear, please open an issue, or submit a PR!
|
||||
|
||||
# 5.4.0 - 2025-06-20
|
||||
|
||||
- feat: add support to session_id context on page method
|
||||
|
||||
# 5.3.0 - 2025-06-19
|
||||
|
||||
- fix: safely handle exception values
|
||||
|
||||
# 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
|
||||
|
||||
## 4.1.0 - 2025-05-22
|
||||
|
||||
Moved ai openai package to a composition approach over inheritance.
|
||||
|
||||
## 4.0.1 – 2025-04-29
|
||||
|
||||
1. Remove deprecated `monotonic` library. Use Python's core `time.monotonic` function instead
|
||||
2. Clarify Python 3.9+ is required
|
||||
|
||||
## 4.0.0 - 2025-04-24
|
||||
|
||||
1. Added new method `get_feature_flag_result` which returns a `FeatureFlagResult` object. This object breaks down the result of a feature flag into its enabled state, variant, and payload. The benefit of this method is it allows you to retrieve the result of a feature flag and its payload in a single API call. You can call `get_value` on the result to get the value of the feature flag, which is the same value returned by `get_feature_flag` (aka the string `variant` if the flag is a multivariate flag or the `boolean` value if the flag is a boolean flag).
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
result = posthog.get_feature_flag_result("my-flag", "distinct_id")
|
||||
print(result.enabled) # True or False
|
||||
print(result.variant) # 'the-variant-value' or None
|
||||
print(result.payload) # {'foo': 'bar'}
|
||||
print(result.get_value()) # 'the-variant-value' or True or False
|
||||
print(result.reason) # 'matched condition set 2' (Not available for local evaluation)
|
||||
```
|
||||
|
||||
Breaking change:
|
||||
|
||||
1. `get_feature_flag_payload` now deserializes payloads from JSON strings to `Any`. Previously, it returned the payload as a JSON encoded string.
|
||||
|
||||
Before:
|
||||
|
||||
```python
|
||||
payload = get_feature_flag_payload('key', 'distinct_id') # "{\"some\": \"payload\"}"
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```python
|
||||
payload = get_feature_flag_payload('key', 'distinct_id') # {"some": "payload"}
|
||||
```
|
||||
|
||||
## 3.25.0 – 2025-04-15
|
||||
|
||||
1. Roll out new `/flags` endpoint to 100% of `/decide` traffic, excluding the top 10 customers.
|
||||
|
||||
## 3.24.3 – 2025-04-15
|
||||
|
||||
1. Fix hash inclusion/exclusion for flag rollout
|
||||
|
||||
## 3.24.2 – 2025-04-15
|
||||
|
||||
1. Roll out new /flags endpoint to 10% of /decide traffic
|
||||
|
||||
## 3.24.1 – 2025-04-11
|
||||
|
||||
1. Add `log_captured_exceptions` option to proxy setup
|
||||
|
||||
## 3.24.0 – 2025-04-10
|
||||
|
||||
1. Add config option to `log_captured_exceptions`
|
||||
|
||||
## 3.23.0 – 2025-03-26
|
||||
|
||||
1. Expand automatic retries to include read errors (e.g. RemoteDisconnected)
|
||||
|
||||
## 3.22.0 – 2025-03-26
|
||||
|
||||
1. Add more information to `$feature_flag_called` events.
|
||||
2. Support for the `/decide?v=4` endpoint which contains more information about feature flags.
|
||||
|
||||
## 3.21.0 – 2025-03-17
|
||||
|
||||
1. Support serializing dataclasses.
|
||||
|
||||
## 3.20.0 – 2025-03-13
|
||||
|
||||
1. Add support for OpenAI Responses API.
|
||||
|
||||
## 3.19.2 – 2025-03-11
|
||||
|
||||
1. Fix install requirements for analytics package
|
||||
|
||||
## 3.19.1 – 2025-03-11
|
||||
|
||||
1. Fix bug where None is sent as delta in azure
|
||||
|
||||
## 3.19.0 – 2025-03-04
|
||||
|
||||
1. Add support for tool calls in OpenAI and Anthropic.
|
||||
2. Add support for cached tokens.
|
||||
|
||||
## 3.18.1 – 2025-03-03
|
||||
|
||||
1. Improve quota-limited feature flag logs
|
||||
|
||||
## 3.18.0 - 2025-02-28
|
||||
|
||||
1. Add support for Azure OpenAI.
|
||||
|
||||
## 3.17.0 - 2025-02-27
|
||||
|
||||
1. The LangChain handler now captures tools in `$ai_generation` events, in property `$ai_tools`. This allows for displaying tools provided to the LLM call in PostHog UI. Note that support for `$ai_tools` in OpenAI and Anthropic SDKs is coming soon.
|
||||
|
||||
## 3.16.0 - 2025-02-26
|
||||
|
||||
1. feat: add some platform info to events (#198)
|
||||
|
||||
## 3.15.1 - 2025-02-23
|
||||
|
||||
1. Fix async client support for OpenAI.
|
||||
|
||||
## 3.15.0 - 2025-02-19
|
||||
|
||||
1. Support quota-limited feature flags
|
||||
|
||||
## 3.14.2 - 2025-02-19
|
||||
|
||||
1. Evaluate feature flag payloads with case sensitivity correctly. Fixes <https://github.com/PostHog/posthog-python/issues/178>
|
||||
|
||||
## 3.14.1 - 2025-02-18
|
||||
|
||||
1. Add support for Bedrock Anthropic Usage
|
||||
|
||||
## 3.13.0 - 2025-02-12
|
||||
|
||||
1. Automatically retry connection errors
|
||||
|
||||
## 3.12.1 - 2025-02-11
|
||||
|
||||
1. Fix mypy support for 3.12.0
|
||||
2. Deprecate `is_simple_flag`
|
||||
|
||||
## 3.12.0 - 2025-02-11
|
||||
|
||||
1. Add support for OpenAI beta parse API.
|
||||
2. Deprecate `context` parameter
|
||||
|
||||
## 3.11.1 - 2025-02-06
|
||||
|
||||
1. Fix LangChain callback handler to capture parent run ID.
|
||||
|
||||
## 3.11.0 - 2025-01-28
|
||||
|
||||
1. Add the `$ai_span` event to the LangChain callback handler to capture the input and output of intermediary chains.
|
||||
|
||||
> LLM observability naming change: event property `$ai_trace_name` is now `$ai_span_name`.
|
||||
|
||||
2. Fix serialiazation of Pydantic models in methods.
|
||||
|
||||
## 3.10.0 - 2025-01-24
|
||||
|
||||
1. Add `$ai_error` and `$ai_is_error` properties to LangChain callback handler, OpenAI, and Anthropic.
|
||||
|
||||
## 3.9.3 - 2025-01-23
|
||||
|
||||
1. Fix capturing of multiple traces in the LangChain callback handler.
|
||||
|
||||
## 3.9.2 - 2025-01-22
|
||||
|
||||
1. Fix importing of LangChain callback handler under certain circumstances.
|
||||
|
||||
## 3.9.0 - 2025-01-22
|
||||
|
||||
1. Add `$ai_trace` event emission to LangChain callback handler.
|
||||
|
||||
## 3.8.4 - 2025-01-17
|
||||
|
||||
1. Add Anthropic support for LLM Observability.
|
||||
2. Update LLM Observability to use output_choices.
|
||||
|
||||
## 3.8.3 - 2025-01-14
|
||||
|
||||
1. Fix setuptools to include the `posthog.ai.openai` and `posthog.ai.langchain` packages for the `posthoganalytics` package.
|
||||
|
||||
## 3.8.2 - 2025-01-14
|
||||
|
||||
1. Fix setuptools to include the `posthog.ai.openai` and `posthog.ai.langchain` packages.
|
||||
|
||||
## 3.8.1 - 2025-01-14
|
||||
|
||||
1. Add LLM Observability with support for OpenAI and Langchain callbacks.
|
||||
|
||||
## 3.7.5 - 2025-01-03
|
||||
|
||||
1. Add `distinct_id` to group_identify
|
||||
|
||||
## 3.7.4 - 2024-11-25
|
||||
|
||||
1. Fix bug where this SDK incorrectly sent feature flag events with null values when calling `get_feature_flag_payload`.
|
||||
|
||||
## 3.7.3 - 2024-11-25
|
||||
|
||||
1. Use personless mode when sending an exception without a provided `distinct_id`.
|
||||
|
||||
## 3.7.2 - 2024-11-19
|
||||
|
||||
1. Add `type` property to exception stacks.
|
||||
|
||||
## 3.7.1 - 2024-10-24
|
||||
|
||||
1. Add `platform` property to each frame of exception stacks.
|
||||
|
||||
## 3.7.0 - 2024-10-03
|
||||
|
||||
1. Adds a new `super_properties` parameter on the client that are appended to every /capture call.
|
||||
|
||||
## 3.6.7 - 2024-09-24
|
||||
|
||||
1. Remove deprecated datetime.utcnow() in favour of datetime.now(tz=tzutc())
|
||||
|
||||
## 3.6.6 - 2024-09-16
|
||||
|
||||
1. Fix manual capture support for in app frames
|
||||
|
||||
## 3.6.5 - 2024-09-10
|
||||
|
||||
1. Fix django integration support for manual exception capture.
|
||||
|
||||
## 3.6.4 - 2024-09-05
|
||||
|
||||
1. Add manual exception capture.
|
||||
|
||||
## 3.6.3 - 2024-09-03
|
||||
|
||||
1. Make sure setup.py for posthoganalytics package also discovers the new exception integration package.
|
||||
|
||||
## 3.6.2 - 2024-09-03
|
||||
|
||||
1. Make sure setup.py discovers the new exception integration package.
|
||||
|
||||
## 3.6.1 - 2024-09-03
|
||||
|
||||
1. Adds django integration to exception autocapture in alpha state. This feature is not yet stable and may change in future versions.
|
||||
|
||||
## 3.6.0 - 2024-08-28
|
||||
|
||||
1. Adds exception autocapture in alpha state. This feature is not yet stable and may change in future versions.
|
||||
|
||||
## 3.5.2 - 2024-08-21
|
||||
|
||||
1. Guard for None values in local evaluation
|
||||
|
||||
## 3.5.1 - 2024-08-13
|
||||
|
||||
1. Remove "-api" suffix from ingestion hostnames
|
||||
|
||||
## 3.5.0 - 2024-02-29
|
||||
|
||||
1. - Adds a new `feature_flags_request_timeout_seconds` timeout parameter for feature flags which defaults to 3 seconds, updated from the default 10s for all other API calls.
|
||||
|
||||
## 3.4.2 - 2024-02-20
|
||||
|
||||
1. Add `historical_migration` option for bulk migration to PostHog Cloud.
|
||||
|
||||
## 3.4.1 - 2024-02-09
|
||||
|
||||
1. Use new hosts for event capture as well
|
||||
|
||||
## 3.4.0 - 2024-02-05
|
||||
|
||||
1. Point given hosts to new ingestion hosts
|
||||
|
||||
## 3.3.4 - 2024-01-30
|
||||
|
||||
1. Update type hints for module variables to work with newer versions of mypy
|
||||
|
||||
## 3.3.3 - 2024-01-26
|
||||
|
||||
1. Remove new relative date operators, combine into regular date operators
|
||||
|
||||
## 3.3.2 - 2024-01-19
|
||||
|
||||
1. Return success/failure with all capture calls from module functions
|
||||
|
||||
## 3.3.1 - 2024-01-10
|
||||
|
||||
1. Make sure we don't override any existing feature flag properties when adding locally evaluated feature flag properties.
|
||||
|
||||
## 3.3.0 - 2024-01-09
|
||||
|
||||
1. When local evaluation is enabled, we automatically add flag information to all events sent to PostHog, whenever possible. This makes it easier to use these events in experiments.
|
||||
|
||||
## 3.2.0 - 2024-01-09
|
||||
|
||||
1. Numeric property handling for feature flags now does the expected: When passed in a number, we do a numeric comparison. When passed in a string, we do a string comparison. Previously, we always did a string comparison.
|
||||
2. Add support for relative date operators for local evaluation.
|
||||
|
||||
## 3.1.0 - 2023-12-04
|
||||
|
||||
1. Increase maximum event size and batch size
|
||||
|
||||
## 3.0.2 - 2023-08-17
|
||||
|
||||
1. Returns the current flag property with $feature_flag_called events, to make it easier to use in experiments
|
||||
|
||||
## 3.0.1 - 2023-04-21
|
||||
|
||||
1. Restore how feature flags work when the client library is disabled: All requests return `None` and no events are sent when the client is disabled.
|
||||
2. Add a `feature_flag_definitions()` debug option, which returns currently loaded feature flag definitions. You can use this to more cleverly decide when to request local evaluation of feature flags.
|
||||
|
||||
## 3.0.0 - 2023-04-14
|
||||
|
||||
Breaking change:
|
||||
|
||||
All events by default now send the `$geoip_disable` property to disable geoip lookup in app. This is because usually we don't
|
||||
want to update person properties to take the server's location.
|
||||
|
||||
The same now happens for feature flag requests, where we discard the IP address of the server for matching on geoip properties like city, country, continent.
|
||||
|
||||
To restore previous behaviour, you can set the default to False like so:
|
||||
|
||||
```python
|
||||
posthog.disable_geoip = False
|
||||
|
||||
# // and if using client instantiation:
|
||||
posthog = Posthog('api_key', disable_geoip=False)
|
||||
|
||||
```
|
||||
|
||||
## 2.5.0 - 2023-04-10
|
||||
|
||||
1. Add option for instantiating separate client object
|
||||
|
||||
## 2.4.2 - 2023-03-30
|
||||
|
||||
1. Update backoff dependency for posthoganalytics package to be the same as posthog package
|
||||
|
||||
## 2.4.1 - 2023-03-17
|
||||
|
||||
1. Removes accidental print call left in for decide response
|
||||
|
||||
## 2.4.0 - 2023-03-14
|
||||
|
||||
1. Support evaluating all cohorts in feature flags for local evaluation
|
||||
|
||||
## 2.3.1 - 2023-02-07
|
||||
|
||||
1. Log instead of raise error on posthog personal api key errors
|
||||
2. Remove upper bound on backoff dependency
|
||||
|
||||
## 2.3.0 - 2023-01-31
|
||||
|
||||
1. Add support for returning payloads of matched feature flags
|
||||
|
||||
## 2.2.0 - 2022-11-14
|
||||
|
||||
Changes:
|
||||
|
||||
1. Add support for feature flag variant overrides with local evaluation
|
||||
|
||||
## 2.1.2 - 2022-09-15
|
||||
|
||||
Changes:
|
||||
|
||||
1. Fixes issues with date comparison.
|
||||
|
||||
## 2.1.1 - 2022-09-14
|
||||
|
||||
Changes:
|
||||
|
||||
1. Feature flags local evaluation now supports date property filters as well. Accepts both strings and datetime objects.
|
||||
|
||||
## 2.1.0 - 2022-08-11
|
||||
|
||||
Changes:
|
||||
|
||||
1. Feature flag defaults have been removed
|
||||
2. Setup logging only when debug mode is enabled.
|
||||
|
||||
## 2.0.1 - 2022-08-04
|
||||
|
||||
- Make poll_interval configurable
|
||||
- Add `send_feature_flag_events` parameter to feature flag calls, which determine whether the `$feature_flag_called` event should be sent or not.
|
||||
- Add `only_evaluate_locally` parameter to feature flag calls, which determines whether the feature flag should only be evaluated locally or not.
|
||||
|
||||
## 2.0.0 - 2022-08-02
|
||||
|
||||
Breaking changes:
|
||||
|
||||
1. The minimum version requirement for PostHog servers is now 1.38. If you're using PostHog Cloud, you satisfy this requirement automatically.
|
||||
2. Feature flag defaults apply only when there's an error fetching feature flag results. Earlier, if the default was set to `True`, even if a flag resolved to `False`, the default would override this.
|
||||
**Note: These are removed in 2.0.2**
|
||||
3. Feature flag remote evaluation doesn't require a personal API key.
|
||||
|
||||
New Changes:
|
||||
|
||||
1. You can now evaluate feature flags locally (i.e. without sending a request to your PostHog servers) by setting a personal API key, and passing in groups and person properties to `is_feature_enabled` and `get_feature_flag` calls.
|
||||
2. Introduces a `get_all_flags` method that returns all feature flags. This is useful for when you want to seed your frontend with some initial flags, given a user ID.
|
||||
|
||||
## 1.4.9 - 2022-06-13
|
||||
|
||||
- Support for sending feature flags with capture calls
|
||||
|
||||
## 1.4.8 - 2022-05-12
|
||||
|
||||
- Support multi variate feature flags
|
||||
|
||||
## 1.4.7 - 2022-04-25
|
||||
|
||||
- Allow feature flags usage without project_api_key
|
||||
|
||||
## 1.4.1 - 2021-05-28
|
||||
|
||||
- Fix packaging issues with Sentry integrations
|
||||
|
||||
## 1.4.0 - 2021-05-18
|
||||
|
||||
- Improve support for `project_api_key` (#32)
|
||||
- Resolve polling issues with feature flags (#29)
|
||||
- Add Sentry (and Sentry+Django) integrations (#13)
|
||||
- Fix feature flag issue with no percentage rollout (#30)
|
||||
|
||||
## 1.3.1 - 2021-05-07
|
||||
|
||||
- Add `$set` and `$set_once` support (#23)
|
||||
- Add distinct ID to `$create_alias` event (#27)
|
||||
- Add `UUID` to `ID_TYPES` (#26)
|
||||
|
||||
## 1.2.1 - 2021-02-05
|
||||
|
||||
Initial release logged in CHANGELOG.md.
|
||||
@@ -0,0 +1 @@
|
||||
@PostHog/team-feature-flags
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2020 PostHog (part of Hiberly Inc)
|
||||
Copyright (c) 2023 PostHog (part of Hiberly Inc)
|
||||
|
||||
Copyright (c) 2013 Segment Inc. friends@segment.com
|
||||
|
||||
@@ -20,3 +20,29 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
Some files in this codebase contain code from getsentry/sentry-javascript by Software, Inc. dba Sentry.
|
||||
In such cases it is explicitly stated in the file header. This license only applies to the relevant code in such cases.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2012 Functional Software, Inc. dba Sentry
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
lint:
|
||||
uvx ruff format
|
||||
|
||||
test:
|
||||
pylint --rcfile=.pylintrc --reports=y --exit-zero analytics | tee pylint.out
|
||||
flake8 --max-complexity=10 --statistics analytics > flake8.out || true
|
||||
coverage run --branch --include=analytics/\* --omit=*/test* setup.py test
|
||||
coverage run -m pytest
|
||||
coverage report
|
||||
|
||||
release:
|
||||
rm -rf dist/*
|
||||
@@ -14,16 +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 -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 -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
|
||||
|
||||
.PHONY: test release e2e_test
|
||||
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 prep_local
|
||||
|
||||
@@ -1,5 +1,66 @@
|
||||
# PostHog Python
|
||||
|
||||
Please see the main [PostHog docs](https://posthog.com/docs).
|
||||
<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>
|
||||
|
||||
Specifically, the [Python integration](https://posthog.com/docs/integrations/python-integration) details.
|
||||
Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details.
|
||||
|
||||
## Development
|
||||
|
||||
### Testing Locally
|
||||
|
||||
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 `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
|
||||
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.
|
||||
|
||||
### Releasing Versions
|
||||
|
||||
Updates are released automatically using GitHub Actions when `version.py` is updated on `master`. After bumping `version.py` in `master` and adding to `CHANGELOG.md`, the [release workflow](https://github.com/PostHog/posthog-python/blob/master/.github/workflows/release.yaml) will automatically trigger and deploy the new version.
|
||||
|
||||
If you need to check the latest runs or manually trigger a release, you can 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`.
|
||||
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/build
|
||||
#/ Description: Runs linter and mypy
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
|
||||
flake8 posthog --ignore E501,W503
|
||||
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/docs
|
||||
#/ Description: Generate documentation for the PostHog Python SDK
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
ensure_virtual_env
|
||||
|
||||
exec python3 "$(dirname "$0")/docs_scripts/generate_json_schemas.py" "$@"
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Constants for PostHog Python SDK documentation generation.
|
||||
"""
|
||||
|
||||
from typing import Dict, Union
|
||||
|
||||
# Documentation generation metadata
|
||||
DOCUMENTATION_METADATA = {
|
||||
"hogRef": "0.3",
|
||||
"slugPrefix": "posthog-python",
|
||||
"specUrl": "https://github.com/PostHog/posthog-python",
|
||||
}
|
||||
|
||||
# Docstring parsing patterns for new format
|
||||
DOCSTRING_PATTERNS = {
|
||||
"examples_section": r"Examples:\s*\n(.*?)(?=\n\s*\n\s*Category:|\Z)",
|
||||
"args_section": r"Args:\s*\n(.*?)(?=\n\s*\n\s*Examples:|\n\s*\n\s*Details:|\n\s*\n\s*Category:|\Z)",
|
||||
"details_section": r"Details:\s*\n(.*?)(?=\n\s*\n\s*Examples:|\n\s*\n\s*Category:|\Z)",
|
||||
"category_section": r"Category:\s*\n\s*(.+?)\s*(?:\n|$)",
|
||||
"code_block": r"```(?:python)?\n(.*?)```",
|
||||
"param_description": r"^\s*{param_name}:\s*(.+?)(?=\n\s*\w+:|\Z)",
|
||||
"args_marker": r"\n\s*Args:\s*\n",
|
||||
"examples_marker": r"\n\s*Examples:\s*\n",
|
||||
"details_marker": r"\n\s*Details:\s*\n",
|
||||
"category_marker": r"\n\s*Category:\s*\n",
|
||||
}
|
||||
|
||||
# Output file configuration
|
||||
OUTPUT_CONFIG: Dict[str, Union[str, int]] = {
|
||||
"output_dir": ".",
|
||||
"filename": "posthog-python-references.json",
|
||||
"indent": 2,
|
||||
}
|
||||
|
||||
# Documentation structure defaults
|
||||
DOC_DEFAULTS = {
|
||||
"showDocs": True,
|
||||
"releaseTag": "public",
|
||||
"return_type_void": "None",
|
||||
"max_optional_params": 3,
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate comprehensive SDK documentation JSON from PostHog Python SDK.
|
||||
This script inspects the code and docstrings to create documentation in the specified format.
|
||||
"""
|
||||
|
||||
import json
|
||||
import inspect
|
||||
import re
|
||||
from dataclasses import is_dataclass, fields
|
||||
from typing import get_origin, get_args, Union
|
||||
from textwrap import dedent
|
||||
from doc_constant import (
|
||||
DOCUMENTATION_METADATA,
|
||||
DOCSTRING_PATTERNS,
|
||||
OUTPUT_CONFIG,
|
||||
DOC_DEFAULTS,
|
||||
)
|
||||
import os
|
||||
|
||||
|
||||
def extract_examples_from_docstring(docstring: str) -> list:
|
||||
"""Extract code examples from docstring."""
|
||||
if not docstring:
|
||||
return []
|
||||
|
||||
examples = []
|
||||
|
||||
# Look for Examples section in the new format
|
||||
examples_section_match = re.search(
|
||||
DOCSTRING_PATTERNS["examples_section"], docstring, re.DOTALL
|
||||
)
|
||||
if examples_section_match:
|
||||
examples_content = examples_section_match.group(1).strip()
|
||||
# Extract code blocks from the Examples section
|
||||
code_blocks = re.findall(
|
||||
DOCSTRING_PATTERNS["code_block"], examples_content, re.DOTALL
|
||||
)
|
||||
for i, code_block in enumerate(code_blocks):
|
||||
# Remove common leading whitespace while preserving relative indentation
|
||||
code = dedent(code_block).strip()
|
||||
|
||||
# Extract name from first comment line if present
|
||||
lines = code.split("\n")
|
||||
name = f"Example {i + 1}" # Default fallback
|
||||
|
||||
if lines and lines[0].strip().startswith("#"):
|
||||
# Extract name from first comment, keep the comment in the code
|
||||
comment_text = lines[0].strip()[1:].strip()
|
||||
if comment_text:
|
||||
name = comment_text
|
||||
|
||||
examples.append({"id": f"example_{i + 1}", "name": name, "code": code})
|
||||
|
||||
return examples
|
||||
|
||||
|
||||
def extract_details_from_docstring(docstring: str) -> str:
|
||||
"""Extract details section from docstring."""
|
||||
if not docstring:
|
||||
return ""
|
||||
|
||||
# Look for Details section
|
||||
details_match = re.search(
|
||||
DOCSTRING_PATTERNS["details_section"], docstring, re.DOTALL
|
||||
)
|
||||
if details_match:
|
||||
details_content = details_match.group(1).strip()
|
||||
# Clean up formatting
|
||||
return details_content.replace("\n", " ")
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def parse_docstring_tags(docstring: str) -> dict:
|
||||
"""Parse tags from docstring Category section."""
|
||||
if not docstring:
|
||||
return {}
|
||||
|
||||
tags = {}
|
||||
|
||||
# Extract Category section
|
||||
category_match = re.search(DOCSTRING_PATTERNS["category_section"], docstring)
|
||||
if category_match:
|
||||
category_value = category_match.group(1).strip()
|
||||
tags["category"] = category_value
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
def extract_description_from_docstring(docstring: str) -> str:
|
||||
"""Extract main description from docstring."""
|
||||
if not docstring:
|
||||
return ""
|
||||
|
||||
# Clean up the docstring
|
||||
cleaned = dedent(docstring).strip()
|
||||
|
||||
# Find the end of the description by looking for first section marker
|
||||
# Check for Args:, Examples:, Details:, or Category: sections
|
||||
section_patterns = [
|
||||
DOCSTRING_PATTERNS["args_marker"],
|
||||
DOCSTRING_PATTERNS["examples_marker"],
|
||||
DOCSTRING_PATTERNS["details_marker"],
|
||||
DOCSTRING_PATTERNS["category_marker"],
|
||||
]
|
||||
|
||||
end_pos = len(cleaned)
|
||||
for pattern in section_patterns:
|
||||
match = re.search(pattern, cleaned)
|
||||
if match:
|
||||
end_pos = min(end_pos, match.start())
|
||||
|
||||
# Extract description up to the first section marker
|
||||
description = cleaned[:end_pos].strip()
|
||||
|
||||
# Remove one level of \n since it will be rendered as markdown
|
||||
# and \n will be padded in later steps
|
||||
description = description.replace("\n", " ")
|
||||
|
||||
return description
|
||||
|
||||
|
||||
def get_type_name(type_annotation) -> str:
|
||||
"""Convert type annotation to string name."""
|
||||
if type_annotation is None or type_annotation is type(None):
|
||||
return "any"
|
||||
|
||||
# Handle typing constructs
|
||||
origin = get_origin(type_annotation)
|
||||
if origin is not None:
|
||||
# Handle Union types (including Optional)
|
||||
if origin is Union:
|
||||
args = get_args(type_annotation)
|
||||
if len(args) == 2 and type(None) in args:
|
||||
# This is Optional[Type] - get the non-None type
|
||||
non_none_type = next(arg for arg in args if arg is not type(None))
|
||||
return f"Optional[{get_type_name(non_none_type)}]"
|
||||
else:
|
||||
# Regular Union - list all types
|
||||
type_names = [get_type_name(arg) for arg in args]
|
||||
return f"Union[{', '.join(type_names)}]"
|
||||
|
||||
# Handle other generic types (List, Dict, etc.)
|
||||
origin_name = getattr(origin, "__name__", str(origin))
|
||||
args = get_args(type_annotation)
|
||||
if args:
|
||||
arg_names = [get_type_name(arg) for arg in args]
|
||||
return f"{origin_name}[{', '.join(arg_names)}]"
|
||||
else:
|
||||
return origin_name
|
||||
|
||||
# Handle regular types
|
||||
elif hasattr(type_annotation, "__name__"):
|
||||
return type_annotation.__name__
|
||||
else:
|
||||
return str(type_annotation)
|
||||
|
||||
|
||||
def analyze_parameter(param: inspect.Parameter, docstring: str = "") -> dict:
|
||||
"""Analyze a function parameter and return its documentation."""
|
||||
# Determine if parameter is optional (has default value)
|
||||
is_optional = param.default == inspect.Parameter.empty
|
||||
|
||||
# Get the type annotation
|
||||
type_annotation = param.annotation
|
||||
param_type = "any"
|
||||
|
||||
if type_annotation != inspect.Parameter.empty:
|
||||
# Handle Union/Optional types first
|
||||
origin = get_origin(type_annotation)
|
||||
if origin is Union:
|
||||
args = get_args(type_annotation)
|
||||
if len(args) == 2 and type(None) in args:
|
||||
# This is Optional[Type]
|
||||
non_none_type = next(arg for arg in args if arg is not type(None))
|
||||
param_type = get_type_name(non_none_type)
|
||||
is_optional = True
|
||||
else:
|
||||
# Other Union types, use first type
|
||||
param_type = get_type_name(args[0]) if args else "any"
|
||||
else:
|
||||
param_type = get_type_name(type_annotation)
|
||||
elif param.default != inspect.Parameter.empty:
|
||||
# No type annotation, but has default value - infer type from default
|
||||
param_type = get_type_name(type(param.default))
|
||||
|
||||
# Extract parameter description from Args section
|
||||
param_description = ""
|
||||
if docstring:
|
||||
# Look for Args section and extract description for this parameter
|
||||
args_section_match = re.search(
|
||||
DOCSTRING_PATTERNS["args_section"], docstring, re.DOTALL
|
||||
)
|
||||
if args_section_match:
|
||||
args_content = args_section_match.group(1)
|
||||
# Look for the parameter description
|
||||
param_pattern = DOCSTRING_PATTERNS["param_description"].format(
|
||||
param_name=re.escape(param.name)
|
||||
)
|
||||
param_match = re.search(
|
||||
param_pattern, args_content, re.MULTILINE | re.DOTALL
|
||||
)
|
||||
if param_match:
|
||||
param_description = param_match.group(1).strip().replace("\n", " ")
|
||||
|
||||
param_info = {
|
||||
"name": param.name,
|
||||
"description": param_description,
|
||||
"isOptional": is_optional,
|
||||
"type": param_type,
|
||||
}
|
||||
|
||||
return param_info
|
||||
|
||||
|
||||
def analyze_function(func, name: str) -> dict:
|
||||
"""Analyze a function and return its documentation."""
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
docstring = inspect.getdoc(func) or ""
|
||||
|
||||
# Skip functions with empty docstrings
|
||||
if not docstring.strip():
|
||||
return {}
|
||||
|
||||
# Extract parameters (excluding 'self')
|
||||
params = []
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name != "self":
|
||||
params.append(analyze_parameter(param, docstring))
|
||||
|
||||
# Special handling for constructor
|
||||
display_name = name
|
||||
if name == "__init__":
|
||||
display_name = func.__qualname__.split(".")[0]
|
||||
|
||||
# Parse tags from docstring
|
||||
tags = parse_docstring_tags(docstring)
|
||||
|
||||
category = tags.get("category", None)
|
||||
|
||||
# Extract description
|
||||
description = extract_description_from_docstring(docstring)
|
||||
|
||||
# Skip if no meaningful description
|
||||
if not description.strip():
|
||||
return {}
|
||||
|
||||
# Extract details section (only if it exists)
|
||||
details = extract_details_from_docstring(docstring)
|
||||
|
||||
# Get examples from docstring, do not generate fallback examples
|
||||
examples = extract_examples_from_docstring(docstring)
|
||||
# If no examples, do not include the examples key or set to empty list
|
||||
|
||||
result = {
|
||||
"id": name,
|
||||
"title": display_name,
|
||||
"description": description,
|
||||
"details": details,
|
||||
"category": category,
|
||||
"params": params,
|
||||
"showDocs": DOC_DEFAULTS["showDocs"],
|
||||
"releaseTag": DOC_DEFAULTS["releaseTag"],
|
||||
"returnType": {
|
||||
"id": "return_type",
|
||||
"name": get_type_name(sig.return_annotation)
|
||||
if sig.return_annotation != inspect.Signature.empty
|
||||
else DOC_DEFAULTS["return_type_void"],
|
||||
},
|
||||
}
|
||||
if examples:
|
||||
result["examples"] = examples
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Error analyzing function {name}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def analyze_class(cls) -> dict:
|
||||
"""Analyze a class and return its documentation."""
|
||||
class_doc = inspect.getdoc(cls) or f"Class: {cls.__name__}"
|
||||
|
||||
# Get all public methods and constructor
|
||||
functions = []
|
||||
for method_name in dir(cls):
|
||||
if method_name.startswith("_") and method_name != "__init__":
|
||||
continue
|
||||
|
||||
method = getattr(cls, method_name)
|
||||
if callable(method):
|
||||
func_info = analyze_function(method, method_name)
|
||||
if func_info: # Only add if not None (empty docstring check)
|
||||
functions.append(func_info)
|
||||
|
||||
return {
|
||||
"id": cls.__name__,
|
||||
"title": cls.__name__,
|
||||
"description": extract_description_from_docstring(class_doc),
|
||||
"functions": functions,
|
||||
}
|
||||
|
||||
|
||||
def analyze_type(cls) -> dict:
|
||||
"""Analyze a type/dataclass and return its documentation."""
|
||||
type_info = {
|
||||
"id": cls.__name__,
|
||||
"name": cls.__name__,
|
||||
"path": f"{cls.__module__}.{cls.__name__}",
|
||||
"properties": [],
|
||||
"example": "",
|
||||
}
|
||||
|
||||
if is_dataclass(cls):
|
||||
# Handle dataclass
|
||||
for field in fields(cls):
|
||||
prop = {
|
||||
"name": field.name,
|
||||
"type": get_type_name(field.type),
|
||||
"description": f"Field: {field.name}",
|
||||
}
|
||||
type_info["properties"].append(prop)
|
||||
elif hasattr(cls, "__annotations__"):
|
||||
# Handle TypedDict or annotated class
|
||||
for field_name, field_type in cls.__annotations__.items():
|
||||
prop = {
|
||||
"name": field_name,
|
||||
"type": get_type_name(field_type),
|
||||
"description": f"Field: {field_name}",
|
||||
}
|
||||
type_info["properties"].append(prop)
|
||||
|
||||
return type_info
|
||||
|
||||
|
||||
def generate_sdk_documentation():
|
||||
"""Generate complete SDK documentation in the requested format."""
|
||||
|
||||
# Import PostHog components
|
||||
import posthog
|
||||
from posthog.client import Client
|
||||
import posthog.types as types_module
|
||||
import posthog.args as args_module
|
||||
from posthog.version import VERSION
|
||||
|
||||
# Main SDK info
|
||||
sdk_info = {
|
||||
"version": VERSION,
|
||||
"id": "posthog-python",
|
||||
"title": "PostHog Python SDK",
|
||||
"description": "Integrate PostHog into any python application.",
|
||||
"slugPrefix": DOCUMENTATION_METADATA["slugPrefix"],
|
||||
"specUrl": DOCUMENTATION_METADATA["specUrl"],
|
||||
}
|
||||
|
||||
# Collect types
|
||||
types_list = []
|
||||
|
||||
# Types from posthog.types
|
||||
for name in dir(types_module):
|
||||
obj = getattr(types_module, name)
|
||||
if inspect.isclass(obj) and not name.startswith("_"):
|
||||
try:
|
||||
type_info = analyze_type(obj)
|
||||
types_list.append(type_info)
|
||||
except Exception as e:
|
||||
print(f"Error analyzing type {name}: {e}")
|
||||
|
||||
# Types from posthog.args
|
||||
for name in dir(args_module):
|
||||
obj = getattr(args_module, name)
|
||||
if inspect.isclass(obj) and not name.startswith("_"):
|
||||
try:
|
||||
type_info = analyze_type(obj)
|
||||
types_list.append(type_info)
|
||||
except Exception as e:
|
||||
print(f"Error analyzing type {name}: {e}")
|
||||
|
||||
# Clean types of empty types
|
||||
|
||||
# Remove types that have no properties and no examples
|
||||
# Remove types that have no properties and no examples
|
||||
types_list = [
|
||||
t for t in types_list if len(t["properties"]) > 0 or t["example"] != ""
|
||||
]
|
||||
|
||||
# Collect classes
|
||||
classes_list = []
|
||||
|
||||
# Main PostHog class (renamed from Client)
|
||||
client_class = analyze_class(Client)
|
||||
client_class["id"] = "PostHog"
|
||||
client_class["title"] = "PostHog"
|
||||
classes_list.append(client_class)
|
||||
|
||||
# Global module functions (functions callable as posthog.function_name)
|
||||
global_functions = []
|
||||
for func_name in dir(posthog):
|
||||
# Skip private functions and non-callables
|
||||
if func_name.startswith("_") or not callable(getattr(posthog, func_name)):
|
||||
continue
|
||||
|
||||
func = getattr(posthog, func_name)
|
||||
# Only include functions actually defined in the posthog module (not imported)
|
||||
# and exclude class references
|
||||
if (
|
||||
func_name not in ["Client", "Posthog"]
|
||||
and hasattr(func, "__module__")
|
||||
and func.__module__ == "posthog"
|
||||
):
|
||||
try:
|
||||
func_info = analyze_function(func, func_name)
|
||||
if func_info: # Only add if not None (has proper docstring)
|
||||
global_functions.append(func_info)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Add global functions as a "class"
|
||||
if global_functions:
|
||||
classes_list.append(
|
||||
{
|
||||
"id": "PostHogModule",
|
||||
"title": "PostHog Module Functions",
|
||||
"description": "Global functions available in the PostHog module",
|
||||
"functions": global_functions,
|
||||
}
|
||||
)
|
||||
|
||||
# Collect categories from functions
|
||||
categories = ["Initialization", "Identification", "Capture"]
|
||||
seen_categories = set(categories)
|
||||
for class_info in classes_list:
|
||||
if "functions" in class_info:
|
||||
for func in class_info["functions"]:
|
||||
if (
|
||||
"category" in func
|
||||
and func["category"] not in seen_categories
|
||||
and func["category"]
|
||||
):
|
||||
categories.append(func["category"])
|
||||
seen_categories.add(func["category"])
|
||||
|
||||
# Create the final structure
|
||||
result = {
|
||||
"id": "posthog-python",
|
||||
"hogRef": DOCUMENTATION_METADATA["hogRef"],
|
||||
"info": sdk_info,
|
||||
"types": types_list,
|
||||
"classes": classes_list,
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Generating PostHog Python SDK documentation...")
|
||||
|
||||
try:
|
||||
documentation = generate_sdk_documentation()
|
||||
|
||||
# Write to file
|
||||
output_file = os.path.join(
|
||||
str(OUTPUT_CONFIG["output_dir"]), str(OUTPUT_CONFIG["filename"])
|
||||
)
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(documentation, f, indent=int(OUTPUT_CONFIG["indent"]))
|
||||
|
||||
print(f"✓ Generated {output_file}")
|
||||
|
||||
# Print summary
|
||||
types_count = len(documentation["types"])
|
||||
classes_count = len(documentation["classes"])
|
||||
|
||||
total_functions = sum(len(cls["functions"]) for cls in documentation["classes"])
|
||||
|
||||
print("📊 Documentation Summary:")
|
||||
print(f" • {types_count} types documented")
|
||||
print(f" • {classes_count} classes documented")
|
||||
print(f" • {total_functions} functions documented")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error generating documentation: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/fmt
|
||||
#/ Description: Formats and lints the code
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
ensure_virtual_env
|
||||
|
||||
if [[ "$1" == "--check" ]]; then
|
||||
ruff format --check .
|
||||
else
|
||||
ruff format .
|
||||
fi
|
||||
@@ -0,0 +1,26 @@
|
||||
error() {
|
||||
echo "$@" >&2
|
||||
}
|
||||
|
||||
fatal() {
|
||||
error "$@"
|
||||
exit 1
|
||||
}
|
||||
|
||||
set_source_and_root_dir() {
|
||||
{ set +x; } 2>/dev/null
|
||||
source_dir="$( cd -P "$( dirname "$0" )" >/dev/null 2>&1 && pwd )"
|
||||
root_dir=$(cd "$source_dir" && cd ../ && pwd)
|
||||
cd "$root_dir"
|
||||
}
|
||||
|
||||
ensure_virtual_env() {
|
||||
if [ -z "$VIRTUAL_ENV" ]; then
|
||||
echo "Virtual environment not activated. Activating now..."
|
||||
if [ ! -f env/bin/activate ]; then
|
||||
echo "Virtual environment not found. Please run 'python -m venv env' first."
|
||||
exit 1
|
||||
fi
|
||||
source env/bin/activate
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/setup
|
||||
#/ Description: Sets up the dependencies needed to develop this project
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
|
||||
if [ ! -d "env" ]; then
|
||||
python3 -m venv env
|
||||
fi
|
||||
|
||||
source env/bin/activate
|
||||
pip install -e ".[dev,test]"
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
#/ Usage: bin/test
|
||||
#/ Description: Runs all the unit tests for this project
|
||||
source bin/helpers/_utils.sh
|
||||
set_source_and_root_dir
|
||||
|
||||
ensure_virtual_env
|
||||
|
||||
# Pass through all arguments to pytest
|
||||
pytest "$@"
|
||||
+491
-18
@@ -1,29 +1,502 @@
|
||||
# PostHog Python library example
|
||||
#
|
||||
# This script demonstrates various PostHog Python SDK capabilities including:
|
||||
# - Basic event capture and user identification
|
||||
# - Feature flag local evaluation
|
||||
# - Feature flag payloads
|
||||
# - Context management and tagging
|
||||
#
|
||||
# Setup:
|
||||
# 1. Copy .env.example to .env and fill in your PostHog credentials
|
||||
# 2. Run this script and choose from the interactive menu
|
||||
|
||||
import os
|
||||
|
||||
# Import the library
|
||||
import posthog
|
||||
import time
|
||||
|
||||
# You can find this key on the /setup page in PostHog
|
||||
posthog.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'
|
||||
def load_env_file():
|
||||
"""Load environment variables from .env file if it exists."""
|
||||
env_path = os.path.join(os.path.dirname(__file__), ".env")
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
# Capture an event
|
||||
posthog.capture('distinct_id', 'event', {'property1': 'value', 'property2': 'value'})
|
||||
|
||||
print(posthog.feature_enabled('beta-feature', 'distinct_id'))
|
||||
# Load .env file if it exists
|
||||
load_env_file()
|
||||
|
||||
print('sleeping')
|
||||
time.sleep(45)
|
||||
# Get configuration
|
||||
project_key = os.getenv("POSTHOG_PROJECT_API_KEY", "")
|
||||
personal_api_key = os.getenv("POSTHOG_PERSONAL_API_KEY", "")
|
||||
host = os.getenv("POSTHOG_HOST", "http://localhost:8000")
|
||||
|
||||
print(posthog.feature_enabled('beta-feature', 'distinct_id'))
|
||||
# Check if credentials are provided
|
||||
if not project_key or not personal_api_key:
|
||||
print("❌ Missing PostHog credentials!")
|
||||
print(
|
||||
" Please set POSTHOG_PROJECT_API_KEY and POSTHOG_PERSONAL_API_KEY environment variables"
|
||||
)
|
||||
print(" or copy .env.example to .env and fill in your values")
|
||||
exit(1)
|
||||
|
||||
# # Alias a previous distinct id with a new one
|
||||
posthog.alias('distinct_id', 'new_distinct_id')
|
||||
# Test authentication before proceeding
|
||||
print("🔑 Testing PostHog authentication...")
|
||||
|
||||
# # Add properties to the person
|
||||
posthog.identify('distinct_id', {'email': 'something@something.com'})
|
||||
try:
|
||||
# Configure PostHog with credentials
|
||||
posthog.debug = False # Keep quiet during auth test
|
||||
posthog.api_key = project_key
|
||||
posthog.project_api_key = project_key
|
||||
posthog.personal_api_key = personal_api_key
|
||||
posthog.host = host
|
||||
posthog.poll_interval = 10
|
||||
|
||||
# Test by attempting to get feature flags (this validates both keys)
|
||||
# This will fail if credentials are invalid
|
||||
test_flags = posthog.get_all_flags("test_user", only_evaluate_locally=True)
|
||||
|
||||
# If we get here without exception, credentials work
|
||||
print("✅ Authentication successful!")
|
||||
print(f" Project API Key: {project_key[:9]}...")
|
||||
print(" Personal API Key: [REDACTED]")
|
||||
print(f" Host: {host}\n\n")
|
||||
|
||||
except Exception as e:
|
||||
print("❌ Authentication failed!")
|
||||
print(f" Error: {e}")
|
||||
print("\n Please check your credentials:")
|
||||
print(" - POSTHOG_PROJECT_API_KEY: Project API key from PostHog settings")
|
||||
print(
|
||||
" - POSTHOG_PERSONAL_API_KEY: Personal API key (required for local evaluation)"
|
||||
)
|
||||
print(" - POSTHOG_HOST: Your PostHog instance URL")
|
||||
exit(1)
|
||||
|
||||
# Display menu and get user choice
|
||||
print("🚀 PostHog Python SDK Demo - Choose an example to run:\n")
|
||||
print("1. Identify and capture examples")
|
||||
print("2. Feature flag local evaluation examples")
|
||||
print("3. Feature flag payload examples")
|
||||
print("4. Flag dependencies examples")
|
||||
print("5. Context management and tagging examples")
|
||||
print("6. Run all examples")
|
||||
print("7. Exit")
|
||||
choice = input("\nEnter your choice (1-7): ").strip()
|
||||
|
||||
if choice == "1":
|
||||
print("\n" + "=" * 60)
|
||||
print("IDENTIFY AND CAPTURE EXAMPLES")
|
||||
print("=" * 60)
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
# Capture an event
|
||||
print("📊 Capturing events...")
|
||||
posthog.capture(
|
||||
"event",
|
||||
distinct_id="distinct_id",
|
||||
properties={"property1": "value", "property2": "value"},
|
||||
send_feature_flags=True,
|
||||
)
|
||||
|
||||
# Alias a previous distinct id with a new one
|
||||
print("🔗 Creating alias...")
|
||||
posthog.alias("distinct_id", "new_distinct_id")
|
||||
|
||||
posthog.capture(
|
||||
"event2",
|
||||
distinct_id="new_distinct_id",
|
||||
properties={"property1": "value", "property2": "value"},
|
||||
)
|
||||
posthog.capture(
|
||||
"event-with-groups",
|
||||
distinct_id="new_distinct_id",
|
||||
properties={"property1": "value", "property2": "value"},
|
||||
groups={"company": "id:5"},
|
||||
)
|
||||
|
||||
# Add properties to the person
|
||||
print("👤 Identifying user...")
|
||||
posthog.set(
|
||||
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
|
||||
)
|
||||
|
||||
# Add properties to a group
|
||||
print("🏢 Identifying group...")
|
||||
posthog.group_identify("company", "id:5", {"employees": 11})
|
||||
|
||||
# Properties set only once to the person
|
||||
print("🔒 Setting properties once...")
|
||||
posthog.set_once(
|
||||
distinct_id="new_distinct_id", properties={"self_serve_signup": True}
|
||||
)
|
||||
|
||||
# This will not change the property (because it was already set)
|
||||
posthog.set_once(
|
||||
distinct_id="new_distinct_id", properties={"self_serve_signup": False}
|
||||
)
|
||||
|
||||
print("🔄 Updating properties...")
|
||||
posthog.set(distinct_id="new_distinct_id", properties={"current_browser": "Chrome"})
|
||||
posthog.set(
|
||||
distinct_id="new_distinct_id", properties={"current_browser": "Firefox"}
|
||||
)
|
||||
|
||||
elif choice == "2":
|
||||
print("\n" + "=" * 60)
|
||||
print("FEATURE FLAG LOCAL EVALUATION EXAMPLES")
|
||||
print("=" * 60)
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
print("🏁 Testing basic feature flags...")
|
||||
print(
|
||||
f"beta-feature for 'distinct_id': {posthog.feature_enabled('beta-feature', 'distinct_id')}"
|
||||
)
|
||||
print(
|
||||
f"beta-feature for 'new_distinct_id': {posthog.feature_enabled('beta-feature', 'new_distinct_id')}"
|
||||
)
|
||||
print(
|
||||
f"beta-feature with groups: {posthog.feature_enabled('beta-feature-groups', 'distinct_id', groups={'company': 'id:5'})}"
|
||||
)
|
||||
|
||||
print("\n🌍 Testing location-based flags...")
|
||||
# Assume test-flag has `City Name = Sydney` as a person property set
|
||||
print(
|
||||
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
|
||||
)
|
||||
|
||||
print(
|
||||
f"Sydney user (local only): {posthog.feature_enabled('test-flag', 'distinct_id_random_22', person_properties={'$geoip_city_name': 'Sydney'}, only_evaluate_locally=True)}"
|
||||
)
|
||||
|
||||
print("\n📋 Getting all flags...")
|
||||
print(f"All flags: {posthog.get_all_flags('distinct_id_random_22')}")
|
||||
print(
|
||||
f"All flags (local): {posthog.get_all_flags('distinct_id_random_22', only_evaluate_locally=True)}"
|
||||
)
|
||||
print(
|
||||
f"All flags with properties: {posthog.get_all_flags('distinct_id_random_22', person_properties={'$geoip_city_name': 'Sydney'}, only_evaluate_locally=True)}"
|
||||
)
|
||||
|
||||
elif choice == "3":
|
||||
print("\n" + "=" * 60)
|
||||
print("FEATURE FLAG PAYLOAD EXAMPLES")
|
||||
print("=" * 60)
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
print("📦 Testing feature flag payloads...")
|
||||
print(
|
||||
f"beta-feature payload: {posthog.get_feature_flag_payload('beta-feature', 'distinct_id')}"
|
||||
)
|
||||
print(
|
||||
f"All flags and payloads: {posthog.get_all_flags_and_payloads('distinct_id')}"
|
||||
)
|
||||
print(
|
||||
f"Remote config payload: {posthog.get_remote_config_payload('encrypted_payload_flag_key')}"
|
||||
)
|
||||
|
||||
# Get feature flag result with all details (enabled, variant, payload, key, reason)
|
||||
print("\n🔍 Getting detailed flag result...")
|
||||
result = posthog.get_feature_flag_result("beta-feature", "distinct_id")
|
||||
if result:
|
||||
print(f"Flag key: {result.key}")
|
||||
print(f"Flag enabled: {result.enabled}")
|
||||
print(f"Variant: {result.variant}")
|
||||
print(f"Payload: {result.payload}")
|
||||
print(f"Reason: {result.reason}")
|
||||
# get_value() returns the variant if it exists, otherwise the enabled value
|
||||
print(f"Value (variant or enabled): {result.get_value()}")
|
||||
|
||||
elif choice == "4":
|
||||
print("\n" + "=" * 60)
|
||||
print("FLAG DEPENDENCIES EXAMPLES")
|
||||
print("=" * 60)
|
||||
print("🔗 Testing flag dependencies with local evaluation...")
|
||||
print(
|
||||
" Flag structure: 'test-flag-dependency' depends on 'beta-feature' being enabled"
|
||||
)
|
||||
print("")
|
||||
print("📋 Required setup (if 'test-flag-dependency' doesn't exist):")
|
||||
print(" 1. Create feature flag 'beta-feature':")
|
||||
print(" - Condition: email contains '@example.com'")
|
||||
print(" - Rollout: 100%")
|
||||
print(" 2. Create feature flag 'test-flag-dependency':")
|
||||
print(" - Condition: flag 'beta-feature' is enabled")
|
||||
print(" - Rollout: 100%")
|
||||
print("")
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
# Test @example.com user (should satisfy dependency if flags exist)
|
||||
result1 = posthog.feature_enabled(
|
||||
"test-flag-dependency",
|
||||
"example_user",
|
||||
person_properties={"email": "user@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
print(f"✅ @example.com user (test-flag-dependency): {result1}")
|
||||
|
||||
# Test non-example.com user (dependency should not be satisfied)
|
||||
result2 = posthog.feature_enabled(
|
||||
"test-flag-dependency",
|
||||
"regular_user",
|
||||
person_properties={"email": "user@other.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
print(f"❌ Regular user (test-flag-dependency): {result2}")
|
||||
|
||||
# Test beta-feature directly for comparison
|
||||
beta1 = posthog.feature_enabled(
|
||||
"beta-feature",
|
||||
"example_user",
|
||||
person_properties={"email": "user@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
beta2 = posthog.feature_enabled(
|
||||
"beta-feature",
|
||||
"regular_user",
|
||||
person_properties={"email": "user@other.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
print(f"📊 Beta feature comparison - @example.com: {beta1}, regular: {beta2}")
|
||||
|
||||
print("\n🎯 Results Summary:")
|
||||
print(
|
||||
f" - Flag dependencies evaluated locally: {'✅ YES' if result1 != result2 else '❌ NO'}"
|
||||
)
|
||||
print(" - Zero API calls needed: ✅ YES (all evaluated locally)")
|
||||
print(" - Python SDK supports flag dependencies: ✅ YES")
|
||||
|
||||
print("\n" + "-" * 60)
|
||||
print("PRODUCTION-STYLE MULTIVARIATE DEPENDENCY CHAIN")
|
||||
print("-" * 60)
|
||||
print("🔗 Testing complex multivariate flag dependencies...")
|
||||
print(
|
||||
" Structure: multivariate-root-flag -> multivariate-intermediate-flag -> multivariate-leaf-flag"
|
||||
)
|
||||
print("")
|
||||
print("📋 Required setup (if flags don't exist):")
|
||||
print(
|
||||
" 1. Create 'multivariate-leaf-flag' with fruit variants (pineapple, mango, papaya, kiwi)"
|
||||
)
|
||||
print(" - pineapple: email = 'pineapple@example.com'")
|
||||
print(" - mango: email = 'mango@example.com'")
|
||||
print(
|
||||
" 2. Create 'multivariate-intermediate-flag' with color variants (blue, red)"
|
||||
)
|
||||
print(" - blue: depends on multivariate-leaf-flag = 'pineapple'")
|
||||
print(" - red: depends on multivariate-leaf-flag = 'mango'")
|
||||
print(
|
||||
" 3. Create 'multivariate-root-flag' with show variants (breaking-bad, the-wire)"
|
||||
)
|
||||
print(" - breaking-bad: depends on multivariate-intermediate-flag = 'blue'")
|
||||
print(" - the-wire: depends on multivariate-intermediate-flag = 'red'")
|
||||
print("")
|
||||
|
||||
# Test pineapple -> blue -> breaking-bad chain
|
||||
dependent_result3 = posthog.get_feature_flag(
|
||||
"multivariate-root-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": "pineapple@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
if str(dependent_result3) != "breaking-bad":
|
||||
print(
|
||||
f" ❌ Something went wrong evaluating 'multivariate-root-flag' with pineapple@example.com. Expected 'breaking-bad', got '{dependent_result3}'"
|
||||
)
|
||||
else:
|
||||
print("✅ 'multivariate-root-flag' with email pineapple@example.com succeeded")
|
||||
|
||||
# Test mango -> red -> the-wire chain
|
||||
dependent_result4 = posthog.get_feature_flag(
|
||||
"multivariate-root-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": "mango@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
if str(dependent_result4) != "the-wire":
|
||||
print(
|
||||
f" ❌ Something went wrong evaluating multivariate-root-flag with mango@example.com. Expected 'the-wire', got '{dependent_result4}'"
|
||||
)
|
||||
else:
|
||||
print("✅ 'multivariate-root-flag' with email mango@example.com succeeded")
|
||||
|
||||
# Show the complete chain evaluation
|
||||
print("\n🔍 Complete dependency chain evaluation:")
|
||||
for email, expected_chain in [
|
||||
("pineapple@example.com", ["pineapple", "blue", "breaking-bad"]),
|
||||
("mango@example.com", ["mango", "red", "the-wire"]),
|
||||
]:
|
||||
leaf = posthog.get_feature_flag(
|
||||
"multivariate-leaf-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": email},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
intermediate = posthog.get_feature_flag(
|
||||
"multivariate-intermediate-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": email},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
root = posthog.get_feature_flag(
|
||||
"multivariate-root-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": email},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
|
||||
actual_chain = [str(leaf), str(intermediate), str(root)]
|
||||
chain_success = actual_chain == expected_chain
|
||||
|
||||
print(f" 📧 {email}:")
|
||||
print(f" Expected: {' -> '.join(map(str, expected_chain))}")
|
||||
print(f" Actual: {' -> '.join(map(str, actual_chain))}")
|
||||
print(f" Status: {'✅ SUCCESS' if chain_success else '❌ FAILED'}")
|
||||
|
||||
print("\n🎯 Multivariate Chain Summary:")
|
||||
print(" - Complex dependency chains: ✅ SUPPORTED")
|
||||
print(" - Multivariate flag dependencies: ✅ SUPPORTED")
|
||||
print(" - Local evaluation of chains: ✅ WORKING")
|
||||
|
||||
elif choice == "5":
|
||||
print("\n" + "=" * 60)
|
||||
print("CONTEXT MANAGEMENT AND TAGGING EXAMPLES")
|
||||
print("=" * 60)
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
print("🏷️ Testing context management...")
|
||||
print(
|
||||
"You can add tags to a context, and these are automatically added to any events 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.
|
||||
try:
|
||||
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")
|
||||
print("✅ Event captured with inherited context tags")
|
||||
# This exception will be captured with the tags set above
|
||||
# raise Exception("Order processing failed")
|
||||
except Exception as e:
|
||||
print(f"Exception captured: {e}")
|
||||
|
||||
# Use fresh=True to start with a clean context (no inherited tags)
|
||||
try:
|
||||
with posthog.new_context(fresh=True):
|
||||
posthog.tag("session_id", "xyz789")
|
||||
# Only session_id tag will be present, no inherited tags
|
||||
posthog.capture("session_event")
|
||||
print("✅ Event captured with fresh context tags")
|
||||
# raise Exception("Session handling failed")
|
||||
except Exception as e:
|
||||
print(f"Exception captured: {e}")
|
||||
|
||||
# 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)
|
||||
posthog.capture("order_step_completed")
|
||||
print(f"✅ Order {order_id} processed with scoped context")
|
||||
# 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)
|
||||
posthog.capture("payment_processed")
|
||||
print(f"✅ Payment {payment_id} processed with fresh scoped context")
|
||||
# Only payment_id tag will be present, no inherited tags
|
||||
# raise Exception("Payment processing failed")
|
||||
|
||||
process_order("12345")
|
||||
process_payment("67890")
|
||||
|
||||
elif choice == "6":
|
||||
print("\n🔄 Running all examples...")
|
||||
|
||||
# Run example 1
|
||||
print(f"\n{'🔸' * 20} IDENTIFY AND CAPTURE {'🔸' * 20}")
|
||||
posthog.debug = True
|
||||
print("📊 Capturing events...")
|
||||
posthog.capture(
|
||||
"event",
|
||||
distinct_id="distinct_id",
|
||||
properties={"property1": "value", "property2": "value"},
|
||||
send_feature_flags=True,
|
||||
)
|
||||
print("🔗 Creating alias...")
|
||||
posthog.alias("distinct_id", "new_distinct_id")
|
||||
print("👤 Identifying user...")
|
||||
posthog.set(
|
||||
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
|
||||
)
|
||||
|
||||
# Run example 2
|
||||
print(f"\n{'🔸' * 20} FEATURE FLAGS {'🔸' * 20}")
|
||||
print("🏁 Testing basic feature flags...")
|
||||
print(f"beta-feature: {posthog.feature_enabled('beta-feature', 'distinct_id')}")
|
||||
print(
|
||||
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
|
||||
)
|
||||
|
||||
# Run example 3
|
||||
print(f"\n{'🔸' * 20} PAYLOADS {'🔸' * 20}")
|
||||
print("📦 Testing payloads...")
|
||||
print(f"Payload: {posthog.get_feature_flag_payload('beta-feature', 'distinct_id')}")
|
||||
|
||||
# Run example 4
|
||||
print(f"\n{'🔸' * 20} FLAG DEPENDENCIES {'🔸' * 20}")
|
||||
print("🔗 Testing flag dependencies...")
|
||||
result1 = posthog.feature_enabled(
|
||||
"test-flag-dependency",
|
||||
"demo_user",
|
||||
person_properties={"email": "user@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
result2 = posthog.feature_enabled(
|
||||
"test-flag-dependency",
|
||||
"demo_user2",
|
||||
person_properties={"email": "user@other.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
print(f"✅ @example.com user: {result1}, regular user: {result2}")
|
||||
|
||||
# Run example 5
|
||||
print(f"\n{'🔸' * 20} CONTEXT MANAGEMENT {'🔸' * 20}")
|
||||
print("🏷️ Testing context management...")
|
||||
with posthog.new_context():
|
||||
posthog.tag("demo_run", "all_examples")
|
||||
posthog.capture("demo_completed")
|
||||
print("✅ Demo completed with context tags")
|
||||
|
||||
elif choice == "7":
|
||||
print("👋 Goodbye!")
|
||||
posthog.shutdown()
|
||||
exit()
|
||||
|
||||
else:
|
||||
print("❌ Invalid choice. Please run again and select 1-7.")
|
||||
posthog.shutdown()
|
||||
exit()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ Example completed!")
|
||||
print("=" * 60)
|
||||
|
||||
posthog.shutdown()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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/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]
|
||||
posthog/request.py:0: error: Incompatible types in assignment (expression has type "bytes", variable has type "str") [assignment]
|
||||
posthog/consumer.py:0: error: Name "Empty" already defined (possibly by an import) [no-redef]
|
||||
posthog/consumer.py:0: error: Need type annotation for "items" (hint: "items: list[<type>] = ...") [var-annotated]
|
||||
posthog/consumer.py:0: error: Unsupported operand types for <= ("int" and "str") [operator]
|
||||
posthog/consumer.py:0: note: Right operand is of type "int | str"
|
||||
posthog/consumer.py:0: error: Unsupported operand types for < ("str" and "int") [operator]
|
||||
posthog/consumer.py:0: note: Left operand is of type "int | str"
|
||||
posthog/feature_flags.py:0: error: Library stubs not installed for "dateutil" [import-untyped]
|
||||
posthog/feature_flags.py:0: error: Library stubs not installed for "dateutil.relativedelta" [import-untyped]
|
||||
posthog/feature_flags.py:0: error: Unused "type: ignore" comment [unused-ignore]
|
||||
posthog/client.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
|
||||
posthog/client.py:0: note: Hint: "python3 -m pip install types-python-dateutil"
|
||||
posthog/client.py:0: note: (or run "mypy --install-types" to install all missing stub packages)
|
||||
posthog/client.py:0: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
|
||||
posthog/client.py:0: error: Library stubs not installed for "six" [import-untyped]
|
||||
posthog/client.py:0: note: Hint: "python3 -m pip install types-six"
|
||||
posthog/client.py:0: error: Name "queue" already defined (by an import) [no-redef]
|
||||
posthog/client.py:0: error: Need type annotation for "queue" [var-annotated]
|
||||
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | list[Any]", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Any, Any]", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: "None" has no attribute "__iter__" (not iterable) [attr-defined]
|
||||
posthog/client.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | dict[Any, Any]", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | dict[Any, Any]", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Never, Never]", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Never, Never]", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: Right operand of "and" is never evaluated [unreachable]
|
||||
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Poller", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: "None" has no attribute "start" [attr-defined]
|
||||
posthog/client.py:0: error: "None" has no attribute "get" [attr-defined]
|
||||
posthog/client.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/client.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"?
|
||||
posthog/client.py:0: error: Name "urlparse" already defined (possibly by an import) [no-redef]
|
||||
posthog/client.py:0: error: Name "parse_qs" already defined (possibly by an import) [no-redef]
|
||||
@@ -0,0 +1,39 @@
|
||||
[mypy]
|
||||
python_version = 3.11
|
||||
plugins =
|
||||
pydantic.mypy
|
||||
strict_optional = True
|
||||
no_implicit_optional = True
|
||||
warn_unused_ignores = True
|
||||
check_untyped_defs = True
|
||||
warn_unreachable = True
|
||||
strict_equality = True
|
||||
ignore_missing_imports = True
|
||||
exclude = env/.*|venv/.*|build/.*
|
||||
|
||||
[mypy-django.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-sentry_sdk.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-posthog.test.*]
|
||||
ignore_errors = True
|
||||
|
||||
[mypy-posthog.*.test.*]
|
||||
ignore_errors = True
|
||||
|
||||
[mypy-openai.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-langchain.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-langchain_core.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-anthropic.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-httpx.*]
|
||||
ignore_missing_imports = True
|
||||
+708
-105
@@ -1,161 +1,764 @@
|
||||
import datetime # noqa: F401
|
||||
from typing import Callable, Dict, Optional, Any # noqa: F401
|
||||
from typing_extensions import Unpack
|
||||
|
||||
from posthog.version import VERSION
|
||||
from posthog.args import OptionalCaptureArgs, OptionalSetArgs, ExceptionArg
|
||||
from posthog.client import Client
|
||||
from typing import Optional, Dict, Callable
|
||||
from posthog.contexts import (
|
||||
new_context as inner_new_context,
|
||||
scoped as inner_scoped,
|
||||
tag as inner_tag,
|
||||
set_context_session as inner_set_context_session,
|
||||
identify_context as inner_identify_context,
|
||||
)
|
||||
from posthog.types import FeatureFlag, FlagsAndPayloads, FeatureFlagResult
|
||||
from posthog.version import VERSION
|
||||
|
||||
__version__ = VERSION
|
||||
|
||||
"""Context management."""
|
||||
|
||||
|
||||
def new_context(fresh=False, capture_exceptions=True):
|
||||
"""
|
||||
Create a new context scope that will be active for the duration of the with block.
|
||||
|
||||
Args:
|
||||
fresh: Whether to start with a fresh context (default: False)
|
||||
capture_exceptions: Whether to capture exceptions raised within the context (default: True)
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import new_context, tag, capture
|
||||
with new_context():
|
||||
tag("request_id", "123")
|
||||
capture("event_name", properties={"property": "value"})
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
return inner_new_context(fresh=fresh, capture_exceptions=capture_exceptions)
|
||||
|
||||
|
||||
def scoped(fresh=False, capture_exceptions=True):
|
||||
"""
|
||||
Decorator that creates a new context for the function.
|
||||
|
||||
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)
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import scoped, tag, capture
|
||||
@scoped()
|
||||
def process_payment(payment_id):
|
||||
tag("payment_id", payment_id)
|
||||
capture("payment_started")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
return inner_scoped(fresh=fresh, capture_exceptions=capture_exceptions)
|
||||
|
||||
|
||||
def set_context_session(session_id: str):
|
||||
"""
|
||||
Set the session ID for the current context.
|
||||
|
||||
Args:
|
||||
session_id: The session ID to associate with the current context and its children
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import set_context_session
|
||||
set_context_session("session_123")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
return inner_set_context_session(session_id)
|
||||
|
||||
|
||||
def identify_context(distinct_id: str):
|
||||
"""
|
||||
Identify the current context with a distinct ID.
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID to associate with the current context and its children
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import identify_context
|
||||
identify_context("user_123")
|
||||
```
|
||||
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
return inner_identify_context(distinct_id)
|
||||
|
||||
|
||||
def tag(name: str, value: Any):
|
||||
"""
|
||||
Add a tag to the current context.
|
||||
|
||||
Args:
|
||||
name: The tag key
|
||||
value: The tag value
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import tag
|
||||
tag("user_id", "123")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
return inner_tag(name, value)
|
||||
|
||||
|
||||
"""Settings."""
|
||||
api_key = None # type: str
|
||||
host = None # type: str
|
||||
on_error = None # type: Callable
|
||||
debug = False # type: bool
|
||||
send = True # type: bool
|
||||
sync_mode = False # type: bool
|
||||
disabled = False # type: bool
|
||||
personal_api_key = None # type: str
|
||||
api_key = None # type: Optional[str]
|
||||
host = None # type: Optional[str]
|
||||
on_error = None # type: Optional[Callable]
|
||||
debug = False # type: bool
|
||||
send = True # type: bool
|
||||
sync_mode = False # type: bool
|
||||
disabled = False # type: bool
|
||||
personal_api_key = None # type: Optional[str]
|
||||
project_api_key = None # type: Optional[str]
|
||||
poll_interval = 30 # type: int
|
||||
disable_geoip = True # type: bool
|
||||
feature_flags_request_timeout_seconds = 3 # type: int
|
||||
super_properties = None # type: Optional[Dict]
|
||||
# Currently alpha, use at your own risk
|
||||
enable_exception_autocapture = False # type: bool
|
||||
log_captured_exceptions = False # type: bool
|
||||
# Used to determine in app paths for exception autocapture. Defaults to the current working directory
|
||||
project_root = None # type: Optional[str]
|
||||
# Used for our AI observability feature to not capture any prompt or output just usage + metadata
|
||||
privacy_mode = False # type: bool
|
||||
# Whether to enable feature flag polling for local evaluation by default. Defaults to True.
|
||||
# We recommend setting this to False if you are only using the personalApiKey for evaluating remote config payloads via `get_remote_config_payload` and not using local evaluation.
|
||||
enable_local_evaluation = True # type: bool
|
||||
|
||||
default_client = None
|
||||
default_client = None # type: Optional[Client]
|
||||
|
||||
|
||||
def capture(
|
||||
distinct_id, # type: str,
|
||||
event, # type: str,
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> None
|
||||
# NOTE - this and following functions take unpacked kwargs because we needed to make
|
||||
# it impossible to write `posthog.capture(distinct-id, event-name)` - basically, to enforce
|
||||
# the breaking change made between 5.3.0 and 6.0.0. This decision can be unrolled in later
|
||||
# versions, without a breaking change, to get back the type information in function signatures
|
||||
def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]:
|
||||
"""
|
||||
Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up.
|
||||
Capture anything a user does within your system.
|
||||
|
||||
A `capture` call requires
|
||||
- `distinct id` which uniquely identifies your user
|
||||
- `event name` to make sure
|
||||
- We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on.
|
||||
Args:
|
||||
event: The event name to specify the event
|
||||
**kwargs: Optional arguments including:
|
||||
distinct_id: Unique identifier for the user
|
||||
properties: Dict of event properties
|
||||
timestamp: When the event occurred
|
||||
groups: Dict of group types and IDs
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
Optionally you can submit
|
||||
- `properties`, which can be a dict with any information you'd like to add
|
||||
Details:
|
||||
Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up. A capture call requires an event name to specify the event. We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on. Capture takes a number of optional arguments, which are defined by the `OptionalCaptureArgs` type.
|
||||
|
||||
For example:
|
||||
```python
|
||||
posthog.capture('distinct id', 'movie played', {'movie_id': '123', 'category': 'romcom'})
|
||||
```
|
||||
Examples:
|
||||
```python
|
||||
# Context and capture usage
|
||||
from posthog import new_context, identify_context, tag_context, capture
|
||||
# Enter a new context (e.g. a request/response cycle, an instance of a background job, etc)
|
||||
with new_context():
|
||||
# Associate this context with some user, by distinct_id
|
||||
identify_context('some user')
|
||||
|
||||
# Capture an event, associated with the context-level distinct ID ('some user')
|
||||
capture('movie started')
|
||||
|
||||
# Capture an event associated with some other user (overriding the context-level distinct ID)
|
||||
capture('movie joined', distinct_id='some-other-user')
|
||||
|
||||
# Capture an event with some properties
|
||||
capture('movie played', properties={'movie_id': '123', 'category': 'romcom'})
|
||||
|
||||
# Capture an event with some properties
|
||||
capture('purchase', properties={'product_id': '123', 'category': 'romcom'})
|
||||
# Capture an event with some associated group
|
||||
capture('purchase', groups={'company': 'id:5'})
|
||||
|
||||
# Adding a tag to the current context will cause it to appear on all subsequent events
|
||||
tag_context('some-tag', 'some-value')
|
||||
|
||||
capture('another-event') # Will be captured with `'some-tag': 'some-value'` in the properties dict
|
||||
```
|
||||
```python
|
||||
# Set event properties
|
||||
from posthog import capture
|
||||
capture(
|
||||
"user_signed_up",
|
||||
distinct_id="distinct_id_of_the_user",
|
||||
properties={
|
||||
"login_type": "email",
|
||||
"is_free_trial": "true"
|
||||
}
|
||||
)
|
||||
```
|
||||
Category:
|
||||
Events
|
||||
"""
|
||||
_proxy('capture', distinct_id=distinct_id, event=event, properties=properties, context=context, timestamp=timestamp, message_id=message_id)
|
||||
|
||||
def identify(
|
||||
distinct_id, # type: str,
|
||||
properties=None, # type: Optional[Dict]
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> None
|
||||
return _proxy("capture", event, **kwargs)
|
||||
|
||||
|
||||
def set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
|
||||
"""
|
||||
Identify lets you add metadata on your users so you can more easily identify who they are in PostHog, and even do things like segment users by these properties.
|
||||
Set properties on a user record.
|
||||
|
||||
An `identify` call requires
|
||||
- `distinct id` which uniquely identifies your user
|
||||
- `properties` with a dict with any key: value pairs
|
||||
Details:
|
||||
This will overwrite previous people property values. Generally operates similar to `capture`, with distinct_id being an optional argument, defaulting to the current context's distinct ID. If there is no context-level distinct ID, and no override distinct_id is passed, this function will do nothing. Context tags are folded into $set properties, so tagging the current context and then calling `set` will cause those tags to be set on the user (unlike capture, which causes them to just be set on the event).
|
||||
|
||||
For example:
|
||||
```python
|
||||
posthog.identify('distinct id', {
|
||||
'email': 'dwayne@gmail.com',
|
||||
'name': 'Dwayne Johnson'
|
||||
})
|
||||
```
|
||||
Examples:
|
||||
```python
|
||||
# Set person properties
|
||||
from posthog import capture
|
||||
capture(
|
||||
'distinct_id',
|
||||
event='event_name',
|
||||
properties={
|
||||
'$set': {'name': 'Max Hedgehog'},
|
||||
'$set_once': {'initial_url': '/blog'}
|
||||
}
|
||||
)
|
||||
```
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
_proxy('identify', distinct_id=distinct_id, properties=properties, context=context, timestamp=timestamp, message_id=message_id)
|
||||
|
||||
def group(*args, **kwargs):
|
||||
"""Send a group call."""
|
||||
_proxy('group', *args, **kwargs)
|
||||
return _proxy("set", **kwargs)
|
||||
|
||||
|
||||
def set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
|
||||
"""
|
||||
Set properties on a user record, only if they do not yet exist.
|
||||
|
||||
Details:
|
||||
This will not overwrite previous people property values, unlike `set`. Otherwise, operates in an identical manner to `set`.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Set property once
|
||||
from posthog import capture
|
||||
capture(
|
||||
'distinct_id',
|
||||
event='event_name',
|
||||
properties={
|
||||
'$set': {'name': 'Max Hedgehog'},
|
||||
'$set_once': {'initial_url': '/blog'}
|
||||
}
|
||||
)
|
||||
|
||||
```
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
return _proxy("set_once", **kwargs)
|
||||
|
||||
|
||||
def group_identify(
|
||||
group_type, # type: str
|
||||
group_key, # type: str
|
||||
properties=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> Optional[str]
|
||||
"""
|
||||
Set properties on a group.
|
||||
|
||||
Args:
|
||||
group_type: Type of your group
|
||||
group_key: Unique identifier of the group
|
||||
properties: Properties to set on the group
|
||||
timestamp: Optional timestamp for the event
|
||||
uuid: Optional UUID for the event
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Group identify
|
||||
from posthog import group_identify
|
||||
group_identify('company', 'company_id_in_your_db', {
|
||||
'name': 'Awesome Inc.',
|
||||
'employees': 11
|
||||
})
|
||||
```
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
|
||||
return _proxy(
|
||||
"group_identify",
|
||||
group_type=group_type,
|
||||
group_key=group_key,
|
||||
properties=properties,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def alias(
|
||||
previous_id, # type: str,
|
||||
distinct_id, # type: str,
|
||||
context=None, # type: Optional[Dict]
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
message_id=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> None
|
||||
previous_id, # type: str
|
||||
distinct_id, # type: str
|
||||
timestamp=None, # type: Optional[datetime.datetime]
|
||||
uuid=None, # type: Optional[str]
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> Optional[str]
|
||||
"""
|
||||
To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?"
|
||||
Associate user behaviour before and after they e.g. register, login, or perform some other identifying action.
|
||||
|
||||
In a purely back-end implementation, this means whenever an anonymous user does something, you'll want to send a session ID ([Django](https://stackoverflow.com/questions/526179/in-django-how-can-i-find-out-the-request-session-sessionid-and-use-it-as-a-vari), [Flask](https://stackoverflow.com/questions/15156132/flask-login-how-to-get-session-id)) with the capture call. Then, when that users signs up, you want to do an alias call with the session ID and the newly created user ID.
|
||||
Args:
|
||||
previous_id: The unique ID of the user before
|
||||
distinct_id: The current unique id
|
||||
timestamp: Optional timestamp for the event
|
||||
uuid: Optional UUID for the event
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
The same concept applies for when a user logs in.
|
||||
Details:
|
||||
To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?". Particularly useful for associating user behaviour before and after they e.g. register, login, or perform some other identifying action.
|
||||
|
||||
An `alias` call requires
|
||||
- `previous distinct id` the unique ID of the user before
|
||||
- `distinct id` the current unique id
|
||||
|
||||
For example:
|
||||
```python
|
||||
posthog.alias('anonymous session id', 'distinct id')
|
||||
```
|
||||
Examples:
|
||||
```python
|
||||
# Alias user
|
||||
from posthog import alias
|
||||
alias(previous_id='distinct_id', distinct_id='alias_id')
|
||||
```
|
||||
Category:
|
||||
Identification
|
||||
"""
|
||||
_proxy('alias', previous_id=previous_id, distinct_id=distinct_id, context=context, timestamp=timestamp, message_id=message_id)
|
||||
|
||||
return _proxy(
|
||||
"alias",
|
||||
previous_id=previous_id,
|
||||
distinct_id=distinct_id,
|
||||
timestamp=timestamp,
|
||||
uuid=uuid,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def capture_exception(
|
||||
exception: Optional[ExceptionArg] = None,
|
||||
**kwargs: Unpack[OptionalCaptureArgs],
|
||||
):
|
||||
"""
|
||||
Capture exceptions that happen in your code.
|
||||
|
||||
Args:
|
||||
exception: The exception to capture. If not provided, the current exception is captured via `sys.exc_info()`
|
||||
|
||||
Details:
|
||||
Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with posthog.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the "traceback"). If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError("Some Error"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Capture exception
|
||||
from posthog import capture_exception
|
||||
try:
|
||||
risky_operation()
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
```
|
||||
Category:
|
||||
Events
|
||||
"""
|
||||
|
||||
return _proxy("capture_exception", exception=exception, **kwargs)
|
||||
|
||||
|
||||
def feature_enabled(
|
||||
key, # type: str,
|
||||
distinct_id, # type: str,
|
||||
default=False, # type: bool
|
||||
):
|
||||
key, # type: str
|
||||
distinct_id, # type: str
|
||||
groups=None, # type: Optional[dict]
|
||||
person_properties=None, # type: Optional[dict]
|
||||
group_properties=None, # type: Optional[dict]
|
||||
only_evaluate_locally=False, # type: bool
|
||||
send_feature_flag_events=True, # type: bool
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> bool
|
||||
"""
|
||||
Use feature flags to enable or disable features for users.
|
||||
|
||||
For example:
|
||||
```python
|
||||
if posthog.feature_enabled('beta feature', 'distinct id'):
|
||||
# do something
|
||||
```
|
||||
Args:
|
||||
key: The feature flag key
|
||||
distinct_id: The user's distinct ID
|
||||
groups: Groups mapping
|
||||
person_properties: Person properties
|
||||
group_properties: Group properties
|
||||
only_evaluate_locally: Whether to evaluate only locally
|
||||
send_feature_flag_events: Whether to send feature flag events
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.
|
||||
Details:
|
||||
You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Boolean feature flag
|
||||
from posthog import feature_enabled, get_feature_flag_payload
|
||||
is_my_flag_enabled = feature_enabled('flag-key', 'distinct_id_of_your_user')
|
||||
if is_my_flag_enabled:
|
||||
matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
|
||||
```
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy('feature_enabled', key=key, distinct_id=distinct_id, default=default)
|
||||
return _proxy(
|
||||
"feature_enabled",
|
||||
key=key,
|
||||
distinct_id=distinct_id,
|
||||
groups=groups or {},
|
||||
person_properties=person_properties or {},
|
||||
group_properties=group_properties or {},
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def page(*args, **kwargs):
|
||||
"""Send a page call."""
|
||||
_proxy('page', *args, **kwargs)
|
||||
def get_feature_flag(
|
||||
key, # type: str
|
||||
distinct_id, # type: str
|
||||
groups=None, # type: Optional[dict]
|
||||
person_properties=None, # type: Optional[dict]
|
||||
group_properties=None, # type: Optional[dict]
|
||||
only_evaluate_locally=False, # type: bool
|
||||
send_feature_flag_events=True, # type: bool
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
) -> Optional[FeatureFlag]:
|
||||
"""
|
||||
Get feature flag variant for users. Used with experiments.
|
||||
|
||||
Args:
|
||||
key: The feature flag key
|
||||
distinct_id: The user's distinct ID
|
||||
groups: Groups mapping from group type to group key
|
||||
person_properties: Person properties
|
||||
group_properties: Group properties in format { group_type_name: { group_properties } }
|
||||
only_evaluate_locally: Whether to evaluate only locally
|
||||
send_feature_flag_events: Whether to send feature flag events
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
Details:
|
||||
`groups` are a mapping from group type to group key. So, if you have a group type of "organization" and a group key of "5", you would pass groups={"organization": "5"}. `group_properties` take the format: { group_type_name: { group_properties } }. So, for example, if you have the group type "organization" and the group key "5", with the properties name, and employee count, you'll send these as: group_properties={"organization": {"name": "PostHog", "employees": 11}}.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# Multivariate feature flag
|
||||
from posthog import get_feature_flag, get_feature_flag_payload
|
||||
enabled_variant = get_feature_flag('flag-key', 'distinct_id_of_your_user')
|
||||
if enabled_variant == 'variant-key':
|
||||
matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
|
||||
```
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy(
|
||||
"get_feature_flag",
|
||||
key=key,
|
||||
distinct_id=distinct_id,
|
||||
groups=groups or {},
|
||||
person_properties=person_properties or {},
|
||||
group_properties=group_properties or {},
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def screen(*args, **kwargs):
|
||||
"""Send a screen call."""
|
||||
_proxy('screen', *args, **kwargs)
|
||||
def get_all_flags(
|
||||
distinct_id, # type: str
|
||||
groups=None, # type: Optional[dict]
|
||||
person_properties=None, # type: Optional[dict]
|
||||
group_properties=None, # type: Optional[dict]
|
||||
only_evaluate_locally=False, # type: bool
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
) -> Optional[dict[str, FeatureFlag]]:
|
||||
"""
|
||||
Get all flags for a given user.
|
||||
|
||||
Args:
|
||||
distinct_id: The user's distinct ID
|
||||
groups: Groups mapping
|
||||
person_properties: Person properties
|
||||
group_properties: Group properties
|
||||
only_evaluate_locally: Whether to evaluate only locally
|
||||
disable_geoip: Whether to disable GeoIP lookup
|
||||
|
||||
Details:
|
||||
Flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# All flags for user
|
||||
from posthog import get_all_flags
|
||||
get_all_flags('distinct_id_of_your_user')
|
||||
```
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy(
|
||||
"get_all_flags",
|
||||
distinct_id=distinct_id,
|
||||
groups=groups or {},
|
||||
person_properties=person_properties or {},
|
||||
group_properties=group_properties or {},
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def get_feature_flag_result(
|
||||
key,
|
||||
distinct_id,
|
||||
groups=None, # type: Optional[dict]
|
||||
person_properties=None, # type: Optional[dict]
|
||||
group_properties=None, # type: Optional[dict]
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
):
|
||||
# type: (...) -> Optional[FeatureFlagResult]
|
||||
"""
|
||||
Get a FeatureFlagResult object which contains the flag result and payload.
|
||||
|
||||
This method evaluates a feature flag and returns a FeatureFlagResult object containing:
|
||||
- enabled: Whether the flag is enabled
|
||||
- variant: The variant value if the flag has variants
|
||||
- payload: The payload associated with the flag (automatically deserialized from JSON)
|
||||
- key: The flag key
|
||||
- reason: Why the flag was enabled/disabled
|
||||
|
||||
Example:
|
||||
```python
|
||||
result = posthog.get_feature_flag_result('beta-feature', 'distinct_id')
|
||||
if result and result.enabled:
|
||||
# Use the variant and payload
|
||||
print(f"Variant: {result.variant}")
|
||||
print(f"Payload: {result.payload}")
|
||||
```
|
||||
"""
|
||||
return _proxy(
|
||||
"get_feature_flag_result",
|
||||
key=key,
|
||||
distinct_id=distinct_id,
|
||||
groups=groups or {},
|
||||
person_properties=person_properties or {},
|
||||
group_properties=group_properties or {},
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def get_feature_flag_payload(
|
||||
key,
|
||||
distinct_id,
|
||||
match_value=None,
|
||||
groups=None, # type: Optional[dict]
|
||||
person_properties=None, # type: Optional[dict]
|
||||
group_properties=None, # type: Optional[dict]
|
||||
only_evaluate_locally=False,
|
||||
send_feature_flag_events=True,
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
) -> Optional[str]:
|
||||
return _proxy(
|
||||
"get_feature_flag_payload",
|
||||
key=key,
|
||||
distinct_id=distinct_id,
|
||||
match_value=match_value,
|
||||
groups=groups or {},
|
||||
person_properties=person_properties or {},
|
||||
group_properties=group_properties or {},
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
send_feature_flag_events=send_feature_flag_events,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def get_remote_config_payload(
|
||||
key, # type: str
|
||||
):
|
||||
"""Get the payload for a remote config feature flag.
|
||||
|
||||
Args:
|
||||
key: The key of the feature flag
|
||||
|
||||
Returns:
|
||||
The payload associated with the feature flag. If payload is encrypted, the return value will decrypted
|
||||
|
||||
Note:
|
||||
Requires personal_api_key to be set for authentication
|
||||
"""
|
||||
return _proxy(
|
||||
"get_remote_config_payload",
|
||||
key=key,
|
||||
)
|
||||
|
||||
|
||||
def get_all_flags_and_payloads(
|
||||
distinct_id,
|
||||
groups=None, # type: Optional[dict]
|
||||
person_properties=None, # type: Optional[dict]
|
||||
group_properties=None, # type: Optional[dict]
|
||||
only_evaluate_locally=False,
|
||||
disable_geoip=None, # type: Optional[bool]
|
||||
) -> FlagsAndPayloads:
|
||||
return _proxy(
|
||||
"get_all_flags_and_payloads",
|
||||
distinct_id=distinct_id,
|
||||
groups=groups or {},
|
||||
person_properties=person_properties or {},
|
||||
group_properties=group_properties or {},
|
||||
only_evaluate_locally=only_evaluate_locally,
|
||||
disable_geoip=disable_geoip,
|
||||
)
|
||||
|
||||
|
||||
def feature_flag_definitions():
|
||||
"""
|
||||
Returns loaded feature flags.
|
||||
|
||||
Details:
|
||||
Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import feature_flag_definitions
|
||||
definitions = feature_flag_definitions()
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy("feature_flag_definitions")
|
||||
|
||||
|
||||
def load_feature_flags():
|
||||
"""
|
||||
Load feature flag definitions from PostHog.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import load_feature_flags
|
||||
load_feature_flags()
|
||||
```
|
||||
|
||||
Category:
|
||||
Feature flags
|
||||
"""
|
||||
return _proxy("load_feature_flags")
|
||||
|
||||
|
||||
def flush():
|
||||
"""Tell the client to flush."""
|
||||
_proxy('flush')
|
||||
"""
|
||||
Tell the client to flush all queued events.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import flush
|
||||
flush()
|
||||
```
|
||||
|
||||
Category:
|
||||
Client management
|
||||
"""
|
||||
_proxy("flush")
|
||||
|
||||
|
||||
def join():
|
||||
"""Block program until the client clears the queue"""
|
||||
_proxy('join')
|
||||
"""
|
||||
Block program until the client clears the queue. Used during program shutdown. You should use `shutdown()` directly in most cases.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import join
|
||||
join()
|
||||
```
|
||||
|
||||
Category:
|
||||
Client management
|
||||
"""
|
||||
_proxy("join")
|
||||
|
||||
|
||||
def shutdown():
|
||||
"""Flush all messages and cleanly shutdown the client"""
|
||||
_proxy('flush')
|
||||
_proxy('join')
|
||||
"""
|
||||
Flush all messages and cleanly shutdown the client.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from posthog import shutdown
|
||||
shutdown()
|
||||
```
|
||||
|
||||
Category:
|
||||
Client management
|
||||
"""
|
||||
_proxy("flush")
|
||||
_proxy("join")
|
||||
|
||||
|
||||
def setup() -> Client:
|
||||
global default_client
|
||||
if not default_client:
|
||||
if not api_key:
|
||||
raise ValueError("API key is required")
|
||||
default_client = Client(
|
||||
api_key,
|
||||
host=host,
|
||||
debug=debug,
|
||||
on_error=on_error,
|
||||
send=send,
|
||||
sync_mode=sync_mode,
|
||||
personal_api_key=personal_api_key,
|
||||
poll_interval=poll_interval,
|
||||
disabled=disabled,
|
||||
disable_geoip=disable_geoip,
|
||||
feature_flags_request_timeout_seconds=feature_flags_request_timeout_seconds,
|
||||
super_properties=super_properties,
|
||||
# TODO: Currently this monitoring begins only when the Client is initialised (which happens when you do something with the SDK)
|
||||
# This kind of initialisation is very annoying for exception capture. We need to figure out a way around this,
|
||||
# or deprecate this proxy option fully (it's already in the process of deprecation, no new clients should be using this method since like 5-6 months)
|
||||
enable_exception_autocapture=enable_exception_autocapture,
|
||||
log_captured_exceptions=log_captured_exceptions,
|
||||
enable_local_evaluation=enable_local_evaluation,
|
||||
)
|
||||
|
||||
# always set incase user changes it
|
||||
default_client.disabled = disabled
|
||||
default_client.debug = debug
|
||||
|
||||
return default_client
|
||||
|
||||
|
||||
def _proxy(method, *args, **kwargs):
|
||||
"""Create an analytics client if one doesn't exist and send to it."""
|
||||
global default_client
|
||||
if disabled:
|
||||
return None
|
||||
if not default_client:
|
||||
default_client = Client(api_key, host=host, debug=debug,
|
||||
on_error=on_error, send=send,
|
||||
sync_mode=sync_mode, personal_api_key=personal_api_key)
|
||||
setup()
|
||||
|
||||
fn = getattr(default_client, method)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
|
||||
class Posthog(Client):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from .anthropic import Anthropic
|
||||
from .anthropic_async import AsyncAnthropic
|
||||
from .anthropic_providers import (
|
||||
AnthropicBedrock,
|
||||
AnthropicVertex,
|
||||
AsyncAnthropicBedrock,
|
||||
AsyncAnthropicVertex,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Anthropic",
|
||||
"AsyncAnthropic",
|
||||
"AnthropicBedrock",
|
||||
"AsyncAnthropicBedrock",
|
||||
"AnthropicVertex",
|
||||
"AsyncAnthropicVertex",
|
||||
]
|
||||
@@ -0,0 +1,219 @@
|
||||
try:
|
||||
import anthropic
|
||||
from anthropic.resources import Messages
|
||||
except ImportError:
|
||||
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.sanitization import sanitize_anthropic
|
||||
from posthog.client import Client as PostHogClient
|
||||
from posthog import setup
|
||||
|
||||
|
||||
class Anthropic(anthropic.Anthropic):
|
||||
"""
|
||||
A wrapper around the Anthropic SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
posthog_client: PostHog client for tracking usage
|
||||
**kwargs: Additional arguments passed to the Anthropic client
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = WrappedMessages(self)
|
||||
|
||||
|
||||
class WrappedMessages(Messages):
|
||||
_client: Anthropic
|
||||
|
||||
def create(
|
||||
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,
|
||||
):
|
||||
"""
|
||||
Create a message using Anthropic's API while tracking 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 redact sensitive information in tracking
|
||||
posthog_groups: Optional group analytics properties
|
||||
**kwargs: Arguments passed to Anthropic's messages.create
|
||||
"""
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
if kwargs.get("stream", False):
|
||||
return self._create_streaming(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return call_llm_and_track_usage(
|
||||
posthog_distinct_id,
|
||||
self._client._ph_client,
|
||||
"anthropic",
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
super().create,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def stream(
|
||||
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,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
return self._create_streaming(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _create_streaming(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
|
||||
accumulated_content = []
|
||||
response = super().create(**kwargs)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
try:
|
||||
for event in response:
|
||||
if hasattr(event, "usage") and event.usage:
|
||||
usage_stats = {
|
||||
k: getattr(event.usage, k, 0)
|
||||
for k in [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
]
|
||||
}
|
||||
|
||||
if hasattr(event, "content") and event.content:
|
||||
accumulated_content.append(event.content)
|
||||
|
||||
yield event
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
output = "".join(accumulated_content)
|
||||
|
||||
self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
)
|
||||
|
||||
return generator()
|
||||
|
||||
def _capture_streaming_event(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
latency: float,
|
||||
output: str,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "anthropic",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
sanitize_anthropic(merge_system_prompt(kwargs, "anthropic")),
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
[{"content": output, "role": "assistant"}],
|
||||
),
|
||||
"$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_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
@@ -0,0 +1,219 @@
|
||||
try:
|
||||
import anthropic
|
||||
from anthropic.resources import AsyncMessages
|
||||
except ImportError:
|
||||
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 import setup
|
||||
from posthog.ai.utils import (
|
||||
call_llm_and_track_usage_async,
|
||||
get_model_params,
|
||||
merge_system_prompt,
|
||||
with_privacy_mode,
|
||||
)
|
||||
from posthog.ai.sanitization import sanitize_anthropic
|
||||
from posthog.client import Client as PostHogClient
|
||||
|
||||
|
||||
class AsyncAnthropic(anthropic.AsyncAnthropic):
|
||||
"""
|
||||
An async wrapper around the Anthropic SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
posthog_client: PostHog client for tracking usage
|
||||
**kwargs: Additional arguments passed to the Anthropic client
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = AsyncWrappedMessages(self)
|
||||
|
||||
|
||||
class AsyncWrappedMessages(AsyncMessages):
|
||||
_client: AsyncAnthropic
|
||||
|
||||
async def create(
|
||||
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,
|
||||
):
|
||||
"""
|
||||
Create a message using Anthropic's API while tracking 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 redact sensitive information in tracking
|
||||
posthog_groups: Optional group analytics properties
|
||||
**kwargs: Arguments passed to Anthropic's messages.create
|
||||
"""
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
if kwargs.get("stream", False):
|
||||
return await self._create_streaming(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return await call_llm_and_track_usage_async(
|
||||
posthog_distinct_id,
|
||||
self._client._ph_client,
|
||||
"anthropic",
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
super().create,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def stream(
|
||||
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,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
return await self._create_streaming(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def _create_streaming(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
|
||||
accumulated_content = []
|
||||
response = await super().create(**kwargs)
|
||||
|
||||
async def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
try:
|
||||
async for event in response:
|
||||
if hasattr(event, "usage") and event.usage:
|
||||
usage_stats = {
|
||||
k: getattr(event.usage, k, 0)
|
||||
for k in [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
]
|
||||
}
|
||||
|
||||
if hasattr(event, "content") and event.content:
|
||||
accumulated_content.append(event.content)
|
||||
|
||||
yield event
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
output = "".join(accumulated_content)
|
||||
|
||||
await self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
)
|
||||
|
||||
return generator()
|
||||
|
||||
async def _capture_streaming_event(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
latency: float,
|
||||
output: str,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "anthropic",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
sanitize_anthropic(merge_system_prompt(kwargs, "anthropic")),
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
[{"content": output, "role": "assistant"}],
|
||||
),
|
||||
"$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_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the Anthropic SDK to use this feature: 'pip install anthropic'"
|
||||
)
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from posthog.ai.anthropic.anthropic import WrappedMessages
|
||||
from posthog.ai.anthropic.anthropic_async import AsyncWrappedMessages
|
||||
from posthog.client import Client as PostHogClient
|
||||
from posthog import setup
|
||||
|
||||
|
||||
class AnthropicBedrock(anthropic.AnthropicBedrock):
|
||||
"""
|
||||
A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = WrappedMessages(self)
|
||||
|
||||
|
||||
class AsyncAnthropicBedrock(anthropic.AsyncAnthropicBedrock):
|
||||
"""
|
||||
A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = AsyncWrappedMessages(self)
|
||||
|
||||
|
||||
class AnthropicVertex(anthropic.AnthropicVertex):
|
||||
"""
|
||||
A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = WrappedMessages(self)
|
||||
|
||||
|
||||
class AsyncAnthropicVertex(anthropic.AsyncAnthropicVertex):
|
||||
"""
|
||||
A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = AsyncWrappedMessages(self)
|
||||
@@ -0,0 +1,11 @@
|
||||
from .gemini import Client
|
||||
|
||||
|
||||
# Create a genai-like module for perfect drop-in replacement
|
||||
class _GenAI:
|
||||
Client = Client
|
||||
|
||||
|
||||
genai = _GenAI()
|
||||
|
||||
__all__ = ["Client", "genai"]
|
||||
@@ -0,0 +1,426 @@
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
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'"
|
||||
)
|
||||
|
||||
from posthog import setup
|
||||
from posthog.ai.utils import (
|
||||
call_llm_and_track_usage,
|
||||
get_model_params,
|
||||
with_privacy_mode,
|
||||
)
|
||||
from posthog.ai.sanitization import sanitize_gemini
|
||||
from posthog.client import Client as PostHogClient
|
||||
|
||||
|
||||
class Client:
|
||||
"""
|
||||
A drop-in replacement for genai.Client that automatically sends LLM usage events to PostHog.
|
||||
|
||||
Usage:
|
||||
client = Client(
|
||||
api_key="your_api_key",
|
||||
posthog_client=posthog_client,
|
||||
posthog_distinct_id="default_user", # Optional defaults
|
||||
posthog_properties={"team": "ai"} # Optional defaults
|
||||
)
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello world"],
|
||||
posthog_distinct_id="specific_user" # Override default
|
||||
)
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
vertexai: Optional[bool] = None,
|
||||
credentials: Optional[Any] = None,
|
||||
project: Optional[str] = None,
|
||||
location: Optional[str] = None,
|
||||
debug_config: Optional[Any] = None,
|
||||
http_options: Optional[Any] = None,
|
||||
posthog_client: Optional[PostHogClient] = None,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable (not required for Vertex AI)
|
||||
vertexai: Whether to use Vertex AI authentication
|
||||
credentials: Vertex AI credentials object
|
||||
project: GCP project ID for Vertex AI
|
||||
location: GCP location for Vertex AI
|
||||
debug_config: Debug configuration for the client
|
||||
http_options: HTTP options for the client
|
||||
posthog_client: PostHog client for tracking usage
|
||||
posthog_distinct_id: Default distinct ID for all calls (can be overridden per call)
|
||||
posthog_properties: Default properties for all calls (can be overridden per call)
|
||||
posthog_privacy_mode: Default privacy mode for all calls (can be overridden per call)
|
||||
posthog_groups: Default groups for all calls (can be overridden per call)
|
||||
**kwargs: Additional arguments (for future compatibility)
|
||||
"""
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
if self._ph_client is None:
|
||||
raise ValueError("posthog_client is required for PostHog tracking")
|
||||
|
||||
self.models = Models(
|
||||
api_key=api_key,
|
||||
vertexai=vertexai,
|
||||
credentials=credentials,
|
||||
project=project,
|
||||
location=location,
|
||||
debug_config=debug_config,
|
||||
http_options=http_options,
|
||||
posthog_client=self._ph_client,
|
||||
posthog_distinct_id=posthog_distinct_id,
|
||||
posthog_properties=posthog_properties,
|
||||
posthog_privacy_mode=posthog_privacy_mode,
|
||||
posthog_groups=posthog_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class Models:
|
||||
"""
|
||||
Models interface that mimics genai.Client().models with PostHog tracking.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient # Not None after __init__ validation
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
vertexai: Optional[bool] = None,
|
||||
credentials: Optional[Any] = None,
|
||||
project: Optional[str] = None,
|
||||
location: Optional[str] = None,
|
||||
debug_config: Optional[Any] = None,
|
||||
http_options: Optional[Any] = None,
|
||||
posthog_client: Optional[PostHogClient] = None,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable (not required for Vertex AI)
|
||||
vertexai: Whether to use Vertex AI authentication
|
||||
credentials: Vertex AI credentials object
|
||||
project: GCP project ID for Vertex AI
|
||||
location: GCP location for Vertex AI
|
||||
debug_config: Debug configuration for the client
|
||||
http_options: HTTP options for the client
|
||||
posthog_client: PostHog client for tracking usage
|
||||
posthog_distinct_id: Default distinct ID for all calls
|
||||
posthog_properties: Default properties for all calls
|
||||
posthog_privacy_mode: Default privacy mode for all calls
|
||||
posthog_groups: Default groups for all calls
|
||||
**kwargs: Additional arguments (for future compatibility)
|
||||
"""
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
if self._ph_client is None:
|
||||
raise ValueError("posthog_client is required for PostHog tracking")
|
||||
|
||||
# Store default PostHog settings
|
||||
self._default_distinct_id = posthog_distinct_id
|
||||
self._default_properties = posthog_properties or {}
|
||||
self._default_privacy_mode = posthog_privacy_mode
|
||||
self._default_groups = posthog_groups
|
||||
|
||||
# Build genai.Client arguments
|
||||
client_args: Dict[str, Any] = {}
|
||||
|
||||
# Add Vertex AI parameters if provided
|
||||
if vertexai is not None:
|
||||
client_args["vertexai"] = vertexai
|
||||
if credentials is not None:
|
||||
client_args["credentials"] = credentials
|
||||
if project is not None:
|
||||
client_args["project"] = project
|
||||
if location is not None:
|
||||
client_args["location"] = location
|
||||
if debug_config is not None:
|
||||
client_args["debug_config"] = debug_config
|
||||
if http_options is not None:
|
||||
client_args["http_options"] = http_options
|
||||
|
||||
# Handle API key authentication
|
||||
if vertexai:
|
||||
# For Vertex AI, api_key is optional
|
||||
if api_key is not None:
|
||||
client_args["api_key"] = api_key
|
||||
else:
|
||||
# For non-Vertex AI mode, api_key is required (backwards compatibility)
|
||||
if api_key is None:
|
||||
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("API_KEY")
|
||||
|
||||
if api_key is None:
|
||||
raise ValueError(
|
||||
"API key must be provided either as parameter or via GOOGLE_API_KEY/API_KEY environment variable"
|
||||
)
|
||||
client_args["api_key"] = api_key
|
||||
|
||||
self._client = genai.Client(**client_args)
|
||||
self._base_url = "https://generativelanguage.googleapis.com"
|
||||
|
||||
def _merge_posthog_params(
|
||||
self,
|
||||
call_distinct_id: Optional[str],
|
||||
call_trace_id: Optional[str],
|
||||
call_properties: Optional[Dict[str, Any]],
|
||||
call_privacy_mode: Optional[bool],
|
||||
call_groups: Optional[Dict[str, Any]],
|
||||
):
|
||||
"""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
|
||||
)
|
||||
groups = call_groups if call_groups is not None else self._default_groups
|
||||
|
||||
# Merge properties: default properties + call properties (call properties override)
|
||||
properties = dict(self._default_properties)
|
||||
if call_properties:
|
||||
properties.update(call_properties)
|
||||
|
||||
if call_trace_id is None:
|
||||
call_trace_id = str(uuid.uuid4())
|
||||
|
||||
return distinct_id, call_trace_id, properties, privacy_mode, groups
|
||||
|
||||
def generate_content(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: Optional[bool] = None,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Generate content using Gemini's API while tracking usage in PostHog.
|
||||
|
||||
This method signature exactly matches genai.Client().models.generate_content()
|
||||
with additional PostHog tracking parameters.
|
||||
|
||||
Args:
|
||||
model: The model to use (e.g., 'gemini-2.0-flash')
|
||||
contents: The input content for generation
|
||||
posthog_distinct_id: ID to associate with the usage event (overrides client default)
|
||||
posthog_trace_id: Trace UUID for linking events (auto-generated if not provided)
|
||||
posthog_properties: Extra properties to include in the event (merged with client defaults)
|
||||
posthog_privacy_mode: Whether to redact sensitive information (overrides client default)
|
||||
posthog_groups: Group analytics properties (overrides client default)
|
||||
**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,
|
||||
)
|
||||
)
|
||||
|
||||
kwargs_with_contents = {"model": model, "contents": contents, **kwargs}
|
||||
|
||||
return call_llm_and_track_usage(
|
||||
distinct_id,
|
||||
self._ph_client,
|
||||
"gemini",
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
self._base_url,
|
||||
self._client.models.generate_content,
|
||||
**kwargs_with_contents,
|
||||
)
|
||||
|
||||
def _generate_content_streaming(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
distinct_id: Optional[str],
|
||||
trace_id: Optional[str],
|
||||
properties: Optional[Dict[str, Any]],
|
||||
privacy_mode: bool,
|
||||
groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
|
||||
accumulated_content = []
|
||||
|
||||
kwargs_without_stream = {"model": model, "contents": contents, **kwargs}
|
||||
response = self._client.models.generate_content_stream(**kwargs_without_stream)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
try:
|
||||
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
|
||||
),
|
||||
}
|
||||
|
||||
if hasattr(chunk, "text") and chunk.text:
|
||||
accumulated_content.append(chunk.text)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
output = "".join(accumulated_content)
|
||||
|
||||
self._capture_streaming_event(
|
||||
model,
|
||||
contents,
|
||||
distinct_id,
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
)
|
||||
|
||||
return generator()
|
||||
|
||||
def _capture_streaming_event(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
distinct_id: Optional[str],
|
||||
trace_id: Optional[str],
|
||||
properties: Optional[Dict[str, Any]],
|
||||
privacy_mode: bool,
|
||||
groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
latency: float,
|
||||
output: str,
|
||||
):
|
||||
if trace_id is None:
|
||||
trace_id = str(uuid.uuid4())
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "gemini",
|
||||
"$ai_model": model,
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._ph_client,
|
||||
privacy_mode,
|
||||
sanitize_gemini(self._format_input(contents)),
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._ph_client,
|
||||
privacy_mode,
|
||||
[{"content": output, "role": "assistant"}],
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_base_url": self._base_url,
|
||||
**(properties or {}),
|
||||
}
|
||||
|
||||
if distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(self._ph_client, "capture"):
|
||||
self._ph_client.capture(
|
||||
distinct_id=distinct_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=groups,
|
||||
)
|
||||
|
||||
def _format_input(self, contents):
|
||||
"""Format input contents for PostHog tracking"""
|
||||
if isinstance(contents, str):
|
||||
return [{"role": "user", "content": contents}]
|
||||
elif isinstance(contents, list):
|
||||
formatted = []
|
||||
for item in contents:
|
||||
if isinstance(item, str):
|
||||
formatted.append({"role": "user", "content": item})
|
||||
elif hasattr(item, "text"):
|
||||
formatted.append({"role": "user", "content": item.text})
|
||||
else:
|
||||
formatted.append({"role": "user", "content": str(item)})
|
||||
return formatted
|
||||
else:
|
||||
return [{"role": "user", "content": str(contents)}]
|
||||
|
||||
def generate_content_stream(
|
||||
self,
|
||||
model: str,
|
||||
contents,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: Optional[bool] = None,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**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,
|
||||
)
|
||||
)
|
||||
|
||||
return self._generate_content_streaming(
|
||||
model,
|
||||
contents,
|
||||
distinct_id,
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from .callbacks import CallbackHandler
|
||||
|
||||
__all__ = ["CallbackHandler"]
|
||||
@@ -0,0 +1,867 @@
|
||||
try:
|
||||
import langchain # noqa: F401
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError(
|
||||
"Please install LangChain to use this feature: 'pip install langchain'"
|
||||
)
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
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,
|
||||
ToolCall,
|
||||
)
|
||||
from langchain_core.outputs import ChatGeneration, LLMResult
|
||||
from pydantic import BaseModel
|
||||
|
||||
from posthog import setup
|
||||
from posthog.ai.utils import get_model_params, with_privacy_mode
|
||||
from posthog.ai.sanitization import sanitize_langchain
|
||||
from posthog.client import Client
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpanMetadata:
|
||||
name: str
|
||||
"""Name of the run: chain name, model name, etc."""
|
||||
start_time: float
|
||||
"""Start time of the run."""
|
||||
end_time: Optional[float]
|
||||
"""End time of the run."""
|
||||
input: Optional[Any]
|
||||
"""Input of the run: messages, prompt variables, etc."""
|
||||
|
||||
@property
|
||||
def latency(self) -> float:
|
||||
if not self.end_time:
|
||||
return 0
|
||||
return self.end_time - self.start_time
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationMetadata(SpanMetadata):
|
||||
provider: Optional[str] = None
|
||||
"""Provider of the run: OpenAI, Anthropic"""
|
||||
model: Optional[str] = None
|
||||
"""Model used in the run"""
|
||||
model_params: Optional[Dict[str, Any]] = None
|
||||
"""Model parameters of the run: temperature, max_tokens, etc."""
|
||||
base_url: Optional[str] = None
|
||||
"""Base URL of the provider's API used in the run."""
|
||||
tools: Optional[List[Dict[str, Any]]] = None
|
||||
"""Tools provided to the model."""
|
||||
|
||||
|
||||
RunMetadata = Union[SpanMetadata, GenerationMetadata]
|
||||
RunMetadataStorage = Dict[UUID, RunMetadata]
|
||||
|
||||
|
||||
class CallbackHandler(BaseCallbackHandler):
|
||||
"""
|
||||
The PostHog LLM observability callback handler for LangChain.
|
||||
"""
|
||||
|
||||
_ph_client: Client
|
||||
"""PostHog client instance."""
|
||||
|
||||
_distinct_id: Optional[Union[str, int, UUID]]
|
||||
"""Distinct ID of the user to associate the trace with."""
|
||||
|
||||
_trace_id: Optional[Union[str, int, float, UUID]]
|
||||
"""Global trace ID to be sent with every event. Otherwise, the top-level run ID is used."""
|
||||
|
||||
_trace_input: Optional[Any]
|
||||
"""The input at the start of the trace. Any JSON object."""
|
||||
|
||||
_trace_name: Optional[str]
|
||||
"""Name of the trace, exposed in the UI."""
|
||||
|
||||
_properties: Optional[Dict[str, Any]]
|
||||
"""Global properties to be sent with every event."""
|
||||
|
||||
_runs: RunMetadataStorage
|
||||
"""Mapping of run IDs to run metadata as run metadata is only available on the start of generation."""
|
||||
|
||||
_parent_tree: Dict[UUID, UUID]
|
||||
"""
|
||||
A dictionary that maps chain run IDs to their parent chain run IDs (parent pointer tree),
|
||||
so the top level can be found from a bottom-level run ID.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Optional[Client] = None,
|
||||
*,
|
||||
distinct_id: Optional[Union[str, int, UUID]] = None,
|
||||
trace_id: Optional[Union[str, int, float, UUID]] = None,
|
||||
properties: Optional[Dict[str, Any]] = None,
|
||||
privacy_mode: bool = False,
|
||||
groups: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
client: PostHog client instance.
|
||||
distinct_id: Optional distinct ID of the user to associate the trace with.
|
||||
trace_id: Optional trace ID to use for the event.
|
||||
properties: Optional additional metadata to use for the trace.
|
||||
privacy_mode: Whether to redact the input and output of the trace.
|
||||
groups: Optional additional PostHog groups to use for the trace.
|
||||
"""
|
||||
self._ph_client = client or setup()
|
||||
self._distinct_id = distinct_id
|
||||
self._trace_id = trace_id
|
||||
self._properties = properties or {}
|
||||
self._privacy_mode = privacy_mode
|
||||
self._groups = groups or {}
|
||||
self._runs = {}
|
||||
self._parent_tree = {}
|
||||
|
||||
def on_chain_start(
|
||||
self,
|
||||
serialized: Dict[str, Any],
|
||||
inputs: Dict[str, Any],
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
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
|
||||
)
|
||||
|
||||
def on_chain_end(
|
||||
self,
|
||||
outputs: Dict[str, Any],
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._log_debug_event("on_chain_end", run_id, parent_run_id, outputs=outputs)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, outputs)
|
||||
|
||||
def on_chain_error(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._log_debug_event("on_chain_error", run_id, parent_run_id, error=error)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, error)
|
||||
|
||||
def on_chat_model_start(
|
||||
self,
|
||||
serialized: Dict[str, Any],
|
||||
messages: List[List[BaseMessage]],
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._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
|
||||
]
|
||||
self._set_llm_metadata(serialized, run_id, input, **kwargs)
|
||||
|
||||
def on_llm_start(
|
||||
self,
|
||||
serialized: Dict[str, Any],
|
||||
prompts: List[str],
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._log_debug_event("on_llm_start", run_id, parent_run_id, prompts=prompts)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_llm_metadata(serialized, run_id, prompts, **kwargs)
|
||||
|
||||
def on_llm_new_token(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Run on new LLM token. Only available when streaming is enabled."""
|
||||
self._log_debug_event("on_llm_new_token", run_id, parent_run_id, token=token)
|
||||
|
||||
def on_llm_end(
|
||||
self,
|
||||
response: LLMResult,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
The callback works for both streaming and non-streaming runs. For streaming runs, the chain must set `stream_usage=True` in the LLM.
|
||||
"""
|
||||
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(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._log_debug_event("on_llm_error", run_id, parent_run_id, error=error)
|
||||
self._pop_run_and_capture_generation(run_id, parent_run_id, error)
|
||||
|
||||
def on_tool_start(
|
||||
self,
|
||||
serialized: Optional[Dict[str, Any]],
|
||||
input_str: str,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
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._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_trace_or_span_metadata(
|
||||
serialized, input_str, run_id, parent_run_id, **kwargs
|
||||
)
|
||||
|
||||
def on_tool_end(
|
||||
self,
|
||||
output: str,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._log_debug_event("on_tool_end", run_id, parent_run_id, output=output)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, output)
|
||||
|
||||
def on_tool_error(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._log_debug_event("on_tool_error", run_id, parent_run_id, error=error)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, error)
|
||||
|
||||
def on_retriever_start(
|
||||
self,
|
||||
serialized: Optional[Dict[str, Any]],
|
||||
query: str,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> 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
|
||||
)
|
||||
|
||||
def on_retriever_end(
|
||||
self,
|
||||
documents: Sequence[Document],
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
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(
|
||||
self,
|
||||
error: BaseException,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Run when Retriever errors."""
|
||||
self._log_debug_event("on_retriever_error", run_id, parent_run_id, error=error)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, error)
|
||||
|
||||
def on_agent_action(
|
||||
self,
|
||||
action: AgentAction,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Run on agent action."""
|
||||
self._log_debug_event("on_agent_action", run_id, parent_run_id, action=action)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_trace_or_span_metadata(None, action, run_id, parent_run_id, **kwargs)
|
||||
|
||||
def on_agent_finish(
|
||||
self,
|
||||
finish: AgentFinish,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._log_debug_event("on_agent_finish", run_id, parent_run_id, finish=finish)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, finish)
|
||||
|
||||
def _set_parent_of_run(self, run_id: UUID, parent_run_id: Optional[UUID] = None):
|
||||
"""
|
||||
Set the parent run ID for a chain run. If there is no parent, the run is the root.
|
||||
"""
|
||||
if parent_run_id is not None:
|
||||
self._parent_tree[run_id] = parent_run_id
|
||||
|
||||
def _pop_parent_of_run(self, run_id: UUID):
|
||||
"""
|
||||
Remove the parent run ID for a chain run.
|
||||
"""
|
||||
try:
|
||||
self._parent_tree.pop(run_id)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _find_root_run(self, run_id: UUID) -> UUID:
|
||||
"""
|
||||
Finds the root ID of a chain run.
|
||||
"""
|
||||
id: UUID = run_id
|
||||
while id in self._parent_tree:
|
||||
id = self._parent_tree[id]
|
||||
return id
|
||||
|
||||
def _set_trace_or_span_metadata(
|
||||
self,
|
||||
serialized: Optional[Dict[str, Any]],
|
||||
input: Any,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs,
|
||||
):
|
||||
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
|
||||
)
|
||||
|
||||
def _set_llm_metadata(
|
||||
self,
|
||||
serialized: Dict[str, Any],
|
||||
run_id: UUID,
|
||||
messages: Union[List[Dict[str, Any]], List[str]],
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
invocation_params: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
run_name = _get_langchain_run_name(serialized, **kwargs) or "generation"
|
||||
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"):
|
||||
generation.tools = tools
|
||||
if isinstance(metadata, dict):
|
||||
if model := metadata.get("ls_model_name"):
|
||||
generation.model = model
|
||||
if provider := metadata.get("ls_provider"):
|
||||
generation.provider = provider
|
||||
try:
|
||||
base_url = serialized["kwargs"]["openai_api_base"]
|
||||
if base_url is not None:
|
||||
generation.base_url = base_url
|
||||
except KeyError:
|
||||
pass
|
||||
self._runs[run_id] = generation
|
||||
|
||||
def _pop_run_metadata(self, run_id: UUID) -> Optional[RunMetadata]:
|
||||
end_time = time.time()
|
||||
try:
|
||||
run = self._runs.pop(run_id)
|
||||
except KeyError:
|
||||
log.warning(f"No run metadata found for run {run_id}")
|
||||
return None
|
||||
run.end_time = end_time
|
||||
return run
|
||||
|
||||
def _get_trace_id(self, run_id: UUID):
|
||||
trace_id = self._trace_id or self._find_root_run(run_id)
|
||||
if not trace_id:
|
||||
return run_id
|
||||
return trace_id
|
||||
|
||||
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.
|
||||
"""
|
||||
if parent_run_id is not None and parent_run_id not in self._parent_tree:
|
||||
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
|
||||
):
|
||||
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."
|
||||
)
|
||||
return
|
||||
self._capture_trace_or_span(
|
||||
trace_id,
|
||||
run_id,
|
||||
run,
|
||||
outputs,
|
||||
self._get_parent_run_id(trace_id, run_id, parent_run_id),
|
||||
)
|
||||
|
||||
def _capture_trace_or_span(
|
||||
self,
|
||||
trace_id: Any,
|
||||
run_id: UUID,
|
||||
run: SpanMetadata,
|
||||
outputs: Any,
|
||||
parent_run_id: Optional[UUID],
|
||||
):
|
||||
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._ph_client, self._privacy_mode, sanitize_langchain(run.input)
|
||||
),
|
||||
"$ai_latency": run.latency,
|
||||
"$ai_span_name": run.name,
|
||||
"$ai_span_id": run_id,
|
||||
}
|
||||
if parent_run_id is not None:
|
||||
event_properties["$ai_parent_id"] = parent_run_id
|
||||
if self._properties:
|
||||
event_properties.update(self._properties)
|
||||
|
||||
if isinstance(outputs, BaseException):
|
||||
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._ph_client, self._privacy_mode, outputs
|
||||
)
|
||||
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
self._ph_client.capture(
|
||||
distinct_id=self._distinct_id or run_id,
|
||||
event=event_name,
|
||||
properties=event_properties,
|
||||
groups=self._groups,
|
||||
)
|
||||
|
||||
def _pop_run_and_capture_generation(
|
||||
self,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID],
|
||||
response: Union[LLMResult, BaseException],
|
||||
):
|
||||
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 not isinstance(run, GenerationMetadata):
|
||||
log.warning(
|
||||
f"Run {run_id} is not a generation, but attempted to be captured as a generation."
|
||||
)
|
||||
return
|
||||
self._capture_generation(
|
||||
trace_id,
|
||||
run_id,
|
||||
run,
|
||||
response,
|
||||
self._get_parent_run_id(trace_id, run_id, parent_run_id),
|
||||
)
|
||||
|
||||
def _capture_generation(
|
||||
self,
|
||||
trace_id: Any,
|
||||
run_id: UUID,
|
||||
run: GenerationMetadata,
|
||||
output: Union[LLMResult, BaseException],
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
):
|
||||
event_properties = {
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_span_id": run_id,
|
||||
"$ai_span_name": run.name,
|
||||
"$ai_parent_id": parent_run_id,
|
||||
"$ai_provider": run.provider,
|
||||
"$ai_model": run.model,
|
||||
"$ai_model_parameters": run.model_params,
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._ph_client, self._privacy_mode, sanitize_langchain(run.input)
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_latency": run.latency,
|
||||
"$ai_base_url": run.base_url,
|
||||
}
|
||||
|
||||
if run.tools:
|
||||
event_properties["$ai_tools"] = run.tools
|
||||
|
||||
if isinstance(output, BaseException):
|
||||
event_properties["$ai_http_status"] = _get_http_status(output)
|
||||
event_properties["$ai_error"] = _stringify_exception(output)
|
||||
event_properties["$ai_is_error"] = True
|
||||
else:
|
||||
# Add usage
|
||||
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]
|
||||
if isinstance(generation_result[-1], ChatGeneration):
|
||||
completions = [
|
||||
_convert_message_to_dict(cast(ChatGeneration, generation).message)
|
||||
for generation in generation_result
|
||||
]
|
||||
else:
|
||||
completions = [
|
||||
_extract_raw_response(generation)
|
||||
for generation in generation_result
|
||||
]
|
||||
event_properties["$ai_output_choices"] = with_privacy_mode(
|
||||
self._ph_client, self._privacy_mode, completions
|
||||
)
|
||||
|
||||
if self._properties:
|
||||
event_properties.update(self._properties)
|
||||
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
self._ph_client.capture(
|
||||
distinct_id=self._distinct_id or trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=self._groups,
|
||||
)
|
||||
|
||||
def _log_debug_event(
|
||||
self,
|
||||
event_name: str,
|
||||
run_id: UUID,
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs,
|
||||
):
|
||||
log.debug(
|
||||
f"Event: {event_name}, run_id: {str(run_id)[:5]}, parent_run_id: {str(parent_run_id)[:5]}, kwargs: {kwargs}"
|
||||
)
|
||||
|
||||
|
||||
def _extract_raw_response(last_response):
|
||||
"""Extract the response from the last response of the LLM call."""
|
||||
# We return the text of the response if not empty
|
||||
if last_response.text is not None and last_response.text.strip() != "":
|
||||
return last_response.text.strip()
|
||||
elif hasattr(last_response, "message"):
|
||||
# Additional kwargs contains the response in case of tool usage
|
||||
return last_response.message.additional_kwargs
|
||||
else:
|
||||
# Not tool usage, some LLM responses can be simply empty
|
||||
return ""
|
||||
|
||||
|
||||
def _convert_lc_tool_calls_to_oai(
|
||||
tool_calls: list[ToolCall],
|
||||
) -> list[dict[str, Any]]:
|
||||
try:
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"id": tool_call["id"],
|
||||
"function": {
|
||||
"name": tool_call["name"],
|
||||
"arguments": json.dumps(tool_call["args"]),
|
||||
},
|
||||
}
|
||||
for tool_call in tool_calls
|
||||
]
|
||||
except KeyError:
|
||||
return tool_calls
|
||||
|
||||
|
||||
def _convert_message_to_dict(message: BaseMessage) -> dict[str, Any]:
|
||||
# assistant message
|
||||
if isinstance(message, HumanMessage):
|
||||
message_dict = {"role": "user", "content": message.content}
|
||||
elif isinstance(message, AIMessage):
|
||||
message_dict = {"role": "assistant", "content": message.content}
|
||||
if message.tool_calls:
|
||||
message_dict["tool_calls"] = _convert_lc_tool_calls_to_oai(
|
||||
message.tool_calls
|
||||
)
|
||||
elif isinstance(message, SystemMessage):
|
||||
message_dict = {"role": "system", "content": message.content}
|
||||
elif isinstance(message, ToolMessage):
|
||||
message_dict = {"role": "tool", "content": message.content}
|
||||
elif isinstance(message, FunctionMessage):
|
||||
message_dict = {"role": "function", "content": message.content}
|
||||
else:
|
||||
message_dict = {"role": message.type, "content": str(message.content)}
|
||||
|
||||
if message.additional_kwargs:
|
||||
message_dict.update(message.additional_kwargs)
|
||||
|
||||
if "content" in message_dict and not message_dict["content"]:
|
||||
message_dict["content"] = ""
|
||||
|
||||
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],
|
||||
) -> ModelUsage:
|
||||
if isinstance(usage, BaseModel):
|
||||
usage = usage.__dict__
|
||||
|
||||
conversion_list = [
|
||||
# https://pypi.org/project/langchain-anthropic/ (works also for Bedrock-Anthropic)
|
||||
("input_tokens", "input"),
|
||||
("output_tokens", "output"),
|
||||
("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"),
|
||||
]
|
||||
|
||||
parsed_usage = {}
|
||||
for model_key, type_key in conversion_list:
|
||||
if model_key in usage:
|
||||
captured_count = usage[model_key]
|
||||
final_count = (
|
||||
sum(captured_count)
|
||||
if isinstance(captured_count, list)
|
||||
else captured_count
|
||||
) # For Bedrock, the token count is a list when streamed
|
||||
|
||||
parsed_usage[type_key] = final_count
|
||||
|
||||
# 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) -> ModelUsage:
|
||||
# langchain-anthropic uses the usage field
|
||||
llm_usage_keys = ["token_usage", "usage"]
|
||||
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):
|
||||
llm_usage = _parse_usage_model(response.llm_output[key])
|
||||
break
|
||||
|
||||
if hasattr(response, "generations"):
|
||||
for generation in response.generations:
|
||||
if "usage" in generation:
|
||||
llm_usage = _parse_usage_model(generation["usage"])
|
||||
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"]
|
||||
)
|
||||
break
|
||||
|
||||
message_chunk = getattr(generation_chunk, "message", {})
|
||||
response_metadata = getattr(message_chunk, "response_metadata", {})
|
||||
|
||||
bedrock_anthropic_usage = (
|
||||
response_metadata.get("usage", None) # for Bedrock-Anthropic
|
||||
if isinstance(response_metadata, dict)
|
||||
else None
|
||||
)
|
||||
bedrock_titan_usage = (
|
||||
response_metadata.get(
|
||||
"amazon-bedrock-invocationMetrics", None
|
||||
) # for Bedrock-Titan
|
||||
if isinstance(response_metadata, dict)
|
||||
else None
|
||||
)
|
||||
ollama_usage = getattr(
|
||||
message_chunk, "usage_metadata", None
|
||||
) # for Ollama
|
||||
|
||||
chunk_usage = (
|
||||
bedrock_anthropic_usage or bedrock_titan_usage or ollama_usage
|
||||
)
|
||||
if chunk_usage:
|
||||
llm_usage = _parse_usage_model(chunk_usage)
|
||||
break
|
||||
|
||||
return llm_usage
|
||||
|
||||
|
||||
def _get_http_status(error: BaseException) -> int:
|
||||
# OpenAI: https://github.com/openai/openai-python/blob/main/src/openai/_exceptions.py
|
||||
# Anthropic: https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/_exceptions.py
|
||||
# Google: https://github.com/googleapis/python-api-core/blob/main/google/api_core/exceptions.py
|
||||
status_code = getattr(error, "status_code", getattr(error, "code", 0))
|
||||
return status_code
|
||||
|
||||
|
||||
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:
|
||||
- The value assigned to the "name" key in `kwargs`.
|
||||
- The value assigned to the "name" key in `serialized`.
|
||||
- The last entry of the value assigned to the "id" key in `serialized`.
|
||||
- "<unknown>".
|
||||
|
||||
Args:
|
||||
serialized (Optional[Dict[str, Any]]): A dictionary containing the runnable's serialized data.
|
||||
**kwargs (Any): Additional keyword arguments, potentially including the 'name' override.
|
||||
|
||||
Returns:
|
||||
str: The determined name of the Langchain runnable.
|
||||
"""
|
||||
if "name" in kwargs and kwargs["name"] is not None:
|
||||
return kwargs["name"]
|
||||
if serialized is None:
|
||||
return None
|
||||
try:
|
||||
return serialized["name"]
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
try:
|
||||
return serialized["id"][-1]
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _stringify_exception(exception: BaseException) -> str:
|
||||
description = str(exception)
|
||||
if description:
|
||||
return f"{exception.__class__.__name__}: {description}"
|
||||
return exception.__class__.__name__
|
||||
@@ -0,0 +1,3 @@
|
||||
from .litellm import completion, acompletion, embedding
|
||||
|
||||
__all__ = ["completion", "acompletion", "embedding"]
|
||||
@@ -0,0 +1,420 @@
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
try:
|
||||
import litellm
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the LiteLLM SDK to use this feature: 'pip install litellm'"
|
||||
)
|
||||
|
||||
from posthog.ai.utils import (
|
||||
call_llm_and_track_usage,
|
||||
call_llm_and_track_usage_async,
|
||||
extract_available_tool_calls,
|
||||
get_model_params,
|
||||
with_privacy_mode,
|
||||
)
|
||||
from posthog.ai.sanitization import sanitize_openai
|
||||
from posthog.client import Client as PostHogClient
|
||||
from posthog import setup
|
||||
|
||||
|
||||
def _setup_client_and_trace_id(
|
||||
posthog_client: Optional[PostHogClient], posthog_trace_id: Optional[str]
|
||||
) -> tuple[PostHogClient, str]:
|
||||
"""Common setup logic for both sync and async completion functions."""
|
||||
ph_client = posthog_client or setup()
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
return ph_client, posthog_trace_id
|
||||
|
||||
|
||||
def _resolve_base_url(kwargs: Dict[str, Any]) -> str:
|
||||
return str(
|
||||
kwargs.get("base_url")
|
||||
or kwargs.get("api_base")
|
||||
or os.getenv("LITELLM_BASE_URL")
|
||||
or "python-sdk"
|
||||
)
|
||||
|
||||
|
||||
def _strip_provider_from_model(model: str) -> str:
|
||||
"""
|
||||
Strip provider prefix from LiteLLM model name for proper price matching.
|
||||
Examples:
|
||||
- "openai/gpt-4" -> "gpt-4"
|
||||
- "anthropic/claude-3" -> "claude-3"
|
||||
- "gpt-4" -> "gpt-4" (no change if no provider prefix)
|
||||
"""
|
||||
if "/" in model:
|
||||
return model.split("/", 1)[1]
|
||||
return model
|
||||
|
||||
|
||||
def completion(
|
||||
posthog_client: Optional[PostHogClient] = None,
|
||||
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,
|
||||
):
|
||||
ph_client, posthog_trace_id = _setup_client_and_trace_id(
|
||||
posthog_client, posthog_trace_id
|
||||
)
|
||||
|
||||
if kwargs.get("stream", False):
|
||||
return _create_streaming(
|
||||
ph_client,
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
tracking_model = None
|
||||
if "model" in kwargs and kwargs["model"]:
|
||||
tracking_model = _strip_provider_from_model(kwargs["model"])
|
||||
|
||||
return call_llm_and_track_usage(
|
||||
posthog_distinct_id,
|
||||
ph_client,
|
||||
"litellm",
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
_resolve_base_url(kwargs),
|
||||
litellm.completion,
|
||||
tracking_model=tracking_model,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
async def acompletion(
|
||||
posthog_client: Optional[PostHogClient] = None,
|
||||
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,
|
||||
):
|
||||
ph_client, posthog_trace_id = _setup_client_and_trace_id(
|
||||
posthog_client, posthog_trace_id
|
||||
)
|
||||
|
||||
if kwargs.get("stream", False):
|
||||
return await _create_streaming_async(
|
||||
ph_client,
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Strip provider prefix from model name for tracking
|
||||
tracking_model = None
|
||||
if "model" in kwargs and kwargs["model"]:
|
||||
tracking_model = _strip_provider_from_model(kwargs["model"])
|
||||
|
||||
return await call_llm_and_track_usage_async(
|
||||
posthog_distinct_id,
|
||||
ph_client,
|
||||
"litellm",
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
_resolve_base_url(kwargs),
|
||||
litellm.acompletion,
|
||||
tracking_model=tracking_model,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def embedding(
|
||||
posthog_client: Optional[PostHogClient] = None,
|
||||
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,
|
||||
):
|
||||
"""
|
||||
Create embeddings using LiteLLM, with automatic PostHog tracking.
|
||||
|
||||
Args:
|
||||
posthog_client: Optional PostHog client instance
|
||||
posthog_distinct_id: Optional distinct ID for the user
|
||||
posthog_trace_id: Optional trace ID for linking events
|
||||
posthog_properties: Optional additional properties to track
|
||||
posthog_privacy_mode: Whether to sanitize input/output for privacy
|
||||
posthog_groups: Optional groups to associate with the event
|
||||
**kwargs: Additional arguments passed to litellm.embedding()
|
||||
|
||||
Returns:
|
||||
The response from litellm.embedding()
|
||||
"""
|
||||
ph_client, posthog_trace_id = _setup_client_and_trace_id(
|
||||
posthog_client, posthog_trace_id
|
||||
)
|
||||
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
start_time = time.time()
|
||||
response = litellm.embedding(**kwargs)
|
||||
end_time = time.time()
|
||||
|
||||
# Extract usage statistics if available
|
||||
usage_stats = {}
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
usage_stats = {
|
||||
"prompt_tokens": getattr(response.usage, "prompt_tokens", 0),
|
||||
"total_tokens": getattr(response.usage, "total_tokens", 0),
|
||||
}
|
||||
|
||||
latency = end_time - start_time
|
||||
|
||||
# Build the event properties for embeddings
|
||||
model = kwargs.get("model")
|
||||
stripped_model = _strip_provider_from_model(model) if model else model
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "litellm",
|
||||
"$ai_model": stripped_model,
|
||||
"$ai_input": with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, kwargs.get("input")
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": _resolve_base_url(kwargs),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
# Send capture event for embeddings
|
||||
if hasattr(ph_client, "capture"):
|
||||
ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_embedding",
|
||||
properties=event_properties,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _ensure_stream_usage(kwargs: Dict[str, Any]) -> None:
|
||||
if "stream_options" not in kwargs:
|
||||
kwargs["stream_options"] = {}
|
||||
kwargs["stream_options"]["include_usage"] = True # per docs
|
||||
|
||||
|
||||
def _extract_usage_stats(chunk) -> Dict[str, int]:
|
||||
"""Extract usage statistics from a streaming chunk."""
|
||||
usage_stats = {
|
||||
"prompt_tokens": getattr(chunk.usage, "prompt_tokens", 0),
|
||||
"completion_tokens": getattr(chunk.usage, "completion_tokens", 0),
|
||||
"total_tokens": getattr(chunk.usage, "total_tokens", 0),
|
||||
}
|
||||
|
||||
if hasattr(chunk.usage, "prompt_tokens_details") and getattr(
|
||||
chunk.usage.prompt_tokens_details, "cached_tokens", None
|
||||
):
|
||||
usage_stats["cache_read_input_tokens"] = (
|
||||
chunk.usage.prompt_tokens_details.cached_tokens
|
||||
)
|
||||
|
||||
if hasattr(chunk.usage, "output_tokens_details") and getattr(
|
||||
chunk.usage.output_tokens_details, "reasoning_tokens", None
|
||||
):
|
||||
usage_stats["reasoning_tokens"] = (
|
||||
chunk.usage.output_tokens_details.reasoning_tokens
|
||||
)
|
||||
|
||||
return usage_stats
|
||||
|
||||
|
||||
def _extract_chunk_content(chunk) -> Optional[str]:
|
||||
"""Extract content from a streaming chunk if available."""
|
||||
if getattr(chunk, "choices", None):
|
||||
if (
|
||||
chunk.choices
|
||||
and len(chunk.choices) > 0
|
||||
and getattr(chunk.choices[0], "delta", None)
|
||||
and getattr(chunk.choices[0].delta, "content", None)
|
||||
):
|
||||
return chunk.choices[0].delta.content
|
||||
return None
|
||||
|
||||
|
||||
def _create_streaming(
|
||||
ph_client: PostHogClient,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
_ensure_stream_usage(kwargs)
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
accumulated_content: list[str] = []
|
||||
response = litellm.completion(**kwargs)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats, accumulated_content
|
||||
|
||||
try:
|
||||
for chunk in response:
|
||||
if getattr(chunk, "usage", None):
|
||||
usage_stats = _extract_usage_stats(chunk)
|
||||
|
||||
content = _extract_chunk_content(chunk)
|
||||
if content:
|
||||
accumulated_content.append(content)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
latency = time.time() - start_time
|
||||
output = "".join(accumulated_content)
|
||||
_capture_streaming_event(
|
||||
ph_client,
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
base_url=_resolve_base_url(kwargs),
|
||||
available_tool_calls=extract_available_tool_calls("openai", kwargs),
|
||||
)
|
||||
|
||||
return generator()
|
||||
|
||||
|
||||
async def _create_streaming_async(
|
||||
ph_client: PostHogClient,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
_ensure_stream_usage(kwargs)
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
accumulated_content: list[str] = []
|
||||
response = await litellm.acompletion(**kwargs)
|
||||
|
||||
async def generator():
|
||||
nonlocal usage_stats, accumulated_content
|
||||
|
||||
try:
|
||||
async for chunk in response:
|
||||
if getattr(chunk, "usage", None):
|
||||
usage_stats = _extract_usage_stats(chunk)
|
||||
|
||||
content = _extract_chunk_content(chunk)
|
||||
if content:
|
||||
accumulated_content.append(content)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
latency = time.time() - start_time
|
||||
output = "".join(accumulated_content)
|
||||
_capture_streaming_event(
|
||||
ph_client,
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
base_url=_resolve_base_url(kwargs),
|
||||
available_tool_calls=extract_available_tool_calls("openai", kwargs),
|
||||
)
|
||||
|
||||
return generator()
|
||||
|
||||
|
||||
def _capture_streaming_event(
|
||||
ph_client: PostHogClient,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
latency: float,
|
||||
output: Any,
|
||||
*,
|
||||
base_url: str,
|
||||
available_tool_calls: Optional[list[dict]] = None,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
model = kwargs.get("model")
|
||||
stripped_model = _strip_provider_from_model(model) if model else model
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "litellm",
|
||||
"$ai_model": stripped_model,
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, sanitize_openai(kwargs.get("messages"))
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, [{"content": output, "role": "assistant"}]
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
|
||||
"$ai_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,
|
||||
"$ai_base_url": base_url,
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_tool_calls
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(ph_client, "capture"):
|
||||
ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
from .openai import OpenAI
|
||||
from .openai_async import AsyncOpenAI
|
||||
from .openai_providers import AsyncAzureOpenAI, AzureOpenAI
|
||||
|
||||
__all__ = ["OpenAI", "AsyncOpenAI", "AzureOpenAI", "AsyncAzureOpenAI"]
|
||||
@@ -0,0 +1,616 @@
|
||||
import time
|
||||
import uuid
|
||||
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'"
|
||||
)
|
||||
|
||||
from posthog.ai.utils import (
|
||||
call_llm_and_track_usage,
|
||||
extract_available_tool_calls,
|
||||
get_model_params,
|
||||
with_privacy_mode,
|
||||
)
|
||||
from posthog.ai.sanitization import sanitize_openai, sanitize_openai_response
|
||||
from posthog.client import Client as PostHogClient
|
||||
from posthog import setup
|
||||
|
||||
|
||||
class OpenAI(openai.OpenAI):
|
||||
"""
|
||||
A wrapper around the OpenAI SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: OpenAI API key.
|
||||
posthog_client: If provided, events will be captured via this client instead of the global `posthog`.
|
||||
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
self._original_embeddings = getattr(self, "embeddings", None)
|
||||
self._original_beta = getattr(self, "beta", None)
|
||||
self._original_responses = getattr(self, "responses", None)
|
||||
|
||||
# Replace with wrapped versions (only if originals exist)
|
||||
if self._original_chat is not None:
|
||||
self.chat = WrappedChat(self, self._original_chat)
|
||||
|
||||
if self._original_embeddings is not None:
|
||||
self.embeddings = WrappedEmbeddings(self, self._original_embeddings)
|
||||
|
||||
if self._original_beta is not None:
|
||||
self.beta = WrappedBeta(self, self._original_beta)
|
||||
|
||||
if self._original_responses is not None:
|
||||
self.responses = WrappedResponses(self, self._original_responses)
|
||||
|
||||
|
||||
class WrappedResponses:
|
||||
"""Wrapper for OpenAI responses that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_responses):
|
||||
self._client = client
|
||||
self._original = original_responses
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original responses object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
def create(
|
||||
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,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
if kwargs.get("stream", False):
|
||||
return self._create_streaming(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
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.create,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _create_streaming(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
final_content = []
|
||||
response = self._original.create(**kwargs)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal final_content # noqa: F824
|
||||
|
||||
try:
|
||||
for chunk in response:
|
||||
if hasattr(chunk, "type") and chunk.type == "response.completed":
|
||||
res = chunk.response
|
||||
if res.output and len(res.output) > 0:
|
||||
final_content.append(res.output[0])
|
||||
|
||||
if hasattr(chunk, "usage") and chunk.usage:
|
||||
usage_stats = {
|
||||
k: getattr(chunk.usage, k, 0)
|
||||
for k in [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
]
|
||||
}
|
||||
|
||||
# Add support for 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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
output = final_content
|
||||
self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
)
|
||||
|
||||
return generator()
|
||||
|
||||
def _capture_streaming_event(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
latency: float,
|
||||
output: Any,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
event_properties = {
|
||||
"$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,
|
||||
sanitize_openai_response(kwargs.get("input")),
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
output,
|
||||
),
|
||||
"$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_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_tool_calls
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
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."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_chat):
|
||||
self._client = client
|
||||
self._original = original_chat
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original chat object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedCompletions(self._client, self._original.completions)
|
||||
|
||||
|
||||
class WrappedCompletions:
|
||||
"""Wrapper for OpenAI chat completions that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_completions):
|
||||
self._client = client
|
||||
self._original = original_completions
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original completions object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
def create(
|
||||
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,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
if kwargs.get("stream", False):
|
||||
return self._create_streaming(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
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.create,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _create_streaming(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
accumulated_content = []
|
||||
if "stream_options" not in kwargs:
|
||||
kwargs["stream_options"] = {}
|
||||
kwargs["stream_options"]["include_usage"] = True
|
||||
response = self._original.create(**kwargs)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
|
||||
try:
|
||||
for chunk in response:
|
||||
if hasattr(chunk, "usage") and chunk.usage:
|
||||
usage_stats = {
|
||||
k: getattr(chunk.usage, k, 0)
|
||||
for k in [
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
]
|
||||
}
|
||||
|
||||
# Add support for cached tokens
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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:
|
||||
accumulated_content.append(content)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
output = "".join(accumulated_content)
|
||||
self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
)
|
||||
|
||||
return generator()
|
||||
|
||||
def _capture_streaming_event(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
latency: float,
|
||||
output: Any,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
event_properties = {
|
||||
"$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,
|
||||
sanitize_openai(kwargs.get("messages")),
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
[{"content": output, "role": "assistant"}],
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
|
||||
"$ai_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,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_tool_calls
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
|
||||
class WrappedEmbeddings:
|
||||
"""Wrapper for OpenAI embeddings that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_embeddings):
|
||||
self._client = client
|
||||
self._original = original_embeddings
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original embeddings object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
def create(
|
||||
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,
|
||||
):
|
||||
"""
|
||||
Create an embedding using OpenAI's 'embeddings.create' method, but also track usage in PostHog.
|
||||
|
||||
Args:
|
||||
posthog_distinct_id: Optional ID to associate with the usage event.
|
||||
posthog_trace_id: Optional trace UUID for linking events.
|
||||
posthog_properties: Optional dictionary of extra properties to include in the event.
|
||||
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 Embeddings API.
|
||||
|
||||
Returns:
|
||||
The response from OpenAI's embeddings.create call.
|
||||
"""
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
start_time = time.time()
|
||||
response = self._original.create(**kwargs)
|
||||
end_time = time.time()
|
||||
|
||||
# Extract usage statistics if available
|
||||
usage_stats = {}
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
usage_stats = {
|
||||
"prompt_tokens": getattr(response.usage, "prompt_tokens", 0),
|
||||
"total_tokens": getattr(response.usage, "total_tokens", 0),
|
||||
}
|
||||
|
||||
latency = end_time - start_time
|
||||
|
||||
# Build the event properties
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
sanitize_openai_response(kwargs.get("input")),
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
# Send capture event for embeddings
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_embedding",
|
||||
properties=event_properties,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class WrappedBeta:
|
||||
"""Wrapper for OpenAI beta features that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_beta):
|
||||
self._client = client
|
||||
self._original = original_beta
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def chat(self):
|
||||
return WrappedBetaChat(self._client, self._original.chat)
|
||||
|
||||
|
||||
class WrappedBetaChat:
|
||||
"""Wrapper for OpenAI beta chat that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_beta_chat):
|
||||
self._client = client
|
||||
self._original = original_beta_chat
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta chat object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedBetaCompletions(self._client, self._original.completions)
|
||||
|
||||
|
||||
class WrappedBetaCompletions:
|
||||
"""Wrapper for OpenAI beta chat completions that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: OpenAI, original_beta_completions):
|
||||
self._client = client
|
||||
self._original = original_beta_completions
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta completions object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
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,
|
||||
):
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,620 @@
|
||||
import time
|
||||
import uuid
|
||||
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'"
|
||||
)
|
||||
|
||||
from posthog import setup
|
||||
from posthog.ai.utils import (
|
||||
call_llm_and_track_usage_async,
|
||||
extract_available_tool_calls,
|
||||
get_model_params,
|
||||
with_privacy_mode,
|
||||
)
|
||||
from posthog.ai.sanitization import sanitize_openai, sanitize_openai_response
|
||||
from posthog.client import Client as PostHogClient
|
||||
|
||||
|
||||
class AsyncOpenAI(openai.AsyncOpenAI):
|
||||
"""
|
||||
An async wrapper around the OpenAI SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: OpenAI API key.
|
||||
posthog_client: If provided, events will be captured via this client instead
|
||||
of the global posthog.
|
||||
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
self._original_embeddings = getattr(self, "embeddings", None)
|
||||
self._original_beta = getattr(self, "beta", None)
|
||||
self._original_responses = getattr(self, "responses", None)
|
||||
|
||||
# Replace with wrapped versions (only if originals exist)
|
||||
if self._original_chat is not None:
|
||||
self.chat = WrappedChat(self, self._original_chat)
|
||||
|
||||
if self._original_embeddings is not None:
|
||||
self.embeddings = WrappedEmbeddings(self, self._original_embeddings)
|
||||
|
||||
if self._original_beta is not None:
|
||||
self.beta = WrappedBeta(self, self._original_beta)
|
||||
|
||||
if self._original_responses is not None:
|
||||
self.responses = WrappedResponses(self, self._original_responses)
|
||||
|
||||
|
||||
class WrappedResponses:
|
||||
"""Async wrapper for OpenAI responses that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_responses):
|
||||
self._client = client
|
||||
self._original = original_responses
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original responses object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
async def create(
|
||||
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,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
if kwargs.get("stream", False):
|
||||
return await self._create_streaming(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
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.create,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def _create_streaming(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
final_content = []
|
||||
response = await self._original.create(**kwargs)
|
||||
|
||||
async def async_generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal final_content # noqa: F824
|
||||
|
||||
try:
|
||||
async for chunk in response:
|
||||
if hasattr(chunk, "type") and chunk.type == "response.completed":
|
||||
res = chunk.response
|
||||
if res.output and len(res.output) > 0:
|
||||
final_content.append(res.output[0])
|
||||
|
||||
if hasattr(chunk, "usage") and chunk.usage:
|
||||
usage_stats = {
|
||||
k: getattr(chunk.usage, k, 0)
|
||||
for k in [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
]
|
||||
}
|
||||
|
||||
# Add support for 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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
output = final_content
|
||||
await self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
)
|
||||
|
||||
return async_generator()
|
||||
|
||||
async def _capture_streaming_event(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
latency: float,
|
||||
output: Any,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
event_properties = {
|
||||
"$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,
|
||||
sanitize_openai_response(kwargs.get("input")),
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
output,
|
||||
),
|
||||
"$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_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_tool_calls
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
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."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_chat):
|
||||
self._client = client
|
||||
self._original = original_chat
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original chat object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedCompletions(self._client, self._original.completions)
|
||||
|
||||
|
||||
class WrappedCompletions:
|
||||
"""Async wrapper for OpenAI chat completions that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_completions):
|
||||
self._client = client
|
||||
self._original = original_completions
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original completions object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
async def create(
|
||||
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,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
# If streaming, handle streaming specifically
|
||||
if kwargs.get("stream", False):
|
||||
return await self._create_streaming(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
response = 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.create,
|
||||
**kwargs,
|
||||
)
|
||||
return response
|
||||
|
||||
async def _create_streaming(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
accumulated_content = []
|
||||
|
||||
if "stream_options" not in kwargs:
|
||||
kwargs["stream_options"] = {}
|
||||
kwargs["stream_options"]["include_usage"] = True
|
||||
response = await self._original.create(**kwargs)
|
||||
|
||||
async def async_generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
|
||||
try:
|
||||
async for chunk in response:
|
||||
if hasattr(chunk, "usage") and chunk.usage:
|
||||
usage_stats = {
|
||||
k: getattr(chunk.usage, k, 0)
|
||||
for k in [
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
]
|
||||
}
|
||||
|
||||
# Add support for cached tokens
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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:
|
||||
accumulated_content.append(content)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
output = "".join(accumulated_content)
|
||||
await self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
)
|
||||
|
||||
return async_generator()
|
||||
|
||||
async def _capture_streaming_event(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str],
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
latency: float,
|
||||
output: Any,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
event_properties = {
|
||||
"$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,
|
||||
sanitize_openai(kwargs.get("messages")),
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
[{"content": output, "role": "assistant"}],
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
|
||||
"$ai_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,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_tool_calls
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
|
||||
class WrappedEmbeddings:
|
||||
"""Async wrapper for OpenAI embeddings that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_embeddings):
|
||||
self._client = client
|
||||
self._original = original_embeddings
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original embeddings object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
async def create(
|
||||
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,
|
||||
):
|
||||
"""
|
||||
Create an embedding using OpenAI's 'embeddings.create' method, but also track usage in PostHog.
|
||||
|
||||
Args:
|
||||
posthog_distinct_id: Optional ID to associate with the usage event.
|
||||
posthog_trace_id: Optional trace UUID for linking events.
|
||||
posthog_properties: Optional dictionary of extra properties to include in the event.
|
||||
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 Embeddings API.
|
||||
|
||||
Returns:
|
||||
The response from OpenAI's embeddings.create call.
|
||||
"""
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
start_time = time.time()
|
||||
response = await self._original.create(**kwargs)
|
||||
end_time = time.time()
|
||||
|
||||
# Extract usage statistics if available
|
||||
usage_stats = {}
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
usage_stats = {
|
||||
"prompt_tokens": getattr(response.usage, "prompt_tokens", 0),
|
||||
"total_tokens": getattr(response.usage, "total_tokens", 0),
|
||||
}
|
||||
|
||||
latency = end_time - start_time
|
||||
|
||||
# Build the event properties
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
sanitize_openai_response(kwargs.get("input")),
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
# Send capture event for embeddings
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_embedding",
|
||||
properties=event_properties,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class WrappedBeta:
|
||||
"""Async wrapper for OpenAI beta features that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_beta):
|
||||
self._client = client
|
||||
self._original = original_beta
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def chat(self):
|
||||
return WrappedBetaChat(self._client, self._original.chat)
|
||||
|
||||
|
||||
class WrappedBetaChat:
|
||||
"""Async wrapper for OpenAI beta chat that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_beta_chat):
|
||||
self._client = client
|
||||
self._original = original_beta_chat
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta chat object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
def completions(self):
|
||||
return WrappedBetaCompletions(self._client, self._original.completions)
|
||||
|
||||
|
||||
class WrappedBetaCompletions:
|
||||
"""Async wrapper for OpenAI beta chat completions that tracks usage in PostHog."""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, original_beta_completions):
|
||||
self._client = client
|
||||
self._original = original_beta_completions
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta completions object for any methods we don't explicitly handle."""
|
||||
return getattr(self._original, name)
|
||||
|
||||
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,
|
||||
):
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
try:
|
||||
import openai
|
||||
except ImportError:
|
||||
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_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
|
||||
from posthog.ai.openai.openai_async import WrappedResponses as AsyncWrappedResponses
|
||||
from typing import Optional
|
||||
|
||||
from posthog.client import Client as PostHogClient
|
||||
from posthog import setup
|
||||
|
||||
|
||||
class AzureOpenAI(openai.AzureOpenAI):
|
||||
"""
|
||||
A wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: Azure OpenAI API key.
|
||||
posthog_client: If provided, events will be captured via this client instead
|
||||
of the global posthog.
|
||||
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
self._original_embeddings = getattr(self, "embeddings", None)
|
||||
self._original_beta = getattr(self, "beta", None)
|
||||
self._original_responses = getattr(self, "responses", None)
|
||||
|
||||
# Replace with wrapped versions (only if originals exist)
|
||||
if self._original_chat is not None:
|
||||
self.chat = WrappedChat(self, self._original_chat)
|
||||
|
||||
if self._original_embeddings is not None:
|
||||
self.embeddings = WrappedEmbeddings(self, self._original_embeddings)
|
||||
|
||||
if self._original_beta is not None:
|
||||
self.beta = WrappedBeta(self, self._original_beta)
|
||||
|
||||
if self._original_responses is not None:
|
||||
self.responses = WrappedResponses(self, self._original_responses)
|
||||
|
||||
|
||||
class AsyncAzureOpenAI(openai.AsyncAzureOpenAI):
|
||||
"""
|
||||
An async wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog.
|
||||
"""
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: Azure OpenAI API key.
|
||||
posthog_client: If provided, events will be captured via this client instead
|
||||
of the global posthog.
|
||||
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
self._original_embeddings = getattr(self, "embeddings", None)
|
||||
self._original_beta = getattr(self, "beta", None)
|
||||
self._original_responses = getattr(self, "responses", None)
|
||||
|
||||
# Replace with wrapped versions (only if originals exist)
|
||||
if self._original_chat is not None:
|
||||
self.chat = AsyncWrappedChat(self, self._original_chat)
|
||||
|
||||
if self._original_embeddings is not None:
|
||||
self.embeddings = AsyncWrappedEmbeddings(self, self._original_embeddings)
|
||||
|
||||
if self._original_beta is not None:
|
||||
self.beta = AsyncWrappedBeta(self, self._original_beta)
|
||||
|
||||
# Only add responses if available (newer OpenAI versions)
|
||||
if self._original_responses is not None:
|
||||
self.responses = AsyncWrappedResponses(self, self._original_responses)
|
||||
@@ -0,0 +1,226 @@
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
REDACTED_IMAGE_PLACEHOLDER = "[base64 image redacted]"
|
||||
|
||||
|
||||
def is_base64_data_url(text: str) -> bool:
|
||||
return re.match(r"^data:([^;]+);base64,", text) is not None
|
||||
|
||||
|
||||
def is_valid_url(text: str) -> bool:
|
||||
try:
|
||||
result = urlparse(text)
|
||||
return bool(result.scheme and result.netloc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return text.startswith(("/", "./", "../"))
|
||||
|
||||
|
||||
def is_raw_base64(text: str) -> bool:
|
||||
if is_valid_url(text):
|
||||
return False
|
||||
|
||||
return len(text) > 20 and re.match(r"^[A-Za-z0-9+/]+=*$", text) is not None
|
||||
|
||||
|
||||
def redact_base64_data_url(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
if is_base64_data_url(value):
|
||||
return REDACTED_IMAGE_PLACEHOLDER
|
||||
|
||||
if is_raw_base64(value):
|
||||
return REDACTED_IMAGE_PLACEHOLDER
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def process_messages(messages: Any, transform_content_func) -> Any:
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
def process_content(content: Any) -> Any:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
if not content:
|
||||
return content
|
||||
|
||||
if isinstance(content, list):
|
||||
return [transform_content_func(item) for item in content]
|
||||
|
||||
return transform_content_func(content)
|
||||
|
||||
def process_message(msg: Any) -> Any:
|
||||
if not isinstance(msg, dict) or "content" not in msg:
|
||||
return msg
|
||||
return {**msg, "content": process_content(msg["content"])}
|
||||
|
||||
if isinstance(messages, list):
|
||||
return [process_message(msg) for msg in messages]
|
||||
|
||||
return process_message(messages)
|
||||
|
||||
|
||||
def sanitize_openai_image(item: Any) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if (
|
||||
item.get("type") == "image_url"
|
||||
and isinstance(item.get("image_url"), dict)
|
||||
and "url" in item["image_url"]
|
||||
):
|
||||
return {
|
||||
**item,
|
||||
"image_url": {
|
||||
**item["image_url"],
|
||||
"url": redact_base64_data_url(item["image_url"]["url"]),
|
||||
},
|
||||
}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_openai_response_image(item: Any) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if item.get("type") == "input_image" and "image_url" in item:
|
||||
return {
|
||||
**item,
|
||||
"image_url": redact_base64_data_url(item["image_url"]),
|
||||
}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_anthropic_image(item: Any) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if (
|
||||
item.get("type") == "image"
|
||||
and isinstance(item.get("source"), dict)
|
||||
and item["source"].get("type") == "base64"
|
||||
and "data" in item["source"]
|
||||
):
|
||||
# For Anthropic, if the source type is "base64", we should always redact the data
|
||||
# The provider is explicitly telling us this is base64 data
|
||||
return {
|
||||
**item,
|
||||
"source": {
|
||||
**item["source"],
|
||||
"data": REDACTED_IMAGE_PLACEHOLDER,
|
||||
},
|
||||
}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_gemini_part(part: Any) -> Any:
|
||||
if not isinstance(part, dict):
|
||||
return part
|
||||
|
||||
if (
|
||||
"inline_data" in part
|
||||
and isinstance(part["inline_data"], dict)
|
||||
and "data" in part["inline_data"]
|
||||
):
|
||||
# For Gemini, the inline_data structure indicates base64 data
|
||||
# We should redact any string data in this context
|
||||
return {
|
||||
**part,
|
||||
"inline_data": {
|
||||
**part["inline_data"],
|
||||
"data": REDACTED_IMAGE_PLACEHOLDER,
|
||||
},
|
||||
}
|
||||
|
||||
return part
|
||||
|
||||
|
||||
def process_gemini_item(item: Any) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if "parts" in item and item["parts"]:
|
||||
parts = item["parts"]
|
||||
if isinstance(parts, list):
|
||||
parts = [sanitize_gemini_part(part) for part in parts]
|
||||
else:
|
||||
parts = sanitize_gemini_part(parts)
|
||||
|
||||
return {**item, "parts": parts}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_langchain_image(item: Any) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if (
|
||||
item.get("type") == "image_url"
|
||||
and isinstance(item.get("image_url"), dict)
|
||||
and "url" in item["image_url"]
|
||||
):
|
||||
return {
|
||||
**item,
|
||||
"image_url": {
|
||||
**item["image_url"],
|
||||
"url": redact_base64_data_url(item["image_url"]["url"]),
|
||||
},
|
||||
}
|
||||
|
||||
if item.get("type") == "image" and "data" in item:
|
||||
return {**item, "data": redact_base64_data_url(item["data"])}
|
||||
|
||||
if (
|
||||
item.get("type") == "image"
|
||||
and isinstance(item.get("source"), dict)
|
||||
and "data" in item["source"]
|
||||
):
|
||||
# Anthropic style - raw base64 in structured format, always redact
|
||||
return {
|
||||
**item,
|
||||
"source": {
|
||||
**item["source"],
|
||||
"data": REDACTED_IMAGE_PLACEHOLDER,
|
||||
},
|
||||
}
|
||||
|
||||
if item.get("type") == "media" and "data" in item:
|
||||
return {**item, "data": redact_base64_data_url(item["data"])}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_openai(data: Any) -> Any:
|
||||
return process_messages(data, sanitize_openai_image)
|
||||
|
||||
|
||||
def sanitize_openai_response(data: Any) -> Any:
|
||||
return process_messages(data, sanitize_openai_response_image)
|
||||
|
||||
|
||||
def sanitize_anthropic(data: Any) -> Any:
|
||||
return process_messages(data, sanitize_anthropic_image)
|
||||
|
||||
|
||||
def sanitize_gemini(data: Any) -> Any:
|
||||
if not data:
|
||||
return data
|
||||
|
||||
if isinstance(data, list):
|
||||
return [process_gemini_item(item) for item in data]
|
||||
|
||||
return process_gemini_item(data)
|
||||
|
||||
|
||||
def sanitize_langchain(data: Any) -> Any:
|
||||
return process_messages(data, sanitize_langchain_image)
|
||||
@@ -0,0 +1,635 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from httpx import URL
|
||||
|
||||
from posthog.client import Client as PostHogClient
|
||||
from posthog.ai.sanitization import (
|
||||
sanitize_openai,
|
||||
sanitize_anthropic,
|
||||
sanitize_gemini,
|
||||
sanitize_langchain,
|
||||
)
|
||||
|
||||
|
||||
def get_model_params(kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Extracts model parameters from the kwargs dictionary.
|
||||
"""
|
||||
model_params = {}
|
||||
for param in [
|
||||
"temperature",
|
||||
"max_tokens", # Deprecated field
|
||||
"max_completion_tokens",
|
||||
"top_p",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
"n",
|
||||
"stop",
|
||||
"stream", # OpenAI-specific field
|
||||
"streaming", # Anthropic-specific field
|
||||
]:
|
||||
if param in kwargs and kwargs[param] is not None:
|
||||
model_params[param] = kwargs[param]
|
||||
return model_params
|
||||
|
||||
|
||||
def get_usage(response, provider: str) -> Dict[str, Any]:
|
||||
if provider == "anthropic":
|
||||
return {
|
||||
"input_tokens": response.usage.input_tokens,
|
||||
"output_tokens": response.usage.output_tokens,
|
||||
"cache_read_input_tokens": response.usage.cache_read_input_tokens,
|
||||
"cache_creation_input_tokens": response.usage.cache_creation_input_tokens,
|
||||
}
|
||||
elif provider == "openai" or provider == "litellm":
|
||||
cached_tokens = 0
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
reasoning_tokens = 0
|
||||
|
||||
# responses api
|
||||
if hasattr(response.usage, "input_tokens"):
|
||||
input_tokens = response.usage.input_tokens
|
||||
if hasattr(response.usage, "output_tokens"):
|
||||
output_tokens = response.usage.output_tokens
|
||||
if hasattr(response.usage, "input_tokens_details") and hasattr(
|
||||
response.usage.input_tokens_details, "cached_tokens"
|
||||
):
|
||||
cached_tokens = response.usage.input_tokens_details.cached_tokens
|
||||
if hasattr(response.usage, "output_tokens_details") and hasattr(
|
||||
response.usage.output_tokens_details, "reasoning_tokens"
|
||||
):
|
||||
reasoning_tokens = response.usage.output_tokens_details.reasoning_tokens
|
||||
|
||||
# chat completions
|
||||
if hasattr(response.usage, "prompt_tokens"):
|
||||
input_tokens = response.usage.prompt_tokens
|
||||
if hasattr(response.usage, "completion_tokens"):
|
||||
output_tokens = response.usage.completion_tokens
|
||||
if hasattr(response.usage, "prompt_tokens_details") and hasattr(
|
||||
response.usage.prompt_tokens_details, "cached_tokens"
|
||||
):
|
||||
cached_tokens = response.usage.prompt_tokens_details.cached_tokens
|
||||
|
||||
return {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"cache_read_input_tokens": cached_tokens,
|
||||
"reasoning_tokens": reasoning_tokens,
|
||||
}
|
||||
elif provider == "gemini":
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
return {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"cache_read_input_tokens": 0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
}
|
||||
return {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
}
|
||||
|
||||
|
||||
def format_response(response, provider: str):
|
||||
"""
|
||||
Format a regular (non-streaming) response.
|
||||
"""
|
||||
output = []
|
||||
if response is None:
|
||||
return output
|
||||
if provider == "anthropic":
|
||||
return format_response_anthropic(response)
|
||||
elif provider == "openai" or provider == "litellm":
|
||||
return format_response_openai(response)
|
||||
elif provider == "gemini":
|
||||
return format_response_gemini(response)
|
||||
return output
|
||||
|
||||
|
||||
def format_response_anthropic(response):
|
||||
output = []
|
||||
content = []
|
||||
|
||||
for choice in response.content:
|
||||
if (
|
||||
hasattr(choice, "type")
|
||||
and choice.type == "text"
|
||||
and hasattr(choice, "text")
|
||||
and choice.text
|
||||
):
|
||||
content.append({"type": "text", "text": choice.text})
|
||||
elif (
|
||||
hasattr(choice, "type")
|
||||
and choice.type == "tool_use"
|
||||
and hasattr(choice, "name")
|
||||
and hasattr(choice, "id")
|
||||
):
|
||||
tool_call = {
|
||||
"type": "function",
|
||||
"id": choice.id,
|
||||
"function": {
|
||||
"name": choice.name,
|
||||
"arguments": getattr(choice, "input", {}),
|
||||
},
|
||||
}
|
||||
content.append(tool_call)
|
||||
|
||||
if content:
|
||||
message = {
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
}
|
||||
output.append(message)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def format_response_openai(response):
|
||||
output = []
|
||||
|
||||
if hasattr(response, "choices"):
|
||||
content = []
|
||||
role = "assistant"
|
||||
|
||||
for choice in response.choices:
|
||||
# Handle Chat Completions response format
|
||||
if hasattr(choice, "message") and choice.message:
|
||||
if choice.message.role:
|
||||
role = choice.message.role
|
||||
|
||||
if choice.message.content:
|
||||
content.append({"type": "text", "text": choice.message.content})
|
||||
|
||||
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
|
||||
for tool_call in choice.message.tool_calls:
|
||||
content.append(
|
||||
{
|
||||
"type": "function",
|
||||
"id": tool_call.id,
|
||||
"function": {
|
||||
"name": tool_call.function.name,
|
||||
"arguments": tool_call.function.arguments,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if content:
|
||||
message = {
|
||||
"role": role,
|
||||
"content": content,
|
||||
}
|
||||
output.append(message)
|
||||
|
||||
# Handle Responses API format
|
||||
if hasattr(response, "output"):
|
||||
content = []
|
||||
role = "assistant"
|
||||
|
||||
for item in response.output:
|
||||
if item.type == "message":
|
||||
role = item.role
|
||||
|
||||
if hasattr(item, "content") and isinstance(item.content, list):
|
||||
for content_item in item.content:
|
||||
if (
|
||||
hasattr(content_item, "type")
|
||||
and content_item.type == "output_text"
|
||||
and hasattr(content_item, "text")
|
||||
):
|
||||
content.append({"type": "text", "text": content_item.text})
|
||||
elif hasattr(content_item, "text"):
|
||||
content.append({"type": "text", "text": content_item.text})
|
||||
elif (
|
||||
hasattr(content_item, "type")
|
||||
and content_item.type == "input_image"
|
||||
and hasattr(content_item, "image_url")
|
||||
):
|
||||
content.append(
|
||||
{
|
||||
"type": "image",
|
||||
"image": content_item.image_url,
|
||||
}
|
||||
)
|
||||
elif hasattr(item, "content"):
|
||||
content.append({"type": "text", "text": str(item.content)})
|
||||
|
||||
elif hasattr(item, "type") and item.type == "function_call":
|
||||
content.append(
|
||||
{
|
||||
"type": "function",
|
||||
"id": getattr(item, "call_id", getattr(item, "id", "")),
|
||||
"function": {
|
||||
"name": item.name,
|
||||
"arguments": getattr(item, "arguments", {}),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if content:
|
||||
message = {
|
||||
"role": role,
|
||||
"content": content,
|
||||
}
|
||||
output.append(message)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def format_response_gemini(response):
|
||||
output = []
|
||||
|
||||
if hasattr(response, "candidates") and response.candidates:
|
||||
for candidate in response.candidates:
|
||||
if hasattr(candidate, "content") and candidate.content:
|
||||
content = []
|
||||
|
||||
if hasattr(candidate.content, "parts") and candidate.content.parts:
|
||||
for part in candidate.content.parts:
|
||||
if hasattr(part, "text") and part.text:
|
||||
content.append({"type": "text", "text": part.text})
|
||||
elif hasattr(part, "function_call") and part.function_call:
|
||||
function_call = part.function_call
|
||||
content.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": function_call.name,
|
||||
"arguments": function_call.args,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if content:
|
||||
message = {
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
}
|
||||
output.append(message)
|
||||
|
||||
elif hasattr(candidate, "text") and candidate.text:
|
||||
output.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": candidate.text}],
|
||||
}
|
||||
)
|
||||
elif hasattr(response, "text") and response.text:
|
||||
output.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": response.text}],
|
||||
}
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def extract_available_tool_calls(provider: str, kwargs: Dict[str, Any]):
|
||||
if provider == "anthropic":
|
||||
if "tools" in kwargs:
|
||||
return kwargs["tools"]
|
||||
|
||||
return None
|
||||
elif provider == "gemini":
|
||||
if "config" in kwargs and hasattr(kwargs["config"], "tools"):
|
||||
return kwargs["config"].tools
|
||||
|
||||
return None
|
||||
elif provider == "openai" or provider == "litellm":
|
||||
if "tools" in kwargs:
|
||||
return kwargs["tools"]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def merge_system_prompt(kwargs: Dict[str, Any], provider: str):
|
||||
messages: List[Dict[str, Any]] = []
|
||||
if provider == "anthropic":
|
||||
messages = kwargs.get("messages") or []
|
||||
if kwargs.get("system") is None:
|
||||
return messages
|
||||
return [{"role": "system", "content": kwargs.get("system")}] + messages
|
||||
elif provider == "gemini":
|
||||
contents = kwargs.get("contents", [])
|
||||
if isinstance(contents, str):
|
||||
return [{"role": "user", "content": contents}]
|
||||
elif isinstance(contents, list):
|
||||
formatted = []
|
||||
for item in contents:
|
||||
if isinstance(item, str):
|
||||
formatted.append({"role": "user", "content": item})
|
||||
elif hasattr(item, "text"):
|
||||
formatted.append({"role": "user", "content": item.text})
|
||||
else:
|
||||
formatted.append({"role": "user", "content": str(item)})
|
||||
return formatted
|
||||
else:
|
||||
return [{"role": "user", "content": str(contents)}]
|
||||
|
||||
# For OpenAI, handle both Chat Completions and Responses API
|
||||
if kwargs.get("messages") is not None:
|
||||
messages = list(kwargs.get("messages", []))
|
||||
|
||||
if kwargs.get("input") is not None:
|
||||
input_data = kwargs.get("input")
|
||||
if isinstance(input_data, list):
|
||||
messages.extend(input_data)
|
||||
else:
|
||||
messages.append({"role": "user", "content": input_data})
|
||||
|
||||
# Check if system prompt is provided as a separate parameter
|
||||
if kwargs.get("system") is not None:
|
||||
has_system = any(msg.get("role") == "system" for msg in messages)
|
||||
if not has_system:
|
||||
messages = [{"role": "system", "content": kwargs.get("system")}] + messages
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
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')}"
|
||||
)
|
||||
else:
|
||||
# Create a new system message with instructions
|
||||
messages = [
|
||||
{"role": "system", "content": kwargs.get("instructions")}
|
||||
] + messages
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
def call_llm_and_track_usage(
|
||||
posthog_distinct_id: Optional[str],
|
||||
ph_client: PostHogClient,
|
||||
provider: str,
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
base_url: URL,
|
||||
call_method: Callable[..., Any],
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Common usage-tracking logic for both sync and async calls.
|
||||
call_method: the llm call method (e.g. openai.chat.completions.create)
|
||||
"""
|
||||
start_time = time.time()
|
||||
response = None
|
||||
error = None
|
||||
http_status = 200
|
||||
usage: Dict[str, Any] = {}
|
||||
error_params: Dict[str, any] = {}
|
||||
|
||||
tracking_model = kwargs.pop("tracking_model", None)
|
||||
try:
|
||||
response = call_method(**kwargs)
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
http_status = getattr(
|
||||
exc, "status_code", 0
|
||||
) # default to 0 becuase its likely an SDK error
|
||||
error_params = {
|
||||
"$ai_is_error": True,
|
||||
"$ai_error": exc.__str__(),
|
||||
}
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
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"))
|
||||
):
|
||||
usage = get_usage(response, provider)
|
||||
|
||||
messages = merge_system_prompt(kwargs, provider)
|
||||
sanitized_messages = sanitize_messages(messages, provider)
|
||||
|
||||
ai_model = tracking_model or kwargs.get("model")
|
||||
event_properties = {
|
||||
"$ai_provider": provider,
|
||||
"$ai_model": ai_model,
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, sanitized_messages
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, format_response(response, provider)
|
||||
),
|
||||
"$ai_http_status": http_status,
|
||||
"$ai_input_tokens": usage.get("input_tokens", 0),
|
||||
"$ai_output_tokens": usage.get("output_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(base_url),
|
||||
**(posthog_properties or {}),
|
||||
**(error_params or {}),
|
||||
}
|
||||
|
||||
available_tool_calls = extract_available_tool_calls(provider, kwargs)
|
||||
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_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_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
|
||||
):
|
||||
event_properties["$ai_reasoning_tokens"] = usage.get("reasoning_tokens", 0)
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
# Process instructions for Responses API
|
||||
if provider == "openai" and kwargs.get("instructions") is not None:
|
||||
event_properties["$ai_instructions"] = with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, kwargs.get("instructions")
|
||||
)
|
||||
|
||||
# send the event to posthog
|
||||
if hasattr(ph_client, "capture") and callable(ph_client.capture):
|
||||
ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise error
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def call_llm_and_track_usage_async(
|
||||
posthog_distinct_id: Optional[str],
|
||||
ph_client: PostHogClient,
|
||||
provider: str,
|
||||
posthog_trace_id: Optional[str],
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
base_url: URL,
|
||||
call_async_method: Callable[..., Any],
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
start_time = time.time()
|
||||
response = None
|
||||
error = None
|
||||
http_status = 200
|
||||
usage: Dict[str, Any] = {}
|
||||
error_params: Dict[str, any] = {}
|
||||
|
||||
tracking_model = kwargs.pop("tracking_model", None)
|
||||
try:
|
||||
response = await call_async_method(**kwargs)
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
http_status = getattr(
|
||||
exc, "status_code", 0
|
||||
) # default to 0 because its likely an SDK error
|
||||
error_params = {
|
||||
"$ai_is_error": True,
|
||||
"$ai_error": exc.__str__(),
|
||||
}
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
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"))
|
||||
):
|
||||
usage = get_usage(response, provider)
|
||||
|
||||
messages = merge_system_prompt(kwargs, provider)
|
||||
sanitized_messages = sanitize_messages(messages, provider)
|
||||
|
||||
ai_model = tracking_model or kwargs.get("model")
|
||||
event_properties = {
|
||||
"$ai_provider": provider,
|
||||
"$ai_model": ai_model,
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, sanitized_messages
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, format_response(response, provider)
|
||||
),
|
||||
"$ai_http_status": http_status,
|
||||
"$ai_input_tokens": usage.get("input_tokens", 0),
|
||||
"$ai_output_tokens": usage.get("output_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(base_url),
|
||||
**(posthog_properties or {}),
|
||||
**(error_params or {}),
|
||||
}
|
||||
|
||||
available_tool_calls = extract_available_tool_calls(provider, kwargs)
|
||||
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_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_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
|
||||
|
||||
# Process instructions for Responses API
|
||||
if provider == "openai" and kwargs.get("instructions") is not None:
|
||||
event_properties["$ai_instructions"] = with_privacy_mode(
|
||||
ph_client, posthog_privacy_mode, kwargs.get("instructions")
|
||||
)
|
||||
|
||||
# send the event to posthog
|
||||
if hasattr(ph_client, "capture") and callable(ph_client.capture):
|
||||
ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise error
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def sanitize_messages(data: Any, provider: str) -> Any:
|
||||
"""Sanitize messages using provider-specific sanitization functions."""
|
||||
if provider == "anthropic":
|
||||
return sanitize_anthropic(data)
|
||||
elif provider == "openai" or provider == "litellm":
|
||||
return sanitize_openai(data)
|
||||
elif provider == "gemini":
|
||||
return sanitize_gemini(data)
|
||||
elif provider == "langchain":
|
||||
return sanitize_langchain(data)
|
||||
return data
|
||||
|
||||
|
||||
def with_privacy_mode(ph_client: PostHogClient, privacy_mode: bool, value: Any):
|
||||
if ph_client.privacy_mode or privacy_mode:
|
||||
return None
|
||||
return value
|
||||
@@ -0,0 +1,71 @@
|
||||
from typing import TypedDict, Optional, Any, Dict, Union, Tuple, Type
|
||||
from types import TracebackType
|
||||
from typing_extensions import NotRequired # For Python < 3.11 compatibility
|
||||
from datetime import datetime
|
||||
import numbers
|
||||
from uuid import UUID
|
||||
|
||||
from posthog.types import SendFeatureFlagsOptions
|
||||
|
||||
ID_TYPES = Union[numbers.Number, str, UUID, int]
|
||||
|
||||
|
||||
class OptionalCaptureArgs(TypedDict):
|
||||
"""Optional arguments for the capture method.
|
||||
|
||||
Args:
|
||||
distinct_id: Unique identifier for the person associated with this event. If not set, the context
|
||||
distinct_id is used, if available, otherwise a UUID is generated, and the event is marked
|
||||
as personless. Setting context-level distinct_id's is recommended.
|
||||
properties: Dictionary of properties to track with the event
|
||||
timestamp: When the event occurred (defaults to current time)
|
||||
uuid: Unique identifier for this specific event. If not provided, one is generated. The event
|
||||
UUID is returned, so you can correlate it with actions in your app (like showing users an
|
||||
error ID if you capture an exception).
|
||||
groups: Group identifiers to associate with this event (format: {group_type: group_key})
|
||||
send_feature_flags: Whether to include currently active feature flags in the event properties.
|
||||
Can be a boolean (True/False) or a SendFeatureFlagsOptions object for advanced configuration.
|
||||
Defaults to False.
|
||||
disable_geoip: Whether to disable GeoIP lookup for this event. Defaults to False.
|
||||
"""
|
||||
|
||||
distinct_id: NotRequired[Optional[ID_TYPES]]
|
||||
properties: NotRequired[Optional[Dict[str, Any]]]
|
||||
timestamp: NotRequired[Optional[Union[datetime, str]]]
|
||||
uuid: NotRequired[Optional[str]]
|
||||
groups: NotRequired[Optional[Dict[str, str]]]
|
||||
send_feature_flags: NotRequired[
|
||||
Optional[Union[bool, SendFeatureFlagsOptions]]
|
||||
] # Updated to support both boolean and options object
|
||||
disable_geoip: NotRequired[
|
||||
Optional[bool]
|
||||
] # As above, optional so we can tell if the user is intentionally overriding a client setting or not
|
||||
|
||||
|
||||
class OptionalSetArgs(TypedDict):
|
||||
"""Optional arguments for the set method.
|
||||
|
||||
Args:
|
||||
distinct_id: Unique identifier for the user to set properties on. If not set, the context
|
||||
distinct_id is used, if available, otherwise this function does nothing. Setting
|
||||
context-level distinct_id's is recommended.
|
||||
properties: Dictionary of properties to set on the person
|
||||
timestamp: When the properties were set (defaults to current time)
|
||||
uuid: Unique identifier for this operation. If not provided, one is generated. This
|
||||
UUID is returned, so you can correlate it with actions in your app.
|
||||
disable_geoip: Whether to disable GeoIP lookup for this operation. Defaults to False.
|
||||
"""
|
||||
|
||||
distinct_id: NotRequired[Optional[ID_TYPES]]
|
||||
properties: NotRequired[Optional[Dict[str, Any]]]
|
||||
timestamp: NotRequired[Optional[Union[datetime, str]]]
|
||||
uuid: NotRequired[Optional[str]]
|
||||
disable_geoip: NotRequired[Optional[bool]]
|
||||
|
||||
|
||||
ExcInfo = Union[
|
||||
Tuple[Type[BaseException], BaseException, Optional[TracebackType]],
|
||||
Tuple[None, None, None],
|
||||
]
|
||||
|
||||
ExceptionArg = Union[BaseException, ExcInfo]
|
||||
+1882
-171
File diff suppressed because it is too large
Load Diff
+50
-32
@@ -1,30 +1,43 @@
|
||||
import logging
|
||||
from threading import Thread
|
||||
import monotonic
|
||||
import backoff
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from threading import Thread
|
||||
|
||||
from posthog.request import post, APIError, DatetimeSerializer
|
||||
import backoff
|
||||
|
||||
from posthog.request import APIError, DatetimeSerializer, batch_post
|
||||
|
||||
try:
|
||||
from queue import Empty
|
||||
except ImportError:
|
||||
from Queue import Empty
|
||||
|
||||
MAX_MSG_SIZE = 32 << 10
|
||||
|
||||
# Our servers only accept batches less than 500KB. Here limit is set slightly
|
||||
# lower to leave space for extra data that will be added later, eg. "sentAt".
|
||||
BATCH_SIZE_LIMIT = 475000
|
||||
MAX_MSG_SIZE = 900 * 1024 # 900KiB per event
|
||||
|
||||
# The maximum request body size is currently 20MiB, let's be conservative
|
||||
# in case we want to lower it in the future.
|
||||
BATCH_SIZE_LIMIT = 5 * 1024 * 1024
|
||||
|
||||
|
||||
class Consumer(Thread):
|
||||
"""Consumes the messages from the client's queue."""
|
||||
log = logging.getLogger('posthog')
|
||||
|
||||
def __init__(self, queue, api_key, flush_at=100, host=None,
|
||||
on_error=None, flush_interval=0.5, gzip=False, retries=10,
|
||||
timeout=15):
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
queue,
|
||||
api_key,
|
||||
flush_at=100,
|
||||
host=None,
|
||||
on_error=None,
|
||||
flush_interval=0.5,
|
||||
gzip=False,
|
||||
retries=10,
|
||||
timeout=15,
|
||||
historical_migration=False,
|
||||
):
|
||||
"""Create a consumer thread."""
|
||||
Thread.__init__(self)
|
||||
# Make consumer a daemon thread so that it doesn't block program exit
|
||||
@@ -43,14 +56,15 @@ class Consumer(Thread):
|
||||
self.running = True
|
||||
self.retries = retries
|
||||
self.timeout = timeout
|
||||
self.historical_migration = historical_migration
|
||||
|
||||
def run(self):
|
||||
"""Runs the consumer."""
|
||||
self.log.debug('consumer is running...')
|
||||
self.log.debug("consumer is running...")
|
||||
while self.running:
|
||||
self.upload()
|
||||
|
||||
self.log.debug('consumer exited.')
|
||||
self.log.debug("consumer exited.")
|
||||
|
||||
def pause(self):
|
||||
"""Pause the consumer."""
|
||||
@@ -67,7 +81,7 @@ class Consumer(Thread):
|
||||
self.request(batch)
|
||||
success = True
|
||||
except Exception as e:
|
||||
self.log.error('error uploading: %s', e)
|
||||
self.log.error("error uploading: %s", e)
|
||||
success = False
|
||||
if self.on_error:
|
||||
self.on_error(e, batch)
|
||||
@@ -82,27 +96,25 @@ class Consumer(Thread):
|
||||
queue = self.queue
|
||||
items = []
|
||||
|
||||
start_time = monotonic.monotonic()
|
||||
start_time = time.monotonic()
|
||||
total_size = 0
|
||||
|
||||
while len(items) < self.flush_at:
|
||||
elapsed = monotonic.monotonic() - start_time
|
||||
elapsed = time.monotonic() - start_time
|
||||
if elapsed >= self.flush_interval:
|
||||
break
|
||||
try:
|
||||
item = queue.get(
|
||||
block=True, timeout=self.flush_interval - elapsed)
|
||||
item_size = len(json.dumps(
|
||||
item, cls=DatetimeSerializer).encode())
|
||||
item = queue.get(block=True, timeout=self.flush_interval - elapsed)
|
||||
item_size = len(json.dumps(item, cls=DatetimeSerializer).encode())
|
||||
if item_size > MAX_MSG_SIZE:
|
||||
self.log.error(
|
||||
'Item exceeds 32kb limit, dropping. (%s)', str(item))
|
||||
"Item exceeds 900kib limit, dropping. (%s)", str(item)
|
||||
)
|
||||
continue
|
||||
items.append(item)
|
||||
total_size += item_size
|
||||
if total_size >= BATCH_SIZE_LIMIT:
|
||||
self.log.debug(
|
||||
'hit batch size limit (size: %d)', total_size)
|
||||
self.log.debug("hit batch size limit (size: %d)", total_size)
|
||||
break
|
||||
except Empty:
|
||||
break
|
||||
@@ -110,25 +122,31 @@ class Consumer(Thread):
|
||||
return items
|
||||
|
||||
def request(self, batch):
|
||||
"""Attempt to upload the batch and retry before raising an error """
|
||||
"""Attempt to upload the batch and retry before raising an error"""
|
||||
|
||||
def fatal_exception(exc):
|
||||
if isinstance(exc, APIError):
|
||||
# retry on server errors and client errors
|
||||
# with 429 status code (rate limited),
|
||||
# don't retry on other client errors
|
||||
if exc.status == "N/A":
|
||||
return False
|
||||
return (400 <= exc.status < 500) and exc.status != 429
|
||||
else:
|
||||
# retry on all other errors (eg. network)
|
||||
return False
|
||||
|
||||
@backoff.on_exception(
|
||||
backoff.expo,
|
||||
Exception,
|
||||
max_tries=self.retries + 1,
|
||||
giveup=fatal_exception)
|
||||
backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception
|
||||
)
|
||||
def send_request():
|
||||
post(self.api_key, self.host, gzip=self.gzip,
|
||||
timeout=self.timeout, batch=batch)
|
||||
batch_post(
|
||||
self.api_key,
|
||||
self.host,
|
||||
gzip=self.gzip,
|
||||
timeout=self.timeout,
|
||||
batch=batch,
|
||||
historical_migration=self.historical_migration,
|
||||
)
|
||||
|
||||
send_request()
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import contextvars
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional, Any, Callable, Dict, TypeVar, cast, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# To avoid circular imports
|
||||
from posthog.client import Client
|
||||
|
||||
|
||||
class ContextScope:
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
fresh: bool = False,
|
||||
capture_exceptions: bool = True,
|
||||
client: Optional["Client"] = None,
|
||||
):
|
||||
self.client: Optional[Client] = client
|
||||
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: bool = False,
|
||||
capture_exceptions: bool = True,
|
||||
client: Optional["Client"] = None,
|
||||
):
|
||||
"""
|
||||
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.
|
||||
client: Optional client instance to use for capturing exceptions (default: None).
|
||||
If provided, the client will be used to capture exceptions within the context.
|
||||
If not provided, the default (global) client will be used. Note that the passed
|
||||
client is only used to capture exceptions within the context - other events captured
|
||||
within the context via `Client.capture` or `posthog.capture` will still carry the context
|
||||
state (tags, identity, session id), but will be captured by the client directly used (or
|
||||
the global one, in the case of `posthog.capture`)
|
||||
|
||||
Examples:
|
||||
```python
|
||||
# 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")
|
||||
```
|
||||
```python
|
||||
# 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")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
from posthog import capture_exception
|
||||
|
||||
current_context = _get_current_context()
|
||||
new_context = ContextScope(current_context, fresh, capture_exceptions, client)
|
||||
_context_stack.set(new_context)
|
||||
|
||||
try:
|
||||
yield
|
||||
except Exception as e:
|
||||
if new_context.capture_exceptions:
|
||||
if new_context.client:
|
||||
new_context.client.capture_exception(e)
|
||||
else:
|
||||
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. All tags are added as properties to any event, including exceptions, captured
|
||||
within the context.
|
||||
|
||||
Args:
|
||||
key: The tag key
|
||||
value: The tag value
|
||||
|
||||
Example:
|
||||
```python
|
||||
posthog.tag("user_id", "123")
|
||||
```
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
current_context.add_tag(key, value)
|
||||
|
||||
|
||||
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
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
return current_context.collect_tags()
|
||||
return {}
|
||||
|
||||
|
||||
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. The distinct-id passed should be uniquely associated
|
||||
with one of your users. Events captured outside of a context, or in a context with no associated distinct
|
||||
ID, will be assigned a random UUID, and captured as "personless".
|
||||
|
||||
Args:
|
||||
distinct_id: The distinct ID to associate with the current context and its children.
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
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
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
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
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
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
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
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: bool = False, capture_exceptions: bool = 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()
|
||||
|
||||
Category:
|
||||
Contexts
|
||||
"""
|
||||
|
||||
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
|
||||
@@ -0,0 +1,49 @@
|
||||
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
|
||||
# Licensed under the MIT License
|
||||
|
||||
# 💖open source (under MIT License)
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from posthog.client import Client
|
||||
|
||||
|
||||
class ExceptionCapture:
|
||||
# TODO: Add client side rate limiting to prevent spamming the server with exceptions
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
def __init__(self, client: "Client"):
|
||||
self.client = client
|
||||
self.original_excepthook = sys.excepthook
|
||||
sys.excepthook = self.exception_handler
|
||||
threading.excepthook = self.thread_exception_handler
|
||||
|
||||
def close(self):
|
||||
sys.excepthook = self.original_excepthook
|
||||
|
||||
def exception_handler(self, exc_type, exc_value, exc_traceback):
|
||||
# don't affect default behaviour.
|
||||
self.capture_exception((exc_type, exc_value, exc_traceback))
|
||||
self.original_excepthook(exc_type, exc_value, exc_traceback)
|
||||
|
||||
def thread_exception_handler(self, args):
|
||||
self.capture_exception((args.exc_type, args.exc_value, args.exc_traceback))
|
||||
|
||||
def exception_receiver(self, exc_info, extra_properties):
|
||||
if "distinct_id" in extra_properties:
|
||||
metadata = {"distinct_id": extra_properties["distinct_id"]}
|
||||
else:
|
||||
metadata = None
|
||||
self.capture_exception((exc_info[0], exc_info[1], exc_info[2]), metadata)
|
||||
|
||||
def capture_exception(self, exception, metadata=None):
|
||||
try:
|
||||
distinct_id = metadata.get("distinct_id") if metadata else None
|
||||
self.client.capture_exception(exception, distinct_id=distinct_id)
|
||||
except Exception as e:
|
||||
self.log.exception(f"Failed to capture exception: {e}")
|
||||
@@ -0,0 +1,886 @@
|
||||
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
|
||||
# Licensed under the MIT License
|
||||
|
||||
# copied and adapted from https://github.com/getsentry/sentry-python/blob/269d96d6e9821122fbff280e6a26956e5ed03c0b/sentry_sdk/utils.py#L689
|
||||
# 💖open source (under MIT License)
|
||||
# We want to keep payloads as similar to Sentry as possible for easy interoperability
|
||||
|
||||
import linecache
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime
|
||||
from types import FrameType, TracebackType # noqa: F401
|
||||
from typing import ( # noqa: F401
|
||||
Any,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
from posthog.args import ExcInfo, ExceptionArg # noqa: F401
|
||||
|
||||
try:
|
||||
# Python 3.11
|
||||
from builtins import BaseExceptionGroup
|
||||
except ImportError:
|
||||
# Python 3.10 and below
|
||||
BaseExceptionGroup = None # type: ignore
|
||||
|
||||
|
||||
DEFAULT_MAX_VALUE_LENGTH = 1024
|
||||
|
||||
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
|
||||
|
||||
Event = TypedDict(
|
||||
"Event",
|
||||
{
|
||||
"breadcrumbs": Dict[
|
||||
Literal["values"], List[Dict[str, Any]]
|
||||
], # TODO: We can expand on this type
|
||||
"check_in_id": str,
|
||||
"contexts": Dict[str, Dict[str, object]],
|
||||
"dist": str,
|
||||
"duration": Optional[float],
|
||||
"environment": str,
|
||||
"errors": List[Dict[str, Any]], # TODO: We can expand on this type
|
||||
"event_id": str,
|
||||
"exception": Dict[
|
||||
Literal["values"], List[Dict[str, Any]]
|
||||
], # TODO: We can expand on this type
|
||||
# "extra": MutableMapping[str, object],
|
||||
# "fingerprint": List[str],
|
||||
"level": LogLevelStr,
|
||||
# "logentry": Mapping[str, object],
|
||||
"logger": str,
|
||||
# "measurements": Dict[str, MeasurementValue],
|
||||
"message": str,
|
||||
"modules": Dict[str, str],
|
||||
# "monitor_config": Mapping[str, object],
|
||||
"monitor_slug": Optional[str],
|
||||
"platform": Literal["python"],
|
||||
"profile": object,
|
||||
"release": str,
|
||||
"request": Dict[str, object],
|
||||
# "sdk": Mapping[str, object],
|
||||
"server_name": str,
|
||||
"spans": List[Dict[str, object]],
|
||||
"stacktrace": Dict[
|
||||
str, object
|
||||
], # We access this key in the code, but I am unsure whether we ever set it
|
||||
"start_timestamp": datetime,
|
||||
"status": Optional[str],
|
||||
# "tags": MutableMapping[
|
||||
# str, str
|
||||
# ], # Tags must be less than 200 characters each
|
||||
"threads": Dict[
|
||||
Literal["values"], List[Dict[str, Any]]
|
||||
], # TODO: We can expand on this type
|
||||
"timestamp": Optional[datetime], # Must be set before sending the event
|
||||
"transaction": str,
|
||||
# "transaction_info": Mapping[str, Any], # TODO: We can expand on this type
|
||||
"type": Literal["check_in", "transaction"],
|
||||
"user": Dict[str, object],
|
||||
"_metrics_summary": Dict[str, object],
|
||||
},
|
||||
total=False,
|
||||
)
|
||||
|
||||
|
||||
epoch = datetime(1970, 1, 1)
|
||||
|
||||
|
||||
BASE64_ALPHABET = re.compile(r"^[a-zA-Z0-9/+=]*$")
|
||||
|
||||
SENSITIVE_DATA_SUBSTITUTE = "[Filtered]"
|
||||
|
||||
|
||||
def to_timestamp(value):
|
||||
# type: (datetime) -> float
|
||||
return (value - epoch).total_seconds()
|
||||
|
||||
|
||||
def format_timestamp(value):
|
||||
# type: (datetime) -> str
|
||||
return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
|
||||
|
||||
def event_hint_with_exc_info(exc_info=None):
|
||||
# type: (Optional[ExcInfo]) -> Dict[str, Optional[ExcInfo]]
|
||||
"""Creates a hint with the exc info filled in."""
|
||||
if exc_info is None:
|
||||
exc_info = sys.exc_info()
|
||||
else:
|
||||
exc_info = exc_info_from_error(exc_info)
|
||||
if exc_info[0] is None:
|
||||
exc_info = None
|
||||
return {"exc_info": exc_info}
|
||||
|
||||
|
||||
class AnnotatedValue:
|
||||
"""
|
||||
Meta information for a data field in the event payload.
|
||||
"""
|
||||
|
||||
__slots__ = ("value", "metadata")
|
||||
|
||||
def __init__(self, value, metadata):
|
||||
# type: (Optional[Any], Dict[str, Any]) -> None
|
||||
self.value = value
|
||||
self.metadata = metadata
|
||||
|
||||
def __eq__(self, other):
|
||||
# type: (Any) -> bool
|
||||
if not isinstance(other, AnnotatedValue):
|
||||
return False
|
||||
|
||||
return self.value == other.value and self.metadata == other.metadata
|
||||
|
||||
@classmethod
|
||||
def removed_because_raw_data(cls):
|
||||
# type: () -> AnnotatedValue
|
||||
"""The value was removed because it could not be parsed. This is done for request body values that are not json nor a form."""
|
||||
return AnnotatedValue(
|
||||
value="",
|
||||
metadata={
|
||||
"rem": [ # Remark
|
||||
[
|
||||
"!raw", # Unparsable raw data
|
||||
"x", # The fields original value was removed
|
||||
]
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def removed_because_over_size_limit(cls):
|
||||
# type: () -> AnnotatedValue
|
||||
"""The actual value was removed because the size of the field exceeded the configured maximum size (specified with the max_request_body_size sdk option)"""
|
||||
return AnnotatedValue(
|
||||
value="",
|
||||
metadata={
|
||||
"rem": [ # Remark
|
||||
[
|
||||
"!config", # Because of configured maximum size
|
||||
"x", # The fields original value was removed
|
||||
]
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def substituted_because_contains_sensitive_data(cls):
|
||||
# type: () -> AnnotatedValue
|
||||
"""The actual value was removed because it contained sensitive information."""
|
||||
return AnnotatedValue(
|
||||
value=SENSITIVE_DATA_SUBSTITUTE,
|
||||
metadata={
|
||||
"rem": [ # Remark
|
||||
[
|
||||
"!config", # Because of SDK configuration (in this case the config is the hard coded removal of certain django cookies)
|
||||
"s", # The fields original value was substituted
|
||||
]
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
T = TypeVar("T")
|
||||
Annotated = Union[AnnotatedValue, T]
|
||||
|
||||
|
||||
def get_type_name(cls):
|
||||
# type: (Optional[type]) -> Optional[str]
|
||||
return getattr(cls, "__qualname__", None) or getattr(cls, "__name__", None)
|
||||
|
||||
|
||||
def get_type_module(cls):
|
||||
# type: (Optional[type]) -> Optional[str]
|
||||
mod = getattr(cls, "__module__", None)
|
||||
if mod not in (None, "builtins", "__builtins__"):
|
||||
return mod
|
||||
return None
|
||||
|
||||
|
||||
def should_hide_frame(frame: "FrameType") -> bool:
|
||||
try:
|
||||
mod = frame.f_globals["__name__"]
|
||||
if mod.startswith("sentry_sdk."):
|
||||
return True
|
||||
except (AttributeError, KeyError):
|
||||
pass
|
||||
|
||||
for flag_name in "__traceback_hide__", "__tracebackhide__":
|
||||
try:
|
||||
if frame.f_locals[flag_name]:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def iter_stacks(tb):
|
||||
# type: (Optional[TracebackType]) -> Iterator[TracebackType]
|
||||
tb_ = tb # type: Optional[TracebackType]
|
||||
while tb_ is not None:
|
||||
if not should_hide_frame(tb_.tb_frame):
|
||||
yield tb_
|
||||
tb_ = tb_.tb_next
|
||||
|
||||
|
||||
def get_lines_from_file(
|
||||
filename, # type: str
|
||||
lineno, # type: int
|
||||
max_length=None, # type: Optional[int]
|
||||
loader=None, # type: Optional[Any]
|
||||
module=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> Tuple[List[Annotated[str]], Optional[Annotated[str]], List[Annotated[str]]]
|
||||
context_lines = 5
|
||||
source = None
|
||||
if loader is not None and hasattr(loader, "get_source"):
|
||||
try:
|
||||
source_str = loader.get_source(module) # type: Optional[str]
|
||||
except (ImportError, IOError):
|
||||
source_str = None
|
||||
if source_str is not None:
|
||||
source = source_str.splitlines()
|
||||
|
||||
if source is None:
|
||||
try:
|
||||
source = linecache.getlines(filename)
|
||||
except (OSError, IOError):
|
||||
return [], None, []
|
||||
|
||||
if not source:
|
||||
return [], None, []
|
||||
|
||||
lower_bound = max(0, lineno - context_lines)
|
||||
upper_bound = min(lineno + 1 + context_lines, len(source))
|
||||
|
||||
try:
|
||||
pre_context = [
|
||||
strip_string(line.strip("\r\n"), max_length=max_length)
|
||||
for line in source[lower_bound:lineno]
|
||||
]
|
||||
context_line = strip_string(source[lineno].strip("\r\n"), max_length=max_length)
|
||||
post_context = [
|
||||
strip_string(line.strip("\r\n"), max_length=max_length)
|
||||
for line in source[(lineno + 1) : upper_bound] # noqa: E203
|
||||
]
|
||||
return pre_context, context_line, post_context
|
||||
except IndexError:
|
||||
# the file may have changed since it was loaded into memory
|
||||
return [], None, []
|
||||
|
||||
|
||||
def get_source_context(
|
||||
frame, # type: FrameType
|
||||
tb_lineno, # type: int
|
||||
max_value_length=None, # type: Optional[int]
|
||||
):
|
||||
# type: (...) -> Tuple[List[Annotated[str]], Optional[Annotated[str]], List[Annotated[str]]]
|
||||
try:
|
||||
abs_path = frame.f_code.co_filename # type: Optional[str]
|
||||
except Exception:
|
||||
abs_path = None
|
||||
try:
|
||||
module = frame.f_globals["__name__"]
|
||||
except Exception:
|
||||
return [], None, []
|
||||
try:
|
||||
loader = frame.f_globals["__loader__"]
|
||||
except Exception:
|
||||
loader = None
|
||||
lineno = tb_lineno - 1
|
||||
if lineno is not None and abs_path:
|
||||
return get_lines_from_file(
|
||||
abs_path, lineno, max_value_length, loader=loader, module=module
|
||||
)
|
||||
return [], None, []
|
||||
|
||||
|
||||
def safe_str(value):
|
||||
# type: (Any) -> str
|
||||
try:
|
||||
return str(value)
|
||||
except Exception:
|
||||
return safe_repr(value)
|
||||
|
||||
|
||||
def safe_repr(value):
|
||||
# type: (Any) -> str
|
||||
try:
|
||||
return repr(value)
|
||||
except Exception:
|
||||
return "<broken repr>"
|
||||
|
||||
|
||||
def filename_for_module(module, abs_path):
|
||||
# type: (Optional[str], Optional[str]) -> Optional[str]
|
||||
if not abs_path or not module:
|
||||
return abs_path
|
||||
|
||||
try:
|
||||
if abs_path.endswith(".pyc"):
|
||||
abs_path = abs_path[:-1]
|
||||
|
||||
base_module = module.split(".", 1)[0]
|
||||
if base_module == module:
|
||||
return os.path.basename(abs_path)
|
||||
|
||||
base_module_path = sys.modules[base_module].__file__
|
||||
if not base_module_path:
|
||||
return abs_path
|
||||
|
||||
return abs_path.split(base_module_path.rsplit(os.sep, 2)[0], 1)[-1].lstrip(
|
||||
os.sep
|
||||
)
|
||||
except Exception:
|
||||
return abs_path
|
||||
|
||||
|
||||
def serialize_frame(
|
||||
frame,
|
||||
tb_lineno=None,
|
||||
max_value_length=None,
|
||||
):
|
||||
# type: (FrameType, Optional[int], Optional[int]) -> Dict[str, Any]
|
||||
f_code = getattr(frame, "f_code", None)
|
||||
if not f_code:
|
||||
abs_path = None
|
||||
function = None
|
||||
else:
|
||||
abs_path = frame.f_code.co_filename
|
||||
function = frame.f_code.co_name
|
||||
try:
|
||||
module = frame.f_globals["__name__"]
|
||||
except Exception:
|
||||
module = None
|
||||
|
||||
if tb_lineno is None:
|
||||
tb_lineno = frame.f_lineno
|
||||
|
||||
rv = {
|
||||
"platform": "python",
|
||||
"filename": filename_for_module(module, abs_path) or None,
|
||||
"abs_path": os.path.abspath(abs_path) if abs_path else None,
|
||||
"function": function or "<unknown>",
|
||||
"module": module,
|
||||
"lineno": tb_lineno,
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
rv["pre_context"], rv["context_line"], rv["post_context"] = get_source_context(
|
||||
frame, tb_lineno, max_value_length
|
||||
)
|
||||
|
||||
return rv
|
||||
|
||||
|
||||
def get_errno(exc_value):
|
||||
# type: (BaseException) -> Optional[Any]
|
||||
return getattr(exc_value, "errno", None)
|
||||
|
||||
|
||||
def get_error_message(exc_value):
|
||||
# type: (Optional[BaseException]) -> str
|
||||
message = (
|
||||
getattr(exc_value, "message", "")
|
||||
or getattr(exc_value, "detail", "")
|
||||
or exc_value
|
||||
)
|
||||
|
||||
return safe_str(message)
|
||||
|
||||
|
||||
def single_exception_from_error_tuple(
|
||||
exc_type, # type: Optional[type]
|
||||
exc_value, # type: Optional[BaseException]
|
||||
tb, # type: Optional[TracebackType]
|
||||
mechanism=None, # type: Optional[Dict[str, Any]]
|
||||
exception_id=None, # type: Optional[int]
|
||||
parent_id=None, # type: Optional[int]
|
||||
source=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> Dict[str, Any]
|
||||
"""
|
||||
Creates a dict that goes into the events `exception.values` list
|
||||
"""
|
||||
exception_value = {} # type: Dict[str, Any]
|
||||
exception_value["mechanism"] = (
|
||||
mechanism.copy() if mechanism else {"type": "generic", "handled": True}
|
||||
)
|
||||
if exception_id is not None:
|
||||
exception_value["mechanism"]["exception_id"] = exception_id
|
||||
|
||||
if exc_value is not None:
|
||||
errno = get_errno(exc_value)
|
||||
else:
|
||||
errno = None
|
||||
|
||||
if errno is not None:
|
||||
exception_value["mechanism"].setdefault("meta", {}).setdefault(
|
||||
"errno", {}
|
||||
).setdefault("number", errno)
|
||||
|
||||
if source is not None:
|
||||
exception_value["mechanism"]["source"] = source
|
||||
|
||||
is_root_exception = exception_id == 0
|
||||
if not is_root_exception and parent_id is not None:
|
||||
exception_value["mechanism"]["parent_id"] = parent_id
|
||||
exception_value["mechanism"]["type"] = "chained"
|
||||
|
||||
if is_root_exception and "type" not in exception_value["mechanism"]:
|
||||
exception_value["mechanism"]["type"] = "generic"
|
||||
|
||||
is_exception_group = BaseExceptionGroup is not None and isinstance(
|
||||
exc_value, BaseExceptionGroup
|
||||
)
|
||||
if is_exception_group:
|
||||
exception_value["mechanism"]["is_exception_group"] = True
|
||||
|
||||
exception_value["module"] = get_type_module(exc_type)
|
||||
exception_value["type"] = get_type_name(exc_type)
|
||||
exception_value["value"] = get_error_message(exc_value)
|
||||
|
||||
max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback
|
||||
|
||||
frames = [
|
||||
serialize_frame(
|
||||
tb.tb_frame,
|
||||
tb_lineno=tb.tb_lineno,
|
||||
max_value_length=max_value_length,
|
||||
)
|
||||
for tb in iter_stacks(tb)
|
||||
]
|
||||
|
||||
if frames:
|
||||
exception_value["stacktrace"] = {"frames": frames, "type": "raw"}
|
||||
|
||||
return exception_value
|
||||
|
||||
|
||||
HAS_CHAINED_EXCEPTIONS = hasattr(Exception, "__suppress_context__")
|
||||
|
||||
if HAS_CHAINED_EXCEPTIONS:
|
||||
|
||||
def walk_exception_chain(exc_info):
|
||||
# type: (ExcInfo) -> Iterator[ExcInfo]
|
||||
exc_type, exc_value, tb = exc_info
|
||||
|
||||
seen_exceptions = []
|
||||
seen_exception_ids = set() # type: Set[int]
|
||||
|
||||
while (
|
||||
exc_type is not None
|
||||
and exc_value is not None
|
||||
and id(exc_value) not in seen_exception_ids
|
||||
):
|
||||
yield exc_type, exc_value, tb
|
||||
|
||||
# Avoid hashing random types we don't know anything
|
||||
# about. Use the list to keep a ref so that the `id` is
|
||||
# not used for another object.
|
||||
seen_exceptions.append(exc_value)
|
||||
seen_exception_ids.add(id(exc_value))
|
||||
|
||||
if exc_value.__suppress_context__:
|
||||
cause = exc_value.__cause__
|
||||
else:
|
||||
cause = exc_value.__context__
|
||||
if cause is None:
|
||||
break
|
||||
exc_type = type(cause)
|
||||
exc_value = cause
|
||||
tb = getattr(cause, "__traceback__", None)
|
||||
|
||||
else:
|
||||
|
||||
def walk_exception_chain(exc_info):
|
||||
# type: (ExcInfo) -> Iterator[ExcInfo]
|
||||
yield exc_info
|
||||
|
||||
|
||||
def exceptions_from_error(
|
||||
exc_type, # type: Optional[type]
|
||||
exc_value, # type: Optional[BaseException]
|
||||
tb, # type: Optional[TracebackType]
|
||||
mechanism=None, # type: Optional[Dict[str, Any]]
|
||||
exception_id=0, # type: int
|
||||
parent_id=0, # type: int
|
||||
source=None, # type: Optional[str]
|
||||
):
|
||||
# type: (...) -> Tuple[int, List[Dict[str, Any]]]
|
||||
"""
|
||||
Creates the list of exceptions.
|
||||
This can include chained exceptions and exceptions from an ExceptionGroup.
|
||||
"""
|
||||
|
||||
parent = single_exception_from_error_tuple(
|
||||
exc_type=exc_type,
|
||||
exc_value=exc_value,
|
||||
tb=tb,
|
||||
mechanism=mechanism,
|
||||
exception_id=exception_id,
|
||||
parent_id=parent_id,
|
||||
source=source,
|
||||
)
|
||||
exceptions = [parent]
|
||||
|
||||
parent_id = exception_id
|
||||
exception_id += 1
|
||||
|
||||
should_supress_context = (
|
||||
hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore
|
||||
)
|
||||
if should_supress_context:
|
||||
# Add direct cause.
|
||||
# The field `__cause__` is set when raised with the exception (using the `from` keyword).
|
||||
exception_has_cause = (
|
||||
exc_value
|
||||
and hasattr(exc_value, "__cause__")
|
||||
and exc_value.__cause__ is not None
|
||||
)
|
||||
if exception_has_cause:
|
||||
cause = exc_value.__cause__ # type: ignore
|
||||
(exception_id, child_exceptions) = exceptions_from_error(
|
||||
exc_type=type(cause),
|
||||
exc_value=cause,
|
||||
tb=getattr(cause, "__traceback__", None),
|
||||
mechanism=mechanism,
|
||||
exception_id=exception_id,
|
||||
source="__cause__",
|
||||
)
|
||||
exceptions.extend(child_exceptions)
|
||||
|
||||
else:
|
||||
# Add indirect cause.
|
||||
# The field `__context__` is assigned if another exception occurs while handling the exception.
|
||||
exception_has_content = (
|
||||
exc_value
|
||||
and hasattr(exc_value, "__context__")
|
||||
and exc_value.__context__ is not None
|
||||
)
|
||||
if exception_has_content:
|
||||
context = exc_value.__context__ # type: ignore
|
||||
(exception_id, child_exceptions) = exceptions_from_error(
|
||||
exc_type=type(context),
|
||||
exc_value=context,
|
||||
tb=getattr(context, "__traceback__", None),
|
||||
mechanism=mechanism,
|
||||
exception_id=exception_id,
|
||||
source="__context__",
|
||||
)
|
||||
exceptions.extend(child_exceptions)
|
||||
|
||||
# Add exceptions from an ExceptionGroup.
|
||||
is_exception_group = exc_value and hasattr(exc_value, "exceptions")
|
||||
if is_exception_group:
|
||||
for idx, e in enumerate(exc_value.exceptions): # type: ignore
|
||||
(exception_id, child_exceptions) = exceptions_from_error(
|
||||
exc_type=type(e),
|
||||
exc_value=e,
|
||||
tb=getattr(e, "__traceback__", None),
|
||||
mechanism=mechanism,
|
||||
exception_id=exception_id,
|
||||
parent_id=parent_id,
|
||||
source="exceptions[%s]" % idx,
|
||||
)
|
||||
exceptions.extend(child_exceptions)
|
||||
|
||||
return (exception_id, exceptions)
|
||||
|
||||
|
||||
def exceptions_from_error_tuple(
|
||||
exc_info, # type: ExcInfo
|
||||
mechanism=None, # type: Optional[Dict[str, Any]]
|
||||
):
|
||||
# type: (...) -> List[Dict[str, Any]]
|
||||
exc_type, exc_value, tb = exc_info
|
||||
|
||||
is_exception_group = BaseExceptionGroup is not None and isinstance(
|
||||
exc_value, BaseExceptionGroup
|
||||
)
|
||||
|
||||
if is_exception_group:
|
||||
(_, exceptions) = exceptions_from_error(
|
||||
exc_type=exc_type,
|
||||
exc_value=exc_value,
|
||||
tb=tb,
|
||||
mechanism=mechanism,
|
||||
exception_id=0,
|
||||
parent_id=0,
|
||||
)
|
||||
|
||||
else:
|
||||
exceptions = []
|
||||
for exc_type, exc_value, tb in walk_exception_chain(exc_info):
|
||||
exceptions.append(
|
||||
single_exception_from_error_tuple(exc_type, exc_value, tb, mechanism)
|
||||
)
|
||||
|
||||
exceptions.reverse()
|
||||
|
||||
return exceptions
|
||||
|
||||
|
||||
def to_string(value):
|
||||
# type: (str) -> str
|
||||
try:
|
||||
return str(value)
|
||||
except UnicodeDecodeError:
|
||||
return repr(value)[1:-1]
|
||||
|
||||
|
||||
def iter_event_stacktraces(event):
|
||||
# type: (Event) -> Iterator[Dict[str, Any]]
|
||||
if "stacktrace" in event:
|
||||
yield event["stacktrace"]
|
||||
if "threads" in event:
|
||||
for thread in event["threads"].get("values") or ():
|
||||
if "stacktrace" in thread:
|
||||
yield thread["stacktrace"]
|
||||
if "exception" in event:
|
||||
for exception in event["exception"].get("values") or ():
|
||||
if "stacktrace" in exception:
|
||||
yield exception["stacktrace"]
|
||||
|
||||
|
||||
def iter_event_frames(event):
|
||||
# type: (Event) -> Iterator[Dict[str, Any]]
|
||||
for stacktrace in iter_event_stacktraces(event):
|
||||
for frame in stacktrace.get("frames") or ():
|
||||
yield frame
|
||||
|
||||
|
||||
def handle_in_app(event, in_app_exclude=None, in_app_include=None, project_root=None):
|
||||
# type: (Event, Optional[List[str]], Optional[List[str]], Optional[str]) -> Event
|
||||
for stacktrace in iter_event_stacktraces(event):
|
||||
set_in_app_in_frames(
|
||||
stacktrace.get("frames"),
|
||||
in_app_exclude=in_app_exclude,
|
||||
in_app_include=in_app_include,
|
||||
project_root=project_root,
|
||||
)
|
||||
|
||||
return event
|
||||
|
||||
|
||||
def set_in_app_in_frames(frames, in_app_exclude, in_app_include, project_root=None):
|
||||
# type: (Any, Optional[List[str]], Optional[List[str]], Optional[str]) -> Optional[Any]
|
||||
if not frames:
|
||||
return None
|
||||
|
||||
for frame in frames:
|
||||
# if frame has already been marked as in_app, skip it
|
||||
current_in_app = frame.get("in_app")
|
||||
if current_in_app is not None:
|
||||
continue
|
||||
|
||||
module = frame.get("module")
|
||||
|
||||
# check if module in frame is in the list of modules to include
|
||||
if _module_in_list(module, in_app_include):
|
||||
frame["in_app"] = True
|
||||
continue
|
||||
|
||||
# check if module in frame is in the list of modules to exclude
|
||||
if _module_in_list(module, in_app_exclude):
|
||||
frame["in_app"] = False
|
||||
continue
|
||||
|
||||
# if frame has no abs_path, skip further checks
|
||||
abs_path = frame.get("abs_path")
|
||||
if abs_path is None:
|
||||
continue
|
||||
|
||||
if _is_external_source(abs_path):
|
||||
frame["in_app"] = False
|
||||
continue
|
||||
|
||||
if _is_in_project_root(abs_path, project_root):
|
||||
frame["in_app"] = True
|
||||
continue
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def exception_is_already_captured(error):
|
||||
# type: (ExceptionArg) -> 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, uuid):
|
||||
# type: (ExceptionArg, str) -> None
|
||||
if isinstance(error, BaseException):
|
||||
setattr(error, "__posthog_exception_captured", True)
|
||||
setattr(error, "__posthog_exception_uuid", uuid)
|
||||
# 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)
|
||||
setattr(error[1], "__posthog_exception_uuid", uuid)
|
||||
|
||||
|
||||
def exc_info_from_error(error):
|
||||
# type: (ExceptionArg) -> 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)
|
||||
exc_value = error
|
||||
else:
|
||||
exc_type, exc_value, tb = sys.exc_info()
|
||||
if exc_value is not error:
|
||||
tb = None
|
||||
exc_value = error
|
||||
exc_type = type(error)
|
||||
|
||||
else:
|
||||
raise ValueError("Expected Exception object to report, got %s!" % type(error))
|
||||
|
||||
exc_info = (exc_type, exc_value, tb)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This cast is safe because exc_type and exc_value are either both
|
||||
# None or both not None.
|
||||
exc_info = cast(ExcInfo, exc_info)
|
||||
|
||||
return exc_info
|
||||
|
||||
|
||||
def 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 _module_in_list(name, items):
|
||||
# type: (str | None, Optional[List[str]]) -> bool
|
||||
if name is None:
|
||||
return False
|
||||
|
||||
if not items:
|
||||
return False
|
||||
|
||||
for item in items:
|
||||
if item == name or name.startswith(item + "."):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _is_external_source(abs_path):
|
||||
# type: (str) -> bool
|
||||
# check if frame is in 'site-packages' or 'dist-packages'
|
||||
external_source = (
|
||||
re.search(r"[\\/](?:dist|site)-packages[\\/]", abs_path) is not None
|
||||
)
|
||||
return external_source
|
||||
|
||||
|
||||
def _is_in_project_root(abs_path, project_root):
|
||||
# type: (str, Optional[str]) -> bool
|
||||
if project_root is None:
|
||||
return False
|
||||
|
||||
# check if path is in the project root
|
||||
if abs_path.startswith(project_root):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _truncate_by_bytes(string, max_bytes):
|
||||
# type: (str, int) -> str
|
||||
"""
|
||||
Truncate a UTF-8-encodable string to the last full codepoint so that it fits in max_bytes.
|
||||
"""
|
||||
truncated = string.encode("utf-8")[: max_bytes - 3].decode("utf-8", errors="ignore")
|
||||
|
||||
return truncated + "..."
|
||||
|
||||
|
||||
def _get_size_in_bytes(value):
|
||||
# type: (str) -> Optional[int]
|
||||
try:
|
||||
return len(value.encode("utf-8"))
|
||||
except (UnicodeEncodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def strip_string(value, max_length=None):
|
||||
# type: (str, Optional[int]) -> Union[AnnotatedValue, str]
|
||||
if not value:
|
||||
return value
|
||||
|
||||
if max_length is None:
|
||||
max_length = DEFAULT_MAX_VALUE_LENGTH
|
||||
|
||||
byte_size = _get_size_in_bytes(value)
|
||||
text_size = len(value)
|
||||
|
||||
if byte_size is not None and byte_size > max_length:
|
||||
# truncate to max_length bytes, preserving code points
|
||||
truncated_value = _truncate_by_bytes(value, max_length)
|
||||
elif text_size is not None and text_size > max_length:
|
||||
# fallback to truncating by string length
|
||||
truncated_value = value[: max_length - 3] + "..."
|
||||
else:
|
||||
return value
|
||||
|
||||
return AnnotatedValue(
|
||||
value=truncated_value,
|
||||
metadata={
|
||||
"len": byte_size or text_size,
|
||||
"rem": [["!limit", "x", max_length - 3, max_length]],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,604 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from dateutil import parser
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from posthog import utils
|
||||
from posthog.types import FlagValue
|
||||
from posthog.utils import convert_to_datetime_aware, is_valid_regex
|
||||
|
||||
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
NONE_VALUES_ALLOWED_OPERATORS = ["is_not"]
|
||||
|
||||
|
||||
class InconclusiveMatchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
||||
# Given the same distinct_id and key, it'll always return the same float. These floats are
|
||||
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
||||
# we can do _hash(key, distinct_id) < 0.2
|
||||
def _hash(key: str, distinct_id: str, salt: str = "") -> float:
|
||||
hash_key = f"{key}.{distinct_id}{salt}"
|
||||
hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16)
|
||||
return hash_val / __LONG_SCALE__
|
||||
|
||||
|
||||
def get_matching_variant(flag, distinct_id):
|
||||
hash_value = _hash(flag["key"], distinct_id, salt="variant")
|
||||
for variant in variant_lookup_table(flag):
|
||||
if hash_value >= variant["value_min"] and hash_value < variant["value_max"]:
|
||||
return variant["key"]
|
||||
return None
|
||||
|
||||
|
||||
def variant_lookup_table(feature_flag):
|
||||
lookup_table = []
|
||||
value_min = 0
|
||||
multivariates = ((feature_flag.get("filters") or {}).get("multivariate") or {}).get(
|
||||
"variants"
|
||||
) or []
|
||||
for variant in multivariates:
|
||||
value_max = value_min + variant["rollout_percentage"] / 100
|
||||
lookup_table.append(
|
||||
{"value_min": value_min, "value_max": value_max, "key": variant["key"]}
|
||||
)
|
||||
value_min = value_max
|
||||
return lookup_table
|
||||
|
||||
|
||||
def evaluate_flag_dependency(
|
||||
property, flags_by_key, evaluation_cache, distinct_id, properties, cohort_properties
|
||||
):
|
||||
"""
|
||||
Evaluate a flag dependency property according to the dependency chain algorithm.
|
||||
|
||||
Args:
|
||||
property: Flag property with type="flag" and dependency_chain
|
||||
flags_by_key: Dictionary of all flags by their key
|
||||
evaluation_cache: Cache for storing evaluation results
|
||||
distinct_id: The distinct ID being evaluated
|
||||
properties: Person properties for evaluation
|
||||
cohort_properties: Cohort properties for evaluation
|
||||
|
||||
Returns:
|
||||
bool: True if all dependencies in the chain evaluate to True, False otherwise
|
||||
"""
|
||||
if flags_by_key is None or evaluation_cache is None:
|
||||
# Cannot evaluate flag dependencies without required context
|
||||
raise InconclusiveMatchError(
|
||||
f"Cannot evaluate flag dependency on '{property.get('key', 'unknown')}' without flags_by_key and evaluation_cache"
|
||||
)
|
||||
|
||||
# Check if dependency_chain is present - it should always be provided for flag dependencies
|
||||
if "dependency_chain" not in property:
|
||||
# Missing dependency_chain indicates malformed server data
|
||||
raise InconclusiveMatchError(
|
||||
f"Flag dependency property for '{property.get('key', 'unknown')}' is missing required 'dependency_chain' field"
|
||||
)
|
||||
|
||||
dependency_chain = property["dependency_chain"]
|
||||
|
||||
# Handle circular dependency (empty chain means circular)
|
||||
if len(dependency_chain) == 0:
|
||||
log.debug(f"Circular dependency detected for flag: {property.get('key')}")
|
||||
raise InconclusiveMatchError(
|
||||
f"Circular dependency detected for flag '{property.get('key', 'unknown')}'"
|
||||
)
|
||||
|
||||
# Evaluate all dependencies in the chain order
|
||||
for dep_flag_key in dependency_chain:
|
||||
if dep_flag_key not in evaluation_cache:
|
||||
# Need to evaluate this dependency first
|
||||
dep_flag = flags_by_key.get(dep_flag_key)
|
||||
if not dep_flag:
|
||||
# Missing flag dependency - cannot evaluate locally
|
||||
evaluation_cache[dep_flag_key] = None
|
||||
raise InconclusiveMatchError(
|
||||
f"Cannot evaluate flag dependency '{dep_flag_key}' - flag not found in local flags"
|
||||
)
|
||||
else:
|
||||
# Check if the flag is active (same check as in client._compute_flag_locally)
|
||||
if not dep_flag.get("active"):
|
||||
evaluation_cache[dep_flag_key] = False
|
||||
else:
|
||||
# Recursively evaluate the dependency
|
||||
try:
|
||||
dep_result = match_feature_flag_properties(
|
||||
dep_flag,
|
||||
distinct_id,
|
||||
properties,
|
||||
cohort_properties,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
)
|
||||
evaluation_cache[dep_flag_key] = dep_result
|
||||
except InconclusiveMatchError as e:
|
||||
# If we can't evaluate a dependency, store None and propagate the error
|
||||
evaluation_cache[dep_flag_key] = None
|
||||
raise InconclusiveMatchError(
|
||||
f"Cannot evaluate flag dependency '{dep_flag_key}': {e}"
|
||||
) from e
|
||||
|
||||
# Check the cached result
|
||||
cached_result = evaluation_cache[dep_flag_key]
|
||||
if cached_result is None:
|
||||
# Previously inconclusive - raise error again
|
||||
raise InconclusiveMatchError(
|
||||
f"Flag dependency '{dep_flag_key}' was previously inconclusive"
|
||||
)
|
||||
elif not cached_result:
|
||||
# Definitive False result - dependency failed
|
||||
return False
|
||||
|
||||
# All dependencies in the chain have been evaluated successfully
|
||||
# Now check if the final flag value matches the expected value in the property
|
||||
flag_key = property.get("key")
|
||||
expected_value = property.get("value")
|
||||
operator = property.get("operator", "exact")
|
||||
|
||||
if flag_key and expected_value is not None:
|
||||
# Get the actual value of the flag we're checking
|
||||
actual_value = evaluation_cache.get(flag_key)
|
||||
|
||||
if actual_value is None:
|
||||
# Flag wasn't evaluated - this shouldn't happen if dependency chain is correct
|
||||
raise InconclusiveMatchError(
|
||||
f"Flag '{flag_key}' was not evaluated despite being in dependency chain"
|
||||
)
|
||||
|
||||
# For flag dependencies, we need to compare the actual flag result with expected value
|
||||
# using the flag_evaluates_to operator logic
|
||||
if operator == "flag_evaluates_to":
|
||||
return matches_dependency_value(expected_value, actual_value)
|
||||
else:
|
||||
# This should never happen, but just to be defensive.
|
||||
raise InconclusiveMatchError(
|
||||
f"Flag dependency property for '{property.get('key', 'unknown')}' has invalid operator '{operator}'"
|
||||
)
|
||||
|
||||
# If no value check needed, return True (all dependencies passed)
|
||||
return True
|
||||
|
||||
|
||||
def matches_dependency_value(expected_value, actual_value):
|
||||
"""
|
||||
Check if the actual flag value matches the expected dependency value.
|
||||
|
||||
This follows the same logic as the C# MatchesDependencyValue function:
|
||||
- String variant case: check for exact match or boolean true
|
||||
- Boolean case: must match expected boolean value
|
||||
|
||||
Args:
|
||||
expected_value: The expected value from the property
|
||||
actual_value: The actual value returned by the flag evaluation
|
||||
|
||||
Returns:
|
||||
bool: True if the values match according to flag dependency rules
|
||||
"""
|
||||
# String variant case - check for exact match or boolean true
|
||||
if isinstance(actual_value, str) and len(actual_value) > 0:
|
||||
if isinstance(expected_value, bool):
|
||||
# Any variant matches boolean true
|
||||
return expected_value
|
||||
elif isinstance(expected_value, str):
|
||||
# variants are case-sensitive, hence our comparison is too
|
||||
return actual_value == expected_value
|
||||
else:
|
||||
return False
|
||||
|
||||
# Boolean case - must match expected boolean value
|
||||
elif isinstance(actual_value, bool) and isinstance(expected_value, bool):
|
||||
return actual_value == expected_value
|
||||
|
||||
# Default case
|
||||
return False
|
||||
|
||||
|
||||
def match_feature_flag_properties(
|
||||
flag,
|
||||
distinct_id,
|
||||
properties,
|
||||
cohort_properties=None,
|
||||
flags_by_key=None,
|
||||
evaluation_cache=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 []
|
||||
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
|
||||
# evaluated first, and the variant override is applied to the first matching condition.
|
||||
sorted_flag_conditions = sorted(
|
||||
flag_conditions,
|
||||
key=lambda condition: 0 if condition.get("variant") else 1,
|
||||
)
|
||||
|
||||
for condition in sorted_flag_conditions:
|
||||
try:
|
||||
# if any one condition resolves to True, we can shortcircuit and return
|
||||
# the matching variant
|
||||
if is_condition_match(
|
||||
flag,
|
||||
distinct_id,
|
||||
condition,
|
||||
properties,
|
||||
cohort_properties,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
):
|
||||
variant_override = condition.get("variant")
|
||||
if variant_override and variant_override in valid_variant_keys:
|
||||
variant = variant_override
|
||||
else:
|
||||
variant = get_matching_variant(flag, distinct_id)
|
||||
return variant or True
|
||||
except InconclusiveMatchError:
|
||||
is_inconclusive = True
|
||||
|
||||
if is_inconclusive:
|
||||
raise InconclusiveMatchError(
|
||||
"Can't determine if feature flag is enabled or not with given properties"
|
||||
)
|
||||
|
||||
# We can only return False when either all conditions are False, or
|
||||
# no condition was inconclusive.
|
||||
return False
|
||||
|
||||
|
||||
def is_condition_match(
|
||||
feature_flag,
|
||||
distinct_id,
|
||||
condition,
|
||||
properties,
|
||||
cohort_properties,
|
||||
flags_by_key=None,
|
||||
evaluation_cache=None,
|
||||
) -> bool:
|
||||
rollout_percentage = condition.get("rollout_percentage")
|
||||
if len(condition.get("properties") or []) > 0:
|
||||
for prop in condition.get("properties"):
|
||||
property_type = prop.get("type")
|
||||
if property_type == "cohort":
|
||||
matches = match_cohort(
|
||||
prop,
|
||||
properties,
|
||||
cohort_properties,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
distinct_id,
|
||||
)
|
||||
elif property_type == "flag":
|
||||
matches = evaluate_flag_dependency(
|
||||
prop,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
distinct_id,
|
||||
properties,
|
||||
cohort_properties,
|
||||
)
|
||||
else:
|
||||
matches = match_property(prop, properties)
|
||||
if not matches:
|
||||
return False
|
||||
|
||||
if rollout_percentage is None:
|
||||
return True
|
||||
|
||||
if rollout_percentage is not None and _hash(feature_flag["key"], distinct_id) > (
|
||||
rollout_percentage / 100
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def match_property(property, property_values) -> bool:
|
||||
# only looks for matches where key exists in override_property_values
|
||||
# doesn't support operator is_not_set
|
||||
key = property.get("key")
|
||||
operator = property.get("operator") or "exact"
|
||||
value = property.get("value")
|
||||
|
||||
if key not in property_values:
|
||||
raise InconclusiveMatchError(
|
||||
"can't match properties without a given property value"
|
||||
)
|
||||
|
||||
if operator == "is_not_set":
|
||||
raise InconclusiveMatchError("can't match properties with operator is_not_set")
|
||||
|
||||
override_value = property_values[key]
|
||||
|
||||
if (operator not in NONE_VALUES_ALLOWED_OPERATORS) and override_value is None:
|
||||
return False
|
||||
|
||||
if operator in ("exact", "is_not"):
|
||||
|
||||
def compute_exact_match(value, override_value):
|
||||
if isinstance(value, list):
|
||||
return str(override_value).casefold() in [
|
||||
str(val).casefold() for val in value
|
||||
]
|
||||
return utils.str_iequals(value, override_value)
|
||||
|
||||
if operator == "exact":
|
||||
return compute_exact_match(value, override_value)
|
||||
else:
|
||||
return not compute_exact_match(value, override_value)
|
||||
|
||||
if operator == "is_set":
|
||||
return key in property_values
|
||||
|
||||
if operator == "icontains":
|
||||
return utils.str_icontains(override_value, value)
|
||||
|
||||
if operator == "not_icontains":
|
||||
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
|
||||
)
|
||||
|
||||
if operator == "not_regex":
|
||||
return (
|
||||
is_valid_regex(str(value))
|
||||
and re.compile(str(value)).search(str(override_value)) is None
|
||||
)
|
||||
|
||||
if operator in ("gt", "gte", "lt", "lte"):
|
||||
# :TRICKY: We adjust comparison based on the override value passed in,
|
||||
# to make sure we handle both numeric and string comparisons appropriately.
|
||||
def compare(lhs, rhs, operator):
|
||||
if operator == "gt":
|
||||
return lhs > rhs
|
||||
elif operator == "gte":
|
||||
return lhs >= rhs
|
||||
elif operator == "lt":
|
||||
return lhs < rhs
|
||||
elif operator == "lte":
|
||||
return lhs <= rhs
|
||||
else:
|
||||
raise ValueError(f"Invalid operator: {operator}")
|
||||
|
||||
parsed_value = None
|
||||
try:
|
||||
parsed_value = float(value) # type: ignore
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if parsed_value is not None and override_value is not None:
|
||||
if isinstance(override_value, str):
|
||||
return compare(override_value, str(value), operator)
|
||||
else:
|
||||
return compare(override_value, parsed_value, operator)
|
||||
else:
|
||||
return compare(str(override_value), str(value), operator)
|
||||
|
||||
if operator in ["is_date_before", "is_date_after"]:
|
||||
try:
|
||||
parsed_date = relative_date_parse_for_feature_flag_matching(str(value))
|
||||
|
||||
if not parsed_date:
|
||||
parsed_date = parser.parse(str(value))
|
||||
parsed_date = convert_to_datetime_aware(parsed_date)
|
||||
except Exception as e:
|
||||
raise InconclusiveMatchError(
|
||||
"The date set on the flag is not a valid format"
|
||||
) from e
|
||||
|
||||
if not parsed_date:
|
||||
raise InconclusiveMatchError(
|
||||
"The date set on the flag is not a valid format"
|
||||
)
|
||||
|
||||
if isinstance(override_value, datetime.datetime):
|
||||
override_date = convert_to_datetime_aware(override_value)
|
||||
if operator == "is_date_before":
|
||||
return override_date < parsed_date
|
||||
else:
|
||||
return override_date > parsed_date
|
||||
elif isinstance(override_value, datetime.date):
|
||||
if operator == "is_date_before":
|
||||
return override_value < parsed_date.date()
|
||||
else:
|
||||
return override_value > parsed_date.date()
|
||||
elif isinstance(override_value, str):
|
||||
try:
|
||||
override_date = parser.parse(override_value)
|
||||
override_date = convert_to_datetime_aware(override_date)
|
||||
if operator == "is_date_before":
|
||||
return override_date < parsed_date
|
||||
else:
|
||||
return override_date > parsed_date
|
||||
except Exception:
|
||||
raise InconclusiveMatchError("The date provided is not a valid format")
|
||||
else:
|
||||
raise InconclusiveMatchError(
|
||||
"The date provided must be a string or date object"
|
||||
)
|
||||
|
||||
# if we get here, we don't know how to handle the operator
|
||||
raise InconclusiveMatchError(f"Unknown operator {operator}")
|
||||
|
||||
|
||||
def match_cohort(
|
||||
property,
|
||||
property_values,
|
||||
cohort_properties,
|
||||
flags_by_key=None,
|
||||
evaluation_cache=None,
|
||||
distinct_id=None,
|
||||
) -> bool:
|
||||
# Cohort properties are in the form of property groups like this:
|
||||
# {
|
||||
# "cohort_id": {
|
||||
# "type": "AND|OR",
|
||||
# "values": [{
|
||||
# "key": "property_name", "value": "property_value"
|
||||
# }]
|
||||
# }
|
||||
# }
|
||||
cohort_id = str(property.get("value"))
|
||||
if cohort_id not in cohort_properties:
|
||||
raise InconclusiveMatchError(
|
||||
"can't match cohort without a given cohort property value"
|
||||
)
|
||||
|
||||
property_group = cohort_properties[cohort_id]
|
||||
return match_property_group(
|
||||
property_group,
|
||||
property_values,
|
||||
cohort_properties,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
distinct_id,
|
||||
)
|
||||
|
||||
|
||||
def match_property_group(
|
||||
property_group,
|
||||
property_values,
|
||||
cohort_properties,
|
||||
flags_by_key=None,
|
||||
evaluation_cache=None,
|
||||
distinct_id=None,
|
||||
) -> bool:
|
||||
if not property_group:
|
||||
return True
|
||||
|
||||
property_group_type = property_group.get("type")
|
||||
properties = property_group.get("values")
|
||||
|
||||
if not properties or len(properties) == 0:
|
||||
# empty groups are no-ops, always match
|
||||
return True
|
||||
|
||||
error_matching_locally = False
|
||||
|
||||
if "values" in properties[0]:
|
||||
# a nested property group
|
||||
for prop in properties:
|
||||
try:
|
||||
matches = match_property_group(
|
||||
prop,
|
||||
property_values,
|
||||
cohort_properties,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
distinct_id,
|
||||
)
|
||||
if property_group_type == "AND":
|
||||
if not matches:
|
||||
return False
|
||||
else:
|
||||
# OR group
|
||||
if matches:
|
||||
return True
|
||||
except InconclusiveMatchError as e:
|
||||
log.debug(f"Failed to compute property {prop} locally: {e}")
|
||||
error_matching_locally = True
|
||||
|
||||
if error_matching_locally:
|
||||
raise InconclusiveMatchError(
|
||||
"Can't match cohort without a given cohort property value"
|
||||
)
|
||||
# if we get here, all matched in AND case, or none matched in OR case
|
||||
return property_group_type == "AND"
|
||||
|
||||
else:
|
||||
for prop in properties:
|
||||
try:
|
||||
if prop.get("type") == "cohort":
|
||||
matches = match_cohort(
|
||||
prop,
|
||||
property_values,
|
||||
cohort_properties,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
distinct_id,
|
||||
)
|
||||
elif prop.get("type") == "flag":
|
||||
matches = evaluate_flag_dependency(
|
||||
prop,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
distinct_id,
|
||||
property_values,
|
||||
cohort_properties,
|
||||
)
|
||||
else:
|
||||
matches = match_property(prop, property_values)
|
||||
|
||||
negation = prop.get("negation", False)
|
||||
|
||||
if property_group_type == "AND":
|
||||
# if negated property, do the inverse
|
||||
if not matches and not negation:
|
||||
return False
|
||||
if matches and negation:
|
||||
return False
|
||||
else:
|
||||
# OR group
|
||||
if matches and not negation:
|
||||
return True
|
||||
if not matches and negation:
|
||||
return True
|
||||
except InconclusiveMatchError as e:
|
||||
log.debug(f"Failed to compute property {prop} locally: {e}")
|
||||
error_matching_locally = True
|
||||
|
||||
if error_matching_locally:
|
||||
raise InconclusiveMatchError(
|
||||
"can't match cohort without a given cohort property value"
|
||||
)
|
||||
|
||||
# if we get here, all matched in AND case, or none matched in OR case
|
||||
return property_group_type == "AND"
|
||||
|
||||
|
||||
def relative_date_parse_for_feature_flag_matching(
|
||||
value: str,
|
||||
) -> Optional[datetime.datetime]:
|
||||
regex = r"^-?(?P<number>[0-9]+)(?P<interval>[a-z])$"
|
||||
match = re.search(regex, value)
|
||||
parsed_dt = datetime.datetime.now(datetime.timezone.utc)
|
||||
if match:
|
||||
number = int(match.group("number"))
|
||||
|
||||
if number >= 10_000:
|
||||
# Guard against overflow, disallow numbers greater than 10_000
|
||||
return None
|
||||
|
||||
interval = match.group("interval")
|
||||
if interval == "h":
|
||||
parsed_dt = parsed_dt - relativedelta(hours=number)
|
||||
elif interval == "d":
|
||||
parsed_dt = parsed_dt - relativedelta(days=number)
|
||||
elif interval == "w":
|
||||
parsed_dt = parsed_dt - relativedelta(weeks=number)
|
||||
elif interval == "m":
|
||||
parsed_dt = parsed_dt - relativedelta(months=number)
|
||||
elif interval == "y":
|
||||
parsed_dt = parsed_dt - relativedelta(years=number)
|
||||
else:
|
||||
return None
|
||||
|
||||
return parsed_dt
|
||||
else:
|
||||
return None
|
||||
@@ -0,0 +1,181 @@
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from posthog import contexts, capture_exception
|
||||
from posthog.client import Client
|
||||
|
||||
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 exceptions are captured using the
|
||||
global client, unless the setting `POSTHOG_MW_CLIENT` is set to a custom client instance
|
||||
|
||||
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
|
||||
|
||||
if hasattr(settings, "POSTHOG_MW_CLIENT") and isinstance(
|
||||
settings.POSTHOG_MW_CLIENT, Client
|
||||
):
|
||||
self.client = cast("Optional[Client]", settings.POSTHOG_MW_CLIENT)
|
||||
else:
|
||||
self.client = None
|
||||
|
||||
def extract_tags(self, request):
|
||||
# type: (HttpRequest) -> Dict[str, Any]
|
||||
tags = {}
|
||||
|
||||
(user_id, user_email) = self.extract_request_user(request)
|
||||
|
||||
# Extract session ID from X-POSTHOG-SESSION-ID header
|
||||
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
|
||||
if session_id:
|
||||
contexts.set_context_session(session_id)
|
||||
|
||||
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id
|
||||
distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID") or user_id
|
||||
if distinct_id:
|
||||
contexts.identify_context(distinct_id)
|
||||
|
||||
# Extract user email
|
||||
if user_email:
|
||||
tags["email"] = user_email
|
||||
|
||||
# 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
|
||||
|
||||
# Extract request path
|
||||
if request.path:
|
||||
tags["$request_path"] = request.path
|
||||
|
||||
# Extract IP address
|
||||
ip_address = request.headers.get("X-Forwarded-For")
|
||||
if ip_address:
|
||||
tags["$ip_address"] = ip_address
|
||||
|
||||
# Extract user agent
|
||||
user_agent = request.headers.get("User-Agent")
|
||||
if user_agent:
|
||||
tags["$user_agent"] = user_agent
|
||||
|
||||
# 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 extract_request_user(self, request):
|
||||
user_id = None
|
||||
email = None
|
||||
|
||||
user = getattr(request, "user", None)
|
||||
|
||||
if user and getattr(user, "is_authenticated", False):
|
||||
try:
|
||||
user_id = str(user.pk)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
email = str(user.email)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return user_id, email
|
||||
|
||||
def __call__(self, request):
|
||||
# type: (HttpRequest) -> HttpResponse
|
||||
if self.request_filter and not self.request_filter(request):
|
||||
return self.get_response(request)
|
||||
|
||||
with contexts.new_context(self.capture_exceptions, client=self.client):
|
||||
for k, v in self.extract_tags(request).items():
|
||||
contexts.tag(k, v)
|
||||
|
||||
return self.get_response(request)
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
if self.request_filter and not self.request_filter(request):
|
||||
return
|
||||
|
||||
if not self.capture_exceptions:
|
||||
return
|
||||
|
||||
if self.client:
|
||||
self.client.capture_exception(exception)
|
||||
else:
|
||||
capture_exception(exception)
|
||||
+4
-3
@@ -1,19 +1,20 @@
|
||||
import threading
|
||||
|
||||
|
||||
class Poller(threading.Thread):
|
||||
def __init__(self, interval, execute, *args, **kwargs):
|
||||
threading.Thread.__init__(self)
|
||||
self.daemon = False
|
||||
self.daemon = True # Make daemon to not interfere with program exit
|
||||
self.stopped = threading.Event()
|
||||
self.interval = interval
|
||||
self.execute = execute
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
|
||||
def stop(self):
|
||||
self.stopped.set()
|
||||
self.join()
|
||||
|
||||
def run(self):
|
||||
while not self.stopped.wait(self.interval.total_seconds()):
|
||||
self.execute(*self.args, **self.kwargs)
|
||||
self.execute(*self.args, **self.kwargs)
|
||||
|
||||
+153
-49
@@ -1,80 +1,180 @@
|
||||
from datetime import date, datetime
|
||||
from dateutil.tz import tzutc
|
||||
import logging
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from gzip import GzipFile
|
||||
from requests.auth import HTTPBasicAuth
|
||||
import requests
|
||||
from io import BytesIO
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import requests
|
||||
from dateutil.tz import tzutc
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from posthog.version import VERSION
|
||||
from posthog.utils import remove_trailing_slash
|
||||
from posthog.version import VERSION
|
||||
|
||||
# Retry on both connect and read errors
|
||||
# by default read errors will only retry idempotent HTTP methods (so not POST)
|
||||
adapter = requests.adapters.HTTPAdapter(
|
||||
max_retries=Retry(
|
||||
total=2,
|
||||
connect=2,
|
||||
read=2,
|
||||
)
|
||||
)
|
||||
_session = requests.sessions.Session()
|
||||
_session.mount("https://", adapter)
|
||||
|
||||
DEFAULT_HOST = 'https://app.posthog.com'
|
||||
USER_AGENT = 'posthog-python/' + VERSION
|
||||
US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
|
||||
EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"
|
||||
DEFAULT_HOST = US_INGESTION_ENDPOINT
|
||||
USER_AGENT = "posthog-python/" + VERSION
|
||||
|
||||
def post(api_key, host=None, gzip=False, timeout=15, **kwargs):
|
||||
|
||||
def determine_server_host(host: Optional[str]) -> str:
|
||||
"""Determines the server host to use."""
|
||||
host_or_default = host or DEFAULT_HOST
|
||||
trimmed_host = remove_trailing_slash(host_or_default)
|
||||
if trimmed_host in ("https://app.posthog.com", "https://us.posthog.com"):
|
||||
return US_INGESTION_ENDPOINT
|
||||
elif trimmed_host == "https://eu.posthog.com":
|
||||
return EU_INGESTION_ENDPOINT
|
||||
else:
|
||||
return host_or_default
|
||||
|
||||
|
||||
def post(
|
||||
api_key: str,
|
||||
host: Optional[str] = None,
|
||||
path=None,
|
||||
gzip: bool = False,
|
||||
timeout: int = 15,
|
||||
**kwargs,
|
||||
) -> requests.Response:
|
||||
"""Post the `kwargs` to the API"""
|
||||
log = logging.getLogger('posthog')
|
||||
log = logging.getLogger("posthog")
|
||||
body = kwargs
|
||||
body["sentAt"] = datetime.utcnow().replace(tzinfo=tzutc()).isoformat()
|
||||
url = remove_trailing_slash(host or DEFAULT_HOST) + '/batch/'
|
||||
body['api_key'] = api_key
|
||||
body["sentAt"] = datetime.now(tz=tzutc()).isoformat()
|
||||
url = remove_trailing_slash(host or DEFAULT_HOST) + path
|
||||
body["api_key"] = api_key
|
||||
data = json.dumps(body, cls=DatetimeSerializer)
|
||||
log.debug('making request: %s', data)
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': USER_AGENT
|
||||
}
|
||||
log.debug("making request: %s to url: %s", data, url)
|
||||
headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
|
||||
if gzip:
|
||||
headers['Content-Encoding'] = 'gzip'
|
||||
headers["Content-Encoding"] = "gzip"
|
||||
buf = BytesIO()
|
||||
with GzipFile(fileobj=buf, mode='w') as gz:
|
||||
with GzipFile(fileobj=buf, mode="w") as gz:
|
||||
# 'data' was produced by json.dumps(),
|
||||
# whose default encoding is utf-8.
|
||||
gz.write(data.encode('utf-8'))
|
||||
gz.write(data.encode("utf-8"))
|
||||
data = buf.getvalue()
|
||||
|
||||
res = _session.post(url, data=data,
|
||||
headers=headers, timeout=timeout)
|
||||
res = _session.post(url, data=data, headers=headers, timeout=timeout)
|
||||
|
||||
if res.status_code == 200:
|
||||
log.debug('data uploaded successfully')
|
||||
return res
|
||||
log.debug("data uploaded successfully")
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _process_response(
|
||||
res: requests.Response, success_message: str, *, return_json: bool = True
|
||||
) -> Union[requests.Response, Any]:
|
||||
log = logging.getLogger("posthog")
|
||||
if res.status_code == 200:
|
||||
log.debug(success_message)
|
||||
response = res.json() if return_json else res
|
||||
# Handle quota limited decide responses by raising a specific error
|
||||
# NB: other services also put entries into the quotaLimited key, but right now we only care about feature flags
|
||||
# since most of the other services handle quota limiting in other places in the application.
|
||||
if (
|
||||
isinstance(response, dict)
|
||||
and "quotaLimited" in response
|
||||
and isinstance(response["quotaLimited"], list)
|
||||
and "feature_flags" in response["quotaLimited"]
|
||||
):
|
||||
log.warning(
|
||||
"[FEATURE FLAGS] PostHog feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts"
|
||||
)
|
||||
raise QuotaLimitError(res.status_code, "Feature flags quota limited")
|
||||
return response
|
||||
try:
|
||||
payload = res.json()
|
||||
log.debug('received response: %s', payload)
|
||||
raise APIError(res.status_code, payload['detail'])
|
||||
except ValueError:
|
||||
log.debug("received response: %s", payload)
|
||||
raise APIError(res.status_code, payload["detail"])
|
||||
except (KeyError, ValueError):
|
||||
raise APIError(res.status_code, res.text)
|
||||
|
||||
def get(api_key, url, host=None, timeout=None):
|
||||
log = logging.getLogger('posthog')
|
||||
url = remove_trailing_slash(host or DEFAULT_HOST) + url
|
||||
response = requests.get(
|
||||
url,
|
||||
headers={
|
||||
'Authorization': 'Bearer %s' % api_key,
|
||||
'User-Agent': USER_AGENT
|
||||
},
|
||||
timeout=timeout
|
||||
|
||||
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:
|
||||
"""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"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
try:
|
||||
payload = response.json()
|
||||
log.debug('received response: %s', payload)
|
||||
raise APIError(response.status_code, payload['detail'])
|
||||
except ValueError:
|
||||
raise APIError(response.status_code, response.text)
|
||||
|
||||
|
||||
def remote_config(
|
||||
personal_api_key: str,
|
||||
project_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?token={project_api_key}",
|
||||
host,
|
||||
timeout,
|
||||
)
|
||||
|
||||
|
||||
def batch_post(
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
return _process_response(res, success_message=f"GET {url} completed successfully")
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
|
||||
def __init__(self, status, message):
|
||||
def __init__(self, status: Union[int, str], message: str):
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
@@ -83,8 +183,12 @@ class APIError(Exception):
|
||||
return msg.format(self.message, self.status)
|
||||
|
||||
|
||||
class QuotaLimitError(APIError):
|
||||
pass
|
||||
|
||||
|
||||
class DatetimeSerializer(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
def default(self, obj: Any):
|
||||
if isinstance(obj, (date, datetime)):
|
||||
return obj.isoformat()
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import unittest
|
||||
import pkgutil
|
||||
import logging
|
||||
import pkgutil
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
def all_names():
|
||||
for _, modname, _ in pkgutil.iter_modules(__path__):
|
||||
yield 'posthog.test.' + modname
|
||||
yield "posthog.test." + modname
|
||||
|
||||
|
||||
def all():
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from anthropic.types import Message, Usage
|
||||
|
||||
from posthog.ai.anthropic import Anthropic, AsyncAnthropic
|
||||
|
||||
ANTHROPIC_AVAILABLE = True
|
||||
except ImportError:
|
||||
ANTHROPIC_AVAILABLE = False
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
with patch("posthog.client.Client") as mock_client:
|
||||
mock_client.privacy_mode = False
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anthropic_response():
|
||||
return Message(
|
||||
id="msg_123",
|
||||
type="message",
|
||||
role="assistant",
|
||||
content=[{"type": "text", "text": "Test response"}],
|
||||
model="claude-3-opus-20240229",
|
||||
usage=Usage(
|
||||
input_tokens=20,
|
||||
output_tokens=10,
|
||||
),
|
||||
stop_reason="end_turn",
|
||||
stop_sequence=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anthropic_stream():
|
||||
class MockStreamEvent:
|
||||
def __init__(self, content, usage=None):
|
||||
self.content = content
|
||||
self.usage = usage
|
||||
|
||||
def stream_generator():
|
||||
yield MockStreamEvent("A")
|
||||
yield MockStreamEvent("B")
|
||||
yield MockStreamEvent(
|
||||
"C",
|
||||
usage=Usage(
|
||||
input_tokens=20,
|
||||
output_tokens=10,
|
||||
),
|
||||
)
|
||||
|
||||
return stream_generator()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anthropic_response_with_cached_tokens():
|
||||
# Create a mock Usage object with cached_tokens in input_tokens_details
|
||||
usage = Usage(
|
||||
input_tokens=20,
|
||||
output_tokens=10,
|
||||
cache_read_input_tokens=15,
|
||||
cache_creation_input_tokens=2,
|
||||
)
|
||||
|
||||
return Message(
|
||||
id="msg_123",
|
||||
type="message",
|
||||
role="assistant",
|
||||
content=[{"type": "text", "text": "Test response"}],
|
||||
model="claude-3-opus-20240229",
|
||||
usage=usage,
|
||||
stop_reason="end_turn",
|
||||
stop_sequence=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anthropic_response_with_tool_calls():
|
||||
return Message(
|
||||
id="msg_456",
|
||||
type="message",
|
||||
role="assistant",
|
||||
content=[
|
||||
{"type": "text", "text": "I'll help you check the weather."},
|
||||
{"type": "text", "text": " Let me look that up."},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_abc123",
|
||||
"name": "get_weather",
|
||||
"input": {"location": "San Francisco"},
|
||||
},
|
||||
],
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
usage=Usage(
|
||||
input_tokens=25,
|
||||
output_tokens=15,
|
||||
),
|
||||
stop_reason="tool_use",
|
||||
stop_sequence=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anthropic_response_tool_calls_only():
|
||||
return Message(
|
||||
id="msg_789",
|
||||
type="message",
|
||||
role="assistant",
|
||||
content=[
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_def456",
|
||||
"name": "get_weather",
|
||||
"input": {"location": "New York", "unit": "fahrenheit"},
|
||||
}
|
||||
],
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
usage=Usage(
|
||||
input_tokens=30,
|
||||
output_tokens=12,
|
||||
),
|
||||
stop_reason="tool_use",
|
||||
stop_sequence=None,
|
||||
)
|
||||
|
||||
|
||||
def test_basic_completion(mock_client, 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",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
assert response == mock_anthropic_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"] == "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": [{"type": "text", "text": "Test response"}],
|
||||
}
|
||||
]
|
||||
assert props["$ai_input_tokens"] == 20
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
def test_streaming(mock_client, 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",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
stream=True,
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
# Consume the stream
|
||||
chunks = list(response)
|
||||
assert len(chunks) == 3
|
||||
assert chunks[0].content == "A"
|
||||
assert chunks[1].content == "B"
|
||||
assert chunks[2].content == "C"
|
||||
|
||||
# Wait a bit to ensure the capture is called
|
||||
time.sleep(0.1)
|
||||
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"] == "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": "ABC"}]
|
||||
assert props["$ai_input_tokens"] == 20
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
assert props["foo"] == "bar"
|
||||
|
||||
|
||||
def test_streaming_with_stream_endpoint(mock_client, 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",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
# Consume the stream
|
||||
chunks = list(response)
|
||||
assert len(chunks) == 3
|
||||
assert chunks[0].content == "A"
|
||||
assert chunks[1].content == "B"
|
||||
assert chunks[2].content == "C"
|
||||
|
||||
# Wait a bit to ensure the capture is called
|
||||
time.sleep(0.1)
|
||||
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"] == "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": "ABC"}]
|
||||
assert props["$ai_input_tokens"] == 20
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
assert props["foo"] == "bar"
|
||||
|
||||
|
||||
def test_groups(mock_client, 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",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_groups={"company": "test_company"},
|
||||
)
|
||||
|
||||
assert response == mock_anthropic_response
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
assert call_args["groups"] == {"company": "test_company"}
|
||||
|
||||
|
||||
def test_privacy_mode_local(mock_client, 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",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_privacy_mode=True,
|
||||
)
|
||||
|
||||
assert response == mock_anthropic_response
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] is None
|
||||
assert props["$ai_output_choices"] is None
|
||||
|
||||
|
||||
def test_privacy_mode_global(mock_client, 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(
|
||||
model="claude-3-opus-20240229",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_privacy_mode=False,
|
||||
)
|
||||
|
||||
assert response == mock_anthropic_response
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] is None
|
||||
assert props["$ai_output_choices"] is None
|
||||
|
||||
|
||||
@pytest.mark.skipif(not ANTHROPIC_API_KEY, reason="ANTHROPIC_API_KEY is not set")
|
||||
def test_basic_integration(mock_client):
|
||||
client = Anthropic(posthog_client=mock_client)
|
||||
client.messages.create(
|
||||
model="claude-3-opus-20240229",
|
||||
messages=[{"role": "user", "content": "Foo"}],
|
||||
max_tokens=1,
|
||||
temperature=0,
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
system="You must always answer with 'Bar'.",
|
||||
)
|
||||
|
||||
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"] == "anthropic"
|
||||
assert props["$ai_model"] == "claude-3-opus-20240229"
|
||||
assert props["$ai_input"] == [
|
||||
{"role": "system", "content": "You must always answer with 'Bar'."},
|
||||
{"role": "user", "content": "Foo"},
|
||||
]
|
||||
assert props["$ai_output_choices"][0]["role"] == "assistant"
|
||||
assert props["$ai_output_choices"][0]["content"] == [
|
||||
{"type": "text", "text": "Bar"}
|
||||
]
|
||||
assert props["$ai_input_tokens"] == 18
|
||||
assert props["$ai_output_tokens"] == 1
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not ANTHROPIC_API_KEY, reason="ANTHROPIC_API_KEY is not set")
|
||||
async def test_basic_async_integration(mock_client):
|
||||
client = AsyncAnthropic(posthog_client=mock_client)
|
||||
await client.messages.create(
|
||||
model="claude-3-opus-20240229",
|
||||
messages=[{"role": "user", "content": "You must always answer with 'Bar'."}],
|
||||
max_tokens=1,
|
||||
temperature=0,
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
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"] == "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_output_choices"][0]["role"] == "assistant"
|
||||
assert props["$ai_input_tokens"] == 16
|
||||
assert props["$ai_output_tokens"] == 1
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
def test_streaming_system_prompt(mock_client, 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",
|
||||
system="Foo",
|
||||
messages=[{"role": "user", "content": "Bar"}],
|
||||
stream=True,
|
||||
)
|
||||
|
||||
# Consume the stream
|
||||
list(response)
|
||||
|
||||
# Wait a bit to ensure the capture is called
|
||||
time.sleep(0.1)
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
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")
|
||||
async def test_async_streaming_system_prompt(mock_client, mock_anthropic_stream):
|
||||
client = AsyncAnthropic(posthog_client=mock_client)
|
||||
response = await client.messages.create(
|
||||
model="claude-3-opus-20240229",
|
||||
system="You must always answer with 'Bar'.",
|
||||
messages=[{"role": "user", "content": "Foo"}],
|
||||
stream=True,
|
||||
max_tokens=1,
|
||||
)
|
||||
|
||||
# Consume the stream
|
||||
[c async for c in response]
|
||||
|
||||
# Wait a bit to ensure the capture is called
|
||||
time.sleep(0.1)
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert props["$ai_input"] == [
|
||||
{"role": "system", "content": "You must always answer with 'Bar'."},
|
||||
{"role": "user", "content": "Foo"},
|
||||
]
|
||||
|
||||
|
||||
def test_error(mock_client, mock_anthropic_response):
|
||||
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"}],
|
||||
)
|
||||
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_is_error"] is True
|
||||
assert props["$ai_error"] == "Test error"
|
||||
|
||||
|
||||
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,
|
||||
):
|
||||
client = Anthropic(api_key="test-key", posthog_client=mock_client)
|
||||
response = client.messages.create(
|
||||
model="claude-3-opus-20240229",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
assert response == mock_anthropic_response_with_cached_tokens
|
||||
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"] == "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": [{"type": "text", "text": "Test response"}],
|
||||
}
|
||||
]
|
||||
assert props["$ai_input_tokens"] == 20
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["$ai_cache_read_input_tokens"] == 15
|
||||
assert props["$ai_cache_creation_input_tokens"] == 2
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
def test_tool_definition(mock_client, mock_anthropic_response):
|
||||
with patch(
|
||||
"anthropic.resources.Messages.create",
|
||||
return_value=mock_anthropic_response,
|
||||
):
|
||||
client = Anthropic(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
tools = [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get the current weather for a specific location",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city or location name to get weather for",
|
||||
}
|
||||
},
|
||||
"required": ["location"],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
response = client.messages.create(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
max_tokens=200,
|
||||
temperature=0.7,
|
||||
tools=tools,
|
||||
messages=[{"role": "user", "content": "hey"}],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
assert response == mock_anthropic_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"] == "anthropic"
|
||||
assert props["$ai_model"] == "claude-3-5-sonnet-20241022"
|
||||
assert props["$ai_input"] == [{"role": "user", "content": "hey"}]
|
||||
assert props["$ai_output_choices"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "Test response"}],
|
||||
}
|
||||
]
|
||||
assert props["$ai_input_tokens"] == 20
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
# Verify that tools are captured in the $ai_tools property
|
||||
assert props["$ai_tools"] == tools
|
||||
|
||||
|
||||
def test_tool_calls_in_output_choices(
|
||||
mock_client, mock_anthropic_response_with_tool_calls
|
||||
):
|
||||
with patch(
|
||||
"anthropic.resources.Messages.create",
|
||||
return_value=mock_anthropic_response_with_tool_calls,
|
||||
):
|
||||
client = Anthropic(api_key="test-key", posthog_client=mock_client)
|
||||
response = client.messages.create(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
max_tokens=200,
|
||||
messages=[
|
||||
{"role": "user", "content": "What's the weather in San Francisco?"}
|
||||
],
|
||||
tools=[
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"location": {"type": "string"}},
|
||||
"required": ["location"],
|
||||
},
|
||||
}
|
||||
],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
assert response == mock_anthropic_response_with_tool_calls
|
||||
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"] == "anthropic"
|
||||
assert props["$ai_model"] == "claude-3-5-sonnet-20241022"
|
||||
assert props["$ai_output_choices"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "I'll help you check the weather."},
|
||||
{"type": "text", "text": " Let me look that up."},
|
||||
{
|
||||
"type": "function",
|
||||
"id": "toolu_abc123",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": {"location": "San Francisco"},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
# Check token usage
|
||||
assert props["$ai_input_tokens"] == 25
|
||||
assert props["$ai_output_tokens"] == 15
|
||||
assert props["$ai_http_status"] == 200
|
||||
|
||||
|
||||
def test_tool_calls_only_no_content(
|
||||
mock_client, mock_anthropic_response_tool_calls_only
|
||||
):
|
||||
with patch(
|
||||
"anthropic.resources.Messages.create",
|
||||
return_value=mock_anthropic_response_tool_calls_only,
|
||||
):
|
||||
client = Anthropic(api_key="test-key", posthog_client=mock_client)
|
||||
response = client.messages.create(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
max_tokens=200,
|
||||
messages=[{"role": "user", "content": "Get weather for New York"}],
|
||||
tools=[
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string"},
|
||||
"unit": {"type": "string"},
|
||||
},
|
||||
"required": ["location"],
|
||||
},
|
||||
}
|
||||
],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
assert response == mock_anthropic_response_tool_calls_only
|
||||
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"] == "anthropic"
|
||||
assert props["$ai_model"] == "claude-3-5-sonnet-20241022"
|
||||
assert props["$ai_output_choices"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "function",
|
||||
"id": "toolu_def456",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": {"location": "New York", "unit": "fahrenheit"},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
# Check token usage
|
||||
assert props["$ai_input_tokens"] == 30
|
||||
assert props["$ai_output_tokens"] == 12
|
||||
assert props["$ai_http_status"] == 200
|
||||
|
||||
|
||||
def test_async_tool_calls_in_output_choices(
|
||||
mock_client, mock_anthropic_response_with_tool_calls
|
||||
):
|
||||
import asyncio
|
||||
|
||||
async def mock_async_create(**kwargs):
|
||||
return mock_anthropic_response_with_tool_calls
|
||||
|
||||
with patch(
|
||||
"anthropic.resources.AsyncMessages.create",
|
||||
side_effect=mock_async_create,
|
||||
):
|
||||
async_client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
async def run_test():
|
||||
return await async_client.messages.create(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
max_tokens=200,
|
||||
messages=[
|
||||
{"role": "user", "content": "What's the weather in San Francisco?"}
|
||||
],
|
||||
tools=[
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"location": {"type": "string"}},
|
||||
"required": ["location"],
|
||||
},
|
||||
}
|
||||
],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
response = asyncio.run(run_test())
|
||||
|
||||
assert response == mock_anthropic_response_with_tool_calls
|
||||
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"] == "anthropic"
|
||||
assert props["$ai_model"] == "claude-3-5-sonnet-20241022"
|
||||
assert props["$ai_output_choices"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "I'll help you check the weather."},
|
||||
{"type": "text", "text": " Let me look that up."},
|
||||
{
|
||||
"type": "function",
|
||||
"id": "toolu_abc123",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": {"location": "San Francisco"},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
# Check token usage
|
||||
assert props["$ai_input_tokens"] == 25
|
||||
assert props["$ai_output_tokens"] == 15
|
||||
assert props["$ai_http_status"] == 200
|
||||
@@ -0,0 +1,631 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from google import genai as google_genai
|
||||
|
||||
from posthog.ai.gemini import Client
|
||||
|
||||
GEMINI_AVAILABLE = True
|
||||
except ImportError:
|
||||
GEMINI_AVAILABLE = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not GEMINI_AVAILABLE, reason="Google Gemini package is not available"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
with patch("posthog.client.Client") as mock_client:
|
||||
mock_client.privacy_mode = False
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gemini_response():
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Test response from Gemini"
|
||||
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.prompt_token_count = 20
|
||||
mock_usage.candidates_token_count = 10
|
||||
mock_response.usage_metadata = mock_usage
|
||||
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.text = "Test response from Gemini"
|
||||
mock_content = MagicMock()
|
||||
mock_part = MagicMock()
|
||||
mock_part.text = "Test response from Gemini"
|
||||
mock_content.parts = [mock_part]
|
||||
mock_candidate.content = mock_content
|
||||
mock_response.candidates = [mock_candidate]
|
||||
|
||||
return mock_response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_google_genai_client():
|
||||
"""Mock for the new google-genai Client"""
|
||||
with patch.object(google_genai, "Client") as mock_client_class:
|
||||
mock_client_instance = MagicMock()
|
||||
mock_models = MagicMock()
|
||||
mock_client_instance.models = mock_models
|
||||
mock_client_class.return_value = mock_client_instance
|
||||
yield mock_client_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gemini_response_with_function_calls():
|
||||
mock_response = MagicMock()
|
||||
|
||||
# Mock usage metadata
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.prompt_token_count = 25
|
||||
mock_usage.candidates_token_count = 15
|
||||
mock_response.usage_metadata = mock_usage
|
||||
|
||||
# Mock function call
|
||||
mock_function_call = MagicMock()
|
||||
mock_function_call.name = "get_current_weather"
|
||||
mock_function_call.args = {"location": "San Francisco"}
|
||||
|
||||
# Mock text part 1
|
||||
mock_text_part1 = MagicMock()
|
||||
mock_text_part1.text = "I'll check the weather for you."
|
||||
# Make hasattr(part, "text") return True
|
||||
type(mock_text_part1).text = mock_text_part1.text
|
||||
|
||||
# Mock text part 2
|
||||
mock_text_part2 = MagicMock()
|
||||
mock_text_part2.text = " Let me look that up."
|
||||
type(mock_text_part2).text = mock_text_part2.text
|
||||
|
||||
# Mock function call part - need to ensure hasattr() works correctly
|
||||
mock_function_part = MagicMock()
|
||||
mock_function_part.function_call = mock_function_call
|
||||
# Make hasattr(part, "function_call") return True
|
||||
type(mock_function_part).function_call = mock_function_part.function_call
|
||||
# Ensure hasattr(part, "text") returns False for the function part
|
||||
del mock_function_part.text
|
||||
|
||||
# Mock content with 2 text parts and 1 function call part
|
||||
mock_content = MagicMock()
|
||||
mock_content.parts = [mock_text_part1, mock_text_part2, mock_function_part]
|
||||
|
||||
# Mock candidate
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.content = mock_content
|
||||
mock_response.candidates = [mock_candidate]
|
||||
|
||||
return mock_response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gemini_response_function_calls_only():
|
||||
mock_response = MagicMock()
|
||||
|
||||
# Mock usage metadata
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.prompt_token_count = 30
|
||||
mock_usage.candidates_token_count = 12
|
||||
mock_response.usage_metadata = mock_usage
|
||||
|
||||
# Mock function call
|
||||
mock_function_call = MagicMock()
|
||||
mock_function_call.name = "get_current_weather"
|
||||
mock_function_call.args = {"location": "New York", "unit": "fahrenheit"}
|
||||
|
||||
# Mock function call part (no text part) - need to ensure hasattr() works correctly
|
||||
mock_function_part = MagicMock()
|
||||
mock_function_part.function_call = mock_function_call
|
||||
# Make hasattr(part, "function_call") return True
|
||||
type(mock_function_part).function_call = mock_function_part.function_call
|
||||
# Ensure hasattr(part, "text") returns False for the function part
|
||||
del mock_function_part.text
|
||||
|
||||
# Mock content with only function call part
|
||||
mock_content = MagicMock()
|
||||
mock_content.parts = [mock_function_part]
|
||||
|
||||
# Mock candidate
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.content = mock_content
|
||||
mock_response.candidates = [mock_candidate]
|
||||
|
||||
return mock_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
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Tell me a fun fact about hedgehogs"],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
assert response == mock_gemini_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"] == "gemini"
|
||||
assert props["$ai_model"] == "gemini-2.0-flash"
|
||||
assert props["$ai_input_tokens"] == 20
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["foo"] == "bar"
|
||||
assert "$ai_trace_id" in props
|
||||
assert props["$ai_latency"] > 0
|
||||
|
||||
|
||||
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():
|
||||
mock_chunk1 = MagicMock()
|
||||
mock_chunk1.text = "Hello "
|
||||
mock_usage1 = MagicMock()
|
||||
mock_usage1.prompt_token_count = 10
|
||||
mock_usage1.candidates_token_count = 5
|
||||
mock_chunk1.usage_metadata = mock_usage1
|
||||
|
||||
mock_chunk2 = MagicMock()
|
||||
mock_chunk2.text = "world!"
|
||||
mock_usage2 = MagicMock()
|
||||
mock_usage2.prompt_token_count = 10
|
||||
mock_usage2.candidates_token_count = 10
|
||||
mock_chunk2.usage_metadata = mock_usage2
|
||||
|
||||
yield mock_chunk1
|
||||
yield mock_chunk2
|
||||
|
||||
# Mock the generate_content_stream method
|
||||
mock_google_genai_client.models.generate_content_stream.return_value = (
|
||||
mock_streaming_response()
|
||||
)
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = client.models.generate_content_stream(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Write a short story"],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"feature": "streaming"},
|
||||
)
|
||||
|
||||
chunks = list(response)
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0].text == "Hello "
|
||||
assert chunks[1].text == "world!"
|
||||
|
||||
# Check that the streaming event was captured
|
||||
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"] == "gemini"
|
||||
assert props["$ai_model"] == "gemini-2.0-flash"
|
||||
assert props["$ai_input_tokens"] == 10
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["feature"] == "streaming"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
def test_new_client_groups(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
"""Test groups functionality 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)
|
||||
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_groups={"company": "company_123"},
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
assert call_args["groups"] == {"company": "company_123"}
|
||||
|
||||
|
||||
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
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_privacy_mode=True,
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] is None
|
||||
assert props["$ai_output_choices"] is None
|
||||
|
||||
|
||||
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
|
||||
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
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"] is None
|
||||
assert props["$ai_output_choices"] is None
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
|
||||
|
||||
# Test list input
|
||||
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"
|
||||
)
|
||||
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
|
||||
):
|
||||
"""Test model parameters 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)
|
||||
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
posthog_distinct_id="test-id",
|
||||
temperature=0.7,
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_model_parameters"]["temperature"] == 0.7
|
||||
assert props["$ai_model_parameters"]["max_tokens"] == 100
|
||||
|
||||
|
||||
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
|
||||
|
||||
client = Client(
|
||||
api_key="test-key",
|
||||
posthog_client=mock_client,
|
||||
posthog_distinct_id="default_user",
|
||||
posthog_properties={"team": "ai"},
|
||||
posthog_privacy_mode=False,
|
||||
posthog_groups={"company": "acme_corp"},
|
||||
)
|
||||
|
||||
# Call without overriding defaults
|
||||
client.models.generate_content(model="gemini-2.0-flash", contents=["Hello"])
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "default_user"
|
||||
assert call_args["groups"] == {"company": "acme_corp"}
|
||||
assert props["team"] == "ai"
|
||||
|
||||
|
||||
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
|
||||
|
||||
client = Client(
|
||||
api_key="test-key",
|
||||
posthog_client=mock_client,
|
||||
posthog_distinct_id="default_user",
|
||||
posthog_properties={"team": "ai"},
|
||||
posthog_privacy_mode=False,
|
||||
posthog_groups={"company": "acme_corp"},
|
||||
)
|
||||
|
||||
# Override defaults in call
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["Hello"],
|
||||
posthog_distinct_id="specific_user",
|
||||
posthog_properties={"feature": "chat", "urgent": True},
|
||||
posthog_privacy_mode=True,
|
||||
posthog_groups={"organization": "special_org"},
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
# Check overrides
|
||||
assert call_args["distinct_id"] == "specific_user"
|
||||
assert call_args["groups"] == {"organization": "special_org"}
|
||||
assert props["$ai_input"] is None # privacy mode was overridden
|
||||
|
||||
# Check merged properties (defaults + call-specific)
|
||||
assert props["team"] == "ai" # from defaults
|
||||
assert props["feature"] == "chat" # from call
|
||||
assert props["urgent"] is True # from call
|
||||
|
||||
|
||||
def test_vertex_ai_parameters_passed_through(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test that Vertex AI parameters are properly passed to genai.Client"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
# Mock credentials object
|
||||
mock_credentials = MagicMock()
|
||||
mock_debug_config = MagicMock()
|
||||
mock_http_options = MagicMock()
|
||||
|
||||
# Create client with Vertex AI parameters
|
||||
Client(
|
||||
vertexai=True,
|
||||
credentials=mock_credentials,
|
||||
project="test-project",
|
||||
location="us-central1",
|
||||
debug_config=mock_debug_config,
|
||||
http_options=mock_http_options,
|
||||
posthog_client=mock_client,
|
||||
)
|
||||
|
||||
# Verify genai.Client was called with correct parameters
|
||||
google_genai.Client.assert_called_once_with(
|
||||
vertexai=True,
|
||||
credentials=mock_credentials,
|
||||
project="test-project",
|
||||
location="us-central1",
|
||||
debug_config=mock_debug_config,
|
||||
http_options=mock_http_options,
|
||||
)
|
||||
|
||||
|
||||
def test_api_key_mode(mock_client, mock_google_genai_client):
|
||||
"""Test API key authentication mode"""
|
||||
|
||||
# Create client with just API key (traditional mode)
|
||||
Client(
|
||||
api_key="test-api-key",
|
||||
posthog_client=mock_client,
|
||||
)
|
||||
|
||||
# Verify genai.Client was called with only api_key
|
||||
google_genai.Client.assert_called_once_with(api_key="test-api-key")
|
||||
|
||||
|
||||
def test_vertex_ai_mode_with_optional_api_key(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test Vertex AI mode with optional API key"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
mock_credentials = MagicMock()
|
||||
|
||||
# Create client with Vertex AI + API key
|
||||
Client(
|
||||
vertexai=True,
|
||||
api_key="test-api-key",
|
||||
credentials=mock_credentials,
|
||||
project="test-project",
|
||||
posthog_client=mock_client,
|
||||
)
|
||||
|
||||
# Verify genai.Client was called with both Vertex AI params and API key
|
||||
google_genai.Client.assert_called_once_with(
|
||||
vertexai=True,
|
||||
api_key="test-api-key",
|
||||
credentials=mock_credentials,
|
||||
project="test-project",
|
||||
)
|
||||
|
||||
|
||||
def test_tool_use_response(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
"""Test that tools defined in config are captured in $ai_tools property"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
# Create mock tools configuration
|
||||
mock_tool = MagicMock()
|
||||
mock_tool.function_declarations = [
|
||||
MagicMock(
|
||||
name="get_current_weather",
|
||||
description="Gets the current weather for a given location.",
|
||||
parameters=MagicMock(
|
||||
type="OBJECT",
|
||||
properties={
|
||||
"location": MagicMock(
|
||||
type="STRING",
|
||||
description="The city and state, e.g. San Francisco, CA",
|
||||
)
|
||||
},
|
||||
required=["location"],
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.tools = [mock_tool]
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents=["hey"],
|
||||
config=mock_config,
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
assert response == mock_gemini_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"] == "gemini"
|
||||
assert props["$ai_model"] == "gemini-2.5-flash"
|
||||
assert props["$ai_input"] == [{"role": "user", "content": "hey"}]
|
||||
assert props["$ai_output_choices"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "Test response from Gemini"}],
|
||||
}
|
||||
]
|
||||
assert props["$ai_input_tokens"] == 20
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
# Verify that tools are captured in the $ai_tools property
|
||||
assert props["$ai_tools"] == [mock_tool]
|
||||
|
||||
|
||||
def test_function_calls_in_output_choices(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response_with_function_calls
|
||||
):
|
||||
"""Test that function calls are properly included in $ai_output_choices"""
|
||||
mock_google_genai_client.models.generate_content.return_value = (
|
||||
mock_gemini_response_with_function_calls
|
||||
)
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents=["What's the weather in San Francisco?"],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
assert response == mock_gemini_response_with_function_calls
|
||||
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"] == "gemini"
|
||||
assert props["$ai_model"] == "gemini-2.5-flash"
|
||||
assert props["$ai_output_choices"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "I'll check the weather for you."},
|
||||
{"type": "text", "text": " Let me look that up."},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_current_weather",
|
||||
"arguments": {"location": "San Francisco"},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
# Check token usage
|
||||
assert props["$ai_input_tokens"] == 25
|
||||
assert props["$ai_output_tokens"] == 15
|
||||
assert props["$ai_http_status"] == 200
|
||||
|
||||
|
||||
def test_function_calls_only_no_content(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response_function_calls_only
|
||||
):
|
||||
"""Test function calls without text content in $ai_output_choices"""
|
||||
mock_google_genai_client.models.generate_content.return_value = (
|
||||
mock_gemini_response_function_calls_only
|
||||
)
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents=["Get weather for New York"],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
assert response == mock_gemini_response_function_calls_only
|
||||
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"] == "gemini"
|
||||
assert props["$ai_model"] == "gemini-2.5-flash"
|
||||
assert props["$ai_output_choices"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_current_weather",
|
||||
"arguments": {"location": "New York", "unit": "fahrenheit"},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
# Check token usage
|
||||
assert props["$ai_input_tokens"] == 30
|
||||
assert props["$ai_output_tokens"] == 12
|
||||
assert props["$ai_http_status"] == 200
|
||||
@@ -0,0 +1,5 @@
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("langchain")
|
||||
pytest.importorskip("langchain_community")
|
||||
pytest.importorskip("langgraph")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,856 @@
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from posthog.ai.litellm import completion, acompletion, embedding
|
||||
|
||||
LITELLM_AVAILABLE = True
|
||||
except ImportError:
|
||||
LITELLM_AVAILABLE = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not LITELLM_AVAILABLE, reason="LiteLLM package is not available"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
with patch("posthog.client.Client") as mock_client:
|
||||
mock_client.capture = MagicMock()
|
||||
mock_client.privacy_mode = False
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_usage():
|
||||
usage = MagicMock()
|
||||
usage.prompt_tokens = 10
|
||||
usage.completion_tokens = 20
|
||||
usage.total_tokens = 30
|
||||
usage.prompt_tokens_details = MagicMock()
|
||||
usage.prompt_tokens_details.cached_tokens = 0
|
||||
usage.output_tokens_details = MagicMock()
|
||||
usage.output_tokens_details.reasoning_tokens = 0
|
||||
return usage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_usage_with_cached_tokens():
|
||||
usage = MagicMock()
|
||||
usage.prompt_tokens = 20
|
||||
usage.completion_tokens = 15
|
||||
usage.total_tokens = 35
|
||||
usage.prompt_tokens_details = MagicMock()
|
||||
usage.prompt_tokens_details.cached_tokens = 15
|
||||
usage.output_tokens_details = MagicMock()
|
||||
usage.output_tokens_details.reasoning_tokens = 5
|
||||
return usage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_response(mock_usage):
|
||||
response = MagicMock()
|
||||
response.usage = mock_usage
|
||||
response.choices = [MagicMock()]
|
||||
response.choices[0].message = MagicMock()
|
||||
response.choices[0].message.content = "Hello, world!"
|
||||
response.choices[0].message.role = "assistant"
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_response_with_cached_tokens(mock_usage_with_cached_tokens):
|
||||
response = MagicMock()
|
||||
response.usage = mock_usage_with_cached_tokens
|
||||
response.choices = [MagicMock()]
|
||||
response.choices[0].message = MagicMock()
|
||||
response.choices[0].message.content = "Hello with cached tokens!"
|
||||
response.choices[0].message.role = "assistant"
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_response_tool_calls_only():
|
||||
response = MagicMock()
|
||||
response.usage = MagicMock()
|
||||
response.usage.prompt_tokens = 25
|
||||
response.usage.completion_tokens = 10
|
||||
response.usage.total_tokens = 35
|
||||
response.usage.prompt_tokens_details = MagicMock()
|
||||
response.usage.prompt_tokens_details.cached_tokens = 0
|
||||
response.usage.output_tokens_details = MagicMock()
|
||||
response.usage.output_tokens_details.reasoning_tokens = 0
|
||||
response.choices = [MagicMock()]
|
||||
response.choices[0].message = MagicMock()
|
||||
response.choices[0].message.content = None
|
||||
response.choices[0].message.role = "assistant"
|
||||
response.choices[0].message.tool_calls = [MagicMock()]
|
||||
response.choices[0].message.tool_calls[0].id = "call_def456"
|
||||
response.choices[0].message.tool_calls[0].type = "function"
|
||||
response.choices[0].message.tool_calls[0].function = MagicMock()
|
||||
response.choices[0].message.tool_calls[0].function.name = "get_weather"
|
||||
response.choices[0].message.tool_calls[
|
||||
0
|
||||
].function.arguments = '{"location": "New York"}'
|
||||
return response
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_basic(
|
||||
mock_setup, mock_litellm_completion, mock_client, mock_response
|
||||
):
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_completion.return_value = mock_response
|
||||
|
||||
response = completion(
|
||||
model="openai/gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
posthog_distinct_id="test-user",
|
||||
)
|
||||
|
||||
assert response == mock_response
|
||||
|
||||
mock_litellm_completion.assert_called_once()
|
||||
call_kwargs = mock_litellm_completion.call_args[1]
|
||||
assert call_kwargs["model"] == "openai/gpt-3.5-turbo"
|
||||
assert call_kwargs["messages"] == [{"role": "user", "content": "Hello"}]
|
||||
|
||||
mock_client.capture.assert_called_once()
|
||||
call_args = mock_client.capture.call_args
|
||||
assert call_args[1]["event"] == "$ai_generation"
|
||||
assert call_args[1]["properties"]["$ai_provider"] == "litellm"
|
||||
assert call_args[1]["properties"]["$ai_model"] == "gpt-3.5-turbo"
|
||||
assert call_args[1]["properties"]["$ai_input_tokens"] == 10
|
||||
assert call_args[1]["properties"]["$ai_output_tokens"] == 20
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.acompletion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_basic(
|
||||
mock_setup, mock_litellm_acompletion, mock_client, mock_response
|
||||
):
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_acompletion.return_value = mock_response
|
||||
|
||||
response = await acompletion(
|
||||
model="anthropic/claude-3-sonnet-20240229",
|
||||
messages=[{"role": "user", "content": "Hello async"}],
|
||||
posthog_distinct_id="test-user-async",
|
||||
)
|
||||
|
||||
assert response == mock_response
|
||||
|
||||
mock_litellm_acompletion.assert_called_once()
|
||||
call_kwargs = mock_litellm_acompletion.call_args[1]
|
||||
assert call_kwargs["model"] == "anthropic/claude-3-sonnet-20240229"
|
||||
assert call_kwargs["messages"] == [{"role": "user", "content": "Hello async"}]
|
||||
|
||||
mock_client.capture.assert_called_once()
|
||||
call_args = mock_client.capture.call_args
|
||||
assert call_args[1]["event"] == "$ai_generation"
|
||||
assert call_args[1]["properties"]["$ai_provider"] == "litellm"
|
||||
assert call_args[1]["properties"]["$ai_model"] == "claude-3-sonnet-20240229"
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.acompletion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_with_base64_image_sanitization(
|
||||
mock_setup, mock_litellm_acompletion, mock_client, mock_response
|
||||
):
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_acompletion.return_value = mock_response
|
||||
|
||||
base64_image_url = (
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUl=="
|
||||
)
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this image"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": base64_image_url, "detail": "low"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
response = await acompletion(
|
||||
model="anthropic/claude-3-haiku-20240307",
|
||||
messages=messages,
|
||||
posthog_distinct_id="test-user-async",
|
||||
)
|
||||
|
||||
assert response == mock_response
|
||||
|
||||
call_args = mock_client.capture.call_args
|
||||
sanitized_input = call_args[1]["properties"]["$ai_input"]
|
||||
|
||||
assert sanitized_input[0]["content"][0]["text"] == "Analyze this image"
|
||||
assert (
|
||||
sanitized_input[0]["content"][1]["image_url"]["url"]
|
||||
== "[base64 image redacted]"
|
||||
)
|
||||
assert sanitized_input[0]["content"][1]["image_url"]["detail"] == "low"
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_with_tools(
|
||||
mock_setup, mock_litellm_completion, mock_client, mock_response
|
||||
):
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_completion.return_value = mock_response
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather information",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
_response = completion(
|
||||
model="openai/gpt-4",
|
||||
messages=[{"role": "user", "content": "What's the weather?"}],
|
||||
tools=tools,
|
||||
posthog_distinct_id="test-user",
|
||||
)
|
||||
|
||||
call_kwargs = mock_litellm_completion.call_args[1]
|
||||
assert call_kwargs["tools"] == tools
|
||||
|
||||
call_args = mock_client.capture.call_args
|
||||
assert call_args[1]["properties"]["$ai_tools"] == tools
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_privacy_mode(
|
||||
mock_setup, mock_litellm_completion, mock_client, mock_response
|
||||
):
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_completion.return_value = mock_response
|
||||
|
||||
completion(
|
||||
model="openai/gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Sensitive information"}],
|
||||
posthog_distinct_id="test-user",
|
||||
posthog_privacy_mode=True,
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args
|
||||
assert call_args[1]["properties"]["$ai_input"] is None
|
||||
assert call_args[1]["properties"]["$ai_output_choices"] is None
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_with_base64_image_sanitization(
|
||||
mock_setup, mock_litellm_completion, mock_client, mock_response
|
||||
):
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_completion.return_value = mock_response
|
||||
|
||||
base64_image_url = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD"
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What is in this image?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": base64_image_url, "detail": "high"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
response = completion(
|
||||
model="openai/gpt-4-vision-preview",
|
||||
messages=messages,
|
||||
posthog_distinct_id="test-user",
|
||||
)
|
||||
|
||||
assert response == mock_response
|
||||
|
||||
call_args = mock_client.capture.call_args
|
||||
sanitized_input = call_args[1]["properties"]["$ai_input"]
|
||||
|
||||
assert sanitized_input[0]["content"][0]["text"] == "What is in this image?"
|
||||
assert (
|
||||
sanitized_input[0]["content"][1]["image_url"]["url"]
|
||||
== "[base64 image redacted]"
|
||||
)
|
||||
assert sanitized_input[0]["content"][1]["image_url"]["detail"] == "high"
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_streaming(mock_setup, mock_litellm_completion, mock_client):
|
||||
mock_setup.return_value = mock_client
|
||||
|
||||
mock_chunk = MagicMock()
|
||||
mock_chunk.usage = MagicMock()
|
||||
mock_chunk.usage.prompt_tokens = 10
|
||||
mock_chunk.usage.completion_tokens = 5
|
||||
mock_chunk.usage.total_tokens = 15
|
||||
|
||||
mock_chunk.choices = [MagicMock()]
|
||||
mock_chunk.choices[0].delta = MagicMock()
|
||||
mock_chunk.choices[0].delta.content = "Hello"
|
||||
|
||||
mock_litellm_completion.return_value = [mock_chunk]
|
||||
|
||||
generator = completion(
|
||||
model="openai/gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Stream test"}],
|
||||
stream=True,
|
||||
posthog_distinct_id="test-user",
|
||||
)
|
||||
|
||||
list(generator)
|
||||
|
||||
mock_client.capture.assert_called_once()
|
||||
call_args = mock_client.capture.call_args
|
||||
assert call_args[1]["properties"]["$ai_provider"] == "litellm"
|
||||
assert call_args[1]["properties"]["$ai_input_tokens"] == 10
|
||||
assert call_args[1]["properties"]["$ai_output_tokens"] == 5
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_streaming_with_base64_image_sanitization(
|
||||
mock_setup, mock_litellm_completion, mock_client
|
||||
):
|
||||
mock_setup.return_value = mock_client
|
||||
|
||||
mock_chunk = MagicMock()
|
||||
mock_chunk.usage = MagicMock()
|
||||
mock_chunk.usage.prompt_tokens = 15
|
||||
mock_chunk.usage.completion_tokens = 8
|
||||
mock_chunk.usage.total_tokens = 23
|
||||
|
||||
mock_chunk.choices = [MagicMock()]
|
||||
mock_chunk.choices[0].delta = MagicMock()
|
||||
mock_chunk.choices[0].delta.content = "This is an image"
|
||||
|
||||
mock_litellm_completion.return_value = [mock_chunk]
|
||||
|
||||
base64_image_url = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD"
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Describe this image"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": base64_image_url, "detail": "auto"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
generator = completion(
|
||||
model="openai/gpt-4-vision-preview",
|
||||
messages=messages,
|
||||
stream=True,
|
||||
posthog_distinct_id="test-user-streaming",
|
||||
)
|
||||
|
||||
list(generator)
|
||||
|
||||
mock_client.capture.assert_called_once()
|
||||
call_args = mock_client.capture.call_args
|
||||
assert call_args[1]["properties"]["$ai_provider"] == "litellm"
|
||||
|
||||
sanitized_input = call_args[1]["properties"]["$ai_input"]
|
||||
assert sanitized_input[0]["content"][0]["text"] == "Describe this image"
|
||||
assert (
|
||||
sanitized_input[0]["content"][1]["image_url"]["url"]
|
||||
== "[base64 image redacted]"
|
||||
)
|
||||
assert sanitized_input[0]["content"][1]["image_url"]["detail"] == "auto"
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_error_handling(mock_setup, mock_litellm_completion, mock_client):
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_completion.side_effect = Exception("API Error")
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
completion(
|
||||
model="openai/gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "This will fail"}],
|
||||
posthog_distinct_id="test-user",
|
||||
)
|
||||
|
||||
assert str(exc_info.value) == "API Error"
|
||||
|
||||
mock_client.capture.assert_called_once()
|
||||
call_args = mock_client.capture.call_args
|
||||
assert call_args[1]["properties"]["$ai_is_error"] is True
|
||||
assert call_args[1]["properties"]["$ai_error"] == "API Error"
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_custom_properties(
|
||||
mock_setup, mock_litellm_completion, mock_client, mock_response
|
||||
):
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_completion.return_value = mock_response
|
||||
|
||||
custom_props = {"custom_key": "custom_value", "environment": "test"}
|
||||
|
||||
completion(
|
||||
model="openai/gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
posthog_distinct_id="test-user",
|
||||
posthog_properties=custom_props,
|
||||
)
|
||||
|
||||
call_args = mock_client.capture.call_args
|
||||
properties = call_args[1]["properties"]
|
||||
assert properties["custom_key"] == "custom_value"
|
||||
assert properties["environment"] == "test"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_embedding_response():
|
||||
response = MagicMock()
|
||||
response.data = [
|
||||
MagicMock(
|
||||
embedding=[0.1, 0.2, 0.3],
|
||||
index=0,
|
||||
object="embedding",
|
||||
)
|
||||
]
|
||||
response.model = "text-embedding-3-small"
|
||||
response.object = "list"
|
||||
response.usage = MagicMock()
|
||||
response.usage.prompt_tokens = 10
|
||||
response.usage.total_tokens = 10
|
||||
response.usage.prompt_tokens_details = MagicMock()
|
||||
response.usage.prompt_tokens_details.cached_tokens = 0
|
||||
response.usage.output_tokens_details = MagicMock()
|
||||
response.usage.output_tokens_details.reasoning_tokens = 0
|
||||
return response
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.embedding")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_embedding_basic(
|
||||
mock_setup, mock_litellm_embedding, mock_client, mock_embedding_response
|
||||
):
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_embedding.return_value = mock_embedding_response
|
||||
|
||||
response = embedding(
|
||||
model="openai/text-embedding-3-small",
|
||||
input="Hello world",
|
||||
posthog_distinct_id="test-user",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
assert response == mock_embedding_response
|
||||
|
||||
mock_litellm_embedding.assert_called_once()
|
||||
call_kwargs = mock_litellm_embedding.call_args[1]
|
||||
assert call_kwargs["model"] == "openai/text-embedding-3-small"
|
||||
assert call_kwargs["input"] == "Hello world"
|
||||
|
||||
mock_client.capture.assert_called_once()
|
||||
call_args = mock_client.capture.call_args
|
||||
assert call_args[1]["event"] == "$ai_embedding"
|
||||
assert call_args[1]["properties"]["$ai_provider"] == "litellm"
|
||||
assert call_args[1]["properties"]["$ai_model"] == "text-embedding-3-small"
|
||||
assert call_args[1]["properties"]["$ai_input"] == "Hello world"
|
||||
assert call_args[1]["properties"]["$ai_input_tokens"] == 10
|
||||
assert call_args[1]["properties"]["$ai_http_status"] == 200
|
||||
assert call_args[1]["properties"]["foo"] == "bar"
|
||||
assert isinstance(call_args[1]["properties"]["$ai_latency"], float)
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_groups(
|
||||
mock_setup, mock_litellm_completion, mock_client, mock_response
|
||||
):
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_completion.return_value = mock_response
|
||||
|
||||
response = completion(
|
||||
model="openai/gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_groups={"company": "test_company", "team": "engineering"},
|
||||
)
|
||||
|
||||
assert response == mock_response
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
assert call_args["groups"] == {"company": "test_company", "team": "engineering"}
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_cached_tokens(
|
||||
mock_setup, mock_litellm_completion, mock_client, mock_response_with_cached_tokens
|
||||
):
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_completion.return_value = mock_response_with_cached_tokens
|
||||
|
||||
response = completion(
|
||||
model="openai/gpt-4",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
assert response == mock_response_with_cached_tokens
|
||||
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"] == "litellm"
|
||||
assert props["$ai_model"] == "gpt-4"
|
||||
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
|
||||
assert props["$ai_output_choices"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "Hello with cached tokens!"}],
|
||||
}
|
||||
]
|
||||
assert props["$ai_input_tokens"] == 20
|
||||
assert props["$ai_output_tokens"] == 15
|
||||
assert props["$ai_cache_read_input_tokens"] == 15
|
||||
assert props["$ai_reasoning_tokens"] == 5
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_tool_calls_only_no_content(
|
||||
mock_setup, mock_litellm_completion, mock_client, mock_response_tool_calls_only
|
||||
):
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_completion.return_value = mock_response_tool_calls_only
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
response = completion(
|
||||
model="openai/gpt-4",
|
||||
messages=[{"role": "user", "content": "Get weather for New York"}],
|
||||
tools=tools,
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
assert response == mock_response_tool_calls_only
|
||||
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"] == "litellm"
|
||||
assert props["$ai_model"] == "gpt-4"
|
||||
assert props["$ai_output_choices"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "function",
|
||||
"id": "call_def456",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"location": "New York"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
assert "$ai_tools" in props
|
||||
defined_tools = props["$ai_tools"]
|
||||
assert len(defined_tools) == 1
|
||||
|
||||
defined_tool = defined_tools[0]
|
||||
assert defined_tool["type"] == "function"
|
||||
assert defined_tool["function"]["name"] == "get_weather"
|
||||
assert defined_tool["function"]["description"] == "Get weather"
|
||||
assert defined_tool["function"]["parameters"] == {}
|
||||
|
||||
assert props["$ai_input_tokens"] == 25
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["$ai_http_status"] == 200
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_streaming_with_tool_calls(
|
||||
mock_setup, mock_litellm_completion, mock_client
|
||||
):
|
||||
mock_setup.return_value = mock_client
|
||||
|
||||
tool_call_chunks = [
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
]
|
||||
|
||||
tool_call_chunks[0].choices = [MagicMock()]
|
||||
tool_call_chunks[0].choices[0].delta = MagicMock()
|
||||
tool_call_chunks[0].choices[0].delta.tool_calls = [MagicMock()]
|
||||
tool_call_chunks[0].choices[0].delta.tool_calls[0].index = 0
|
||||
tool_call_chunks[0].choices[0].delta.tool_calls[0].id = "call_abc123"
|
||||
tool_call_chunks[0].choices[0].delta.tool_calls[0].type = "function"
|
||||
tool_call_chunks[0].choices[0].delta.tool_calls[0].function = MagicMock()
|
||||
tool_call_chunks[0].choices[0].delta.tool_calls[0].function.name = "get_weather"
|
||||
tool_call_chunks[0].choices[0].delta.tool_calls[
|
||||
0
|
||||
].function.arguments = '{"location": "'
|
||||
tool_call_chunks[0].choices[0].delta.content = None
|
||||
tool_call_chunks[0].choices[0].delta.finish_reason = None
|
||||
|
||||
tool_call_chunks[1].choices = [MagicMock()]
|
||||
tool_call_chunks[1].choices[0].delta = MagicMock()
|
||||
tool_call_chunks[1].choices[0].delta.tool_calls = [MagicMock()]
|
||||
tool_call_chunks[1].choices[0].delta.tool_calls[0].index = 0
|
||||
tool_call_chunks[1].choices[0].delta.tool_calls[0].function = MagicMock()
|
||||
tool_call_chunks[1].choices[0].delta.tool_calls[
|
||||
0
|
||||
].function.arguments = 'San Francisco"'
|
||||
tool_call_chunks[1].choices[0].delta.content = None
|
||||
tool_call_chunks[1].choices[0].delta.finish_reason = None
|
||||
|
||||
tool_call_chunks[2].choices = [MagicMock()]
|
||||
tool_call_chunks[2].choices[0].delta = MagicMock()
|
||||
tool_call_chunks[2].choices[0].delta.tool_calls = [MagicMock()]
|
||||
tool_call_chunks[2].choices[0].delta.tool_calls[0].index = 0
|
||||
tool_call_chunks[2].choices[0].delta.tool_calls[0].function = MagicMock()
|
||||
tool_call_chunks[2].choices[0].delta.tool_calls[
|
||||
0
|
||||
].function.arguments = ', "unit": "celsius"}'
|
||||
tool_call_chunks[2].choices[0].delta.content = None
|
||||
tool_call_chunks[2].choices[0].delta.finish_reason = None
|
||||
|
||||
tool_call_chunks[3].choices = [MagicMock()]
|
||||
tool_call_chunks[3].choices[0].delta = MagicMock()
|
||||
tool_call_chunks[3].choices[
|
||||
0
|
||||
].delta.content = "The weather in San Francisco is 15°C."
|
||||
tool_call_chunks[3].choices[0].delta.tool_calls = None
|
||||
tool_call_chunks[3].usage = MagicMock()
|
||||
tool_call_chunks[3].usage.prompt_tokens = 20
|
||||
tool_call_chunks[3].usage.completion_tokens = 15
|
||||
tool_call_chunks[3].usage.total_tokens = 35
|
||||
tool_call_chunks[3].usage.prompt_tokens_details = MagicMock()
|
||||
tool_call_chunks[3].usage.prompt_tokens_details.cached_tokens = 0
|
||||
tool_call_chunks[3].usage.output_tokens_details = MagicMock()
|
||||
tool_call_chunks[3].usage.output_tokens_details.reasoning_tokens = 0
|
||||
|
||||
mock_litellm_completion.return_value = tool_call_chunks
|
||||
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
generator = completion(
|
||||
model="openai/gpt-4",
|
||||
messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
|
||||
tools=tools,
|
||||
stream=True,
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
chunks = list(generator)
|
||||
|
||||
assert len(chunks) == 4
|
||||
assert chunks == tool_call_chunks
|
||||
|
||||
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"] == "litellm"
|
||||
assert props["$ai_model"] == "gpt-4"
|
||||
|
||||
assert "$ai_tools" in props
|
||||
defined_tools = props["$ai_tools"]
|
||||
assert len(defined_tools) == 1
|
||||
|
||||
defined_tool = defined_tools[0]
|
||||
assert defined_tool["type"] == "function"
|
||||
assert defined_tool["function"]["name"] == "get_weather"
|
||||
assert defined_tool["function"]["description"] == "Get weather"
|
||||
assert defined_tool["function"]["parameters"] == {}
|
||||
|
||||
assert (
|
||||
props["$ai_output_choices"][0]["content"]
|
||||
== "The weather in San Francisco is 15°C."
|
||||
)
|
||||
|
||||
assert props["$ai_input_tokens"] == 20
|
||||
assert props["$ai_output_tokens"] == 15
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_privacy_mode_global(
|
||||
mock_setup, mock_litellm_completion, mock_client, mock_response
|
||||
):
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_completion.return_value = mock_response
|
||||
|
||||
mock_client.privacy_mode = True
|
||||
|
||||
response = completion(
|
||||
model="openai/gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Sensitive information"}],
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_privacy_mode=False,
|
||||
)
|
||||
|
||||
assert response == mock_response
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] is None
|
||||
assert props["$ai_output_choices"] is None
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.acompletion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_streaming(mock_setup, mock_litellm_acompletion, mock_client):
|
||||
mock_setup.return_value = mock_client
|
||||
|
||||
mock_chunk1 = MagicMock()
|
||||
mock_chunk1.usage = MagicMock()
|
||||
mock_chunk1.usage.prompt_tokens = 10
|
||||
mock_chunk1.usage.completion_tokens = 5
|
||||
mock_chunk1.usage.total_tokens = 15
|
||||
|
||||
mock_chunk1.choices = [MagicMock()]
|
||||
mock_chunk1.choices[0].delta = MagicMock()
|
||||
mock_chunk1.choices[0].delta.content = "Hello"
|
||||
|
||||
mock_chunk2 = MagicMock()
|
||||
mock_chunk2.usage = None
|
||||
mock_chunk2.choices = [MagicMock()]
|
||||
mock_chunk2.choices[0].delta = MagicMock()
|
||||
mock_chunk2.choices[0].delta.content = " world!"
|
||||
|
||||
async def async_generator():
|
||||
yield mock_chunk1
|
||||
yield mock_chunk2
|
||||
|
||||
mock_litellm_acompletion.return_value = async_generator()
|
||||
|
||||
generator = await acompletion(
|
||||
model="openai/gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Stream test async"}],
|
||||
stream=True,
|
||||
posthog_distinct_id="test-user-async",
|
||||
)
|
||||
|
||||
chunks = []
|
||||
async for chunk in generator:
|
||||
chunks.append(chunk)
|
||||
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0] == mock_chunk1
|
||||
assert chunks[1] == mock_chunk2
|
||||
|
||||
mock_client.capture.assert_called_once()
|
||||
call_args = mock_client.capture.call_args
|
||||
assert call_args[1]["properties"]["$ai_provider"] == "litellm"
|
||||
assert call_args[1]["properties"]["$ai_input_tokens"] == 10
|
||||
assert call_args[1]["properties"]["$ai_output_tokens"] == 5
|
||||
assert (
|
||||
call_args[1]["properties"]["$ai_output_choices"][0]["content"] == "Hello world!"
|
||||
)
|
||||
|
||||
|
||||
# Tests for provider stripping functionality
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_strips_provider_prefix(
|
||||
mock_setup, mock_litellm_completion, mock_client, mock_response
|
||||
):
|
||||
"""Test that model with provider prefix gets stripped for tracking."""
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_completion.return_value = mock_response
|
||||
|
||||
completion(
|
||||
model="openai/gpt-4",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
posthog_distinct_id="test-user",
|
||||
)
|
||||
|
||||
# Verify tracking uses stripped model name
|
||||
call_args = mock_client.capture.call_args
|
||||
assert call_args[1]["properties"]["$ai_model"] == "gpt-4"
|
||||
|
||||
|
||||
@patch("posthog.ai.litellm.litellm.litellm.completion")
|
||||
@patch("posthog.ai.litellm.litellm.setup")
|
||||
def test_completion_model_without_provider_unchanged(
|
||||
mock_setup, mock_litellm_completion, mock_client, mock_response
|
||||
):
|
||||
"""Test that model without provider prefix remains unchanged."""
|
||||
mock_setup.return_value = mock_client
|
||||
mock_litellm_completion.return_value = mock_response
|
||||
|
||||
completion(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
posthog_distinct_id="test-user",
|
||||
)
|
||||
|
||||
# Verify tracking uses the same model name
|
||||
call_args = mock_client.capture.call_args
|
||||
assert call_args[1]["properties"]["$ai_model"] == "gpt-4"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
import unittest
|
||||
|
||||
from posthog.ai.sanitization import (
|
||||
redact_base64_data_url,
|
||||
sanitize_openai,
|
||||
sanitize_openai_response,
|
||||
sanitize_anthropic,
|
||||
sanitize_gemini,
|
||||
sanitize_langchain,
|
||||
is_base64_data_url,
|
||||
is_raw_base64,
|
||||
REDACTED_IMAGE_PLACEHOLDER,
|
||||
)
|
||||
|
||||
|
||||
class TestSanitization(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.sample_base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
self.sample_base64_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA..."
|
||||
self.regular_url = "https://example.com/image.jpg"
|
||||
self.raw_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUl=="
|
||||
|
||||
def test_is_base64_data_url(self):
|
||||
self.assertTrue(is_base64_data_url(self.sample_base64_image))
|
||||
self.assertTrue(is_base64_data_url(self.sample_base64_png))
|
||||
self.assertFalse(is_base64_data_url(self.regular_url))
|
||||
self.assertFalse(is_base64_data_url("regular text"))
|
||||
|
||||
def test_is_raw_base64(self):
|
||||
self.assertTrue(is_raw_base64(self.raw_base64))
|
||||
self.assertFalse(is_raw_base64("short"))
|
||||
self.assertFalse(is_raw_base64(self.regular_url))
|
||||
self.assertFalse(is_raw_base64("/path/to/file"))
|
||||
|
||||
def test_redact_base64_data_url(self):
|
||||
self.assertEqual(
|
||||
redact_base64_data_url(self.sample_base64_image), REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(
|
||||
redact_base64_data_url(self.sample_base64_png), REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(redact_base64_data_url(self.regular_url), self.regular_url)
|
||||
self.assertEqual(redact_base64_data_url(None), None)
|
||||
self.assertEqual(redact_base64_data_url(123), 123)
|
||||
|
||||
def test_sanitize_openai(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What is in this image?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": self.sample_base64_image,
|
||||
"detail": "high",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
|
||||
self.assertEqual(result[0]["content"][0]["text"], "What is in this image?")
|
||||
self.assertEqual(
|
||||
result[0]["content"][1]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(result[0]["content"][1]["image_url"]["detail"], "high")
|
||||
|
||||
def test_sanitize_openai_preserves_regular_urls(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": self.regular_url},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["image_url"]["url"], self.regular_url)
|
||||
|
||||
def test_sanitize_openai_response(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": self.sample_base64_image,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai_response(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["image_url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_sanitize_anthropic(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What is in this image?"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": "base64data",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_anthropic(input_data)
|
||||
|
||||
self.assertEqual(result[0]["content"][0]["text"], "What is in this image?")
|
||||
self.assertEqual(
|
||||
result[0]["content"][1]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(result[0]["content"][1]["source"]["type"], "base64")
|
||||
self.assertEqual(result[0]["content"][1]["source"]["media_type"], "image/jpeg")
|
||||
|
||||
def test_sanitize_gemini(self):
|
||||
input_data = [
|
||||
{
|
||||
"parts": [
|
||||
{"text": "What is in this image?"},
|
||||
{
|
||||
"inline_data": {
|
||||
"mime_type": "image/jpeg",
|
||||
"data": "base64data",
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_gemini(input_data)
|
||||
|
||||
self.assertEqual(result[0]["parts"][0]["text"], "What is in this image?")
|
||||
self.assertEqual(
|
||||
result[0]["parts"][1]["inline_data"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(
|
||||
result[0]["parts"][1]["inline_data"]["mime_type"], "image/jpeg"
|
||||
)
|
||||
|
||||
def test_sanitize_langchain_openai_style(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": self.sample_base64_image},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_langchain(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_sanitize_langchain_anthropic_style(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"data": "base64data"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_langchain(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_sanitize_with_data_url_format(self):
|
||||
# Test that data URLs are properly detected and redacted across providers
|
||||
data_url = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD"
|
||||
|
||||
# OpenAI format
|
||||
openai_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "image_url", "image_url": {"url": data_url}}],
|
||||
}
|
||||
]
|
||||
result = sanitize_openai(openai_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
# Anthropic format
|
||||
anthropic_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": data_url,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
result = sanitize_anthropic(anthropic_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
# LangChain format
|
||||
langchain_data = [
|
||||
{"role": "user", "content": [{"type": "image", "data": data_url}]}
|
||||
]
|
||||
result = sanitize_langchain(langchain_data)
|
||||
self.assertEqual(result[0]["content"][0]["data"], REDACTED_IMAGE_PLACEHOLDER)
|
||||
|
||||
def test_sanitize_with_raw_base64(self):
|
||||
# Test that raw base64 strings (without data URL prefix) are detected
|
||||
raw_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUl=="
|
||||
|
||||
# Test with Anthropic format
|
||||
anthropic_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": raw_base64,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
result = sanitize_anthropic(anthropic_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
# Test with Gemini format
|
||||
gemini_data = [
|
||||
{"parts": [{"inline_data": {"mime_type": "image/png", "data": raw_base64}}]}
|
||||
]
|
||||
result = sanitize_gemini(gemini_data)
|
||||
self.assertEqual(
|
||||
result[0]["parts"][0]["inline_data"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_sanitize_preserves_regular_content(self):
|
||||
# Ensure non-base64 content is preserved across all providers
|
||||
regular_url = "https://example.com/image.jpg"
|
||||
text_content = "What do you see?"
|
||||
|
||||
# OpenAI
|
||||
openai_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": text_content},
|
||||
{"type": "image_url", "image_url": {"url": regular_url}},
|
||||
],
|
||||
}
|
||||
]
|
||||
result = sanitize_openai(openai_data)
|
||||
self.assertEqual(result[0]["content"][0]["text"], text_content)
|
||||
self.assertEqual(result[0]["content"][1]["image_url"]["url"], regular_url)
|
||||
|
||||
# Anthropic
|
||||
anthropic_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": text_content},
|
||||
{"type": "image", "source": {"type": "url", "url": regular_url}},
|
||||
],
|
||||
}
|
||||
]
|
||||
result = sanitize_anthropic(anthropic_data)
|
||||
self.assertEqual(result[0]["content"][0]["text"], text_content)
|
||||
# URL-based images should remain unchanged
|
||||
self.assertEqual(result[0]["content"][1]["source"]["url"], regular_url)
|
||||
|
||||
def test_sanitize_handles_non_dict_content(self):
|
||||
input_data = [{"role": "user", "content": "Just text"}]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(result, input_data)
|
||||
|
||||
def test_sanitize_handles_none_input(self):
|
||||
self.assertIsNone(sanitize_openai(None))
|
||||
self.assertIsNone(sanitize_anthropic(None))
|
||||
self.assertIsNone(sanitize_gemini(None))
|
||||
self.assertIsNone(sanitize_langchain(None))
|
||||
|
||||
def test_sanitize_handles_single_message(self):
|
||||
input_data = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": self.sample_base64_image},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(
|
||||
result["content"][0]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,315 +0,0 @@
|
||||
from datetime import date, datetime
|
||||
import unittest
|
||||
import six
|
||||
import mock
|
||||
import time
|
||||
from freezegun import freeze_time
|
||||
|
||||
from posthog.version import VERSION
|
||||
from posthog.client import Client
|
||||
from posthog.test.utils import TEST_API_KEY
|
||||
|
||||
from posthog.request import APIError
|
||||
|
||||
class TestClient(unittest.TestCase):
|
||||
|
||||
def set_fail(self, e, batch):
|
||||
"""Mark the failure handler"""
|
||||
print('FAIL', e, batch)
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
self.client = Client(TEST_API_KEY, on_error=self.set_fail)
|
||||
|
||||
def test_requires_api_key(self):
|
||||
self.assertRaises(AssertionError, Client)
|
||||
|
||||
def test_empty_flush(self):
|
||||
self.client.flush()
|
||||
|
||||
def test_basic_capture(self):
|
||||
client = self.client
|
||||
success, msg = client.capture('distinct_id', 'python test event')
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg['event'], 'python test event')
|
||||
self.assertTrue(isinstance(msg['timestamp'], str))
|
||||
self.assertTrue(isinstance(msg['messageId'], str))
|
||||
self.assertEqual(msg['distinct_id'], 'distinct_id')
|
||||
self.assertEqual(msg['properties']['$lib'], 'posthog-python')
|
||||
self.assertEqual(msg['properties']['$lib_version'], VERSION)
|
||||
|
||||
def test_stringifies_distinct_id(self):
|
||||
# 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')
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg['distinct_id'], '157963456373623802')
|
||||
|
||||
def test_advanced_capture(self):
|
||||
client = self.client
|
||||
success, msg = client.capture(
|
||||
'distinct_id', 'python test event', {'property': 'value'},
|
||||
{'ip': '192.168.0.1'}, datetime(2014, 9, 3),
|
||||
'messageId')
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00')
|
||||
self.assertEqual(msg['properties']['property'], 'value')
|
||||
self.assertEqual(msg['context']['ip'], '192.168.0.1')
|
||||
self.assertEqual(msg['event'], 'python test event')
|
||||
self.assertEqual(msg['properties']['$lib'], 'posthog-python')
|
||||
self.assertEqual(msg['properties']['$lib_version'], VERSION)
|
||||
self.assertEqual(msg['messageId'], 'messageId')
|
||||
self.assertEqual(msg['distinct_id'], 'distinct_id')
|
||||
|
||||
def test_basic_identify(self):
|
||||
client = self.client
|
||||
success, msg = client.identify('distinct_id', {'trait': 'value'})
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
self.assertEqual(msg['$set']['trait'], 'value')
|
||||
self.assertTrue(isinstance(msg['timestamp'], str))
|
||||
self.assertTrue(isinstance(msg['messageId'], str))
|
||||
self.assertEqual(msg['distinct_id'], 'distinct_id')
|
||||
|
||||
def test_advanced_identify(self):
|
||||
client = self.client
|
||||
success, msg = client.identify(
|
||||
'distinct_id', {'trait': 'value'}, {'ip': '192.168.0.1'},
|
||||
datetime(2014, 9, 3), 'messageId')
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00')
|
||||
self.assertEqual(msg['context']['ip'], '192.168.0.1')
|
||||
self.assertEqual(msg['$set']['trait'], 'value')
|
||||
self.assertEqual(msg['properties']['$lib'], 'posthog-python')
|
||||
self.assertEqual(msg['properties']['$lib_version'], VERSION)
|
||||
self.assertTrue(isinstance(msg['timestamp'], str))
|
||||
self.assertEqual(msg['messageId'], 'messageId')
|
||||
self.assertEqual(msg['distinct_id'], 'distinct_id')
|
||||
|
||||
def test_basic_alias(self):
|
||||
client = self.client
|
||||
success, msg = client.alias('previousId', 'distinct_id')
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertFalse(self.failed)
|
||||
self.assertEqual(msg['properties']['distinct_id'], 'previousId')
|
||||
self.assertEqual(msg['properties']['alias'], 'distinct_id')
|
||||
|
||||
def test_basic_page(self):
|
||||
client = self.client
|
||||
success, msg = client.page('distinct_id', url='https://posthog.com/contact')
|
||||
self.assertFalse(self.failed)
|
||||
client.flush()
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(msg['distinct_id'], 'distinct_id')
|
||||
self.assertEqual(msg['properties']['$current_url'], 'https://posthog.com/contact')
|
||||
|
||||
def test_advanced_page(self):
|
||||
client = self.client
|
||||
success, msg = client.page(
|
||||
'distinct_id', 'https://posthog.com/contact', {'property': 'value'},
|
||||
{'ip': '192.168.0.1'}, datetime(2014, 9, 3), 'messageId')
|
||||
|
||||
self.assertTrue(success)
|
||||
|
||||
self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00')
|
||||
self.assertEqual(msg['context']['ip'], '192.168.0.1')
|
||||
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)
|
||||
self.assertTrue(isinstance(msg['timestamp'], str))
|
||||
self.assertEqual(msg['messageId'], 'messageId')
|
||||
self.assertEqual(msg['distinct_id'], 'distinct_id')
|
||||
|
||||
def test_flush(self):
|
||||
client = self.client
|
||||
# set up the consumer with more requests than a single batch will allow
|
||||
for i in range(1000):
|
||||
success, msg = client.identify('distinct_id', {'trait': 'value'})
|
||||
# We can't reliably assert that the queue is non-empty here; that's
|
||||
# a race condition. We do our best to load it up though.
|
||||
client.flush()
|
||||
# Make sure that the client queue is empty after flushing
|
||||
self.assertTrue(client.queue.empty())
|
||||
|
||||
def test_shutdown(self):
|
||||
client = self.client
|
||||
# set up the consumer with more requests than a single batch will allow
|
||||
for i in range(1000):
|
||||
success, msg = client.identify('distinct_id', {'trait': 'value'})
|
||||
client.shutdown()
|
||||
# we expect two things after shutdown:
|
||||
# 1. client queue is empty
|
||||
# 2. consumer thread has stopped
|
||||
self.assertTrue(client.queue.empty())
|
||||
for consumer in client.consumers:
|
||||
self.assertFalse(consumer.is_alive())
|
||||
|
||||
def test_synchronous(self):
|
||||
client = Client(TEST_API_KEY, sync_mode=True)
|
||||
|
||||
success, message = client.identify('distinct_id')
|
||||
self.assertFalse(client.consumers)
|
||||
self.assertTrue(client.queue.empty())
|
||||
self.assertTrue(success)
|
||||
|
||||
def test_overflow(self):
|
||||
client = Client(TEST_API_KEY, max_queue_size=1)
|
||||
# Ensure consumer thread is no longer uploading
|
||||
client.join()
|
||||
|
||||
for i in range(10):
|
||||
client.identify('distinct_id')
|
||||
|
||||
success, msg = client.identify('distinct_id')
|
||||
# Make sure we are informed that the queue is at capacity
|
||||
self.assertFalse(success)
|
||||
|
||||
def test_unicode(self):
|
||||
Client(six.u('unicode_key'))
|
||||
|
||||
def test_numeric_distinct_id(self):
|
||||
self.client.capture(1234, 'python event')
|
||||
self.client.flush()
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
def test_debug(self):
|
||||
Client('bad_key', debug=True)
|
||||
|
||||
def test_gzip(self):
|
||||
client = Client(TEST_API_KEY, on_error=self.fail, gzip=True)
|
||||
for _ in range(10):
|
||||
client.identify('distinct_id', {'trait': 'value'})
|
||||
client.flush()
|
||||
self.assertFalse(self.failed)
|
||||
|
||||
def test_user_defined_flush_at(self):
|
||||
client = Client(TEST_API_KEY, on_error=self.fail,
|
||||
flush_at=10, flush_interval=3)
|
||||
|
||||
def mock_post_fn(*args, **kwargs):
|
||||
self.assertEquals(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.post', side_effect=mock_post_fn) \
|
||||
as mock_post:
|
||||
for _ in range(20):
|
||||
client.identify('distinct_id', {'trait': 'value'})
|
||||
time.sleep(1)
|
||||
self.assertEquals(mock_post.call_count, 2)
|
||||
|
||||
def test_user_defined_timeout(self):
|
||||
client = Client(TEST_API_KEY, timeout=10)
|
||||
for consumer in client.consumers:
|
||||
self.assertEquals(consumer.timeout, 10)
|
||||
|
||||
def test_default_timeout_15(self):
|
||||
client = Client(TEST_API_KEY)
|
||||
for consumer in client.consumers:
|
||||
self.assertEquals(consumer.timeout, 15)
|
||||
|
||||
@mock.patch('posthog.client.Poller')
|
||||
@mock.patch('posthog.client.get')
|
||||
def test_load_feature_flags(self, patch_get, patch_poll):
|
||||
patch_get.return_value = {
|
||||
'results': [{
|
||||
'id': 1,
|
||||
'name': 'Beta Feature',
|
||||
'key': 'beta-feature'
|
||||
}]
|
||||
}
|
||||
client = Client(TEST_API_KEY, personal_api_key='test')
|
||||
with freeze_time('2020-01-01T12:01:00.0000Z'):
|
||||
client.load_feature_flags()
|
||||
self.assertEqual(client.feature_flags[0]['key'], 'beta-feature')
|
||||
self.assertEqual(client._last_feature_flag_poll.isoformat(), "2020-01-01T12:01:00+00:00")
|
||||
self.assertEqual(patch_poll.call_count, 1)
|
||||
|
||||
def test_load_feature_flags_wrong_key(self):
|
||||
client = Client(TEST_API_KEY, personal_api_key=TEST_API_KEY)
|
||||
with freeze_time('2020-01-01T12:01:00.0000Z'):
|
||||
self.assertRaises(APIError, client.load_feature_flags)
|
||||
|
||||
@mock.patch('posthog.client.get')
|
||||
def test_feature_enabled_simple(self, patch_get):
|
||||
client = Client(TEST_API_KEY)
|
||||
client.feature_flags = [{
|
||||
'id': 1,
|
||||
'name': 'Beta Feature',
|
||||
'key': 'beta-feature',
|
||||
'is_simple_flag': True,
|
||||
'rollout_percentage': 100
|
||||
}]
|
||||
self.assertTrue(client.feature_enabled('beta-feature', 'distinct_id'))
|
||||
|
||||
@mock.patch('posthog.client.get')
|
||||
def test_feature_enabled_request(self, patch_get):
|
||||
patch_get.return_value = {
|
||||
'featureFlags': ['beta-feature']
|
||||
}
|
||||
client = Client(TEST_API_KEY)
|
||||
client.feature_flags = [{
|
||||
'id': 1,
|
||||
'name': 'Beta Feature',
|
||||
'key': 'beta-feature',
|
||||
'is_simple_flag': False,
|
||||
'rollout_percentage': 100
|
||||
}]
|
||||
self.assertTrue(client.feature_enabled('beta-feature', 'distinct_id'))
|
||||
|
||||
@mock.patch('posthog.client.Poller')
|
||||
@mock.patch('posthog.client.get')
|
||||
def test_feature_enabled_doesnt_exist(self, patch_get, patch_poll):
|
||||
client = Client(TEST_API_KEY, personal_api_key='test')
|
||||
client.feature_flags = []
|
||||
|
||||
self.assertFalse(client.feature_enabled('doesnt-exist', 'distinct_id'))
|
||||
self.assertTrue(client.feature_enabled('doesnt-exist', 'distinct_id', True))
|
||||
|
||||
@mock.patch('posthog.client.Poller')
|
||||
@mock.patch('posthog.client.get')
|
||||
def test_personal_api_key_doesnt_exist(self, patch_get, patch_poll):
|
||||
client = Client(TEST_API_KEY)
|
||||
client.feature_flags = []
|
||||
|
||||
self.assertFalse(client.feature_enabled('doesnt-exist', 'distinct_id'))
|
||||
self.assertTrue(client.feature_enabled('doesnt-exist', 'distinct_id', True))
|
||||
|
||||
@mock.patch('posthog.client.Poller')
|
||||
@mock.patch('posthog.client.get')
|
||||
def test_load_feature_flags_error(self, patch_get, patch_poll):
|
||||
def raise_effect():
|
||||
raise Exception('http exception')
|
||||
patch_get.return_value.raiseError.side_effect = raise_effect
|
||||
client = Client(TEST_API_KEY, personal_api_key='test')
|
||||
client.feature_flags = []
|
||||
|
||||
self.assertFalse(client.feature_enabled('doesnt-exist', 'distinct_id'))
|
||||
|
||||
@mock.patch('posthog.client.Poller')
|
||||
@mock.patch('posthog.client.get')
|
||||
def test_call_identify_fails(self, patch_get, patch_poll):
|
||||
def raise_effect():
|
||||
raise Exception('http exception')
|
||||
patch_get.return_value.raiseError.side_effect = raise_effect
|
||||
client = Client(TEST_API_KEY, personal_api_key='test')
|
||||
client.feature_flags = [{'key': 'example', 'is_simple_flag': False}]
|
||||
|
||||
self.assertFalse(client.feature_enabled('example', 'distinct_id'))
|
||||
@@ -0,0 +1,173 @@
|
||||
from posthog.contexts 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()
|
||||
@@ -1,41 +0,0 @@
|
||||
import unittest
|
||||
|
||||
import posthog
|
||||
|
||||
|
||||
class TestModule(unittest.TestCase):
|
||||
|
||||
def failed(self):
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
posthog.api_key = 'testsecret'
|
||||
posthog.on_error = self.failed
|
||||
|
||||
def test_no_api_key(self):
|
||||
posthog.api_key = None
|
||||
self.assertRaises(Exception, posthog.capture)
|
||||
|
||||
def test_no_host(self):
|
||||
posthog.host = None
|
||||
self.assertRaises(Exception, posthog.capture)
|
||||
|
||||
def test_track(self):
|
||||
posthog.capture('distinct_id', 'python module event')
|
||||
posthog.flush()
|
||||
|
||||
def test_identify(self):
|
||||
posthog.identify('distinct_id', {'email': 'user@email.com'})
|
||||
posthog.flush()
|
||||
|
||||
def test_alias(self):
|
||||
posthog.alias('previousId', 'distinct_id')
|
||||
posthog.flush()
|
||||
|
||||
def test_page(self):
|
||||
posthog.page('distinct_id', 'https://posthog.com/contact')
|
||||
posthog.flush()
|
||||
|
||||
def test_flush(self):
|
||||
posthog.flush()
|
||||
@@ -1,54 +0,0 @@
|
||||
from datetime import datetime, date
|
||||
import unittest
|
||||
import json
|
||||
import requests
|
||||
|
||||
from posthog.request import post, DatetimeSerializer
|
||||
from posthog.test.utils import TEST_API_KEY
|
||||
|
||||
|
||||
class TestRequests(unittest.TestCase):
|
||||
|
||||
def test_valid_request(self):
|
||||
res = 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, post, 'testsecret',
|
||||
'https://t.posthog.com', False, '[{]')
|
||||
|
||||
def test_invalid_host(self):
|
||||
self.assertRaises(Exception, post, 'testsecret',
|
||||
't.posthog.com/', batch=[])
|
||||
|
||||
def test_datetime_serialization(self):
|
||||
data = {'created': datetime(2012, 3, 4, 5, 6, 7, 891011)}
|
||||
result = json.dumps(data, cls=DatetimeSerializer)
|
||||
self.assertEqual(result, '{"created": "2012-03-04T05:06:07.891011"}')
|
||||
|
||||
def test_date_serialization(self):
|
||||
today = date.today()
|
||||
data = {'created': today}
|
||||
result = json.dumps(data, cls=DatetimeSerializer)
|
||||
expected = '{"created": "%s"}' % today.isoformat()
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_should_not_timeout(self):
|
||||
res = post(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):
|
||||
post('key', batch=[{
|
||||
'distinct_id': 'distinct_id',
|
||||
'event': 'python event',
|
||||
'type': 'track'
|
||||
}], timeout=0.0001)
|
||||
@@ -0,0 +1,218 @@
|
||||
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
|
||||
|
||||
with mock.patch("posthog.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
before_send=my_before_send,
|
||||
sync_mode=True,
|
||||
)
|
||||
msg_uuid = client.capture(
|
||||
"test_event", distinct_id="user1", properties={"original": "value"}
|
||||
)
|
||||
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
|
||||
# Get the enqueued message from the mock
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
enqueued_msg = batch_data[0]
|
||||
|
||||
self.assertEqual(
|
||||
enqueued_msg["properties"]["processed_by_before_send"], True
|
||||
)
|
||||
self.assertEqual(enqueued_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
|
||||
|
||||
with mock.patch("posthog.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
before_send=drop_test_events,
|
||||
sync_mode=True,
|
||||
)
|
||||
|
||||
# Event should be dropped
|
||||
msg_uuid = client.capture("test_drop_me", distinct_id="user1")
|
||||
self.assertIsNone(msg_uuid)
|
||||
|
||||
# Event should go through
|
||||
msg_uuid = client.capture("keep_me", distinct_id="user1")
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
|
||||
# Check the enqueued message
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
enqueued_msg = batch_data[0]
|
||||
self.assertEqual(enqueued_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!")
|
||||
|
||||
with mock.patch("posthog.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
before_send=buggy_before_send,
|
||||
sync_mode=True,
|
||||
)
|
||||
msg_uuid = client.capture("robust_event", distinct_id="user1")
|
||||
|
||||
# Event should still be sent despite the exception
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
|
||||
# Check the enqueued message
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
enqueued_msg = batch_data[0]
|
||||
self.assertEqual(enqueued_msg["event"], "robust_event")
|
||||
|
||||
def test_before_send_callback_works_with_all_event_types(self):
|
||||
"""Test that before_send works with capture, set, etc."""
|
||||
|
||||
def add_marker(event):
|
||||
if "properties" not in event:
|
||||
event["properties"] = {}
|
||||
event["properties"]["marked"] = True
|
||||
return event
|
||||
|
||||
with mock.patch("posthog.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
before_send=add_marker,
|
||||
sync_mode=True,
|
||||
)
|
||||
|
||||
# Test capture
|
||||
msg_uuid = client.capture("event", distinct_id="user1")
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
|
||||
# Test set
|
||||
msg_uuid = client.set(distinct_id="user1", properties={"prop": "value"})
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
|
||||
# Check all events were marked
|
||||
self.assertEqual(mock_post.call_count, 2)
|
||||
for call in mock_post.call_args_list:
|
||||
batch_data = call[1]["batch"]
|
||||
enqueued_msg = batch_data[0]
|
||||
self.assertTrue(enqueued_msg["properties"]["marked"])
|
||||
|
||||
def test_before_send_callback_disabled_when_none(self):
|
||||
"""Test that client works normally when before_send is None."""
|
||||
with mock.patch("posthog.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
before_send=None,
|
||||
sync_mode=True,
|
||||
)
|
||||
msg_uuid = client.capture("normal_event", distinct_id="user1")
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
|
||||
# Check the event was sent normally
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
enqueued_msg = batch_data[0]
|
||||
self.assertEqual(enqueued_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
|
||||
|
||||
with mock.patch("posthog.client.batch_post") as mock_post:
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
before_send=scrub_pii,
|
||||
sync_mode=True,
|
||||
)
|
||||
msg_uuid = client.capture(
|
||||
"form_submit",
|
||||
distinct_id="user1",
|
||||
properties={
|
||||
"email": "user@example.com",
|
||||
"credit_card": "1234-5678-9012-3456",
|
||||
"form_name": "contact",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNotNone(msg_uuid)
|
||||
|
||||
# Check the enqueued message was scrubbed
|
||||
mock_post.assert_called_once()
|
||||
batch_data = mock_post.call_args[1]["batch"]
|
||||
enqueued_msg = batch_data[0]
|
||||
|
||||
self.assertEqual(enqueued_msg["properties"]["email"], "***@example.com")
|
||||
self.assertNotIn("credit_card", enqueued_msg["properties"])
|
||||
self.assertEqual(enqueued_msg["properties"]["form_name"], "contact")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,23 @@
|
||||
import unittest
|
||||
import mock
|
||||
import time
|
||||
import json
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
try:
|
||||
from queue import Queue
|
||||
except ImportError:
|
||||
from Queue import Queue
|
||||
|
||||
from posthog.consumer import Consumer, MAX_MSG_SIZE
|
||||
from posthog.consumer import MAX_MSG_SIZE, Consumer
|
||||
from posthog.request import APIError
|
||||
from posthog.test.utils import TEST_API_KEY
|
||||
from posthog.test.test_utils import TEST_API_KEY
|
||||
|
||||
|
||||
class TestConsumer(unittest.TestCase):
|
||||
|
||||
def test_next(self):
|
||||
q = Queue()
|
||||
consumer = Consumer(q, '')
|
||||
consumer = Consumer(q, "")
|
||||
q.put(1)
|
||||
next = consumer.next()
|
||||
self.assertEqual(next, [1])
|
||||
@@ -25,7 +25,7 @@ class TestConsumer(unittest.TestCase):
|
||||
def test_next_limit(self):
|
||||
q = Queue()
|
||||
flush_at = 50
|
||||
consumer = Consumer(q, '', flush_at)
|
||||
consumer = Consumer(q, "", flush_at)
|
||||
for i in range(10000):
|
||||
q.put(i)
|
||||
next = consumer.next()
|
||||
@@ -33,8 +33,8 @@ class TestConsumer(unittest.TestCase):
|
||||
|
||||
def test_dropping_oversize_msg(self):
|
||||
q = Queue()
|
||||
consumer = Consumer(q, '')
|
||||
oversize_msg = {'m': 'x' * MAX_MSG_SIZE}
|
||||
consumer = Consumer(q, "")
|
||||
oversize_msg = {"m": "x" * MAX_MSG_SIZE}
|
||||
q.put(oversize_msg)
|
||||
next = consumer.next()
|
||||
self.assertEqual(next, [])
|
||||
@@ -43,11 +43,7 @@ class TestConsumer(unittest.TestCase):
|
||||
def test_upload(self):
|
||||
q = Queue()
|
||||
consumer = Consumer(q, TEST_API_KEY)
|
||||
track = {
|
||||
'type': 'track',
|
||||
'event': 'python event',
|
||||
'distinct_id': 'distinct_id'
|
||||
}
|
||||
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
|
||||
q.put(track)
|
||||
success = consumer.upload()
|
||||
self.assertTrue(success)
|
||||
@@ -58,15 +54,14 @@ class TestConsumer(unittest.TestCase):
|
||||
# The consumer should upload _n_ times.
|
||||
q = Queue()
|
||||
flush_interval = 0.3
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=10,
|
||||
flush_interval=flush_interval)
|
||||
with mock.patch('posthog.consumer.post') as mock_post:
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=10, flush_interval=flush_interval)
|
||||
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'
|
||||
"type": "track",
|
||||
"event": "python event %d" % i,
|
||||
"distinct_id": "distinct_id",
|
||||
}
|
||||
q.put(track)
|
||||
time.sleep(flush_interval * 1.1)
|
||||
@@ -78,15 +73,16 @@ 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)
|
||||
with mock.patch('posthog.consumer.post') as mock_post:
|
||||
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'
|
||||
"type": "track",
|
||||
"event": "python event %d" % i,
|
||||
"distinct_id": "distinct_id",
|
||||
}
|
||||
q.put(track)
|
||||
time.sleep(flush_interval * 1.1)
|
||||
@@ -94,28 +90,24 @@ class TestConsumer(unittest.TestCase):
|
||||
|
||||
def test_request(self):
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
track = {
|
||||
'type': 'track',
|
||||
'event': 'python event',
|
||||
'distinct_id': 'distinct_id'
|
||||
}
|
||||
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
|
||||
consumer.request([track])
|
||||
|
||||
def _test_request_retry(self, consumer,
|
||||
expected_exception, exception_count):
|
||||
|
||||
def _test_request_retry(self, consumer, expected_exception, exception_count):
|
||||
def mock_post(*args, **kwargs):
|
||||
mock_post.call_count += 1
|
||||
if mock_post.call_count <= exception_count:
|
||||
raise expected_exception
|
||||
|
||||
mock_post.call_count = 0
|
||||
|
||||
with mock.patch('posthog.consumer.post',
|
||||
mock.Mock(side_effect=mock_post)):
|
||||
with mock.patch(
|
||||
"posthog.consumer.batch_post", mock.Mock(side_effect=mock_post)
|
||||
):
|
||||
track = {
|
||||
'type': 'track',
|
||||
'event': 'python event',
|
||||
'distinct_id': 'distinct_id'
|
||||
"type": "track",
|
||||
"event": "python event",
|
||||
"distinct_id": "distinct_id",
|
||||
}
|
||||
# request() should succeed if the number of exceptions raised is
|
||||
# less than the retries paramater.
|
||||
@@ -131,38 +123,36 @@ 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):
|
||||
# we should retry on general errors
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
self._test_request_retry(consumer, Exception('generic exception'), 2)
|
||||
self._test_request_retry(consumer, Exception("generic exception"), 2)
|
||||
|
||||
# we should retry on server errors
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
self._test_request_retry(consumer, APIError(
|
||||
500, 'Internal Server Error'), 2)
|
||||
self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 2)
|
||||
|
||||
# we should retry on HTTP 429 errors
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
self._test_request_retry(consumer, APIError(
|
||||
429, 'Too Many Requests'), 2)
|
||||
self._test_request_retry(consumer, APIError(429, "Too Many Requests"), 2)
|
||||
|
||||
# we should NOT retry on other client errors
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
api_error = APIError(400, 'Client Errors')
|
||||
api_error = APIError(400, "Client Errors")
|
||||
try:
|
||||
self._test_request_retry(consumer, api_error, 1)
|
||||
except APIError:
|
||||
pass
|
||||
else:
|
||||
self.fail('request() should not retry on client errors')
|
||||
self.fail("request() should not retry on client errors")
|
||||
|
||||
# test for number of exceptions raise > retries value
|
||||
consumer = Consumer(None, TEST_API_KEY, retries=3)
|
||||
self._test_request_retry(consumer, APIError(
|
||||
500, 'Internal Server Error'), 3)
|
||||
self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 3)
|
||||
|
||||
def test_pause(self):
|
||||
consumer = Consumer(None, TEST_API_KEY)
|
||||
@@ -171,29 +161,36 @@ class TestConsumer(unittest.TestCase):
|
||||
|
||||
def test_max_batch_size(self):
|
||||
q = Queue()
|
||||
consumer = Consumer(
|
||||
q, TEST_API_KEY, flush_at=100000, flush_interval=3)
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=100000, flush_interval=3)
|
||||
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'
|
||||
"type": "track",
|
||||
"event": "python event",
|
||||
"distinct_id": "distinct_id",
|
||||
"properties": properties,
|
||||
}
|
||||
msg_size = len(json.dumps(track).encode())
|
||||
# number of messages in a maximum-size batch
|
||||
n_msgs = int(475000 / msg_size)
|
||||
# Let's capture 8MB of data to trigger two batches
|
||||
n_msgs = int(8_000_000 / msg_size)
|
||||
|
||||
def mock_post_fn(_, data, **kwargs):
|
||||
res = mock.Mock()
|
||||
res.status_code = 200
|
||||
self.assertTrue(len(data.encode()) < 500000,
|
||||
'batch size (%d) exceeds 500KB limit'
|
||||
% len(data.encode()))
|
||||
request_size = len(data.encode())
|
||||
# Batches close after the first message bringing it bigger than BATCH_SIZE_LIMIT, let's add 10% of margin
|
||||
self.assertTrue(
|
||||
request_size < (5 * 1024 * 1024) * 1.1,
|
||||
"batch size (%d) higher than limit" % request_size,
|
||||
)
|
||||
return res
|
||||
|
||||
with mock.patch('posthog.request._session.post',
|
||||
side_effect=mock_post_fn) as mock_post:
|
||||
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)
|
||||
q.join()
|
||||
self.assertEquals(mock_post.call_count, 2)
|
||||
self.assertEqual(mock_post.call_count, 2)
|
||||
@@ -0,0 +1,207 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from posthog.contexts import (
|
||||
get_tags,
|
||||
new_context,
|
||||
scoped,
|
||||
tag,
|
||||
identify_context,
|
||||
set_context_session,
|
||||
get_context_session_id,
|
||||
get_context_distinct_id,
|
||||
)
|
||||
|
||||
|
||||
class TestContexts(unittest.TestCase):
|
||||
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_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
|
||||
@@ -0,0 +1,34 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_excepthook(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
from posthog import Posthog
|
||||
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
|
||||
|
||||
# frame_value = "LOL"
|
||||
|
||||
1/0
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output
|
||||
|
||||
assert b"ZeroDivisionError" in output
|
||||
assert b"LOL" in output
|
||||
assert b"DEBUG:posthog:data uploaded successfully" in output
|
||||
assert (
|
||||
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
|
||||
in output
|
||||
)
|
||||
@@ -0,0 +1,189 @@
|
||||
import unittest
|
||||
|
||||
from posthog.types import FeatureFlag, FlagMetadata, FlagReason, LegacyFlagMetadata
|
||||
|
||||
|
||||
class TestFeatureFlag(unittest.TestCase):
|
||||
def test_feature_flag_from_json(self):
|
||||
# Test with full metadata
|
||||
resp = {
|
||||
"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",
|
||||
},
|
||||
}
|
||||
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
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",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
flag.metadata,
|
||||
FlagMetadata(
|
||||
id=1,
|
||||
payload='{"some": "json"}',
|
||||
version=2,
|
||||
description="test-description",
|
||||
),
|
||||
)
|
||||
|
||||
def test_feature_flag_from_json_minimal(self):
|
||||
# Test with minimal required fields
|
||||
resp = {"key": "test-flag", "enabled": True}
|
||||
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertIsNone(flag.variant)
|
||||
self.assertEqual(flag.get_value(), True)
|
||||
self.assertIsNone(flag.reason)
|
||||
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None))
|
||||
|
||||
def test_feature_flag_from_json_without_metadata(self):
|
||||
# Test with reason but no metadata
|
||||
resp = {
|
||||
"key": "test-flag",
|
||||
"enabled": True,
|
||||
"variant": "test-variant",
|
||||
"reason": {
|
||||
"code": "matched_condition",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
}
|
||||
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
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",
|
||||
),
|
||||
)
|
||||
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'",
|
||||
}
|
||||
reason = FlagReason.from_json(resp)
|
||||
self.assertEqual(reason.code, "user_in_segment")
|
||||
self.assertEqual(reason.condition_index, 1)
|
||||
self.assertEqual(reason.description, "User is in segment 'beta_users'")
|
||||
|
||||
# Test with partial data
|
||||
resp = {"code": "user_in_segment"}
|
||||
reason = FlagReason.from_json(resp)
|
||||
self.assertEqual(reason.code, "user_in_segment")
|
||||
self.assertIsNone(reason.condition_index) # default value
|
||||
self.assertEqual(reason.description, "")
|
||||
|
||||
# Test with None
|
||||
self.assertIsNone(FlagReason.from_json(None))
|
||||
|
||||
def test_flag_metadata_from_json(self):
|
||||
# Test with complete data
|
||||
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"})
|
||||
self.assertEqual(metadata.version, 1)
|
||||
self.assertEqual(metadata.description, "Test flag")
|
||||
|
||||
# Test with partial data
|
||||
resp = {"id": 123}
|
||||
metadata = FlagMetadata.from_json(resp)
|
||||
self.assertEqual(metadata.id, 123)
|
||||
self.assertIsNone(metadata.payload)
|
||||
self.assertEqual(metadata.version, 0) # default value
|
||||
self.assertEqual(metadata.description, "") # default value
|
||||
|
||||
# Test with None
|
||||
self.assertIsInstance(FlagMetadata.from_json(None), LegacyFlagMetadata)
|
||||
|
||||
def test_feature_flag_from_json_complete(self):
|
||||
# Test with complete data
|
||||
resp = {
|
||||
"key": "test-flag",
|
||||
"enabled": True,
|
||||
"variant": "control",
|
||||
"reason": {
|
||||
"code": "user_in_segment",
|
||||
"condition_index": 1,
|
||||
"description": "User is in segment 'beta_users'",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 123,
|
||||
"payload": {"key": "value"},
|
||||
"version": 1,
|
||||
"description": "Test flag",
|
||||
},
|
||||
}
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertEqual(flag.variant, "control")
|
||||
self.assertIsInstance(flag.reason, FlagReason)
|
||||
self.assertEqual(flag.reason.code, "user_in_segment")
|
||||
self.assertIsInstance(flag.metadata, FlagMetadata)
|
||||
self.assertEqual(flag.metadata.id, 123)
|
||||
self.assertEqual(flag.metadata.payload, {"key": "value"})
|
||||
|
||||
def test_feature_flag_from_json_minimal_data(self):
|
||||
# Test with minimal data
|
||||
resp = {"key": "test-flag", "enabled": False}
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertFalse(flag.enabled)
|
||||
self.assertIsNone(flag.variant)
|
||||
self.assertIsNone(flag.reason)
|
||||
self.assertIsInstance(flag.metadata, LegacyFlagMetadata)
|
||||
self.assertIsNone(flag.metadata.payload)
|
||||
|
||||
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"},
|
||||
}
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertIsNone(flag.variant)
|
||||
self.assertIsInstance(flag.reason, FlagReason)
|
||||
self.assertEqual(flag.reason.code, "user_in_segment")
|
||||
self.assertIsInstance(flag.metadata, LegacyFlagMetadata)
|
||||
self.assertIsNone(flag.metadata.payload)
|
||||
@@ -0,0 +1,444 @@
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
from posthog.types import FeatureFlag, FeatureFlagResult, FlagMetadata, FlagReason
|
||||
|
||||
|
||||
class TestFeatureFlagResult(unittest.TestCase):
|
||||
def test_from_bool_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", True, "[1, 2, 3]"
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
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"}'
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, False)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_variant_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", "control", "true"
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, "control")
|
||||
self.assertEqual(result.payload, True)
|
||||
|
||||
def test_from_none_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", None, '{"some": "value"}'
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_from_boolean_flag_details(self):
|
||||
flag_details = FeatureFlag(
|
||||
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
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, "Some string")
|
||||
|
||||
def test_from_boolean_flag_details_with_override_variant_match_value(self):
|
||||
flag_details = FeatureFlag(
|
||||
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
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(
|
||||
flag_details, override_match_value="control"
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, "control")
|
||||
self.assertEqual(result.payload, "Some string")
|
||||
|
||||
def test_from_boolean_flag_details_with_override_boolean_match_value(self):
|
||||
flag_details = FeatureFlag(
|
||||
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
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(
|
||||
flag_details, override_match_value=True
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_boolean_flag_details_with_override_false_match_value(self):
|
||||
flag_details = FeatureFlag(
|
||||
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
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(
|
||||
flag_details, override_match_value=False
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, False)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_variant_flag_details(self):
|
||||
flag_details = FeatureFlag(
|
||||
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
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, "control")
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_none_flag_details(self):
|
||||
result = FeatureFlagResult.from_flag_details(None)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_from_flag_details_with_none_payload(self):
|
||||
flag_details = FeatureFlag(
|
||||
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
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
self.assertEqual(result.variant, None)
|
||||
self.assertIsNone(result.payload)
|
||||
|
||||
|
||||
class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# This ensures no real HTTP POST requests are made
|
||||
cls.capture_patch = mock.patch.object(Client, "capture")
|
||||
cls.capture_patch.start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.capture_patch.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)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_boolean_local_evaluation(self, patch_capture):
|
||||
basic_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "region",
|
||||
"operator": "exact",
|
||||
"value": ["USA"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
"payloads": {"true": "300"},
|
||||
},
|
||||
}
|
||||
self.client.feature_flags = [basic_flag]
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
|
||||
)
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, None)
|
||||
self.assertEqual(flag_result.payload, 300)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": True,
|
||||
"$feature_flag_payload": 300,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture):
|
||||
basic_flag = {
|
||||
"id": 1,
|
||||
"name": "Beta Feature",
|
||||
"key": "person-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [
|
||||
{
|
||||
"key": "region",
|
||||
"operator": "exact",
|
||||
"value": ["USA"],
|
||||
"type": "person",
|
||||
}
|
||||
],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
],
|
||||
"multivariate": {
|
||||
"variants": [
|
||||
{"key": "variant-1", "rollout_percentage": 50},
|
||||
{"key": "variant-2", "rollout_percentage": 50},
|
||||
]
|
||||
},
|
||||
"payloads": {"variant-1": '{"some": "value"}'},
|
||||
},
|
||||
}
|
||||
self.client.feature_flags = [basic_flag]
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "distinct_id", person_properties={"region": "USA"}
|
||||
)
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, "variant-1")
|
||||
self.assertEqual(flag_result.get_value(), "variant-1")
|
||||
self.assertEqual(flag_result.payload, {"some": "value"})
|
||||
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="distinct_id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-1",
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": "variant-1",
|
||||
"$feature_flag_payload": {"some": "value"},
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
another_flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "another-distinct-id", person_properties={"region": "USA"}
|
||||
)
|
||||
self.assertEqual(another_flag_result.enabled, True)
|
||||
self.assertEqual(another_flag_result.variant, "variant-2")
|
||||
self.assertEqual(another_flag_result.get_value(), "variant-2")
|
||||
self.assertIsNone(another_flag_result.payload)
|
||||
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="another-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-2",
|
||||
"locally_evaluated": True,
|
||||
"$feature/person-flag": "variant-2",
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"person-flag": {
|
||||
"key": "person-flag",
|
||||
"enabled": True,
|
||||
"variant": None,
|
||||
"reason": {
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 23,
|
||||
"version": 42,
|
||||
"payload": "300",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": False,
|
||||
"$feature/person-flag": True,
|
||||
"$feature_flag_reason": "Matched condition set 1",
|
||||
"$feature_flag_id": 23,
|
||||
"$feature_flag_version": 42,
|
||||
"$feature_flag_payload": 300,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"person-flag": {
|
||||
"key": "person-flag",
|
||||
"enabled": True,
|
||||
"variant": "variant-1",
|
||||
"reason": {
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 1,
|
||||
"version": 2,
|
||||
"payload": "[1, 2, 3]",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("person-flag", "distinct_id")
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, "variant-1")
|
||||
self.assertEqual(flag_result.get_value(), "variant-1")
|
||||
self.assertEqual(flag_result.payload, [1, 2, 3])
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="distinct_id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-1",
|
||||
"locally_evaluated": False,
|
||||
"$feature/person-flag": "variant-1",
|
||||
"$feature_flag_reason": "Matched condition set 1",
|
||||
"$feature_flag_id": 1,
|
||||
"$feature_flag_version": 2,
|
||||
"$feature_flag_payload": [1, 2, 3],
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_unknown_flag(self, patch_capture, patch_flags):
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"person-flag": {
|
||||
"key": "person-flag",
|
||||
"enabled": True,
|
||||
"variant": None,
|
||||
"reason": {
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 23,
|
||||
"version": 42,
|
||||
"payload": "300",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"no-person-flag", "some-distinct-id"
|
||||
)
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "no-person-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/no-person-flag": None,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
import unittest
|
||||
|
||||
from posthog import Posthog
|
||||
|
||||
|
||||
class TestModule(unittest.TestCase):
|
||||
posthog = None
|
||||
|
||||
def _assert_enqueue_result(self, result):
|
||||
self.assertEqual(type(result[0]), str)
|
||||
|
||||
def failed(self):
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
self.posthog = Posthog(
|
||||
"testsecret", host="http://localhost:8000", on_error=self.failed
|
||||
)
|
||||
|
||||
def test_no_api_key(self):
|
||||
self.posthog.api_key = None
|
||||
self.assertRaises(Exception, self.posthog.capture)
|
||||
|
||||
def test_no_host(self):
|
||||
self.posthog.host = None
|
||||
self.assertRaises(Exception, self.posthog.capture)
|
||||
|
||||
def test_track(self):
|
||||
res = self.posthog.capture("python module event", distinct_id="distinct_id")
|
||||
self._assert_enqueue_result(res)
|
||||
self.posthog.flush()
|
||||
|
||||
def test_alias(self):
|
||||
res = self.posthog.alias("previousId", "distinct_id")
|
||||
self._assert_enqueue_result(res)
|
||||
self.posthog.flush()
|
||||
|
||||
def test_flush(self):
|
||||
self.posthog.flush()
|
||||
@@ -0,0 +1,130 @@
|
||||
import json
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
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"}
|
||||
],
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_invalid_request_error(self):
|
||||
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=[]
|
||||
)
|
||||
|
||||
def test_datetime_serialization(self):
|
||||
data = {"created": datetime(2012, 3, 4, 5, 6, 7, 891011)}
|
||||
result = json.dumps(data, cls=DatetimeSerializer)
|
||||
self.assertEqual(result, '{"created": "2012-03-04T05:06:07.891011"}')
|
||||
|
||||
def test_date_serialization(self):
|
||||
today = date.today()
|
||||
data = {"created": today}
|
||||
result = json.dumps(data, cls=DatetimeSerializer)
|
||||
expected = '{"created": "%s"}' % today.isoformat()
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_should_not_timeout(self):
|
||||
res = batch_post(
|
||||
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,
|
||||
)
|
||||
|
||||
def test_quota_limited_response(self):
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{
|
||||
"quotaLimited": ["feature_flags"],
|
||||
"featureFlags": {},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
with mock.patch("posthog.request._session.post", return_value=mock_response):
|
||||
with self.assertRaises(QuotaLimitError) as cm:
|
||||
decide("fake_key", "fake_host")
|
||||
|
||||
self.assertEqual(cm.exception.status, 200)
|
||||
self.assertEqual(cm.exception.message, "Feature flags quota limited")
|
||||
|
||||
def test_normal_decide_response(self):
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{
|
||||
"featureFlags": {"flag1": True},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
with mock.patch("posthog.request._session.post", return_value=mock_response):
|
||||
response = decide("fake_key", "fake_host")
|
||||
self.assertEqual(response["featureFlags"], {"flag1": True})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"host, expected",
|
||||
[
|
||||
("https://t.posthog.com", "https://t.posthog.com"),
|
||||
("https://t.posthog.com/", "https://t.posthog.com/"),
|
||||
("t.posthog.com", "t.posthog.com"),
|
||||
("t.posthog.com/", "t.posthog.com/"),
|
||||
("https://us.posthog.com.rg.proxy.com", "https://us.posthog.com.rg.proxy.com"),
|
||||
("app.posthog.com", "app.posthog.com"),
|
||||
("eu.posthog.com", "eu.posthog.com"),
|
||||
("https://app.posthog.com", "https://us.i.posthog.com"),
|
||||
("https://eu.posthog.com", "https://eu.i.posthog.com"),
|
||||
("https://us.posthog.com", "https://us.i.posthog.com"),
|
||||
("https://app.posthog.com/", "https://us.i.posthog.com"),
|
||||
("https://eu.posthog.com/", "https://eu.i.posthog.com"),
|
||||
("https://us.posthog.com/", "https://us.i.posthog.com"),
|
||||
(None, "https://us.i.posthog.com"),
|
||||
],
|
||||
)
|
||||
def test_routing_to_custom_host(host, expected):
|
||||
assert determine_server_host(host) == expected
|
||||
@@ -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))
|
||||
@@ -0,0 +1,208 @@
|
||||
import unittest
|
||||
|
||||
from parameterized import parameterized
|
||||
|
||||
from posthog.types import (
|
||||
FeatureFlag,
|
||||
FlagMetadata,
|
||||
FlagReason,
|
||||
LegacyFlagMetadata,
|
||||
normalize_flags_response,
|
||||
to_flags_and_payloads,
|
||||
)
|
||||
|
||||
|
||||
class TestTypes(unittest.TestCase):
|
||||
@parameterized.expand([(True,), (False,)])
|
||||
def test_normalize_decide_response_v4(self, has_errors: bool):
|
||||
resp = {
|
||||
"flags": {
|
||||
"my-flag": FeatureFlag(
|
||||
key="my-flag",
|
||||
enabled=True,
|
||||
variant="test-variant",
|
||||
reason=FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
metadata=FlagMetadata(
|
||||
id=1,
|
||||
payload='{"some": "json"}',
|
||||
version=2,
|
||||
description="test-description",
|
||||
),
|
||||
)
|
||||
},
|
||||
"errorsWhileComputingFlags": has_errors,
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
result = normalize_flags_response(resp)
|
||||
|
||||
flag = result["flags"]["my-flag"]
|
||||
self.assertEqual(flag.key, "my-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
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",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
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")
|
||||
|
||||
def test_normalize_decide_response_legacy(self):
|
||||
# Test legacy response format with "featureFlags" and "featureFlagPayloads"
|
||||
resp = {
|
||||
"featureFlags": {"my-flag": "test-variant"},
|
||||
"featureFlagPayloads": {"my-flag": '{"some": "json-payload"}'},
|
||||
"errorsWhileComputingFlags": False,
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
result = normalize_flags_response(resp)
|
||||
|
||||
flag = result["flags"]["my-flag"]
|
||||
self.assertEqual(flag.key, "my-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
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.assertFalse(result["errorsWhileComputingFlags"])
|
||||
self.assertEqual(result["requestId"], "test-id")
|
||||
# Verify legacy fields are removed
|
||||
self.assertNotIn("featureFlags", result)
|
||||
self.assertNotIn("featureFlagPayloads", result)
|
||||
|
||||
def test_normalize_decide_response_boolean_flag(self):
|
||||
# Test legacy response with boolean flag
|
||||
resp = {"featureFlags": {"my-flag": True}, "errorsWhileComputingFlags": False}
|
||||
|
||||
result = normalize_flags_response(resp)
|
||||
|
||||
self.assertIn("requestId", result)
|
||||
self.assertIsNone(result["requestId"])
|
||||
|
||||
flag = result["flags"]["my-flag"]
|
||||
self.assertEqual(flag.key, "my-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
self.assertIsNone(flag.variant)
|
||||
self.assertIsNone(flag.reason)
|
||||
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None))
|
||||
self.assertFalse(result["errorsWhileComputingFlags"])
|
||||
self.assertNotIn("featureFlags", result)
|
||||
self.assertNotIn("featureFlagPayloads", result)
|
||||
|
||||
def test_to_flags_and_payloads_v4(self):
|
||||
# Test v4 response format
|
||||
resp = {
|
||||
"flags": {
|
||||
"my-variant-flag": FeatureFlag(
|
||||
key="my-variant-flag",
|
||||
enabled=True,
|
||||
variant="test-variant",
|
||||
reason=FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
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",
|
||||
),
|
||||
metadata=FlagMetadata(
|
||||
id=1, payload=None, version=2, description="test-description"
|
||||
),
|
||||
),
|
||||
"disabled-flag": FeatureFlag(
|
||||
key="disabled-flag",
|
||||
enabled=False,
|
||||
variant=None,
|
||||
reason=None,
|
||||
metadata=LegacyFlagMetadata(payload=None),
|
||||
),
|
||||
},
|
||||
"errorsWhileComputingFlags": False,
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
result = to_flags_and_payloads(resp)
|
||||
|
||||
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.assertNotIn("my-boolean-flag", result["featureFlagPayloads"])
|
||||
self.assertNotIn("disabled-flag", result["featureFlagPayloads"])
|
||||
|
||||
def test_to_flags_and_payloads_empty(self):
|
||||
# Test empty response
|
||||
resp = {
|
||||
"flags": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
"requestId": "test-id",
|
||||
}
|
||||
|
||||
result = to_flags_and_payloads(resp)
|
||||
|
||||
self.assertEqual(result["featureFlags"], {})
|
||||
self.assertEqual(result["featureFlagPayloads"], {})
|
||||
|
||||
def test_to_flags_and_payloads_with_payload(self):
|
||||
resp = {
|
||||
"flags": {
|
||||
"decide-flag": {
|
||||
"key": "decide-flag",
|
||||
"enabled": True,
|
||||
"variant": "decide-variant",
|
||||
"reason": {
|
||||
"code": "matched_condition",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 23,
|
||||
"version": 42,
|
||||
"payload": '{"foo": "bar"}',
|
||||
},
|
||||
}
|
||||
},
|
||||
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
|
||||
}
|
||||
|
||||
normalized = normalize_flags_response(resp)
|
||||
result = to_flags_and_payloads(normalized)
|
||||
|
||||
self.assertEqual(result["featureFlags"]["decide-flag"], "decide-variant")
|
||||
self.assertEqual(result["featureFlagPayloads"]["decide-flag"], '{"foo": "bar"}')
|
||||
@@ -0,0 +1,298 @@
|
||||
import time
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
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
|
||||
|
||||
from posthog import utils
|
||||
from posthog.types import FeatureFlagResult
|
||||
|
||||
TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4"
|
||||
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())
|
||||
|
||||
fixed = utils.guess_timezone(now)
|
||||
assert utils.is_naive(fixed) is False
|
||||
|
||||
shouldnt_be_edited = utils.guess_timezone(utcnow)
|
||||
assert utcnow == shouldnt_be_edited
|
||||
|
||||
def test_clean(self):
|
||||
simple = {
|
||||
"decimal": Decimal("0.142857"),
|
||||
"unicode": six.u("woo"),
|
||||
"date": datetime.now(),
|
||||
"long": 200000000,
|
||||
"integer": 1,
|
||||
"float": 2.0,
|
||||
"bool": True,
|
||||
"str": "woo",
|
||||
"none": None,
|
||||
}
|
||||
|
||||
complicated = {
|
||||
"exception": Exception("This should show up"),
|
||||
"timedelta": timedelta(microseconds=20),
|
||||
"list": [1, 2, 3],
|
||||
}
|
||||
|
||||
combined = dict(simple.items())
|
||||
combined.update(complicated.items())
|
||||
|
||||
pre_clean_keys = combined.keys()
|
||||
|
||||
utils.clean(combined)
|
||||
assert combined.keys() == pre_clean_keys
|
||||
|
||||
# test UUID separately, as the UUID object doesn't equal its string representation according to Python
|
||||
assert (
|
||||
utils.clean(UUID("12345678123456781234567812345678"))
|
||||
== "12345678-1234-5678-1234-567812345678"
|
||||
)
|
||||
|
||||
def test_clean_with_dates(self):
|
||||
dict_with_dates = {
|
||||
"birthdate": date(1980, 1, 1),
|
||||
"registration": datetime.now(tz=tzutc()),
|
||||
}
|
||||
assert dict_with_dates == utils.clean(dict_with_dates)
|
||||
|
||||
def test_bytes(self):
|
||||
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})
|
||||
assert cleaned == {"fn": None, "number": 4}
|
||||
|
||||
@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):
|
||||
foo: str
|
||||
bar: int
|
||||
baz: Optional[str] = None
|
||||
|
||||
class ModelV1(BaseModelV1):
|
||||
foo: int
|
||||
bar: str
|
||||
|
||||
class NestedModel(BaseModel):
|
||||
foo: ModelV2
|
||||
|
||||
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: str) -> dict:
|
||||
return {}
|
||||
|
||||
# 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
|
||||
class InnerDataClass:
|
||||
inner_foo: str
|
||||
inner_bar: int
|
||||
inner_uuid: UUID
|
||||
inner_date: datetime
|
||||
inner_optional: Optional[str] = None
|
||||
|
||||
@dataclass
|
||||
class TestDataClass:
|
||||
foo: str
|
||||
bar: int
|
||||
nested: InnerDataClass
|
||||
|
||||
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 TestFlagCache(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.cache = utils.FlagCache(max_size=3, default_ttl=1)
|
||||
self.flag_result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", True, None
|
||||
)
|
||||
|
||||
def test_cache_basic_operations(self):
|
||||
distinct_id = "user123"
|
||||
flag_key = "test-flag"
|
||||
flag_version = 1
|
||||
|
||||
# Test cache miss
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
||||
assert result is None
|
||||
|
||||
# Test cache set and hit
|
||||
self.cache.set_cached_flag(
|
||||
distinct_id, flag_key, self.flag_result, flag_version
|
||||
)
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
||||
assert result is not None
|
||||
assert result.get_value()
|
||||
|
||||
def test_cache_ttl_expiration(self):
|
||||
distinct_id = "user123"
|
||||
flag_key = "test-flag"
|
||||
flag_version = 1
|
||||
|
||||
# Set flag in cache
|
||||
self.cache.set_cached_flag(
|
||||
distinct_id, flag_key, self.flag_result, flag_version
|
||||
)
|
||||
|
||||
# Should be available immediately
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
||||
assert result is not None
|
||||
|
||||
# Wait for TTL to expire (1 second + buffer)
|
||||
time.sleep(1.1)
|
||||
|
||||
# Should be expired
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
||||
assert result is None
|
||||
|
||||
def test_cache_version_invalidation(self):
|
||||
distinct_id = "user123"
|
||||
flag_key = "test-flag"
|
||||
old_version = 1
|
||||
new_version = 2
|
||||
|
||||
# Set flag with old version
|
||||
self.cache.set_cached_flag(distinct_id, flag_key, self.flag_result, old_version)
|
||||
|
||||
# Should hit with old version
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, old_version)
|
||||
assert result is not None
|
||||
|
||||
# Should miss with new version
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, new_version)
|
||||
assert result is None
|
||||
|
||||
# Invalidate old version
|
||||
self.cache.invalidate_version(old_version)
|
||||
|
||||
# Should miss even with old version after invalidation
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, old_version)
|
||||
assert result is None
|
||||
|
||||
def test_stale_cache_functionality(self):
|
||||
distinct_id = "user123"
|
||||
flag_key = "test-flag"
|
||||
flag_version = 1
|
||||
|
||||
# Set flag in cache
|
||||
self.cache.set_cached_flag(
|
||||
distinct_id, flag_key, self.flag_result, flag_version
|
||||
)
|
||||
|
||||
# Wait for TTL to expire
|
||||
time.sleep(1.1)
|
||||
|
||||
# Should not get fresh cache
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
||||
assert result is None
|
||||
|
||||
# Should get stale cache (within 1 hour default)
|
||||
stale_result = self.cache.get_stale_cached_flag(distinct_id, flag_key)
|
||||
assert stale_result is not None
|
||||
assert stale_result.get_value()
|
||||
|
||||
def test_lru_eviction(self):
|
||||
# Cache has max_size=3, so adding 4 users should evict the LRU one
|
||||
flag_version = 1
|
||||
|
||||
# Add 3 users
|
||||
for i in range(3):
|
||||
user_id = f"user{i}"
|
||||
self.cache.set_cached_flag(
|
||||
user_id, "test-flag", self.flag_result, flag_version
|
||||
)
|
||||
|
||||
# Access user0 to make it recently used
|
||||
self.cache.get_cached_flag("user0", "test-flag", flag_version)
|
||||
|
||||
# Add 4th user, should evict user1 (least recently used)
|
||||
self.cache.set_cached_flag("user3", "test-flag", self.flag_result, flag_version)
|
||||
|
||||
# user0 should still be there (was recently accessed)
|
||||
result = self.cache.get_cached_flag("user0", "test-flag", flag_version)
|
||||
assert result is not None
|
||||
|
||||
# user2 should still be there (was recently added)
|
||||
result = self.cache.get_cached_flag("user2", "test-flag", flag_version)
|
||||
assert result is not None
|
||||
|
||||
# user3 should be there (just added)
|
||||
result = self.cache.get_cached_flag("user3", "test-flag", flag_version)
|
||||
assert result is not None
|
||||
@@ -1,79 +0,0 @@
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
import unittest
|
||||
|
||||
from dateutil.tz import tzutc
|
||||
import six
|
||||
|
||||
from posthog import utils
|
||||
|
||||
TEST_API_KEY = 'kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4'
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
|
||||
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))
|
||||
|
||||
shouldnt_be_edited = utils.guess_timezone(utcnow)
|
||||
self.assertEqual(utcnow, shouldnt_be_edited)
|
||||
|
||||
def test_clean(self):
|
||||
simple = {
|
||||
'decimal': Decimal('0.142857'),
|
||||
'unicode': six.u('woo'),
|
||||
'date': datetime.now(),
|
||||
'long': 200000000,
|
||||
'integer': 1,
|
||||
'float': 2.0,
|
||||
'bool': True,
|
||||
'str': 'woo',
|
||||
'none': None
|
||||
}
|
||||
|
||||
complicated = {
|
||||
'exception': Exception('This should show up'),
|
||||
'timedelta': timedelta(microseconds=20),
|
||||
'list': [1, 2, 3]
|
||||
}
|
||||
|
||||
combined = dict(simple.items())
|
||||
combined.update(complicated.items())
|
||||
|
||||
pre_clean_keys = combined.keys()
|
||||
|
||||
utils.clean(combined)
|
||||
self.assertEqual(combined.keys(), pre_clean_keys)
|
||||
|
||||
def test_clean_with_dates(self):
|
||||
dict_with_dates = {
|
||||
'birthdate': date(1980, 1, 1),
|
||||
'registration': datetime.utcnow(),
|
||||
}
|
||||
self.assertEqual(dict_with_dates, utils.clean(dict_with_dates))
|
||||
|
||||
def test_bytes(self):
|
||||
if six.PY3:
|
||||
item = bytes(10)
|
||||
else:
|
||||
item = bytearray(10)
|
||||
|
||||
utils.clean(item)
|
||||
|
||||
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)
|
||||
|
||||
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'))
|
||||
@@ -0,0 +1,308 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
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]]]
|
||||
|
||||
|
||||
# Type alias for the send_feature_flags parameter
|
||||
class SendFeatureFlagsOptions(TypedDict, total=False):
|
||||
"""Options for sending feature flags with capture events.
|
||||
|
||||
Args:
|
||||
only_evaluate_locally: Whether to only use local evaluation for feature flags.
|
||||
If True, only flags that can be evaluated locally will be included.
|
||||
If False, remote evaluation via /flags API will be used when needed.
|
||||
person_properties: Properties to use for feature flag evaluation specific to this event.
|
||||
These properties will be merged with any existing person properties.
|
||||
group_properties: Group properties to use for feature flag evaluation specific to this event.
|
||||
Format: { group_type_name: { group_properties } }
|
||||
"""
|
||||
|
||||
should_send: bool
|
||||
only_evaluate_locally: Optional[bool]
|
||||
person_properties: Optional[dict[str, Any]]
|
||||
group_properties: Optional[dict[str, dict[str, Any]]]
|
||||
flag_keys_filter: Optional[list[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlagReason:
|
||||
code: str
|
||||
condition_index: Optional[int]
|
||||
description: str
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, resp: Any) -> Optional["FlagReason"]:
|
||||
if not resp:
|
||||
return None
|
||||
return cls(
|
||||
code=resp.get("code", ""),
|
||||
condition_index=resp.get("condition_index"),
|
||||
description=resp.get("description", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegacyFlagMetadata:
|
||||
payload: Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlagMetadata:
|
||||
id: int
|
||||
payload: Optional[str]
|
||||
version: int
|
||||
description: str
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]:
|
||||
if not resp:
|
||||
return LegacyFlagMetadata(payload=None)
|
||||
return cls(
|
||||
id=resp.get("id", 0),
|
||||
payload=resp.get("payload"),
|
||||
version=resp.get("version", 0),
|
||||
description=resp.get("description", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureFlag:
|
||||
key: str
|
||||
enabled: bool
|
||||
variant: Optional[str]
|
||||
reason: Optional[FlagReason]
|
||||
metadata: Union[FlagMetadata, LegacyFlagMetadata]
|
||||
|
||||
def get_value(self) -> FlagValue:
|
||||
return self.variant or self.enabled
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, resp: Any) -> "FeatureFlag":
|
||||
reason = None
|
||||
if resp.get("reason"):
|
||||
reason = FlagReason.from_json(resp.get("reason"))
|
||||
|
||||
metadata = None
|
||||
if resp.get("metadata"):
|
||||
metadata = FlagMetadata.from_json(resp.get("metadata"))
|
||||
else:
|
||||
metadata = LegacyFlagMetadata(payload=None)
|
||||
|
||||
return cls(
|
||||
key=resp.get("key"),
|
||||
enabled=resp.get("enabled"),
|
||||
variant=resp.get("variant"),
|
||||
reason=reason,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
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,
|
||||
enabled=enabled,
|
||||
variant=variant,
|
||||
reason=None,
|
||||
metadata=LegacyFlagMetadata(
|
||||
payload=payload,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FlagsResponse(TypedDict, total=False):
|
||||
flags: dict[str, FeatureFlag]
|
||||
errorsWhileComputingFlags: bool
|
||||
requestId: str
|
||||
quotaLimit: Optional[List[str]]
|
||||
|
||||
|
||||
class FlagsAndPayloads(TypedDict, total=True):
|
||||
featureFlags: Optional[dict[str, FlagValue]]
|
||||
featureFlagPayloads: Optional[dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureFlagResult:
|
||||
"""
|
||||
The result of calling a feature flag which includes the flag result, variant, and payload.
|
||||
|
||||
Attributes:
|
||||
key (str): The unique identifier of the feature flag.
|
||||
enabled (bool): Whether the feature flag is enabled for the current context.
|
||||
variant (Optional[str]): The variant value if the flag is enabled and has variants, None otherwise.
|
||||
payload (Optional[Any]): Additional data associated with the feature flag, if any.
|
||||
reason (Optional[str]): A description of why the flag was enabled or disabled, if available.
|
||||
"""
|
||||
|
||||
key: str
|
||||
enabled: bool
|
||||
variant: Optional[str]
|
||||
payload: Optional[Any]
|
||||
reason: Optional[str]
|
||||
|
||||
def get_value(self) -> FlagValue:
|
||||
"""
|
||||
Returns the value of the flag. This is the variant if it exists, otherwise the enabled value.
|
||||
This is the value we report as `$feature_flag_response` in the `$feature_flag_called` event.
|
||||
|
||||
Returns:
|
||||
FlagValue: Either a string variant or boolean value representing the flag's state.
|
||||
"""
|
||||
return self.variant or self.enabled
|
||||
|
||||
@classmethod
|
||||
def from_value_and_payload(
|
||||
cls, key: str, value: Union[FlagValue, None], payload: Any
|
||||
) -> Union["FeatureFlagResult", None]:
|
||||
"""
|
||||
Creates a FeatureFlagResult from a flag value and payload.
|
||||
|
||||
Args:
|
||||
key (str): The unique identifier of the feature flag.
|
||||
value (Union[FlagValue, None]): The value of the flag (string variant or boolean).
|
||||
payload (Any): Additional data associated with the feature flag.
|
||||
|
||||
Returns:
|
||||
Union[FeatureFlagResult, None]: A new FeatureFlagResult instance, or None if value is None.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
enabled, variant = (True, value) if isinstance(value, str) else (value, None)
|
||||
return cls(
|
||||
key=key,
|
||||
enabled=enabled,
|
||||
variant=variant,
|
||||
payload=json.loads(payload)
|
||||
if isinstance(payload, str) and payload
|
||||
else payload,
|
||||
reason=None,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_flag_details(
|
||||
cls,
|
||||
details: Union[FeatureFlag, None],
|
||||
override_match_value: Optional[FlagValue] = None,
|
||||
) -> "FeatureFlagResult | None":
|
||||
"""
|
||||
Create a FeatureFlagResult from a FeatureFlag object.
|
||||
|
||||
Args:
|
||||
details (Union[FeatureFlag, None]): The FeatureFlag object to convert.
|
||||
override_match_value (Optional[FlagValue]): If provided, this value will be used to populate
|
||||
the enabled and variant fields instead of the values from the FeatureFlag.
|
||||
|
||||
Returns:
|
||||
FeatureFlagResult | None: A new FeatureFlagResult instance, or None if details is None.
|
||||
"""
|
||||
|
||||
if details is None:
|
||||
return None
|
||||
|
||||
if override_match_value is not None:
|
||||
enabled, variant = (
|
||||
(True, override_match_value)
|
||||
if isinstance(override_match_value, str)
|
||||
else (override_match_value, None)
|
||||
)
|
||||
else:
|
||||
enabled, variant = (details.enabled, details.variant)
|
||||
|
||||
return cls(
|
||||
key=details.key,
|
||||
enabled=enabled,
|
||||
variant=variant,
|
||||
payload=(
|
||||
json.loads(details.metadata.payload)
|
||||
if isinstance(details.metadata.payload, str)
|
||||
and details.metadata.payload
|
||||
else details.metadata.payload
|
||||
),
|
||||
reason=details.reason.description if details.reason else None,
|
||||
)
|
||||
|
||||
|
||||
def normalize_flags_response(resp: Any) -> FlagsResponse:
|
||||
"""
|
||||
Normalize the response from the decide or flags API endpoint into a FlagsResponse.
|
||||
|
||||
Args:
|
||||
resp: A v3 or v4 response from the decide (or a v1 or v2 response from the flags) API endpoint.
|
||||
|
||||
Returns:
|
||||
A FlagsResponse containing feature flags and their details.
|
||||
"""
|
||||
if "requestId" not in resp:
|
||||
resp["requestId"] = None
|
||||
if "flags" in resp:
|
||||
flags = resp["flags"]
|
||||
# For each flag, create a FeatureFlag object
|
||||
for key, value in flags.items():
|
||||
if isinstance(value, FeatureFlag):
|
||||
continue
|
||||
value["key"] = key
|
||||
flags[key] = FeatureFlag.from_json(value)
|
||||
else:
|
||||
# Handle legacy format
|
||||
featureFlags = resp.get("featureFlags", {})
|
||||
featureFlagPayloads = resp.get("featureFlagPayloads", {})
|
||||
resp.pop("featureFlags", None)
|
||||
resp.pop("featureFlagPayloads", None)
|
||||
# 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)
|
||||
)
|
||||
resp["flags"] = flags
|
||||
return cast(FlagsResponse, resp)
|
||||
|
||||
|
||||
def to_flags_and_payloads(resp: FlagsResponse) -> FlagsAndPayloads:
|
||||
"""
|
||||
Convert a FlagsResponse into a FlagsAndPayloads object which is a
|
||||
dict of feature flags and their payloads. This is needed by certain
|
||||
functions in the client.
|
||||
Args:
|
||||
resp: A FlagsResponse containing feature flags and their payloads.
|
||||
|
||||
Returns:
|
||||
A tuple containing:
|
||||
- A dictionary mapping flag keys to their values (bool or str)
|
||||
- A dictionary mapping flag keys to their payloads
|
||||
"""
|
||||
return {"featureFlags": to_values(resp), "featureFlagPayloads": to_payloads(resp)}
|
||||
|
||||
|
||||
def to_values(response: FlagsResponse) -> Optional[dict[str, FlagValue]]:
|
||||
if "flags" not in response:
|
||||
return None
|
||||
|
||||
flags = response.get("flags", {})
|
||||
return {
|
||||
key: value.get_value()
|
||||
for key, value in flags.items()
|
||||
if isinstance(value, FeatureFlag)
|
||||
}
|
||||
|
||||
|
||||
def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]:
|
||||
if "flags" not in response:
|
||||
return None
|
||||
|
||||
return {
|
||||
key: value.metadata.payload
|
||||
for key, value in response.get("flags", {}).items()
|
||||
if isinstance(value, FeatureFlag)
|
||||
and value.enabled
|
||||
and value.metadata.payload is not None
|
||||
}
|
||||
+454
-22
@@ -1,12 +1,22 @@
|
||||
from dateutil.tz import tzlocal, tzutc
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
import json
|
||||
import logging
|
||||
import numbers
|
||||
import re
|
||||
import time
|
||||
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 sys
|
||||
import platform
|
||||
import distro # For Linux OS detection
|
||||
|
||||
import six
|
||||
from dateutil.tz import tzlocal, tzutc
|
||||
|
||||
log = logging.getLogger('posthog')
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
|
||||
def is_naive(dt):
|
||||
@@ -17,8 +27,7 @@ def is_naive(dt):
|
||||
def total_seconds(delta):
|
||||
"""Determines total seconds with python < 2.7 compat."""
|
||||
# http://stackoverflow.com/questions/3694835/python-2-6-5-divide-timedelta-with-timedelta
|
||||
return (delta.microseconds
|
||||
+ (delta.seconds + delta.days * 24 * 3600) * 1e6) / 1e6
|
||||
return (delta.microseconds + (delta.seconds + delta.days * 24 * 3600) * 1e6) / 1e6
|
||||
|
||||
|
||||
def guess_timezone(dt):
|
||||
@@ -39,7 +48,7 @@ def guess_timezone(dt):
|
||||
|
||||
|
||||
def remove_trailing_slash(host):
|
||||
if host.endswith('/'):
|
||||
if host.endswith("/"):
|
||||
return host[:-1]
|
||||
return host
|
||||
|
||||
@@ -47,15 +56,30 @@ def remove_trailing_slash(host):
|
||||
def clean(item):
|
||||
if isinstance(item, Decimal):
|
||||
return float(item)
|
||||
elif isinstance(item, (six.string_types, bool, numbers.Number, datetime,
|
||||
date, type(None))):
|
||||
if isinstance(item, UUID):
|
||||
return str(item)
|
||||
if isinstance(
|
||||
item, (six.string_types, bool, numbers.Number, datetime, date, type(None))
|
||||
):
|
||||
return item
|
||||
elif isinstance(item, (set, list, tuple)):
|
||||
if isinstance(item, (set, list, tuple)):
|
||||
return _clean_list(item)
|
||||
elif isinstance(item, dict):
|
||||
# Pydantic model
|
||||
try:
|
||||
# v2+
|
||||
if hasattr(item, "model_dump") and callable(item.model_dump):
|
||||
item = item.model_dump()
|
||||
# v1
|
||||
elif hasattr(item, "dict") and callable(item.dict):
|
||||
item = item.dict()
|
||||
except TypeError as e:
|
||||
log.debug(f"Could not serialize Pydantic-like model: {e}")
|
||||
pass
|
||||
if isinstance(item, dict):
|
||||
return _clean_dict(item)
|
||||
else:
|
||||
return _coerce_unicode(item)
|
||||
if is_dataclass(item) and not isinstance(item, type):
|
||||
return _clean_dataclass(item)
|
||||
return _coerce_unicode(item)
|
||||
|
||||
|
||||
def _clean_list(list_):
|
||||
@@ -69,19 +93,427 @@ def _clean_dict(dict_):
|
||||
data[k] = clean(v)
|
||||
except TypeError:
|
||||
log.warning(
|
||||
'Dictionary values must be serializeable to '
|
||||
'JSON "%s" value %s of type %s is unsupported.',
|
||||
k, v, type(v),
|
||||
'Dictionary values must be serializeable to JSON "%s" value %s of type %s is unsupported.',
|
||||
k,
|
||||
v,
|
||||
type(v),
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _coerce_unicode(cmplx):
|
||||
def _clean_dataclass(dataclass_):
|
||||
data = asdict(dataclass_)
|
||||
data = _clean_dict(data)
|
||||
return data
|
||||
|
||||
|
||||
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")
|
||||
log.warning('Error decoding: %s', item)
|
||||
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
|
||||
|
||||
|
||||
def is_valid_regex(value) -> bool:
|
||||
try:
|
||||
re.compile(value)
|
||||
return True
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
|
||||
class SizeLimitedDict(defaultdict):
|
||||
def __init__(self, max_size, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.max_size = max_size
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if len(self) >= self.max_size:
|
||||
self.clear()
|
||||
|
||||
super().__setitem__(key, value)
|
||||
|
||||
|
||||
class FlagCacheEntry:
|
||||
def __init__(self, flag_result, flag_definition_version, timestamp=None):
|
||||
self.flag_result = flag_result
|
||||
self.flag_definition_version = flag_definition_version
|
||||
self.timestamp = timestamp or time.time()
|
||||
|
||||
def is_valid(self, current_time, ttl, current_flag_version):
|
||||
time_valid = (current_time - self.timestamp) < ttl
|
||||
version_valid = self.flag_definition_version == current_flag_version
|
||||
return time_valid and version_valid
|
||||
|
||||
def is_stale_but_usable(self, current_time, max_stale_age=3600):
|
||||
return (current_time - self.timestamp) < max_stale_age
|
||||
|
||||
|
||||
class FlagCache:
|
||||
def __init__(self, max_size=10000, default_ttl=300):
|
||||
self.cache = {} # distinct_id -> {flag_key: FlagCacheEntry}
|
||||
self.access_times = {} # distinct_id -> last_access_time
|
||||
self.max_size = max_size
|
||||
self.default_ttl = default_ttl
|
||||
|
||||
def get_cached_flag(self, distinct_id, flag_key, current_flag_version):
|
||||
current_time = time.time()
|
||||
|
||||
if distinct_id not in self.cache:
|
||||
return None
|
||||
|
||||
user_flags = self.cache[distinct_id]
|
||||
if flag_key not in user_flags:
|
||||
return None
|
||||
|
||||
entry = user_flags[flag_key]
|
||||
if entry.is_valid(current_time, self.default_ttl, current_flag_version):
|
||||
self.access_times[distinct_id] = current_time
|
||||
return entry.flag_result
|
||||
|
||||
return None
|
||||
|
||||
def get_stale_cached_flag(self, distinct_id, flag_key, max_stale_age=3600):
|
||||
current_time = time.time()
|
||||
|
||||
if distinct_id not in self.cache:
|
||||
return None
|
||||
|
||||
user_flags = self.cache[distinct_id]
|
||||
if flag_key not in user_flags:
|
||||
return None
|
||||
|
||||
entry = user_flags[flag_key]
|
||||
if entry.is_stale_but_usable(current_time, max_stale_age):
|
||||
return entry.flag_result
|
||||
|
||||
return None
|
||||
|
||||
def set_cached_flag(
|
||||
self, distinct_id, flag_key, flag_result, flag_definition_version
|
||||
):
|
||||
current_time = time.time()
|
||||
|
||||
# Evict LRU users if we're at capacity
|
||||
if distinct_id not in self.cache and len(self.cache) >= self.max_size:
|
||||
self._evict_lru()
|
||||
|
||||
# Initialize user cache if needed
|
||||
if distinct_id not in self.cache:
|
||||
self.cache[distinct_id] = {}
|
||||
|
||||
# Store the flag result
|
||||
self.cache[distinct_id][flag_key] = FlagCacheEntry(
|
||||
flag_result, flag_definition_version, current_time
|
||||
)
|
||||
self.access_times[distinct_id] = current_time
|
||||
|
||||
def invalidate_version(self, old_version):
|
||||
users_to_remove = []
|
||||
|
||||
for distinct_id, user_flags in self.cache.items():
|
||||
flags_to_remove = []
|
||||
for flag_key, entry in user_flags.items():
|
||||
if entry.flag_definition_version == old_version:
|
||||
flags_to_remove.append(flag_key)
|
||||
|
||||
# Remove invalidated flags
|
||||
for flag_key in flags_to_remove:
|
||||
del user_flags[flag_key]
|
||||
|
||||
# Remove user entirely if no flags remain
|
||||
if not user_flags:
|
||||
users_to_remove.append(distinct_id)
|
||||
|
||||
# Clean up empty users
|
||||
for distinct_id in users_to_remove:
|
||||
del self.cache[distinct_id]
|
||||
if distinct_id in self.access_times:
|
||||
del self.access_times[distinct_id]
|
||||
|
||||
def _evict_lru(self):
|
||||
if not self.access_times:
|
||||
return
|
||||
|
||||
# Remove 20% of least recently used entries
|
||||
sorted_users = sorted(self.access_times.items(), key=lambda x: x[1])
|
||||
to_remove = max(1, len(sorted_users) // 5)
|
||||
|
||||
for distinct_id, _ in sorted_users[:to_remove]:
|
||||
if distinct_id in self.cache:
|
||||
del self.cache[distinct_id]
|
||||
if distinct_id in self.access_times:
|
||||
del self.access_times[distinct_id]
|
||||
|
||||
def clear(self):
|
||||
self.cache.clear()
|
||||
self.access_times.clear()
|
||||
|
||||
|
||||
class RedisFlagCache:
|
||||
def __init__(
|
||||
self, redis_client, default_ttl=300, stale_ttl=3600, key_prefix="posthog:flags:"
|
||||
):
|
||||
self.redis = redis_client
|
||||
self.default_ttl = default_ttl
|
||||
self.stale_ttl = stale_ttl
|
||||
self.key_prefix = key_prefix
|
||||
self.version_key = f"{key_prefix}version"
|
||||
|
||||
def _get_cache_key(self, distinct_id, flag_key):
|
||||
return f"{self.key_prefix}{distinct_id}:{flag_key}"
|
||||
|
||||
def _serialize_entry(self, flag_result, flag_definition_version, timestamp=None):
|
||||
if timestamp is None:
|
||||
timestamp = time.time()
|
||||
|
||||
# Use clean to make flag_result JSON-serializable for cross-platform compatibility
|
||||
serialized_result = clean(flag_result)
|
||||
|
||||
entry = {
|
||||
"flag_result": serialized_result,
|
||||
"flag_version": flag_definition_version,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
return json.dumps(entry)
|
||||
|
||||
def _deserialize_entry(self, data):
|
||||
try:
|
||||
entry = json.loads(data)
|
||||
flag_result = entry["flag_result"]
|
||||
return FlagCacheEntry(
|
||||
flag_result=flag_result,
|
||||
flag_definition_version=entry["flag_version"],
|
||||
timestamp=entry["timestamp"],
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, ValueError):
|
||||
# If deserialization fails, treat as cache miss
|
||||
return None
|
||||
|
||||
def get_cached_flag(self, distinct_id, flag_key, current_flag_version):
|
||||
try:
|
||||
cache_key = self._get_cache_key(distinct_id, flag_key)
|
||||
data = self.redis.get(cache_key)
|
||||
|
||||
if data:
|
||||
entry = self._deserialize_entry(data)
|
||||
if entry and entry.is_valid(
|
||||
time.time(), self.default_ttl, current_flag_version
|
||||
):
|
||||
return entry.flag_result
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
# Redis error - return None to fall back to normal evaluation
|
||||
return None
|
||||
|
||||
def get_stale_cached_flag(self, distinct_id, flag_key, max_stale_age=None):
|
||||
try:
|
||||
if max_stale_age is None:
|
||||
max_stale_age = self.stale_ttl
|
||||
|
||||
cache_key = self._get_cache_key(distinct_id, flag_key)
|
||||
data = self.redis.get(cache_key)
|
||||
|
||||
if data:
|
||||
entry = self._deserialize_entry(data)
|
||||
if entry and entry.is_stale_but_usable(time.time(), max_stale_age):
|
||||
return entry.flag_result
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
# Redis error - return None
|
||||
return None
|
||||
|
||||
def set_cached_flag(
|
||||
self, distinct_id, flag_key, flag_result, flag_definition_version
|
||||
):
|
||||
try:
|
||||
cache_key = self._get_cache_key(distinct_id, flag_key)
|
||||
serialized_entry = self._serialize_entry(
|
||||
flag_result, flag_definition_version
|
||||
)
|
||||
|
||||
# Set with TTL for automatic cleanup (use stale_ttl for total lifetime)
|
||||
self.redis.setex(cache_key, self.stale_ttl, serialized_entry)
|
||||
|
||||
# Update the current version
|
||||
self.redis.set(self.version_key, flag_definition_version)
|
||||
|
||||
except Exception:
|
||||
# Redis error - silently fail, don't break flag evaluation
|
||||
pass
|
||||
|
||||
def invalidate_version(self, old_version):
|
||||
try:
|
||||
# For Redis, we use a simple approach: scan for keys with old version
|
||||
# and delete them. This could be expensive with many keys, but it's
|
||||
# necessary for correctness.
|
||||
|
||||
cursor = 0
|
||||
pattern = f"{self.key_prefix}*"
|
||||
|
||||
while True:
|
||||
cursor, keys = self.redis.scan(cursor, match=pattern, count=100)
|
||||
|
||||
for key in keys:
|
||||
if key.decode() == self.version_key:
|
||||
continue
|
||||
|
||||
try:
|
||||
data = self.redis.get(key)
|
||||
if data:
|
||||
entry_dict = json.loads(data)
|
||||
if entry_dict.get("flag_version") == old_version:
|
||||
self.redis.delete(key)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
# If we can't parse the entry, delete it to be safe
|
||||
self.redis.delete(key)
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
except Exception:
|
||||
# Redis error - silently fail
|
||||
pass
|
||||
|
||||
def clear(self):
|
||||
try:
|
||||
# Delete all keys matching our pattern
|
||||
cursor = 0
|
||||
pattern = f"{self.key_prefix}*"
|
||||
|
||||
while True:
|
||||
cursor, keys = self.redis.scan(cursor, match=pattern, count=100)
|
||||
if keys:
|
||||
self.redis.delete(*keys)
|
||||
if cursor == 0:
|
||||
break
|
||||
except Exception:
|
||||
# Redis error - silently fail
|
||||
pass
|
||||
|
||||
|
||||
def convert_to_datetime_aware(date_obj):
|
||||
if date_obj.tzinfo is None:
|
||||
date_obj = date_obj.replace(tzinfo=timezone.utc)
|
||||
return date_obj
|
||||
|
||||
|
||||
def str_icontains(source, search):
|
||||
"""
|
||||
Check if a string contains another string, ignoring case.
|
||||
|
||||
Args:
|
||||
source: The string to search within
|
||||
search: The substring to search for
|
||||
|
||||
Returns:
|
||||
bool: True if search is a substring of source (case-insensitive), False otherwise
|
||||
|
||||
Examples:
|
||||
>>> str_icontains("Hello World", "WORLD")
|
||||
True
|
||||
>>> str_icontains("Hello World", "python")
|
||||
False
|
||||
"""
|
||||
return str(search).casefold() in str(source).casefold()
|
||||
|
||||
|
||||
def str_iequals(value, comparand):
|
||||
"""
|
||||
Check if a string equals another string, ignoring case.
|
||||
|
||||
Args:
|
||||
value: The string to compare
|
||||
comparand: The string to compare with
|
||||
|
||||
Returns:
|
||||
bool: True if value and comparand are equal (case-insensitive), False otherwise
|
||||
|
||||
Examples:
|
||||
>>> str_iequals("Hello World", "hello world")
|
||||
True
|
||||
>>> str_iequals("Hello World", "hello")
|
||||
False
|
||||
"""
|
||||
return str(value).casefold() == str(comparand).casefold()
|
||||
|
||||
|
||||
def get_os_info():
|
||||
"""
|
||||
Returns standardized OS name and version information.
|
||||
Similar to how user agent parsing works in JS.
|
||||
"""
|
||||
os_name = ""
|
||||
os_version = ""
|
||||
|
||||
platform_name = sys.platform
|
||||
|
||||
if platform_name.startswith("win"):
|
||||
os_name = "Windows"
|
||||
if hasattr(platform, "win32_ver"):
|
||||
win_version = platform.win32_ver()[0]
|
||||
if win_version:
|
||||
os_version = win_version
|
||||
|
||||
elif platform_name == "darwin":
|
||||
os_name = "Mac OS X"
|
||||
if hasattr(platform, "mac_ver"):
|
||||
mac_version = platform.mac_ver()[0]
|
||||
if mac_version:
|
||||
os_version = mac_version
|
||||
|
||||
elif platform_name.startswith("linux"):
|
||||
os_name = "Linux"
|
||||
linux_info = distro.info()
|
||||
if linux_info["version"]:
|
||||
os_version = linux_info["version"]
|
||||
|
||||
elif platform_name.startswith("freebsd"):
|
||||
os_name = "FreeBSD"
|
||||
if hasattr(platform, "release"):
|
||||
os_version = platform.release()
|
||||
|
||||
else:
|
||||
os_name = platform_name
|
||||
if hasattr(platform, "release"):
|
||||
os_version = platform.release()
|
||||
|
||||
return os_name, os_version
|
||||
|
||||
|
||||
def system_context() -> dict[str, Any]:
|
||||
os_name, os_version = get_os_info()
|
||||
|
||||
return {
|
||||
"$python_runtime": platform.python_implementation(),
|
||||
"$python_version": "%s.%s.%s" % (sys.version_info[:3]),
|
||||
"$os": os_name,
|
||||
"$os_version": os_version,
|
||||
}
|
||||
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
VERSION = '1.0.11'
|
||||
VERSION = "6.7.1"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[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",
|
||||
"typing-extensions>=4.2.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",
|
||||
"litellm",
|
||||
"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.ai.litellm",
|
||||
"posthog.test",
|
||||
"posthog.integrations",
|
||||
]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = { attr = "posthog.version.VERSION" }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple test script for PostHog remote config endpoint.
|
||||
"""
|
||||
|
||||
import posthog
|
||||
|
||||
# Initialize PostHog client
|
||||
posthog.api_key = "phc_..."
|
||||
posthog.personal_api_key = "phs_..." # or "phx_..."
|
||||
posthog.host = "http://localhost:8000" # or "https://us.posthog.com"
|
||||
posthog.debug = True
|
||||
|
||||
|
||||
def test_remote_config():
|
||||
"""Test remote config payload retrieval."""
|
||||
print("Testing remote config endpoint...")
|
||||
|
||||
# Test feature flag key - replace with an actual flag key from your project
|
||||
flag_key = "unencrypted-remote-config-setting"
|
||||
|
||||
try:
|
||||
# Get remote config payload
|
||||
payload = posthog.get_remote_config_payload(flag_key)
|
||||
print(f"✅ Success! Remote config payload for '{flag_key}': {payload}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error getting remote config: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_remote_config()
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -8,57 +7,29 @@ except ImportError:
|
||||
from distutils.core import setup
|
||||
|
||||
# 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
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthog"))
|
||||
from version import VERSION # noqa: E402
|
||||
|
||||
long_description = '''
|
||||
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
|
||||
'''
|
||||
long_description = """
|
||||
PostHog is developer-friendly, self-hosted product analytics.
|
||||
posthog-python is the python package.
|
||||
|
||||
install_requires = [
|
||||
"requests>=2.7,<3.0",
|
||||
"six>=1.5",
|
||||
"monotonic>=1.5",
|
||||
"backoff==1.6.0",
|
||||
"python-dateutil>2.1"
|
||||
]
|
||||
|
||||
tests_require = [
|
||||
"mock>=2.0.0",
|
||||
"freezegun==0.3.15"
|
||||
]
|
||||
This package requires Python 3.9 or higher.
|
||||
"""
|
||||
|
||||
# Minimal setup.py for backward compatibility
|
||||
# Most configuration is now in pyproject.toml
|
||||
setup(
|
||||
name='posthog',
|
||||
name="posthog",
|
||||
version=VERSION,
|
||||
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.test'],
|
||||
license='MIT License',
|
||||
install_requires=install_requires,
|
||||
tests_require=tests_require,
|
||||
description='Integrate PostHog into any python application.',
|
||||
# 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",
|
||||
license="MIT License",
|
||||
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 :: 2",
|
||||
"Programming Language :: Python :: 2.6",
|
||||
"Programming Language :: Python :: 2.7",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.2",
|
||||
"Programming Language :: Python :: 3.3",
|
||||
"Programming Language :: Python :: 3.4",
|
||||
"Programming Language :: Python :: 3.5",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
],
|
||||
# This will fallback to pyproject.toml for detailed configuration
|
||||
)
|
||||
|
||||
+52
-47
@@ -1,63 +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
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'posthoganalytics'))
|
||||
from version import VERSION
|
||||
# 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 # noqa: E402
|
||||
|
||||
long_description = '''
|
||||
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
|
||||
'''
|
||||
|
||||
install_requires = [
|
||||
"requests>=2.7,<3.0",
|
||||
"six>=1.5",
|
||||
"monotonic>=1.5",
|
||||
"backoff==1.6.0",
|
||||
"python-dateutil>2.1"
|
||||
]
|
||||
# Copy the original pyproject.toml as backup
|
||||
shutil.copy("pyproject.toml", "pyproject.toml.backup")
|
||||
|
||||
tests_require = [
|
||||
"mock>=2.0.0"
|
||||
]
|
||||
# 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.
|
||||
|
||||
This package requires Python 3.9 or higher.
|
||||
"""
|
||||
|
||||
# Minimal setup.py for backward compatibility
|
||||
# Most configuration is now in pyproject.toml
|
||||
setup(
|
||||
name='posthoganalytics',
|
||||
name="posthoganalytics",
|
||||
version=VERSION,
|
||||
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.test'],
|
||||
license='MIT License',
|
||||
install_requires=install_requires,
|
||||
tests_require=tests_require,
|
||||
description='Integrate PostHog into any python application.',
|
||||
# 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",
|
||||
license="MIT License",
|
||||
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 :: 2",
|
||||
"Programming Language :: Python :: 2.6",
|
||||
"Programming Language :: Python :: 2.7",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.2",
|
||||
"Programming Language :: Python :: 3.3",
|
||||
"Programming Language :: Python :: 3.4",
|
||||
"Programming Language :: Python :: 3.5",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
],
|
||||
# This will fallback to pyproject.toml for detailed configuration
|
||||
)
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import posthog
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
|
||||
__name__ = 'simulator.py'
|
||||
__version__ = '0.0.1'
|
||||
__description__ = 'scripting simulator'
|
||||
|
||||
|
||||
def json_hash(str):
|
||||
if str:
|
||||
return json.loads(str)
|
||||
|
||||
# posthog -method=<method> -posthog-write-key=<posthogWriteKey> [options]
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(description='send a posthog message')
|
||||
|
||||
parser.add_argument('--writeKey', help='the posthog writeKey')
|
||||
parser.add_argument('--type', help='The posthog message type')
|
||||
|
||||
parser.add_argument('--distinct_id', help='the user id to send the event as')
|
||||
parser.add_argument(
|
||||
'--anonymousId', help='the anonymous user id to send the event as')
|
||||
parser.add_argument(
|
||||
'--context', help='additional context for the event (JSON-encoded)')
|
||||
|
||||
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(
|
||||
'--traits', help='the identify/group traits to send (JSON-encoded)')
|
||||
|
||||
parser.add_argument('--groupId', help='the group id')
|
||||
|
||||
options = parser.parse_args()
|
||||
|
||||
|
||||
def failed(status, msg):
|
||||
raise Exception(msg)
|
||||
|
||||
|
||||
def capture():
|
||||
posthog.capture(options.distinct_id, options.event, anonymous_id=options.anonymousId,
|
||||
properties=json_hash(options.properties), context=json_hash(options.context))
|
||||
|
||||
|
||||
def page():
|
||||
posthog.page(options.distinct_id, name=options.name, anonymous_id=options.anonymousId,
|
||||
properties=json_hash(options.properties), context=json_hash(options.context))
|
||||
|
||||
|
||||
def identify():
|
||||
posthog.identify(options.distinct_id, anonymous_id=options.anonymousId,
|
||||
traits=json_hash(options.traits), context=json_hash(options.context))
|
||||
|
||||
|
||||
def unknown():
|
||||
print()
|
||||
|
||||
|
||||
posthog.api_key = options.writeKey
|
||||
posthog.on_error = failed
|
||||
posthog.debug = True
|
||||
|
||||
log = logging.getLogger('posthog')
|
||||
ch = logging.StreamHandler()
|
||||
ch.setLevel(logging.DEBUG)
|
||||
log.addHandler(ch)
|
||||
|
||||
switcher = {
|
||||
"capture": capture,
|
||||
"page": page,
|
||||
"identify": identify
|
||||
}
|
||||
|
||||
func = switcher.get(options.type)
|
||||
if func:
|
||||
func()
|
||||
posthog.shutdown()
|
||||
else:
|
||||
print("Invalid Message Type " + options.type)
|
||||
Reference in New Issue
Block a user