Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14d1d0b99c | ||
|
|
80e6e432b4 | ||
|
|
5d0bae1b22 | ||
|
|
b17928075a | ||
|
|
b6dbff1cb7 | ||
|
|
9f8faf70a1 | ||
|
|
440651d90d | ||
|
|
da8653305f | ||
|
|
88a7c5ec84 | ||
|
|
d72e89adab | ||
|
|
ce38fb2a49 | ||
|
|
103a7ad933 | ||
|
|
fff9992fe9 | ||
|
|
c253e418c3 | ||
|
|
285597740e | ||
|
|
7c7f5293af | ||
|
|
494c78675d | ||
|
|
f75c5efeec | ||
|
|
65785b892e | ||
|
|
6dde2bf9e5 | ||
|
|
48203364a9 | ||
|
|
805c308841 | ||
|
|
898654a174 | ||
|
|
499d54570c | ||
|
|
6c815dffc3 | ||
|
|
3a1b8e49cd | ||
|
|
f3e5d7132f | ||
|
|
88f606994c | ||
|
|
a155e1dfd6 | ||
|
|
f648a5dfd7 | ||
|
|
e309bd7149 | ||
|
|
0cfd678857 | ||
|
|
3d8825fc67 | ||
|
|
3e52e7feda | ||
|
|
98e322695d | ||
|
|
700c922baf | ||
|
|
69293f5198 | ||
|
|
46589f93d5 | ||
|
|
57546d29e6 | ||
|
|
50b0c7170a | ||
|
|
f719c3dadf | ||
|
|
105090a6ba | ||
|
|
edfadcc6a8 | ||
|
|
13184e2e16 | ||
|
|
1b8642331f | ||
|
|
6af129f414 | ||
|
|
02e82a6050 | ||
|
|
9a05db8b20 | ||
|
|
e06830e068 | ||
|
|
465baea6f8 | ||
|
|
2bd6e9eaf1 | ||
|
|
e6fe39a0dd | ||
|
|
67f68c00fe | ||
|
|
6156e51f8f | ||
|
|
461c45772a | ||
|
|
a221bffb52 | ||
|
|
26cfd818af | ||
|
|
e868e23dcb | ||
|
|
0bb6342472 | ||
|
|
d76bfe6e5b | ||
|
|
b3e21c1c0e | ||
|
|
08b11cbf9b | ||
|
|
cee26bb3dc | ||
|
|
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 |
@@ -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,36 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "10:00"
|
||||
timezone: "UTC"
|
||||
groups:
|
||||
ai-providers:
|
||||
patterns:
|
||||
- "openai"
|
||||
- "anthropic"
|
||||
- "google-genai"
|
||||
- "langchain-core"
|
||||
- "langchain-community"
|
||||
- "langchain-openai"
|
||||
- "langchain-anthropic"
|
||||
- "langgraph"
|
||||
allow:
|
||||
- dependency-name: "openai"
|
||||
- dependency-name: "anthropic"
|
||||
- dependency-name: "google-genai"
|
||||
- dependency-name: "langchain-core"
|
||||
- dependency-name: "langchain-community"
|
||||
- dependency-name: "langchain-openai"
|
||||
- dependency-name: "langchain-anthropic"
|
||||
- dependency-name: "langgraph"
|
||||
open-pull-requests-limit: 1
|
||||
reviewers:
|
||||
- "PostHog/team-llm-analytics"
|
||||
# Uncomment below to enable auto-merge for minor updates when CI passes
|
||||
# pull-request-branch-name:
|
||||
# separator: "/"
|
||||
# assignees:
|
||||
# - "PostHog/ai-team"
|
||||
@@ -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
|
||||
+69
-26
@@ -3,63 +3,106 @@ name: CI
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
code-quality:
|
||||
name: Code quality checks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v2
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: 3.11.11
|
||||
|
||||
- uses: actions/cache@v1
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
enable-cache: true
|
||||
pyproject-file: 'pyproject.toml'
|
||||
|
||||
- name: Install dev dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install -e .[dev]
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra dev
|
||||
|
||||
- name: Check formatting with black
|
||||
- name: Check formatting with ruff
|
||||
run: |
|
||||
black --check .
|
||||
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
flake8 posthog --ignore E501
|
||||
ruff format --check .
|
||||
|
||||
- name: Check import order with isort
|
||||
- name: Lint with ruff
|
||||
run: |
|
||||
isort --check-only .
|
||||
ruff check .
|
||||
|
||||
- name: Check types with mypy
|
||||
run: |
|
||||
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
|
||||
|
||||
tests:
|
||||
name: Python tests
|
||||
name: Python ${{ matrix.python-version }} tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
with:
|
||||
python-version: 3.7
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install requirements.txt dependencies with pip
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
enable-cache: true
|
||||
pyproject-file: 'pyproject.toml'
|
||||
|
||||
- name: Install test dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install -e .[test]
|
||||
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra test
|
||||
|
||||
- name: Run posthog tests
|
||||
run: |
|
||||
pytest --verbose --timeout=30
|
||||
|
||||
django5-integration:
|
||||
name: Django 5 integration tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
enable-cache: true
|
||||
pyproject-file: 'integration_tests/django5/pyproject.toml'
|
||||
|
||||
- name: Install Django 5 test project dependencies
|
||||
shell: bash
|
||||
working-directory: integration_tests/django5
|
||||
run: |
|
||||
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync
|
||||
|
||||
- name: Run Django 5 middleware integration tests
|
||||
working-directory: integration_tests/django5
|
||||
run: |
|
||||
uv run pytest test_middleware.py test_exception_capture.py --verbose
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: "Generate References"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
docs-generation:
|
||||
name: Generate references
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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: Generate references
|
||||
run: |
|
||||
uv run bin/docs generate-references
|
||||
|
||||
- name: Check for changes in references
|
||||
id: changes
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain references/)" ]; then
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
echo "New references generated in references directory:"
|
||||
git status --porcelain references/
|
||||
else
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
echo "No new references generated in references directory"
|
||||
fi
|
||||
|
||||
- uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
with:
|
||||
commit_message: "Update generated references"
|
||||
file_pattern: references/
|
||||
@@ -1,38 +0,0 @@
|
||||
name: 'Release'
|
||||
|
||||
on:
|
||||
- workflow_dispatch
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Publish release
|
||||
runs-on: ubuntu-20.04
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
|
||||
- name: Detect version
|
||||
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare for building release
|
||||
run: pip install -U pip setuptools wheel twine
|
||||
|
||||
- name: Push release to PyPI
|
||||
run: make release && make release_analytics
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ env.REPO_VERSION }}
|
||||
release_name: ${{ env.REPO_VERSION }}
|
||||
@@ -0,0 +1,56 @@
|
||||
name: "Release"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "posthog/version.py"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Publish release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
run: uv run make release && uv run make release_analytics
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
|
||||
with:
|
||||
tag_name: v${{ env.REPO_VERSION }}
|
||||
release_name: ${{ env.REPO_VERSION }}
|
||||
|
||||
- name: Dispatch generate-references for posthog-python
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh workflow run generate-references.yml --ref master
|
||||
+6
-1
@@ -14,4 +14,9 @@ pylint.out
|
||||
posthog-analytics
|
||||
.idea
|
||||
.python-version
|
||||
.coverage
|
||||
.coverage
|
||||
pyrightconfig.json
|
||||
.env
|
||||
.DS_Store
|
||||
posthog-python-references.json
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: stable
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.7.0
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.11.12
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff-check
|
||||
args: [ --fix ]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
+237
@@ -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"})
|
||||
```
|
||||
+595
-6
@@ -1,8 +1,593 @@
|
||||
# 7.4.1 - 2025-12-19
|
||||
|
||||
fix: extract model from response for OpenAI stored prompts
|
||||
|
||||
When using OpenAI stored prompts, the model is defined in the OpenAI dashboard rather than passed in the API request. This fix adds a fallback to extract the model from the response object when not provided in kwargs, ensuring generations show up with the correct model and enabling cost calculations.
|
||||
|
||||
# 7.4.0 - 2025-12-16
|
||||
|
||||
feat: Add automatic retries for feature flag requests
|
||||
|
||||
Feature flag API requests now automatically retry on transient failures:
|
||||
- Network errors (connection refused, DNS failures, timeouts)
|
||||
- Server errors (500, 502, 503, 504)
|
||||
- Up to 2 retries with exponential backoff (0.5s, 1s delays)
|
||||
|
||||
Rate limit (429) and quota (402) errors are not retried.
|
||||
|
||||
# 7.3.1 - 2025-12-06
|
||||
|
||||
fix: remove unused $exception_message and $exception_type
|
||||
|
||||
# 7.3.0 - 2025-12-05
|
||||
|
||||
feat: improve code variables capture masking
|
||||
|
||||
# 7.2.0 - 2025-12-01
|
||||
|
||||
feat: add $feature_flag_evaluated_at properties to $feature_flag_called events
|
||||
|
||||
# 7.1.0 - 2025-11-26
|
||||
|
||||
Add support for the async version of Gemini.
|
||||
|
||||
# 7.0.2 - 2025-11-18
|
||||
|
||||
Add support for Python 3.14.
|
||||
Projects upgrading to Python 3.14 should ensure any Pydantic models passed into the SDK use Pydantic v2, as Pydantic v1 is not compatible with Python 3.14.
|
||||
|
||||
# 7.0.1 - 2025-11-15
|
||||
|
||||
Try to use repr() when formatting code variables
|
||||
|
||||
# 7.0.0 - 2025-11-11
|
||||
|
||||
NB Python 3.9 is no longer supported
|
||||
|
||||
- chore(llma): update LLM provider SDKs to latest major versions
|
||||
- openai: 1.102.0 → 2.7.1
|
||||
- anthropic: 0.64.0 → 0.72.0
|
||||
- google-genai: 1.32.0 → 1.49.0
|
||||
- langchain-core: 0.3.75 → 1.0.3
|
||||
- langchain-openai: 0.3.32 → 1.0.2
|
||||
- langchain-anthropic: 0.3.19 → 1.0.1
|
||||
- langchain-community: 0.3.29 → 0.4.1
|
||||
- langgraph: 0.6.6 → 1.0.2
|
||||
|
||||
# 6.9.3 - 2025-11-10
|
||||
|
||||
- feat(ph-ai): PostHog properties dict in GenerationMetadata
|
||||
|
||||
# 6.9.2 - 2025-11-10
|
||||
|
||||
- fix(llma): fix cache token double subtraction in Langchain for non-Anthropic providers causing negative costs
|
||||
|
||||
# 6.9.1 - 2025-11-07
|
||||
|
||||
- fix(error-tracking): pass code variables config from init to client
|
||||
|
||||
# 6.9.0 - 2025-11-06
|
||||
|
||||
- feat(error-tracking): add local variables capture
|
||||
|
||||
# 6.8.0 - 2025-11-03
|
||||
|
||||
- feat(llma): send web search calls to be used for LLM cost calculations
|
||||
|
||||
# 6.7.14 - 2025-11-03
|
||||
|
||||
- fix(django): Handle request.user access in async middleware context to prevent SynchronousOnlyOperation errors in Django 5+ (fixes #355)
|
||||
- test(django): Add Django 5 integration test suite with real ASGI application testing async middleware behavior
|
||||
|
||||
# 6.7.13 - 2025-11-02
|
||||
|
||||
- fix(llma): cache cost calculation in the LangChain callback
|
||||
|
||||
# 6.7.12 - 2025-11-02
|
||||
|
||||
- fix(django): Restore process_exception method to capture view and downstream middleware exceptions (fixes #329)
|
||||
- fix(ai/langchain): Add LangChain 1.0+ compatibility for CallbackHandler imports (fixes #362)
|
||||
|
||||
# 6.7.11 - 2025-10-28
|
||||
|
||||
- feat(ai): Add `$ai_framework` property for framework integrations (e.g. LangChain)
|
||||
|
||||
# 6.7.10 - 2025-10-24
|
||||
|
||||
- fix(django): Make middleware truly hybrid - compatible with both sync (WSGI) and async (ASGI) Django stacks without breaking sync-only deployments
|
||||
|
||||
# 6.7.9 - 2025-10-22
|
||||
|
||||
- fix(flags): multi-condition flags with static cohorts returning wrong variants
|
||||
|
||||
# 6.7.8 - 2025-10-16
|
||||
|
||||
- fix(llma): missing async for OpenAI's streaming implementation
|
||||
|
||||
# 6.7.7 - 2025-10-14
|
||||
|
||||
- fix: remove deprecated attribute $exception_personURL from exception events
|
||||
|
||||
# 6.7.6 - 2025-09-16
|
||||
|
||||
- fix: don't sort condition sets with variant overrides to the top
|
||||
- fix: Prevent core Client methods from raising exceptions
|
||||
|
||||
# 6.7.5 - 2025-09-16
|
||||
|
||||
- feat: Django middleware now supports async request handling.
|
||||
|
||||
# 6.7.4 - 2025-09-05
|
||||
|
||||
- fix: Missing system prompts for some providers
|
||||
|
||||
# 6.7.3 - 2025-09-04
|
||||
|
||||
- fix: missing usage tokens in Gemini
|
||||
|
||||
# 6.7.2 - 2025-09-03
|
||||
|
||||
- fix: tool call results in streaming providers
|
||||
|
||||
# 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.
|
||||
@@ -28,6 +613,7 @@
|
||||
|
||||
1. Restore how feature flags work when the client library is disabled: All requests return `None` and no events are sent when the client is disabled.
|
||||
2. Add a `feature_flag_definitions()` debug option, which returns currently loaded feature flag definitions. You can use this to more cleverly decide when to request local evaluation of feature flags.
|
||||
|
||||
## 3.0.0 - 2023-04-14
|
||||
|
||||
Breaking change:
|
||||
@@ -68,7 +654,6 @@ posthog = Posthog('api_key', disable_geoip=False)
|
||||
1. Log instead of raise error on posthog personal api key errors
|
||||
2. Remove upper bound on backoff dependency
|
||||
|
||||
|
||||
## 2.3.0 - 2023-01-31
|
||||
|
||||
1. Add support for returning payloads of matched feature flags
|
||||
@@ -84,6 +669,7 @@ Changes:
|
||||
Changes:
|
||||
|
||||
1. Fixes issues with date comparison.
|
||||
|
||||
## 2.1.1 - 2022-09-14
|
||||
|
||||
Changes:
|
||||
@@ -96,6 +682,7 @@ Changes:
|
||||
|
||||
1. Feature flag defaults have been removed
|
||||
2. Setup logging only when debug mode is enabled.
|
||||
|
||||
## 2.0.1 - 2022-08-04
|
||||
|
||||
- Make poll_interval configurable
|
||||
@@ -107,8 +694,8 @@ Changes:
|
||||
Breaking changes:
|
||||
|
||||
1. The minimum version requirement for PostHog servers is now 1.38. If you're using PostHog Cloud, you satisfy this requirement automatically.
|
||||
2. Feature flag defaults apply only when there's an error fetching feature flag results. Earlier, if the default was set to `True`, even if a flag resolved to `False`, the default would override this.
|
||||
**Note: These are removed in 2.0.2**
|
||||
2. Feature flag defaults apply only when there's an error fetching feature flag results. Earlier, if the default was set to `True`, even if a flag resolved to `False`, the default would override this.
|
||||
**Note: These are removed in 2.0.2**
|
||||
3. Feature flag remote evaluation doesn't require a personal API key.
|
||||
|
||||
New Changes:
|
||||
@@ -116,18 +703,20 @@ New Changes:
|
||||
1. You can now evaluate feature flags locally (i.e. without sending a request to your PostHog servers) by setting a personal API key, and passing in groups and person properties to `is_feature_enabled` and `get_feature_flag` calls.
|
||||
2. Introduces a `get_all_flags` method that returns all feature flags. This is useful for when you want to seed your frontend with some initial flags, given a user ID.
|
||||
|
||||
|
||||
|
||||
## 1.4.9 - 2022-06-13
|
||||
|
||||
- Support for sending feature flags with capture calls
|
||||
|
||||
## 1.4.8 - 2022-05-12
|
||||
|
||||
- Support multi variate feature flags
|
||||
|
||||
## 1.4.7 - 2022-04-25
|
||||
|
||||
- Allow feature flags usage without project_api_key
|
||||
|
||||
## 1.4.1 - 2021-05-28
|
||||
|
||||
- Fix packaging issues with Sentry integrations
|
||||
|
||||
## 1.4.0 - 2021-05-18
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@PostHog/team-feature-flags
|
||||
@@ -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,6 +1,5 @@
|
||||
lint:
|
||||
pylint --rcfile=.pylintrc --reports=y --exit-zero analytics | tee pylint.out
|
||||
flake8 --max-complexity=10 --statistics analytics > flake8.out || true
|
||||
uvx ruff format
|
||||
|
||||
test:
|
||||
coverage run -m pytest
|
||||
@@ -17,20 +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
|
||||
|
||||
django_example:
|
||||
python -m pip install -e ".[sentry]"
|
||||
cd sentry_django_example && python manage.py runserver 8080
|
||||
prep_local:
|
||||
rm -rf ../posthog-python-local
|
||||
mkdir ../posthog-python-local
|
||||
cp -r . ../posthog-python-local/
|
||||
cd ../posthog-python-local && rm -rf dist build posthoganalytics .git
|
||||
cd ../posthog-python-local && mkdir posthoganalytics
|
||||
cd ../posthog-python-local && cp -r posthog/* posthoganalytics/
|
||||
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \;
|
||||
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \;
|
||||
cd ../posthog-python-local && find ./posthoganalytics -name "*.bak" -delete
|
||||
cd ../posthog-python-local && rm -rf posthog
|
||||
cd ../posthog-python-local && sed -i.bak 's/from version import VERSION/from posthoganalytics.version import VERSION/' setup_analytics.py
|
||||
cd ../posthog-python-local && rm setup_analytics.py.bak
|
||||
cd ../posthog-python-local && sed -i.bak 's/"posthog"/"posthoganalytics"/' setup.py
|
||||
cd ../posthog-python-local && rm setup.py.bak
|
||||
cd ../posthog-python-local && python -c "import setup_analytics" 2>/dev/null || true
|
||||
@echo "Local copy created at ../posthog-python-local"
|
||||
@echo "Install with: pip install -e ../posthog-python-local"
|
||||
|
||||
.PHONY: test lint release e2e_test
|
||||
.PHONY: test lint release e2e_test prep_local
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
# PostHog Python
|
||||
|
||||
[](https://pypi.org/project/posthog/)
|
||||
|
||||
<p align="center">
|
||||
<img alt="posthoglogo" src="https://user-images.githubusercontent.com/65415371/205059737-c8a4f836-4889-4654-902e-f302b187b6a0.png">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://pypi.org/project/posthog/"><img alt="pypi installs" src="https://img.shields.io/pypi/v/posthog"/></a>
|
||||
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/posthog/posthog-python">
|
||||
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/posthog/posthog-python"/>
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/posthog/posthog-python"/>
|
||||
</p>
|
||||
|
||||
Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details.
|
||||
|
||||
@@ -9,38 +16,51 @@ Please see the [Python integration docs](https://posthog.com/docs/integrations/p
|
||||
|
||||
### Testing Locally
|
||||
|
||||
1. Run `python3 -m venv env` (creates virtual environment called "env")
|
||||
We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
|
||||
|
||||
1. Run `uv venv env` (creates virtual environment called "env")
|
||||
* or `python3 -m venv env`
|
||||
2. Run `source env/bin/activate` (activates the virtual environment)
|
||||
3. Run `python3 -m pip install -e ".[test]"` (installs the package in develop mode, along with test dependencies)
|
||||
4. Run `make test`
|
||||
3. Run `uv sync --extra dev --extra test` (installs the package in develop mode, along with test dependencies)
|
||||
* or `pip install -e ".[dev,test]"`
|
||||
4. you have to run `pre-commit install` to have auto linting pre commit
|
||||
5. Run `make test`
|
||||
1. To run a specific test do `pytest -k test_no_api_key`
|
||||
|
||||
## PostHog recommends `uv` so...
|
||||
|
||||
```bash
|
||||
uv python install 3.12
|
||||
uv python pin 3.12
|
||||
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.
|
||||
|
||||
### Running the Django Sentry Integration Locally
|
||||
|
||||
There's a sample Django project included, called `sentry_django_example`, which explains how to use PostHog with Sentry.
|
||||
|
||||
There's 2 places of importance (Changes required are all marked with TODO in the sample project directory)
|
||||
|
||||
1. Settings.py
|
||||
1. Input your Sentry DSN
|
||||
2. Input your Sentry Org and ProjectID details into `PosthogIntegration()`
|
||||
3. Add `POSTHOG_DJANGO` to settings.py. This allows the `PosthogDistinctIdMiddleware` to get the distinct_ids
|
||||
|
||||
2. urls.py
|
||||
1. This includes the `sentry-debug/` endpoint, which generates an exception
|
||||
|
||||
To run things: `make django_example`. This installs the posthog-python library with the sentry-sdk add-on, and then runs the django app.
|
||||
Also start the PostHog app locally.
|
||||
Then navigate to `http://127.0.0.1:8080/sentry-debug/` and you should get an event in both Sentry and PostHog, with links to each other.
|
||||
|
||||
### Releasing Versions
|
||||
|
||||
Updated are released using GitHub Actions: after bumping `version.py` in `master` and adding to `CHANGELOG.md`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
|
||||
Updates are released 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.
|
||||
|
||||
## Questions?
|
||||
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`.
|
||||
|
||||
### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ)
|
||||
|
||||
### Testing changes locally with the PostHog app
|
||||
|
||||
You can run `make prep_local`, and it'll create a new folder alongside the SDK repo one called `posthog-python-local`, which you can then import into the posthog project by changing pyproject.toml to look like this:
|
||||
```toml
|
||||
dependencies = [
|
||||
...
|
||||
"posthoganalytics" #NOTE: no version number
|
||||
...
|
||||
]
|
||||
...
|
||||
[tools.uv.sources]
|
||||
posthoganalytics = { path = "../posthog-python-local" }
|
||||
```
|
||||
This'll let you build and test SDK changes fully locally, incorporating them into your local posthog app stack. It mainly takes care of the `posthog -> posthoganalytics` module renaming. You'll need to re-run `make prep_local` each time you make a change, and re-run `uv sync --active` in the posthog app project.
|
||||
|
||||
@@ -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,43 @@
|
||||
"""
|
||||
Constants for PostHog Python SDK documentation generation.
|
||||
"""
|
||||
|
||||
from typing import Dict, Union
|
||||
from posthog.version import VERSION
|
||||
|
||||
# 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": "./references",
|
||||
"filename": f"posthog-python-references-{VERSION}.json",
|
||||
"filename_latest": "posthog-python-references-latest.json",
|
||||
"indent": 2,
|
||||
}
|
||||
|
||||
# Documentation structure defaults
|
||||
DOC_DEFAULTS = {
|
||||
"showDocs": True,
|
||||
"releaseTag": "public",
|
||||
"return_type_void": "None",
|
||||
"max_optional_params": 3,
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
#!/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()
|
||||
|
||||
# Ensure output directory exists
|
||||
output_dir = str(OUTPUT_CONFIG["output_dir"])
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
output_file = os.path.join(
|
||||
str(OUTPUT_CONFIG["output_dir"]), str(OUTPUT_CONFIG["filename"])
|
||||
)
|
||||
output_file_latest = os.path.join(
|
||||
str(OUTPUT_CONFIG["output_dir"]), str(OUTPUT_CONFIG["filename_latest"])
|
||||
)
|
||||
|
||||
# Write to current version
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(documentation, f, indent=int(OUTPUT_CONFIG["indent"]))
|
||||
# Write to latest
|
||||
with open(output_file_latest, "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 "$@"
|
||||
+489
-82
@@ -1,101 +1,508 @@
|
||||
# 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 the library
|
||||
import time
|
||||
import os
|
||||
|
||||
import posthog
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
# You can find this key on the /setup page in PostHog
|
||||
posthog.project_api_key = "phc_gtWmTq3Pgl06u4sZY3TRcoQfp42yfuXHKoe8ZVSR6Kh"
|
||||
posthog.personal_api_key = "phx_fiRCOQkTA3o2ePSdLrFDAILLHjMu2Mv52vUi8MNruIm"
|
||||
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())
|
||||
|
||||
# Where you host PostHog, with no trailing /.
|
||||
# You can remove this line if you're using posthog.com
|
||||
posthog.host = "http://localhost:8000"
|
||||
|
||||
# Load .env file if it exists
|
||||
load_env_file()
|
||||
|
||||
# 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")
|
||||
|
||||
# Check if project key is provided (required)
|
||||
if not project_key:
|
||||
print("❌ Missing PostHog project API key!")
|
||||
print(" Please set POSTHOG_PROJECT_API_KEY environment variable")
|
||||
print(" or copy .env.example to .env and fill in your values")
|
||||
exit(1)
|
||||
|
||||
# Configure PostHog with credentials
|
||||
posthog.debug = False
|
||||
posthog.api_key = project_key
|
||||
posthog.project_api_key = project_key
|
||||
posthog.host = host
|
||||
posthog.poll_interval = 10
|
||||
|
||||
print(
|
||||
posthog.feature_enabled(
|
||||
"person-on-events-enabled",
|
||||
"12345",
|
||||
groups={"organization": str("0182ee91-8ef7-0000-4cb9-fedc5f00926a")},
|
||||
group_properties={
|
||||
"organization": {
|
||||
"id": "0182ee91-8ef7-0000-4cb9-fedc5f00926a",
|
||||
"created_at": "2022-06-30 11:44:52.984121+00:00",
|
||||
}
|
||||
},
|
||||
# Check if personal API key is available for local evaluation
|
||||
local_eval_available = bool(personal_api_key)
|
||||
if personal_api_key:
|
||||
posthog.personal_api_key = personal_api_key
|
||||
|
||||
print("🔑 PostHog Configuration:")
|
||||
print(f" Project API Key: {project_key[:9]}...")
|
||||
if local_eval_available:
|
||||
print(" Personal API Key: [SET]")
|
||||
else:
|
||||
print(" Personal API Key: [NOT SET] - Local evaluation examples will be skipped")
|
||||
print(f" Host: {host}\n")
|
||||
|
||||
# Display menu and get user choice
|
||||
print("🚀 PostHog Python SDK Demo - Choose an example to run:\n")
|
||||
print("1. Identify and capture examples")
|
||||
local_eval_note = "" if local_eval_available else " [requires personal API key]"
|
||||
print(f"2. Feature flag local evaluation examples{local_eval_note}")
|
||||
print("3. Feature flag payload examples")
|
||||
print(f"4. Flag dependencies examples{local_eval_note}")
|
||||
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":
|
||||
if not local_eval_available:
|
||||
print("\n❌ This example requires a personal API key for local evaluation.")
|
||||
print(
|
||||
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
|
||||
)
|
||||
posthog.shutdown()
|
||||
exit(1)
|
||||
|
||||
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":
|
||||
if not local_eval_available:
|
||||
print("\n❌ This example requires a personal API key for local evaluation.")
|
||||
print(
|
||||
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
|
||||
)
|
||||
posthog.shutdown()
|
||||
exit(1)
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
# Capture an event
|
||||
posthog.capture("distinct_id", "event", {"property1": "value", "property2": "value"}, send_feature_flags=True)
|
||||
|
||||
print(posthog.feature_enabled("beta-feature", "distinct_id"))
|
||||
print(posthog.feature_enabled("beta-feature-groups", "distinct_id", groups={"company": "id:5"}))
|
||||
|
||||
print(posthog.feature_enabled("beta-feature", "distinct_id"))
|
||||
|
||||
# get payload
|
||||
print(posthog.get_feature_flag_payload("beta-feature", "distinct_id"))
|
||||
print(posthog.get_all_flags_and_payloads("distinct_id"))
|
||||
exit()
|
||||
# # Alias a previous distinct id with a new one
|
||||
|
||||
posthog.alias("distinct_id", "new_distinct_id")
|
||||
|
||||
posthog.capture("new_distinct_id", "event2", {"property1": "value", "property2": "value"})
|
||||
posthog.capture(
|
||||
"new_distinct_id", "event-with-groups", {"property1": "value", "property2": "value"}, groups={"company": "id:5"}
|
||||
)
|
||||
|
||||
# # Add properties to the person
|
||||
posthog.identify("new_distinct_id", {"email": "something@something.com"})
|
||||
|
||||
# Add properties to a group
|
||||
posthog.group_identify("company", "id:5", {"employees": 11})
|
||||
|
||||
# properties set only once to the person
|
||||
posthog.set_once("new_distinct_id", {"self_serve_signup": True})
|
||||
|
||||
|
||||
posthog.set_once(
|
||||
"new_distinct_id", {"self_serve_signup": False}
|
||||
) # this will not change the property (because it was already set)
|
||||
|
||||
posthog.set("new_distinct_id", {"current_browser": "Chrome"})
|
||||
posthog.set("new_distinct_id", {"current_browser": "Firefox"})
|
||||
|
||||
|
||||
# #############################################################################
|
||||
# Make sure you have a personal API key for the examples below
|
||||
|
||||
# Local Evaluation
|
||||
|
||||
# If flag has City=Sydney, this call doesn't go to `/decide`
|
||||
print(posthog.feature_enabled("test-flag", "distinct_id_random_22", person_properties={"$geoip_city_name": "Sydney"}))
|
||||
|
||||
print(
|
||||
posthog.feature_enabled(
|
||||
"test-flag",
|
||||
"distinct_id_random_22",
|
||||
person_properties={"$geoip_city_name": "Sydney"},
|
||||
# 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}")
|
||||
|
||||
|
||||
print(posthog.get_all_flags("distinct_id_random_22"))
|
||||
print(posthog.get_all_flags("distinct_id_random_22", only_evaluate_locally=True))
|
||||
print(
|
||||
posthog.get_all_flags(
|
||||
"distinct_id_random_22", person_properties={"$geoip_city_name": "Sydney"}, only_evaluate_locally=True
|
||||
# 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...")
|
||||
if not local_eval_available:
|
||||
print(" (Skipping local evaluation examples - no personal API key set)\n")
|
||||
|
||||
# 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 (requires local evaluation)
|
||||
if local_eval_available:
|
||||
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 (requires local evaluation)
|
||||
if local_eval_available:
|
||||
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,144 @@
|
||||
"""
|
||||
Redis-based distributed cache for PostHog feature flag definitions.
|
||||
|
||||
This example demonstrates how to implement a FlagDefinitionCacheProvider
|
||||
using Redis for multi-instance deployments (leader election pattern).
|
||||
|
||||
Usage:
|
||||
import redis
|
||||
from posthog import Posthog
|
||||
|
||||
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
|
||||
cache = RedisFlagCache(redis_client, service_key="my-service")
|
||||
|
||||
posthog = Posthog(
|
||||
"<project_api_key>",
|
||||
personal_api_key="<personal_api_key>",
|
||||
flag_definition_cache_provider=cache,
|
||||
)
|
||||
|
||||
Requirements:
|
||||
pip install redis
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from posthog import FlagDefinitionCacheData, FlagDefinitionCacheProvider
|
||||
from redis import Redis
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class RedisFlagCache(FlagDefinitionCacheProvider):
|
||||
"""
|
||||
A distributed cache for PostHog feature flag definitions using Redis.
|
||||
|
||||
In a multi-instance deployment (e.g., multiple serverless functions or containers),
|
||||
we want only ONE instance to poll PostHog for flag updates, while all instances
|
||||
share the cached results. This prevents N instances from making N redundant API calls.
|
||||
|
||||
The implementation uses leader election:
|
||||
- One instance "wins" and becomes responsible for fetching
|
||||
- Other instances read from the shared cache
|
||||
- If the leader dies, the lock expires (TTL) and another instance takes over
|
||||
|
||||
Uses Lua scripts for atomic operations, following Redis distributed lock best practices:
|
||||
https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/
|
||||
"""
|
||||
|
||||
LOCK_TTL_MS = 60 * 1000 # 60 seconds, should be longer than the flags poll interval
|
||||
CACHE_TTL_SECONDS = 60 * 60 * 24 # 24 hours
|
||||
|
||||
# Lua script: acquire lock if free, or extend if we own it
|
||||
_LUA_TRY_LEAD = """
|
||||
local current = redis.call('GET', KEYS[1])
|
||||
if current == false then
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
|
||||
return 1
|
||||
elseif current == ARGV[1] then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
||||
return 1
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
|
||||
# Lua script: release lock only if we own it
|
||||
_LUA_STOP_LEAD = """
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Redis[str], service_key: str):
|
||||
"""
|
||||
Initialize the Redis flag cache.
|
||||
|
||||
Args:
|
||||
redis: A redis-py client instance. Must be configured with
|
||||
decode_responses=True for correct string handling.
|
||||
service_key: A unique identifier for this service/environment.
|
||||
Used to scope Redis keys, allowing multiple services
|
||||
or environments to share the same Redis instance.
|
||||
Examples: "my-api-prod", "checkout-service", "staging".
|
||||
|
||||
Redis Keys Created:
|
||||
- posthog:flags:{service_key} - Cached flag definitions (JSON)
|
||||
- posthog:flags:{service_key}:lock - Leader election lock
|
||||
|
||||
Example:
|
||||
redis_client = redis.Redis(
|
||||
host='localhost',
|
||||
port=6379,
|
||||
decode_responses=True
|
||||
)
|
||||
cache = RedisFlagCache(redis_client, service_key="my-api-prod")
|
||||
"""
|
||||
self._redis = redis
|
||||
self._cache_key = f"posthog:flags:{service_key}"
|
||||
self._lock_key = f"posthog:flags:{service_key}:lock"
|
||||
self._instance_id = str(uuid.uuid4())
|
||||
self._try_lead = self._redis.register_script(self._LUA_TRY_LEAD)
|
||||
self._stop_lead = self._redis.register_script(self._LUA_STOP_LEAD)
|
||||
|
||||
def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]:
|
||||
"""
|
||||
Retrieve cached flag definitions from Redis.
|
||||
|
||||
Returns:
|
||||
Cached flag definitions if available, None otherwise.
|
||||
"""
|
||||
cached = self._redis.get(self._cache_key)
|
||||
return json.loads(cached) if cached else None
|
||||
|
||||
def should_fetch_flag_definitions(self) -> bool:
|
||||
"""
|
||||
Determines if this instance should fetch flag definitions from PostHog.
|
||||
|
||||
Atomically either:
|
||||
- Acquires the lock if no one holds it, OR
|
||||
- Extends the lock TTL if we already hold it
|
||||
|
||||
Returns:
|
||||
True if this instance is the leader and should fetch, False otherwise.
|
||||
"""
|
||||
result = self._try_lead(
|
||||
keys=[self._lock_key],
|
||||
args=[self._instance_id, self.LOCK_TTL_MS],
|
||||
)
|
||||
return result == 1
|
||||
|
||||
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
|
||||
"""
|
||||
Store fetched flag definitions in Redis.
|
||||
|
||||
Args:
|
||||
data: The flag definitions to cache.
|
||||
"""
|
||||
self._redis.set(self._cache_key, json.dumps(data), ex=self.CACHE_TTL_SECONDS)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""
|
||||
Release leadership if we hold it. Safe to call even if not the leader.
|
||||
"""
|
||||
self._stop_lead(keys=[self._lock_key], args=[self._instance_id])
|
||||
@@ -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()
|
||||
@@ -0,0 +1,4 @@
|
||||
db.sqlite3
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
@@ -0,0 +1,19 @@
|
||||
[project]
|
||||
name = "test-django5"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"django~=5.2.7",
|
||||
"uvicorn[standard]~=0.38.0",
|
||||
"posthog",
|
||||
"pytest~=8.4.2",
|
||||
"pytest-asyncio~=1.2.0",
|
||||
"pytest-django~=4.11.1",
|
||||
"httpx~=0.28.1",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
required-version = ">=0.5"
|
||||
|
||||
[tool.uv.sources]
|
||||
posthog = { path = "../..", editable = true }
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Test that verifies exception capture functionality.
|
||||
|
||||
These tests verify that exceptions are actually captured to PostHog, not just that
|
||||
500 responses are returned.
|
||||
|
||||
Without process_exception(), view exceptions are NOT captured to PostHog (v6.7.11 and earlier).
|
||||
With process_exception(), Django calls this method to capture exceptions before
|
||||
converting them to 500 responses.
|
||||
"""
|
||||
|
||||
import os
|
||||
import django
|
||||
|
||||
# Setup Django before importing anything else
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
|
||||
django.setup()
|
||||
|
||||
import pytest # noqa: E402
|
||||
from httpx import AsyncClient, ASGITransport # noqa: E402
|
||||
from django.core.asgi import get_asgi_application # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def asgi_app():
|
||||
"""Shared ASGI application for all tests."""
|
||||
return get_asgi_application()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_exception_is_captured(asgi_app):
|
||||
"""
|
||||
Test that async view exceptions are captured to PostHog.
|
||||
|
||||
The middleware's process_exception() method ensures exceptions are captured.
|
||||
Without it (v6.7.11 and earlier), exceptions are NOT captured even though 500 is returned.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
# Track captured exceptions
|
||||
captured = []
|
||||
|
||||
def mock_capture(exception, **kwargs):
|
||||
"""Mock capture_exception to record calls."""
|
||||
captured.append(
|
||||
{
|
||||
"exception": exception,
|
||||
"type": type(exception).__name__,
|
||||
"message": str(exception),
|
||||
}
|
||||
)
|
||||
|
||||
# Patch at the posthog module level where middleware imports from
|
||||
with patch("posthog.capture_exception", side_effect=mock_capture):
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
|
||||
) as ac:
|
||||
response = await ac.get("/test/async-exception")
|
||||
|
||||
# Django returns 500
|
||||
assert response.status_code == 500
|
||||
|
||||
# CRITICAL: Verify PostHog captured the exception
|
||||
assert len(captured) > 0, "Exception was NOT captured to PostHog!"
|
||||
|
||||
# Verify it's the right exception
|
||||
exception_data = captured[0]
|
||||
assert exception_data["type"] == "ValueError"
|
||||
assert "Test exception from Django 5 async view" in exception_data["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_exception_is_captured(asgi_app):
|
||||
"""
|
||||
Test that sync view exceptions are captured to PostHog.
|
||||
|
||||
The middleware's process_exception() method ensures exceptions are captured.
|
||||
Without it (v6.7.11 and earlier), exceptions are NOT captured even though 500 is returned.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
# Track captured exceptions
|
||||
captured = []
|
||||
|
||||
def mock_capture(exception, **kwargs):
|
||||
"""Mock capture_exception to record calls."""
|
||||
captured.append(
|
||||
{
|
||||
"exception": exception,
|
||||
"type": type(exception).__name__,
|
||||
"message": str(exception),
|
||||
}
|
||||
)
|
||||
|
||||
# Patch at the posthog module level where middleware imports from
|
||||
with patch("posthog.capture_exception", side_effect=mock_capture):
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
|
||||
) as ac:
|
||||
response = await ac.get("/test/sync-exception")
|
||||
|
||||
# Django returns 500
|
||||
assert response.status_code == 500
|
||||
|
||||
# CRITICAL: Verify PostHog captured the exception
|
||||
assert len(captured) > 0, "Exception was NOT captured to PostHog!"
|
||||
|
||||
# Verify it's the right exception
|
||||
exception_data = captured[0]
|
||||
assert exception_data["type"] == "ValueError"
|
||||
assert "Test exception from Django 5 sync view" in exception_data["message"]
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Tests for PostHog Django middleware in async context.
|
||||
|
||||
These tests verify that the middleware correctly handles:
|
||||
1. Async user access (request.auser() in Django 5)
|
||||
2. Exception capture in both sync and async views
|
||||
3. No SynchronousOnlyOperation errors in async context
|
||||
|
||||
Tests run directly against the ASGI application without needing a server.
|
||||
"""
|
||||
|
||||
import os
|
||||
import django
|
||||
|
||||
# Setup Django before importing anything else
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
|
||||
django.setup()
|
||||
|
||||
import pytest # noqa: E402
|
||||
from httpx import AsyncClient, ASGITransport # noqa: E402
|
||||
from django.core.asgi import get_asgi_application # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def asgi_app():
|
||||
"""Shared ASGI application for all tests."""
|
||||
return get_asgi_application()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_user_access(asgi_app):
|
||||
"""
|
||||
Test that middleware can access request.user in async context.
|
||||
|
||||
In Django 5, this requires using await request.auser() instead of request.user
|
||||
to avoid SynchronousOnlyOperation error.
|
||||
|
||||
Without authentication, request.user is AnonymousUser which doesn't
|
||||
trigger the lazy loading bug. This test verifies the middleware works
|
||||
in the common case.
|
||||
"""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
|
||||
) as ac:
|
||||
response = await ac.get("/test/async-user")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
assert "django_version" in data
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_authenticated_user_access(asgi_app):
|
||||
"""
|
||||
Test that middleware can access an authenticated user in async context.
|
||||
|
||||
This is the critical test that triggers the SynchronousOnlyOperation bug
|
||||
in v6.7.11. When AuthenticationMiddleware sets request.user to a
|
||||
SimpleLazyObject wrapping a database query, accessing user.pk or user.email
|
||||
in async context causes the error.
|
||||
|
||||
In v6.7.11, extract_request_user() does getattr(user, "is_authenticated", False)
|
||||
which triggers the lazy object evaluation synchronously.
|
||||
|
||||
The fix uses await request.auser() instead to avoid this.
|
||||
"""
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.test import override_settings
|
||||
|
||||
# Create a test user (must use sync_to_async since we're in async test)
|
||||
User = get_user_model()
|
||||
|
||||
@sync_to_async
|
||||
def create_or_get_user():
|
||||
user, created = User.objects.get_or_create(
|
||||
username="testuser",
|
||||
defaults={
|
||||
"email": "test@example.com",
|
||||
},
|
||||
)
|
||||
if created:
|
||||
user.set_password("testpass123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
user = await create_or_get_user()
|
||||
|
||||
# Create a session with authenticated user (sync operation)
|
||||
@sync_to_async
|
||||
def create_session():
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
return client.cookies.get("sessionid")
|
||||
|
||||
session_cookie = await create_session()
|
||||
|
||||
if not session_cookie:
|
||||
pytest.skip("Could not create authenticated session")
|
||||
|
||||
# Make request with session cookie - this should trigger the bug in v6.7.11
|
||||
# Disable exception capture to see the SynchronousOnlyOperation clearly
|
||||
with override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False):
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=asgi_app),
|
||||
base_url="http://testserver",
|
||||
cookies={"sessionid": session_cookie.value},
|
||||
) as ac:
|
||||
response = await ac.get("/test/async-user")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
assert data["user_authenticated"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_user_access(asgi_app):
|
||||
"""
|
||||
Test that middleware works with sync views.
|
||||
|
||||
This should always work regardless of middleware version.
|
||||
"""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
|
||||
) as ac:
|
||||
response = await ac.get("/test/sync-user")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_exception_capture(asgi_app):
|
||||
"""
|
||||
Test that middleware handles exceptions from async views.
|
||||
|
||||
The middleware's process_exception() method captures view exceptions to PostHog
|
||||
before Django converts them to 500 responses. This test verifies the exception
|
||||
causes a 500 response. See test_exception_capture.py for tests that verify
|
||||
actual exception capture to PostHog.
|
||||
"""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
|
||||
) as ac:
|
||||
response = await ac.get("/test/async-exception")
|
||||
|
||||
# Django returns 500 for unhandled exceptions
|
||||
assert response.status_code == 500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_exception_capture(asgi_app):
|
||||
"""
|
||||
Test that middleware handles exceptions from sync views.
|
||||
|
||||
The middleware's process_exception() method captures view exceptions to PostHog.
|
||||
This test verifies the exception causes a 500 response.
|
||||
"""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
|
||||
) as ac:
|
||||
response = await ac.get("/test/sync-exception")
|
||||
|
||||
# Django returns 500 for unhandled exceptions
|
||||
assert response.status_code == 500
|
||||
+3
-3
@@ -1,16 +1,16 @@
|
||||
"""
|
||||
ASGI config for sentry_django_example project.
|
||||
ASGI config for testdjango project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Django settings for testdjango project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 5.2.7.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "django-insecure-q5(&wfw@_lb)noyowbfl$2ls8c82hl__0f9s5(mohlh2)aas#3"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"posthog.integrations.django.PosthogContextMiddleware", # Test PostHog middleware
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "testdjango.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "testdjango.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
|
||||
# PostHog settings for testing
|
||||
POSTHOG_API_KEY = "test-key"
|
||||
POSTHOG_HOST = "https://app.posthog.com"
|
||||
POSTHOG_MW_CAPTURE_EXCEPTIONS = True
|
||||
+9
-8
@@ -1,7 +1,8 @@
|
||||
"""sentry_django_example URL Configuration
|
||||
"""
|
||||
URL configuration for testdjango project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/3.2/topics/http/urls/
|
||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
@@ -13,15 +14,15 @@ Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
|
||||
|
||||
def trigger_error(request):
|
||||
division_by_zero = 1 / 0
|
||||
|
||||
from testdjango import views
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("sentry-debug/", trigger_error),
|
||||
path("test/async-user", views.test_async_user),
|
||||
path("test/sync-user", views.test_sync_user),
|
||||
path("test/async-exception", views.test_async_exception),
|
||||
path("test/sync-exception", views.test_sync_exception),
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Test views for validating PostHog middleware with Django 5 ASGI.
|
||||
"""
|
||||
|
||||
from django.http import JsonResponse
|
||||
|
||||
|
||||
async def test_async_user(request):
|
||||
"""
|
||||
Async view that tests middleware with request.user access.
|
||||
|
||||
The middleware will access request.user (SimpleLazyObject) via auser()
|
||||
in async context. Without the fix, this causes SynchronousOnlyOperation.
|
||||
"""
|
||||
# The middleware has already accessed request.user via auser()
|
||||
# If we got here, the fix works!
|
||||
user = await request.auser()
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Django 5 async middleware test passed!",
|
||||
"django_version": "5.x",
|
||||
"user_authenticated": user.is_authenticated if user else False,
|
||||
"note": "Middleware used await request.auser() successfully",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_sync_user(request):
|
||||
"""Sync view for comparison."""
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Sync view works",
|
||||
"user_authenticated": request.user.is_authenticated
|
||||
if hasattr(request, "user")
|
||||
else False,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_async_exception(request):
|
||||
"""Async view that raises an exception for testing exception capture."""
|
||||
raise ValueError("Test exception from Django 5 async view")
|
||||
|
||||
|
||||
def test_sync_exception(request):
|
||||
"""Sync view that raises an exception for testing exception capture."""
|
||||
raise ValueError("Test exception from Django 5 sync view")
|
||||
+3
-3
@@ -1,16 +1,16 @@
|
||||
"""
|
||||
WSGI config for sentry_django_example project.
|
||||
WSGI config for testdjango project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
Generated
+674
@@ -0,0 +1,674 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/08/4dfec9b90758a59acc6be32ac82e98d1fbfc321cb5cfa410436dbacf821c/asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e", size = 37483, upload-time = "2025-10-05T09:15:06.557Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backoff"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.10.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distro"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/96/bd84e2bb997994de8bcda47ae4560991084e86536541d7214393880f01a8/django-5.2.7.tar.gz", hash = "sha256:e0f6f12e2551b1716a95a63a1366ca91bbcd7be059862c1b18f989b1da356cdd", size = 10865812, upload-time = "2025-10-01T14:22:12.081Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "posthog"
|
||||
source = { editable = "../" }
|
||||
dependencies = [
|
||||
{ name = "backoff" },
|
||||
{ name = "distro" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "requests" },
|
||||
{ name = "six" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "anthropic", marker = "extra == 'test'" },
|
||||
{ name = "backoff", specifier = ">=1.10.0" },
|
||||
{ name = "coverage", marker = "extra == 'test'" },
|
||||
{ name = "distro", specifier = ">=1.5.0" },
|
||||
{ name = "django", marker = "extra == 'test'" },
|
||||
{ name = "django-stubs", marker = "extra == 'dev'" },
|
||||
{ name = "freezegun", marker = "extra == 'test'", specifier = "==1.5.1" },
|
||||
{ name = "google-genai", marker = "extra == 'test'" },
|
||||
{ name = "langchain", marker = "extra == 'langchain'", specifier = ">=0.2.0" },
|
||||
{ name = "langchain-anthropic", marker = "extra == 'test'", specifier = ">=0.3.15" },
|
||||
{ name = "langchain-community", marker = "extra == 'test'", specifier = ">=0.3.25" },
|
||||
{ name = "langchain-core", marker = "extra == 'test'", specifier = ">=0.3.65" },
|
||||
{ name = "langchain-openai", marker = "extra == 'test'", specifier = ">=0.3.22" },
|
||||
{ name = "langgraph", marker = "extra == 'test'", specifier = ">=0.4.8" },
|
||||
{ name = "lxml", marker = "extra == 'dev'" },
|
||||
{ name = "mock", marker = "extra == 'test'", specifier = ">=2.0.0" },
|
||||
{ name = "mypy", marker = "extra == 'dev'" },
|
||||
{ name = "mypy-baseline", marker = "extra == 'dev'" },
|
||||
{ name = "openai", marker = "extra == 'test'" },
|
||||
{ name = "packaging", marker = "extra == 'dev'" },
|
||||
{ name = "parameterized", marker = "extra == 'test'", specifier = ">=0.8.1" },
|
||||
{ name = "pre-commit", marker = "extra == 'dev'" },
|
||||
{ name = "pydantic", marker = "extra == 'dev'" },
|
||||
{ name = "pydantic", marker = "extra == 'test'" },
|
||||
{ name = "pytest", marker = "extra == 'test'" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'test'" },
|
||||
{ name = "pytest-timeout", marker = "extra == 'test'" },
|
||||
{ name = "python-dateutil", specifier = ">=2.2" },
|
||||
{ name = "requests", specifier = ">=2.7,<3.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'" },
|
||||
{ name = "setuptools", marker = "extra == 'dev'" },
|
||||
{ name = "six", specifier = ">=1.5" },
|
||||
{ name = "tomli", marker = "extra == 'dev'" },
|
||||
{ name = "tomli-w", marker = "extra == 'dev'" },
|
||||
{ name = "twine", marker = "extra == 'dev'" },
|
||||
{ name = "types-mock", marker = "extra == 'dev'" },
|
||||
{ name = "types-python-dateutil", marker = "extra == 'dev'" },
|
||||
{ name = "types-requests", marker = "extra == 'dev'" },
|
||||
{ name = "types-setuptools", marker = "extra == 'dev'" },
|
||||
{ name = "types-six", marker = "extra == 'dev'" },
|
||||
{ name = "typing-extensions", specifier = ">=4.2.0" },
|
||||
{ name = "wheel", marker = "extra == 'dev'" },
|
||||
]
|
||||
provides-extras = ["langchain", "dev", "test"]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-django"
|
||||
version = "4.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "test-django5"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "httpx" },
|
||||
{ name = "posthog" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-django" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "django", specifier = "~=5.2.7" },
|
||||
{ name = "httpx", specifier = "~=0.28.1" },
|
||||
{ name = "posthog", editable = "../" },
|
||||
{ name = "pytest", specifier = "~=8.4.2" },
|
||||
{ name = "pytest-asyncio", specifier = "~=1.2.0" },
|
||||
{ name = "pytest-django", specifier = "~=4.11.1" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = "~=0.38.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.38.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
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: 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: Statement is unreachable [unreachable]
|
||||
posthog/client.py:0: error: Statement is unreachable [unreachable]
|
||||
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
|
||||
+599
-240
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
from .anthropic import Anthropic
|
||||
from .anthropic_async import AsyncAnthropic
|
||||
from .anthropic_providers import (
|
||||
AnthropicBedrock,
|
||||
AnthropicVertex,
|
||||
AsyncAnthropicBedrock,
|
||||
AsyncAnthropicVertex,
|
||||
)
|
||||
from .anthropic_converter import (
|
||||
format_anthropic_response,
|
||||
format_anthropic_input,
|
||||
extract_anthropic_tools,
|
||||
format_anthropic_streaming_content,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Anthropic",
|
||||
"AsyncAnthropic",
|
||||
"AnthropicBedrock",
|
||||
"AsyncAnthropicBedrock",
|
||||
"AnthropicVertex",
|
||||
"AsyncAnthropicVertex",
|
||||
"format_anthropic_response",
|
||||
"format_anthropic_input",
|
||||
"extract_anthropic_tools",
|
||||
"format_anthropic_streaming_content",
|
||||
]
|
||||
@@ -0,0 +1,248 @@
|
||||
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, List, Optional
|
||||
|
||||
from posthog.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
|
||||
from posthog.ai.utils import (
|
||||
call_llm_and_track_usage,
|
||||
merge_usage_stats,
|
||||
)
|
||||
from posthog.ai.anthropic.anthropic_converter import (
|
||||
extract_anthropic_usage_from_event,
|
||||
handle_anthropic_content_block_start,
|
||||
handle_anthropic_text_delta,
|
||||
handle_anthropic_tool_delta,
|
||||
finalize_anthropic_tool_input,
|
||||
)
|
||||
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: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
|
||||
accumulated_content = ""
|
||||
content_blocks: List[StreamingContentBlock] = []
|
||||
tools_in_progress: Dict[str, ToolInProgress] = {}
|
||||
current_text_block: Optional[StreamingContentBlock] = None
|
||||
response = super().create(**kwargs)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content
|
||||
nonlocal content_blocks
|
||||
nonlocal tools_in_progress
|
||||
nonlocal current_text_block
|
||||
|
||||
try:
|
||||
for event in response:
|
||||
# Extract usage stats from event
|
||||
event_usage = extract_anthropic_usage_from_event(event)
|
||||
merge_usage_stats(usage_stats, event_usage)
|
||||
|
||||
# Handle content block start events
|
||||
if hasattr(event, "type") and event.type == "content_block_start":
|
||||
block, tool = handle_anthropic_content_block_start(event)
|
||||
|
||||
if block:
|
||||
content_blocks.append(block)
|
||||
|
||||
if block.get("type") == "text":
|
||||
current_text_block = block
|
||||
else:
|
||||
current_text_block = None
|
||||
|
||||
if tool:
|
||||
tool_id = tool["block"].get("id")
|
||||
if tool_id:
|
||||
tools_in_progress[tool_id] = tool
|
||||
|
||||
# Handle text delta events
|
||||
delta_text = handle_anthropic_text_delta(event, current_text_block)
|
||||
|
||||
if delta_text:
|
||||
accumulated_content += delta_text
|
||||
|
||||
# Handle tool input delta events
|
||||
handle_anthropic_tool_delta(
|
||||
event, content_blocks, tools_in_progress
|
||||
)
|
||||
|
||||
# Handle content block stop events
|
||||
if hasattr(event, "type") and event.type == "content_block_stop":
|
||||
current_text_block = None
|
||||
finalize_anthropic_tool_input(
|
||||
event, content_blocks, tools_in_progress
|
||||
)
|
||||
|
||||
yield event
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
content_blocks,
|
||||
accumulated_content,
|
||||
)
|
||||
|
||||
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: TokenUsage,
|
||||
latency: float,
|
||||
content_blocks: List[StreamingContentBlock],
|
||||
accumulated_content: str,
|
||||
):
|
||||
from posthog.ai.types import StreamingEventData
|
||||
from posthog.ai.anthropic.anthropic_converter import (
|
||||
format_anthropic_streaming_input,
|
||||
format_anthropic_streaming_output_complete,
|
||||
)
|
||||
from posthog.ai.utils import capture_streaming_event
|
||||
|
||||
# Prepare standardized event data
|
||||
formatted_input = format_anthropic_streaming_input(kwargs)
|
||||
sanitized_input = sanitize_anthropic(formatted_input)
|
||||
|
||||
event_data = StreamingEventData(
|
||||
provider="anthropic",
|
||||
model=kwargs.get("model", "unknown"),
|
||||
base_url=str(self._client.base_url),
|
||||
kwargs=kwargs,
|
||||
formatted_input=sanitized_input,
|
||||
formatted_output=format_anthropic_streaming_output_complete(
|
||||
content_blocks, accumulated_content
|
||||
),
|
||||
usage_stats=usage_stats,
|
||||
latency=latency,
|
||||
distinct_id=posthog_distinct_id,
|
||||
trace_id=posthog_trace_id,
|
||||
properties=posthog_properties,
|
||||
privacy_mode=posthog_privacy_mode,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._client._ph_client, event_data)
|
||||
@@ -0,0 +1,248 @@
|
||||
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, List, Optional
|
||||
|
||||
from posthog import setup
|
||||
from posthog.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
|
||||
from posthog.ai.utils import (
|
||||
call_llm_and_track_usage_async,
|
||||
merge_usage_stats,
|
||||
)
|
||||
from posthog.ai.anthropic.anthropic_converter import (
|
||||
extract_anthropic_usage_from_event,
|
||||
handle_anthropic_content_block_start,
|
||||
handle_anthropic_text_delta,
|
||||
handle_anthropic_tool_delta,
|
||||
finalize_anthropic_tool_input,
|
||||
)
|
||||
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: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
|
||||
accumulated_content = ""
|
||||
content_blocks: List[StreamingContentBlock] = []
|
||||
tools_in_progress: Dict[str, ToolInProgress] = {}
|
||||
current_text_block: Optional[StreamingContentBlock] = None
|
||||
response = await super().create(**kwargs)
|
||||
|
||||
async def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content
|
||||
nonlocal content_blocks
|
||||
nonlocal tools_in_progress
|
||||
nonlocal current_text_block
|
||||
|
||||
try:
|
||||
async for event in response:
|
||||
# Extract usage stats from event
|
||||
event_usage = extract_anthropic_usage_from_event(event)
|
||||
merge_usage_stats(usage_stats, event_usage)
|
||||
|
||||
# Handle content block start events
|
||||
if hasattr(event, "type") and event.type == "content_block_start":
|
||||
block, tool = handle_anthropic_content_block_start(event)
|
||||
|
||||
if block:
|
||||
content_blocks.append(block)
|
||||
|
||||
if block.get("type") == "text":
|
||||
current_text_block = block
|
||||
else:
|
||||
current_text_block = None
|
||||
|
||||
if tool:
|
||||
tool_id = tool["block"].get("id")
|
||||
if tool_id:
|
||||
tools_in_progress[tool_id] = tool
|
||||
|
||||
# Handle text delta events
|
||||
delta_text = handle_anthropic_text_delta(event, current_text_block)
|
||||
|
||||
if delta_text:
|
||||
accumulated_content += delta_text
|
||||
|
||||
# Handle tool input delta events
|
||||
handle_anthropic_tool_delta(
|
||||
event, content_blocks, tools_in_progress
|
||||
)
|
||||
|
||||
# Handle content block stop events
|
||||
if hasattr(event, "type") and event.type == "content_block_stop":
|
||||
current_text_block = None
|
||||
finalize_anthropic_tool_input(
|
||||
event, content_blocks, tools_in_progress
|
||||
)
|
||||
|
||||
yield event
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
await self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
content_blocks,
|
||||
accumulated_content,
|
||||
)
|
||||
|
||||
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: TokenUsage,
|
||||
latency: float,
|
||||
content_blocks: List[StreamingContentBlock],
|
||||
accumulated_content: str,
|
||||
):
|
||||
from posthog.ai.types import StreamingEventData
|
||||
from posthog.ai.anthropic.anthropic_converter import (
|
||||
format_anthropic_streaming_input,
|
||||
format_anthropic_streaming_output_complete,
|
||||
)
|
||||
from posthog.ai.utils import capture_streaming_event
|
||||
|
||||
# Prepare standardized event data
|
||||
formatted_input = format_anthropic_streaming_input(kwargs)
|
||||
sanitized_input = sanitize_anthropic(formatted_input)
|
||||
|
||||
event_data = StreamingEventData(
|
||||
provider="anthropic",
|
||||
model=kwargs.get("model", "unknown"),
|
||||
base_url=str(self._client.base_url),
|
||||
kwargs=kwargs,
|
||||
formatted_input=sanitized_input,
|
||||
formatted_output=format_anthropic_streaming_output_complete(
|
||||
content_blocks, accumulated_content
|
||||
),
|
||||
usage_stats=usage_stats,
|
||||
latency=latency,
|
||||
distinct_id=posthog_distinct_id,
|
||||
trace_id=posthog_trace_id,
|
||||
properties=posthog_properties,
|
||||
privacy_mode=posthog_privacy_mode,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._client._ph_client, event_data)
|
||||
@@ -0,0 +1,443 @@
|
||||
"""
|
||||
Anthropic-specific conversion utilities.
|
||||
|
||||
This module handles the conversion of Anthropic API responses and inputs
|
||||
into standardized formats for PostHog tracking.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from posthog.ai.types import (
|
||||
FormattedContentItem,
|
||||
FormattedFunctionCall,
|
||||
FormattedMessage,
|
||||
FormattedTextContent,
|
||||
StreamingContentBlock,
|
||||
TokenUsage,
|
||||
ToolInProgress,
|
||||
)
|
||||
|
||||
|
||||
def format_anthropic_response(response: Any) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format an Anthropic response into standardized message format.
|
||||
|
||||
Args:
|
||||
response: The response object from Anthropic API
|
||||
|
||||
Returns:
|
||||
List of formatted messages with role and content
|
||||
"""
|
||||
|
||||
output: List[FormattedMessage] = []
|
||||
|
||||
if response is None:
|
||||
return output
|
||||
|
||||
content: List[FormattedContentItem] = []
|
||||
|
||||
# Process content blocks from the response
|
||||
if hasattr(response, "content"):
|
||||
for choice in response.content:
|
||||
if (
|
||||
hasattr(choice, "type")
|
||||
and choice.type == "text"
|
||||
and hasattr(choice, "text")
|
||||
and choice.text
|
||||
):
|
||||
text_content: FormattedTextContent = {
|
||||
"type": "text",
|
||||
"text": choice.text,
|
||||
}
|
||||
content.append(text_content)
|
||||
|
||||
elif (
|
||||
hasattr(choice, "type")
|
||||
and choice.type == "tool_use"
|
||||
and hasattr(choice, "name")
|
||||
and hasattr(choice, "id")
|
||||
):
|
||||
function_call: FormattedFunctionCall = {
|
||||
"type": "function",
|
||||
"id": choice.id,
|
||||
"function": {
|
||||
"name": choice.name,
|
||||
"arguments": getattr(choice, "input", {}),
|
||||
},
|
||||
}
|
||||
content.append(function_call)
|
||||
|
||||
if content:
|
||||
message: FormattedMessage = {
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
}
|
||||
output.append(message)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def format_anthropic_input(
|
||||
messages: List[Dict[str, Any]], system: Optional[str] = None
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format Anthropic input messages with optional system prompt.
|
||||
|
||||
Args:
|
||||
messages: List of message dictionaries
|
||||
system: Optional system prompt to prepend
|
||||
|
||||
Returns:
|
||||
List of formatted messages
|
||||
"""
|
||||
|
||||
formatted_messages: List[FormattedMessage] = []
|
||||
|
||||
# Add system message if provided
|
||||
if system is not None:
|
||||
formatted_messages.append({"role": "system", "content": system})
|
||||
|
||||
# Add user messages
|
||||
if messages:
|
||||
for msg in messages:
|
||||
# Messages are already in the correct format, just ensure type safety
|
||||
formatted_msg: FormattedMessage = {
|
||||
"role": msg.get("role", "user"),
|
||||
"content": msg.get("content", ""),
|
||||
}
|
||||
formatted_messages.append(formatted_msg)
|
||||
|
||||
return formatted_messages
|
||||
|
||||
|
||||
def extract_anthropic_tools(kwargs: Dict[str, Any]) -> Optional[Any]:
|
||||
"""
|
||||
Extract tool definitions from Anthropic API kwargs.
|
||||
|
||||
Args:
|
||||
kwargs: Keyword arguments passed to Anthropic API
|
||||
|
||||
Returns:
|
||||
Tool definitions if present, None otherwise
|
||||
"""
|
||||
|
||||
return kwargs.get("tools", None)
|
||||
|
||||
|
||||
def format_anthropic_streaming_content(
|
||||
content_blocks: List[StreamingContentBlock],
|
||||
) -> List[FormattedContentItem]:
|
||||
"""
|
||||
Format content blocks from Anthropic streaming response.
|
||||
|
||||
Used by streaming handlers to format accumulated content blocks.
|
||||
|
||||
Args:
|
||||
content_blocks: List of content block dictionaries from streaming
|
||||
|
||||
Returns:
|
||||
List of formatted content items
|
||||
"""
|
||||
|
||||
formatted: List[FormattedContentItem] = []
|
||||
|
||||
for block in content_blocks:
|
||||
if block.get("type") == "text":
|
||||
formatted.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": block.get("text") or "",
|
||||
}
|
||||
)
|
||||
|
||||
elif block.get("type") == "function":
|
||||
formatted.append(
|
||||
{
|
||||
"type": "function",
|
||||
"id": block.get("id"),
|
||||
"function": block.get("function") or {},
|
||||
}
|
||||
)
|
||||
|
||||
return formatted
|
||||
|
||||
|
||||
def extract_anthropic_web_search_count(response: Any) -> int:
|
||||
"""
|
||||
Extract web search count from Anthropic response.
|
||||
|
||||
Anthropic provides exact web search counts via usage.server_tool_use.web_search_requests.
|
||||
|
||||
Args:
|
||||
response: The response from Anthropic API
|
||||
|
||||
Returns:
|
||||
Number of web search requests (0 if none)
|
||||
"""
|
||||
if not hasattr(response, "usage"):
|
||||
return 0
|
||||
|
||||
if not hasattr(response.usage, "server_tool_use"):
|
||||
return 0
|
||||
|
||||
server_tool_use = response.usage.server_tool_use
|
||||
|
||||
if hasattr(server_tool_use, "web_search_requests"):
|
||||
return max(0, int(getattr(server_tool_use, "web_search_requests", 0)))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def extract_anthropic_usage_from_response(response: Any) -> TokenUsage:
|
||||
"""
|
||||
Extract usage from a full Anthropic response (non-streaming).
|
||||
|
||||
Args:
|
||||
response: The complete response from Anthropic API
|
||||
|
||||
Returns:
|
||||
TokenUsage with standardized usage
|
||||
"""
|
||||
if not hasattr(response, "usage"):
|
||||
return TokenUsage(input_tokens=0, output_tokens=0)
|
||||
|
||||
result = TokenUsage(
|
||||
input_tokens=getattr(response.usage, "input_tokens", 0),
|
||||
output_tokens=getattr(response.usage, "output_tokens", 0),
|
||||
)
|
||||
|
||||
if hasattr(response.usage, "cache_read_input_tokens"):
|
||||
cache_read = response.usage.cache_read_input_tokens
|
||||
if cache_read and cache_read > 0:
|
||||
result["cache_read_input_tokens"] = cache_read
|
||||
|
||||
if hasattr(response.usage, "cache_creation_input_tokens"):
|
||||
cache_creation = response.usage.cache_creation_input_tokens
|
||||
if cache_creation and cache_creation > 0:
|
||||
result["cache_creation_input_tokens"] = cache_creation
|
||||
|
||||
web_search_count = extract_anthropic_web_search_count(response)
|
||||
if web_search_count > 0:
|
||||
result["web_search_count"] = web_search_count
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def extract_anthropic_usage_from_event(event: Any) -> TokenUsage:
|
||||
"""
|
||||
Extract usage statistics from an Anthropic streaming event.
|
||||
|
||||
Args:
|
||||
event: Streaming event from Anthropic API
|
||||
|
||||
Returns:
|
||||
Dictionary of usage statistics
|
||||
"""
|
||||
|
||||
usage: TokenUsage = TokenUsage()
|
||||
|
||||
# Handle usage stats from message_start event
|
||||
if hasattr(event, "type") and event.type == "message_start":
|
||||
if hasattr(event, "message") and hasattr(event.message, "usage"):
|
||||
usage["input_tokens"] = getattr(event.message.usage, "input_tokens", 0)
|
||||
usage["cache_creation_input_tokens"] = getattr(
|
||||
event.message.usage, "cache_creation_input_tokens", 0
|
||||
)
|
||||
usage["cache_read_input_tokens"] = getattr(
|
||||
event.message.usage, "cache_read_input_tokens", 0
|
||||
)
|
||||
|
||||
# Handle usage stats from message_delta event
|
||||
if hasattr(event, "usage") and event.usage:
|
||||
usage["output_tokens"] = getattr(event.usage, "output_tokens", 0)
|
||||
|
||||
# Extract web search count from usage
|
||||
if hasattr(event.usage, "server_tool_use"):
|
||||
server_tool_use = event.usage.server_tool_use
|
||||
if hasattr(server_tool_use, "web_search_requests"):
|
||||
web_search_count = int(
|
||||
getattr(server_tool_use, "web_search_requests", 0)
|
||||
)
|
||||
if web_search_count > 0:
|
||||
usage["web_search_count"] = web_search_count
|
||||
|
||||
return usage
|
||||
|
||||
|
||||
def handle_anthropic_content_block_start(
|
||||
event: Any,
|
||||
) -> Tuple[Optional[StreamingContentBlock], Optional[ToolInProgress]]:
|
||||
"""
|
||||
Handle content block start event from Anthropic streaming.
|
||||
|
||||
Args:
|
||||
event: Content block start event
|
||||
|
||||
Returns:
|
||||
Tuple of (content_block, tool_in_progress)
|
||||
"""
|
||||
|
||||
if not (hasattr(event, "type") and event.type == "content_block_start"):
|
||||
return None, None
|
||||
|
||||
if not hasattr(event, "content_block"):
|
||||
return None, None
|
||||
|
||||
block = event.content_block
|
||||
|
||||
if not hasattr(block, "type"):
|
||||
return None, None
|
||||
|
||||
if block.type == "text":
|
||||
content_block: StreamingContentBlock = {"type": "text", "text": ""}
|
||||
return content_block, None
|
||||
|
||||
elif block.type == "tool_use":
|
||||
tool_block: StreamingContentBlock = {
|
||||
"type": "function",
|
||||
"id": getattr(block, "id", ""),
|
||||
"function": {"name": getattr(block, "name", ""), "arguments": {}},
|
||||
}
|
||||
tool_in_progress: ToolInProgress = {"block": tool_block, "input_string": ""}
|
||||
return tool_block, tool_in_progress
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def handle_anthropic_text_delta(
|
||||
event: Any, current_block: Optional[StreamingContentBlock]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Handle text delta event from Anthropic streaming.
|
||||
|
||||
Args:
|
||||
event: Delta event
|
||||
current_block: Current text block being accumulated
|
||||
|
||||
Returns:
|
||||
Text delta if present
|
||||
"""
|
||||
|
||||
if hasattr(event, "delta") and hasattr(event.delta, "text"):
|
||||
delta_text = event.delta.text or ""
|
||||
|
||||
if current_block is not None and current_block.get("type") == "text":
|
||||
text_val = current_block.get("text")
|
||||
if text_val is not None:
|
||||
current_block["text"] = text_val + delta_text
|
||||
else:
|
||||
current_block["text"] = delta_text
|
||||
|
||||
return delta_text
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def handle_anthropic_tool_delta(
|
||||
event: Any,
|
||||
content_blocks: List[StreamingContentBlock],
|
||||
tools_in_progress: Dict[str, ToolInProgress],
|
||||
) -> None:
|
||||
"""
|
||||
Handle tool input delta event from Anthropic streaming.
|
||||
|
||||
Args:
|
||||
event: Tool delta event
|
||||
content_blocks: List of content blocks
|
||||
tools_in_progress: Dictionary tracking tools being accumulated
|
||||
"""
|
||||
|
||||
if not (hasattr(event, "type") and event.type == "content_block_delta"):
|
||||
return
|
||||
|
||||
if not (
|
||||
hasattr(event, "delta")
|
||||
and hasattr(event.delta, "type")
|
||||
and event.delta.type == "input_json_delta"
|
||||
):
|
||||
return
|
||||
|
||||
if hasattr(event, "index") and event.index < len(content_blocks):
|
||||
block = content_blocks[event.index]
|
||||
|
||||
if block.get("type") == "function" and block.get("id") in tools_in_progress:
|
||||
tool = tools_in_progress[block["id"]]
|
||||
partial_json = getattr(event.delta, "partial_json", "")
|
||||
tool["input_string"] += partial_json
|
||||
|
||||
|
||||
def finalize_anthropic_tool_input(
|
||||
event: Any,
|
||||
content_blocks: List[StreamingContentBlock],
|
||||
tools_in_progress: Dict[str, ToolInProgress],
|
||||
) -> None:
|
||||
"""
|
||||
Finalize tool input when content block stops.
|
||||
|
||||
Args:
|
||||
event: Content block stop event
|
||||
content_blocks: List of content blocks
|
||||
tools_in_progress: Dictionary tracking tools being accumulated
|
||||
"""
|
||||
|
||||
if not (hasattr(event, "type") and event.type == "content_block_stop"):
|
||||
return
|
||||
|
||||
if hasattr(event, "index") and event.index < len(content_blocks):
|
||||
block = content_blocks[event.index]
|
||||
|
||||
if block.get("type") == "function" and block.get("id") in tools_in_progress:
|
||||
tool = tools_in_progress[block["id"]]
|
||||
|
||||
try:
|
||||
block["function"]["arguments"] = json.loads(tool["input_string"])
|
||||
except (json.JSONDecodeError, Exception):
|
||||
# Keep empty dict if parsing fails
|
||||
pass
|
||||
|
||||
del tools_in_progress[block["id"]]
|
||||
|
||||
|
||||
def format_anthropic_streaming_input(kwargs: Dict[str, Any]) -> Any:
|
||||
"""
|
||||
Format Anthropic streaming input using system prompt merging.
|
||||
|
||||
Args:
|
||||
kwargs: Keyword arguments passed to Anthropic API
|
||||
|
||||
Returns:
|
||||
Formatted input ready for PostHog tracking
|
||||
"""
|
||||
from posthog.ai.utils import merge_system_prompt
|
||||
|
||||
return merge_system_prompt(kwargs, "anthropic")
|
||||
|
||||
|
||||
def format_anthropic_streaming_output_complete(
|
||||
content_blocks: List[StreamingContentBlock], accumulated_content: str
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format complete Anthropic streaming output.
|
||||
|
||||
Combines existing logic for formatting content blocks with fallback to accumulated content.
|
||||
|
||||
Args:
|
||||
content_blocks: List of content blocks accumulated during streaming
|
||||
accumulated_content: Raw accumulated text content as fallback
|
||||
|
||||
Returns:
|
||||
Formatted messages ready for PostHog tracking
|
||||
"""
|
||||
formatted_content = format_anthropic_streaming_content(content_blocks)
|
||||
|
||||
if formatted_content:
|
||||
return [{"role": "assistant", "content": formatted_content}]
|
||||
else:
|
||||
# Fallback to accumulated content if no blocks
|
||||
return [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": accumulated_content}],
|
||||
}
|
||||
]
|
||||
@@ -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,25 @@
|
||||
from .gemini import Client
|
||||
from .gemini_async import AsyncClient
|
||||
from .gemini_converter import (
|
||||
format_gemini_input,
|
||||
format_gemini_response,
|
||||
extract_gemini_tools,
|
||||
)
|
||||
|
||||
|
||||
# Create a genai-like module for perfect drop-in replacement
|
||||
class _GenAI:
|
||||
Client = Client
|
||||
AsyncClient = AsyncClient
|
||||
|
||||
|
||||
genai = _GenAI()
|
||||
|
||||
__all__ = [
|
||||
"Client",
|
||||
"AsyncClient",
|
||||
"genai",
|
||||
"format_gemini_input",
|
||||
"format_gemini_response",
|
||||
"extract_gemini_tools",
|
||||
]
|
||||
@@ -0,0 +1,420 @@
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from posthog.ai.types import TokenUsage, StreamingEventData
|
||||
from posthog.ai.utils import merge_system_prompt
|
||||
|
||||
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,
|
||||
capture_streaming_event,
|
||||
merge_usage_stats,
|
||||
)
|
||||
from posthog.ai.gemini.gemini_converter import (
|
||||
extract_gemini_usage_from_chunk,
|
||||
extract_gemini_content_from_chunk,
|
||||
format_gemini_streaming_output,
|
||||
)
|
||||
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: TokenUsage = TokenUsage(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
|
||||
try:
|
||||
for chunk in response:
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_gemini_usage_from_chunk(chunk)
|
||||
|
||||
if chunk_usage:
|
||||
# Gemini reports cumulative totals, not incremental values
|
||||
merge_usage_stats(usage_stats, chunk_usage, mode="cumulative")
|
||||
|
||||
# Extract content from chunk (now returns content blocks)
|
||||
content_block = extract_gemini_content_from_chunk(chunk)
|
||||
|
||||
if content_block is not None:
|
||||
accumulated_content.append(content_block)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
self._capture_streaming_event(
|
||||
model,
|
||||
contents,
|
||||
distinct_id,
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
accumulated_content,
|
||||
)
|
||||
|
||||
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: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
):
|
||||
# Prepare standardized event data
|
||||
formatted_input = self._format_input(contents, **kwargs)
|
||||
sanitized_input = sanitize_gemini(formatted_input)
|
||||
|
||||
event_data = StreamingEventData(
|
||||
provider="gemini",
|
||||
model=model,
|
||||
base_url=self._base_url,
|
||||
kwargs=kwargs,
|
||||
formatted_input=sanitized_input,
|
||||
formatted_output=format_gemini_streaming_output(output),
|
||||
usage_stats=usage_stats,
|
||||
latency=latency,
|
||||
distinct_id=distinct_id,
|
||||
trace_id=trace_id,
|
||||
properties=properties,
|
||||
privacy_mode=privacy_mode,
|
||||
groups=groups,
|
||||
)
|
||||
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._ph_client, event_data)
|
||||
|
||||
def _format_input(self, contents, **kwargs):
|
||||
"""Format input contents for PostHog tracking"""
|
||||
|
||||
# Create kwargs dict with contents for merge_system_prompt
|
||||
input_kwargs = {"contents": contents, **kwargs}
|
||||
return merge_system_prompt(input_kwargs, "gemini")
|
||||
|
||||
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,423 @@
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from posthog.ai.types import TokenUsage, StreamingEventData
|
||||
from posthog.ai.utils import merge_system_prompt
|
||||
|
||||
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_async,
|
||||
capture_streaming_event,
|
||||
merge_usage_stats,
|
||||
)
|
||||
from posthog.ai.gemini.gemini_converter import (
|
||||
extract_gemini_usage_from_chunk,
|
||||
extract_gemini_content_from_chunk,
|
||||
format_gemini_streaming_output,
|
||||
)
|
||||
from posthog.ai.sanitization import sanitize_gemini
|
||||
from posthog.client import Client as PostHogClient
|
||||
|
||||
|
||||
class AsyncClient:
|
||||
"""
|
||||
An async drop-in replacement for genai.Client that automatically sends LLM usage events to PostHog.
|
||||
|
||||
Usage:
|
||||
client = AsyncClient(
|
||||
api_key="your_api_key",
|
||||
posthog_client=posthog_client,
|
||||
posthog_distinct_id="default_user", # Optional defaults
|
||||
posthog_properties={"team": "ai"} # Optional defaults
|
||||
)
|
||||
response = await 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 = AsyncModels(
|
||||
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 AsyncModels:
|
||||
"""
|
||||
Async Models interface that mimics genai.Client().aio.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
|
||||
|
||||
async 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().aio.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 await call_llm_and_track_usage_async(
|
||||
distinct_id,
|
||||
self._ph_client,
|
||||
"gemini",
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
self._base_url,
|
||||
self._client.aio.models.generate_content,
|
||||
**kwargs_with_contents,
|
||||
)
|
||||
|
||||
async 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: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
|
||||
accumulated_content = []
|
||||
|
||||
kwargs_without_stream = {"model": model, "contents": contents, **kwargs}
|
||||
response = await self._client.aio.models.generate_content_stream(
|
||||
**kwargs_without_stream
|
||||
)
|
||||
|
||||
async def async_generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content
|
||||
|
||||
try:
|
||||
async for chunk in response:
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_gemini_usage_from_chunk(chunk)
|
||||
|
||||
if chunk_usage:
|
||||
# Gemini reports cumulative totals, not incremental values
|
||||
merge_usage_stats(usage_stats, chunk_usage, mode="cumulative")
|
||||
|
||||
# Extract content from chunk (now returns content blocks)
|
||||
content_block = extract_gemini_content_from_chunk(chunk)
|
||||
|
||||
if content_block is not None:
|
||||
accumulated_content.append(content_block)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
self._capture_streaming_event(
|
||||
model,
|
||||
contents,
|
||||
distinct_id,
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
accumulated_content,
|
||||
)
|
||||
|
||||
return async_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: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
):
|
||||
# Prepare standardized event data
|
||||
formatted_input = self._format_input(contents, **kwargs)
|
||||
sanitized_input = sanitize_gemini(formatted_input)
|
||||
|
||||
event_data = StreamingEventData(
|
||||
provider="gemini",
|
||||
model=model,
|
||||
base_url=self._base_url,
|
||||
kwargs=kwargs,
|
||||
formatted_input=sanitized_input,
|
||||
formatted_output=format_gemini_streaming_output(output),
|
||||
usage_stats=usage_stats,
|
||||
latency=latency,
|
||||
distinct_id=distinct_id,
|
||||
trace_id=trace_id,
|
||||
properties=properties,
|
||||
privacy_mode=privacy_mode,
|
||||
groups=groups,
|
||||
)
|
||||
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._ph_client, event_data)
|
||||
|
||||
def _format_input(self, contents, **kwargs):
|
||||
"""Format input contents for PostHog tracking"""
|
||||
|
||||
# Create kwargs dict with contents for merge_system_prompt
|
||||
input_kwargs = {"contents": contents, **kwargs}
|
||||
return merge_system_prompt(input_kwargs, "gemini")
|
||||
|
||||
async 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 await self._generate_content_streaming(
|
||||
model,
|
||||
contents,
|
||||
distinct_id,
|
||||
trace_id,
|
||||
properties,
|
||||
privacy_mode,
|
||||
groups,
|
||||
**kwargs,
|
||||
)
|
||||
@@ -0,0 +1,652 @@
|
||||
"""
|
||||
Gemini-specific conversion utilities.
|
||||
|
||||
This module handles the conversion of Gemini API responses and inputs
|
||||
into standardized formats for PostHog tracking.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, TypedDict, Union
|
||||
|
||||
from posthog.ai.types import (
|
||||
FormattedContentItem,
|
||||
FormattedMessage,
|
||||
TokenUsage,
|
||||
)
|
||||
|
||||
|
||||
class GeminiPart(TypedDict, total=False):
|
||||
"""Represents a part in a Gemini message."""
|
||||
|
||||
text: str
|
||||
|
||||
|
||||
class GeminiMessage(TypedDict, total=False):
|
||||
"""Represents a Gemini message with various possible fields."""
|
||||
|
||||
role: str
|
||||
parts: List[Union[GeminiPart, Dict[str, Any]]]
|
||||
content: Union[str, List[Any]]
|
||||
text: str
|
||||
|
||||
|
||||
def _format_parts_as_content_blocks(parts: List[Any]) -> List[FormattedContentItem]:
|
||||
"""
|
||||
Format Gemini parts array into structured content blocks.
|
||||
|
||||
Preserves structure for multimodal content (text + images) instead of
|
||||
concatenating everything into a string.
|
||||
|
||||
Args:
|
||||
parts: List of parts that may contain text, inline_data, etc.
|
||||
|
||||
Returns:
|
||||
List of formatted content blocks
|
||||
"""
|
||||
content_blocks: List[FormattedContentItem] = []
|
||||
|
||||
for part in parts:
|
||||
# Handle dict with text field
|
||||
if isinstance(part, dict) and "text" in part:
|
||||
content_blocks.append({"type": "text", "text": part["text"]})
|
||||
|
||||
# Handle string parts
|
||||
elif isinstance(part, str):
|
||||
content_blocks.append({"type": "text", "text": part})
|
||||
|
||||
# Handle dict with inline_data (images, documents, etc.)
|
||||
elif isinstance(part, dict) and "inline_data" in part:
|
||||
inline_data = part["inline_data"]
|
||||
mime_type = inline_data.get("mime_type", "")
|
||||
content_type = "image" if mime_type.startswith("image/") else "document"
|
||||
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": content_type,
|
||||
"inline_data": inline_data,
|
||||
}
|
||||
)
|
||||
|
||||
# Handle object with text attribute
|
||||
elif hasattr(part, "text"):
|
||||
text_value = getattr(part, "text", "")
|
||||
if text_value:
|
||||
content_blocks.append({"type": "text", "text": text_value})
|
||||
|
||||
# Handle object with inline_data attribute
|
||||
elif hasattr(part, "inline_data"):
|
||||
inline_data = part.inline_data
|
||||
# Convert to dict if needed
|
||||
if hasattr(inline_data, "mime_type") and hasattr(inline_data, "data"):
|
||||
# Determine type based on mime_type
|
||||
mime_type = inline_data.mime_type
|
||||
content_type = "image" if mime_type.startswith("image/") else "document"
|
||||
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": content_type,
|
||||
"inline_data": {
|
||||
"mime_type": mime_type,
|
||||
"data": inline_data.data,
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "image",
|
||||
"inline_data": inline_data,
|
||||
}
|
||||
)
|
||||
|
||||
return content_blocks
|
||||
|
||||
|
||||
def _format_dict_message(item: Dict[str, Any]) -> FormattedMessage:
|
||||
"""
|
||||
Format a dictionary message into standardized format.
|
||||
|
||||
Args:
|
||||
item: Dictionary containing message data
|
||||
|
||||
Returns:
|
||||
Formatted message with role and content
|
||||
"""
|
||||
|
||||
# Handle dict format with parts array (Gemini-specific format)
|
||||
if "parts" in item and isinstance(item["parts"], list):
|
||||
content_blocks = _format_parts_as_content_blocks(item["parts"])
|
||||
return {"role": item.get("role", "user"), "content": content_blocks}
|
||||
|
||||
# Handle dict with content field
|
||||
if "content" in item:
|
||||
content = item["content"]
|
||||
|
||||
if isinstance(content, list):
|
||||
# If content is a list, format it as content blocks
|
||||
content_blocks = _format_parts_as_content_blocks(content)
|
||||
return {"role": item.get("role", "user"), "content": content_blocks}
|
||||
|
||||
elif not isinstance(content, str):
|
||||
content = str(content)
|
||||
|
||||
return {"role": item.get("role", "user"), "content": content}
|
||||
|
||||
# Handle dict with text field
|
||||
if "text" in item:
|
||||
return {"role": item.get("role", "user"), "content": item["text"]}
|
||||
|
||||
# Fallback to string representation
|
||||
return {"role": "user", "content": str(item)}
|
||||
|
||||
|
||||
def _format_object_message(item: Any) -> FormattedMessage:
|
||||
"""
|
||||
Format an object (with attributes) into standardized format.
|
||||
|
||||
Args:
|
||||
item: Object that may have text or parts attributes
|
||||
|
||||
Returns:
|
||||
Formatted message with role and content
|
||||
"""
|
||||
|
||||
# Handle object with parts attribute
|
||||
if hasattr(item, "parts") and hasattr(item.parts, "__iter__"):
|
||||
content_blocks = _format_parts_as_content_blocks(list(item.parts))
|
||||
role = getattr(item, "role", "user") if hasattr(item, "role") else "user"
|
||||
|
||||
# Ensure role is a string
|
||||
if not isinstance(role, str):
|
||||
role = "user"
|
||||
|
||||
return {"role": role, "content": content_blocks}
|
||||
|
||||
# Handle object with text attribute
|
||||
if hasattr(item, "text"):
|
||||
role = getattr(item, "role", "user") if hasattr(item, "role") else "user"
|
||||
|
||||
# Ensure role is a string
|
||||
if not isinstance(role, str):
|
||||
role = "user"
|
||||
|
||||
return {"role": role, "content": item.text}
|
||||
|
||||
# Handle object with content attribute
|
||||
if hasattr(item, "content"):
|
||||
role = getattr(item, "role", "user") if hasattr(item, "role") else "user"
|
||||
|
||||
# Ensure role is a string
|
||||
if not isinstance(role, str):
|
||||
role = "user"
|
||||
|
||||
content = item.content
|
||||
|
||||
if isinstance(content, list):
|
||||
content_blocks = _format_parts_as_content_blocks(content)
|
||||
return {"role": role, "content": content_blocks}
|
||||
|
||||
elif not isinstance(content, str):
|
||||
content = str(content)
|
||||
return {"role": role, "content": content}
|
||||
|
||||
# Fallback to string representation
|
||||
return {"role": "user", "content": str(item)}
|
||||
|
||||
|
||||
def format_gemini_response(response: Any) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format a Gemini response into standardized message format.
|
||||
|
||||
Args:
|
||||
response: The response object from Gemini API
|
||||
|
||||
Returns:
|
||||
List of formatted messages with role and content
|
||||
"""
|
||||
|
||||
output: List[FormattedMessage] = []
|
||||
|
||||
if response is None:
|
||||
return output
|
||||
|
||||
if hasattr(response, "candidates") and response.candidates:
|
||||
for candidate in response.candidates:
|
||||
if hasattr(candidate, "content") and candidate.content:
|
||||
content: List[FormattedContentItem] = []
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
elif hasattr(part, "inline_data") and part.inline_data:
|
||||
# Handle audio/media inline data
|
||||
import base64
|
||||
|
||||
inline_data = part.inline_data
|
||||
mime_type = getattr(inline_data, "mime_type", "audio/pcm")
|
||||
raw_data = getattr(inline_data, "data", b"")
|
||||
|
||||
# Encode binary data as base64 string for JSON serialization
|
||||
if isinstance(raw_data, bytes):
|
||||
data = base64.b64encode(raw_data).decode("utf-8")
|
||||
else:
|
||||
# Already a string (base64)
|
||||
data = raw_data
|
||||
|
||||
content.append(
|
||||
{
|
||||
"type": "audio",
|
||||
"mime_type": mime_type,
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
|
||||
if content:
|
||||
output.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
|
||||
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_gemini_system_instruction(config: Any) -> Optional[str]:
|
||||
"""
|
||||
Extract system instruction from Gemini config parameter.
|
||||
|
||||
Args:
|
||||
config: Config object or dict that may contain system instruction
|
||||
|
||||
Returns:
|
||||
System instruction string if present, None otherwise
|
||||
"""
|
||||
if config is None:
|
||||
return None
|
||||
|
||||
# Handle different config formats
|
||||
if hasattr(config, "system_instruction"):
|
||||
return config.system_instruction
|
||||
elif isinstance(config, dict) and "system_instruction" in config:
|
||||
return config["system_instruction"]
|
||||
elif isinstance(config, dict) and "systemInstruction" in config:
|
||||
return config["systemInstruction"]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_gemini_tools(kwargs: Dict[str, Any]) -> Optional[Any]:
|
||||
"""
|
||||
Extract tool definitions from Gemini API kwargs.
|
||||
|
||||
Args:
|
||||
kwargs: Keyword arguments passed to Gemini API
|
||||
|
||||
Returns:
|
||||
Tool definitions if present, None otherwise
|
||||
"""
|
||||
|
||||
if "config" in kwargs and hasattr(kwargs["config"], "tools"):
|
||||
return kwargs["config"].tools
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_gemini_input_with_system(
|
||||
contents: Any, config: Any = None
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format Gemini input contents into standardized message format, including system instruction handling.
|
||||
|
||||
Args:
|
||||
contents: Input contents in various possible formats
|
||||
config: Config object or dict that may contain system instruction
|
||||
|
||||
Returns:
|
||||
List of formatted messages with role and content fields, with system message prepended if needed
|
||||
"""
|
||||
formatted_messages = format_gemini_input(contents)
|
||||
|
||||
# Check if system instruction is provided in config parameter
|
||||
system_instruction = extract_gemini_system_instruction(config)
|
||||
|
||||
if system_instruction is not None:
|
||||
has_system = any(msg.get("role") == "system" for msg in formatted_messages)
|
||||
if not has_system:
|
||||
from posthog.ai.types import FormattedMessage
|
||||
|
||||
system_message: FormattedMessage = {
|
||||
"role": "system",
|
||||
"content": system_instruction,
|
||||
}
|
||||
formatted_messages = [system_message] + list(formatted_messages)
|
||||
|
||||
return formatted_messages
|
||||
|
||||
|
||||
def format_gemini_input(contents: Any) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format Gemini input contents into standardized message format for PostHog tracking.
|
||||
|
||||
This function handles various input formats:
|
||||
- String inputs
|
||||
- List of strings, dicts, or objects
|
||||
- Single dict or object
|
||||
- Gemini-specific format with parts array
|
||||
|
||||
Args:
|
||||
contents: Input contents in various possible formats
|
||||
|
||||
Returns:
|
||||
List of formatted messages with role and content fields
|
||||
"""
|
||||
|
||||
# Handle string input
|
||||
if isinstance(contents, str):
|
||||
return [{"role": "user", "content": contents}]
|
||||
|
||||
# Handle list input
|
||||
if isinstance(contents, list):
|
||||
formatted: List[FormattedMessage] = []
|
||||
|
||||
for item in contents:
|
||||
if isinstance(item, str):
|
||||
formatted.append({"role": "user", "content": item})
|
||||
|
||||
elif isinstance(item, dict):
|
||||
formatted.append(_format_dict_message(item))
|
||||
|
||||
else:
|
||||
formatted.append(_format_object_message(item))
|
||||
|
||||
return formatted
|
||||
|
||||
# Handle single dict input
|
||||
if isinstance(contents, dict):
|
||||
return [_format_dict_message(contents)]
|
||||
|
||||
# Handle single object input
|
||||
return [_format_object_message(contents)]
|
||||
|
||||
|
||||
def extract_gemini_web_search_count(response: Any) -> int:
|
||||
"""
|
||||
Extract web search count from Gemini response.
|
||||
|
||||
Gemini bills per request that uses grounding, not per query.
|
||||
Returns 1 if grounding_metadata is present with actual search data, 0 otherwise.
|
||||
|
||||
Args:
|
||||
response: The response from Gemini API
|
||||
|
||||
Returns:
|
||||
1 if web search/grounding was used, 0 otherwise
|
||||
"""
|
||||
|
||||
# Check for grounding_metadata in candidates
|
||||
if hasattr(response, "candidates"):
|
||||
for candidate in response.candidates:
|
||||
if (
|
||||
hasattr(candidate, "grounding_metadata")
|
||||
and candidate.grounding_metadata
|
||||
):
|
||||
grounding_metadata = candidate.grounding_metadata
|
||||
|
||||
# Check if web_search_queries exists and is non-empty
|
||||
if hasattr(grounding_metadata, "web_search_queries"):
|
||||
queries = grounding_metadata.web_search_queries
|
||||
|
||||
if queries is not None and len(queries) > 0:
|
||||
return 1
|
||||
|
||||
# Check if grounding_chunks exists and is non-empty
|
||||
if hasattr(grounding_metadata, "grounding_chunks"):
|
||||
chunks = grounding_metadata.grounding_chunks
|
||||
|
||||
if chunks is not None and len(chunks) > 0:
|
||||
return 1
|
||||
|
||||
# Also check for google_search or grounding in function call names
|
||||
if hasattr(candidate, "content") and candidate.content:
|
||||
if hasattr(candidate.content, "parts") and candidate.content.parts:
|
||||
for part in candidate.content.parts:
|
||||
if hasattr(part, "function_call") and part.function_call:
|
||||
function_name = getattr(
|
||||
part.function_call, "name", ""
|
||||
).lower()
|
||||
|
||||
if (
|
||||
"google_search" in function_name
|
||||
or "grounding" in function_name
|
||||
):
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_usage_from_metadata(metadata: Any) -> TokenUsage:
|
||||
"""
|
||||
Common logic to extract usage from Gemini metadata.
|
||||
Used by both streaming and non-streaming paths.
|
||||
|
||||
Args:
|
||||
metadata: usage_metadata from Gemini response or chunk
|
||||
|
||||
Returns:
|
||||
TokenUsage with standardized usage
|
||||
"""
|
||||
usage = TokenUsage(
|
||||
input_tokens=getattr(metadata, "prompt_token_count", 0),
|
||||
output_tokens=getattr(metadata, "candidates_token_count", 0),
|
||||
)
|
||||
|
||||
# Add cache tokens if present (don't add if 0)
|
||||
if hasattr(metadata, "cached_content_token_count"):
|
||||
cache_tokens = metadata.cached_content_token_count
|
||||
if cache_tokens and cache_tokens > 0:
|
||||
usage["cache_read_input_tokens"] = cache_tokens
|
||||
|
||||
# Add reasoning tokens if present (don't add if 0)
|
||||
if hasattr(metadata, "thoughts_token_count"):
|
||||
reasoning_tokens = metadata.thoughts_token_count
|
||||
if reasoning_tokens and reasoning_tokens > 0:
|
||||
usage["reasoning_tokens"] = reasoning_tokens
|
||||
|
||||
return usage
|
||||
|
||||
|
||||
def extract_gemini_usage_from_response(response: Any) -> TokenUsage:
|
||||
"""
|
||||
Extract usage statistics from a full Gemini response (non-streaming).
|
||||
|
||||
Args:
|
||||
response: The complete response from Gemini API
|
||||
|
||||
Returns:
|
||||
TokenUsage with standardized usage statistics
|
||||
"""
|
||||
if not hasattr(response, "usage_metadata") or not response.usage_metadata:
|
||||
return TokenUsage(input_tokens=0, output_tokens=0)
|
||||
|
||||
usage = _extract_usage_from_metadata(response.usage_metadata)
|
||||
|
||||
# Add web search count if present
|
||||
web_search_count = extract_gemini_web_search_count(response)
|
||||
if web_search_count > 0:
|
||||
usage["web_search_count"] = web_search_count
|
||||
|
||||
return usage
|
||||
|
||||
|
||||
def extract_gemini_usage_from_chunk(chunk: Any) -> TokenUsage:
|
||||
"""
|
||||
Extract usage statistics from a Gemini streaming chunk.
|
||||
|
||||
Args:
|
||||
chunk: Streaming chunk from Gemini API
|
||||
|
||||
Returns:
|
||||
TokenUsage with standardized usage statistics
|
||||
"""
|
||||
|
||||
usage: TokenUsage = TokenUsage()
|
||||
|
||||
# Extract web search count from the chunk before checking for usage_metadata
|
||||
# Web search indicators can appear on any chunk, not just those with usage data
|
||||
web_search_count = extract_gemini_web_search_count(chunk)
|
||||
if web_search_count > 0:
|
||||
usage["web_search_count"] = web_search_count
|
||||
|
||||
if not hasattr(chunk, "usage_metadata") or not chunk.usage_metadata:
|
||||
return usage
|
||||
|
||||
usage_from_metadata = _extract_usage_from_metadata(chunk.usage_metadata)
|
||||
|
||||
# Merge the usage from metadata with any web search count we found
|
||||
usage.update(usage_from_metadata)
|
||||
|
||||
return usage
|
||||
|
||||
|
||||
def extract_gemini_content_from_chunk(chunk: Any) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Extract content (text or function call) from a Gemini streaming chunk.
|
||||
|
||||
Args:
|
||||
chunk: Streaming chunk from Gemini API
|
||||
|
||||
Returns:
|
||||
Content block dictionary if present, None otherwise
|
||||
"""
|
||||
|
||||
# Check for text content
|
||||
if hasattr(chunk, "text") and chunk.text:
|
||||
return {"type": "text", "text": chunk.text}
|
||||
|
||||
# Check for function calls in candidates
|
||||
if hasattr(chunk, "candidates") and chunk.candidates:
|
||||
for candidate in chunk.candidates:
|
||||
if hasattr(candidate, "content") and candidate.content:
|
||||
if hasattr(candidate.content, "parts") and candidate.content.parts:
|
||||
for part in candidate.content.parts:
|
||||
# Check for function_call part
|
||||
if hasattr(part, "function_call") and part.function_call:
|
||||
function_call = part.function_call
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": function_call.name,
|
||||
"arguments": function_call.args,
|
||||
},
|
||||
}
|
||||
# Also check for text in parts
|
||||
elif hasattr(part, "text") and part.text:
|
||||
return {"type": "text", "text": part.text}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_gemini_streaming_output(
|
||||
accumulated_content: Union[str, List[Any]],
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format the final output from Gemini streaming.
|
||||
|
||||
Args:
|
||||
accumulated_content: Accumulated content from streaming (string, list of strings, or list of content blocks)
|
||||
|
||||
Returns:
|
||||
List of formatted messages
|
||||
"""
|
||||
|
||||
# Handle legacy string input (backward compatibility)
|
||||
if isinstance(accumulated_content, str):
|
||||
return [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": accumulated_content}],
|
||||
}
|
||||
]
|
||||
|
||||
# Handle list input
|
||||
if isinstance(accumulated_content, list):
|
||||
content: List[FormattedContentItem] = []
|
||||
text_parts = []
|
||||
|
||||
for item in accumulated_content:
|
||||
if isinstance(item, str):
|
||||
# Legacy support: accumulate strings
|
||||
text_parts.append(item)
|
||||
elif isinstance(item, dict):
|
||||
# New format: content blocks
|
||||
if item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
elif item.get("type") == "function":
|
||||
# If we have accumulated text, add it first
|
||||
if text_parts:
|
||||
content.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": "".join(text_parts),
|
||||
}
|
||||
)
|
||||
text_parts = []
|
||||
|
||||
# Add the function call
|
||||
content.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": item.get("function", {}),
|
||||
}
|
||||
)
|
||||
|
||||
# Add any remaining text
|
||||
if text_parts:
|
||||
content.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": "".join(text_parts),
|
||||
}
|
||||
)
|
||||
|
||||
# If we have content, return it
|
||||
if content:
|
||||
return [{"role": "assistant", "content": content}]
|
||||
|
||||
# Fallback for empty or unexpected input
|
||||
return [{"role": "assistant", "content": [{"type": "text", "text": ""}]}]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .callbacks import CallbackHandler
|
||||
|
||||
__all__ = ["CallbackHandler"]
|
||||
@@ -0,0 +1,909 @@
|
||||
try:
|
||||
import langchain_core # noqa: F401
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError(
|
||||
"Please install LangChain to use this feature: 'pip install langchain-core'"
|
||||
)
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from uuid import UUID
|
||||
|
||||
try:
|
||||
# LangChain 1.0+ and modern 0.x with langchain-core
|
||||
from langchain_core.callbacks.base import BaseCallbackHandler
|
||||
from langchain_core.agents import AgentAction, AgentFinish
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
# Fallback for older LangChain versions
|
||||
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."""
|
||||
posthog_properties: Optional[Dict[str, Any]] = None
|
||||
"""PostHog properties of the run."""
|
||||
|
||||
|
||||
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
|
||||
|
||||
generation.posthog_properties = metadata.get("posthog_properties")
|
||||
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,
|
||||
"$ai_framework": "langchain",
|
||||
}
|
||||
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,
|
||||
"$ai_framework": "langchain",
|
||||
}
|
||||
|
||||
if isinstance(run.posthog_properties, dict):
|
||||
event_properties.update(run.posthog_properties)
|
||||
|
||||
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, run.provider, run.model)
|
||||
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],
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
) -> 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",
|
||||
}
|
||||
normalized_usage = ModelUsage(
|
||||
**{
|
||||
dataclass_key: parsed_usage.get(mapped_key) or 0
|
||||
for mapped_key, dataclass_key in field_mapping.items()
|
||||
},
|
||||
)
|
||||
# For Anthropic providers, LangChain reports input_tokens as the sum of input and cache read tokens.
|
||||
# Our cost calculation expects them to be separate for Anthropic, so we subtract cache tokens.
|
||||
# For other providers (OpenAI, etc.), input_tokens already includes cache tokens as expected.
|
||||
# Match logic consistent with plugin-server: exact match on provider OR substring match on model
|
||||
is_anthropic = False
|
||||
if provider and provider.lower() == "anthropic":
|
||||
is_anthropic = True
|
||||
elif model and "anthropic" in model.lower():
|
||||
is_anthropic = True
|
||||
|
||||
if (
|
||||
is_anthropic
|
||||
and normalized_usage.input_tokens
|
||||
and normalized_usage.cache_read_tokens
|
||||
):
|
||||
normalized_usage.input_tokens = max(
|
||||
normalized_usage.input_tokens - normalized_usage.cache_read_tokens, 0
|
||||
)
|
||||
return normalized_usage
|
||||
|
||||
|
||||
def _parse_usage(
|
||||
response: LLMResult, provider: Optional[str] = None, model: Optional[str] = None
|
||||
) -> 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], provider, model
|
||||
)
|
||||
break
|
||||
|
||||
if hasattr(response, "generations"):
|
||||
for generation in response.generations:
|
||||
if "usage" in generation:
|
||||
llm_usage = _parse_usage_model(generation["usage"], provider, model)
|
||||
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"],
|
||||
provider,
|
||||
model,
|
||||
)
|
||||
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, provider, model)
|
||||
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,20 @@
|
||||
from .openai import OpenAI
|
||||
from .openai_async import AsyncOpenAI
|
||||
from .openai_providers import AsyncAzureOpenAI, AzureOpenAI
|
||||
from .openai_converter import (
|
||||
format_openai_response,
|
||||
format_openai_input,
|
||||
extract_openai_tools,
|
||||
format_openai_streaming_content,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"OpenAI",
|
||||
"AsyncOpenAI",
|
||||
"AzureOpenAI",
|
||||
"AsyncAzureOpenAI",
|
||||
"format_openai_response",
|
||||
"format_openai_input",
|
||||
"extract_openai_tools",
|
||||
"format_openai_streaming_content",
|
||||
]
|
||||
@@ -0,0 +1,600 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from posthog.ai.types import TokenUsage
|
||||
|
||||
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,
|
||||
merge_usage_stats,
|
||||
with_privacy_mode,
|
||||
)
|
||||
from posthog.ai.openai.openai_converter import (
|
||||
extract_openai_usage_from_chunk,
|
||||
extract_openai_content_from_chunk,
|
||||
extract_openai_tool_calls_from_chunk,
|
||||
accumulate_openai_tool_calls,
|
||||
)
|
||||
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: TokenUsage = TokenUsage()
|
||||
final_content = []
|
||||
model_from_response: Optional[str] = None
|
||||
response = self._original.create(**kwargs)
|
||||
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal final_content # noqa: F824
|
||||
nonlocal model_from_response
|
||||
|
||||
try:
|
||||
for chunk in response:
|
||||
# Extract model from response object in chunk (for stored prompts)
|
||||
if hasattr(chunk, "response") and chunk.response:
|
||||
if model_from_response is None and hasattr(
|
||||
chunk.response, "model"
|
||||
):
|
||||
model_from_response = chunk.response.model
|
||||
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
|
||||
|
||||
if chunk_usage:
|
||||
merge_usage_stats(usage_stats, chunk_usage)
|
||||
|
||||
# Extract content from chunk
|
||||
content = extract_openai_content_from_chunk(chunk, "responses")
|
||||
|
||||
if content is not None:
|
||||
final_content.append(content)
|
||||
|
||||
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,
|
||||
None, # Responses API doesn't have tools
|
||||
model_from_response,
|
||||
)
|
||||
|
||||
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: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
model_from_response: Optional[str] = None,
|
||||
):
|
||||
from posthog.ai.types import StreamingEventData
|
||||
from posthog.ai.openai.openai_converter import (
|
||||
format_openai_streaming_input,
|
||||
format_openai_streaming_output,
|
||||
)
|
||||
from posthog.ai.utils import capture_streaming_event
|
||||
|
||||
# Prepare standardized event data
|
||||
formatted_input = format_openai_streaming_input(kwargs, "responses")
|
||||
sanitized_input = sanitize_openai_response(formatted_input)
|
||||
|
||||
# Use model from kwargs, fallback to model from response
|
||||
model = kwargs.get("model") or model_from_response or "unknown"
|
||||
|
||||
event_data = StreamingEventData(
|
||||
provider="openai",
|
||||
model=model,
|
||||
base_url=str(self._client.base_url),
|
||||
kwargs=kwargs,
|
||||
formatted_input=sanitized_input,
|
||||
formatted_output=format_openai_streaming_output(output, "responses"),
|
||||
usage_stats=usage_stats,
|
||||
latency=latency,
|
||||
distinct_id=posthog_distinct_id,
|
||||
trace_id=posthog_trace_id,
|
||||
properties=posthog_properties,
|
||||
privacy_mode=posthog_privacy_mode,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._client._ph_client, event_data)
|
||||
|
||||
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: TokenUsage = TokenUsage()
|
||||
accumulated_content = []
|
||||
accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
|
||||
model_from_response: Optional[str] = None
|
||||
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
|
||||
nonlocal accumulated_tool_calls
|
||||
nonlocal model_from_response
|
||||
|
||||
try:
|
||||
for chunk in response:
|
||||
# Extract model from chunk (Chat Completions chunks have model field)
|
||||
if model_from_response is None and hasattr(chunk, "model"):
|
||||
model_from_response = chunk.model
|
||||
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
|
||||
|
||||
if chunk_usage:
|
||||
merge_usage_stats(usage_stats, chunk_usage)
|
||||
|
||||
# Extract content from chunk
|
||||
content = extract_openai_content_from_chunk(chunk, "chat")
|
||||
|
||||
if content is not None:
|
||||
accumulated_content.append(content)
|
||||
|
||||
# Extract and accumulate tool calls from chunk
|
||||
chunk_tool_calls = extract_openai_tool_calls_from_chunk(chunk)
|
||||
if chunk_tool_calls:
|
||||
accumulate_openai_tool_calls(
|
||||
accumulated_tool_calls, chunk_tool_calls
|
||||
)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
# Convert accumulated tool calls dict to list
|
||||
tool_calls_list = (
|
||||
list(accumulated_tool_calls.values())
|
||||
if accumulated_tool_calls
|
||||
else None
|
||||
)
|
||||
|
||||
self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
accumulated_content,
|
||||
tool_calls_list,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
model_from_response,
|
||||
)
|
||||
|
||||
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: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
model_from_response: Optional[str] = None,
|
||||
):
|
||||
from posthog.ai.types import StreamingEventData
|
||||
from posthog.ai.openai.openai_converter import (
|
||||
format_openai_streaming_input,
|
||||
format_openai_streaming_output,
|
||||
)
|
||||
from posthog.ai.utils import capture_streaming_event
|
||||
|
||||
# Prepare standardized event data
|
||||
formatted_input = format_openai_streaming_input(kwargs, "chat")
|
||||
sanitized_input = sanitize_openai(formatted_input)
|
||||
|
||||
# Use model from kwargs, fallback to model from response
|
||||
model = kwargs.get("model") or model_from_response or "unknown"
|
||||
|
||||
event_data = StreamingEventData(
|
||||
provider="openai",
|
||||
model=model,
|
||||
base_url=str(self._client.base_url),
|
||||
kwargs=kwargs,
|
||||
formatted_input=sanitized_input,
|
||||
formatted_output=format_openai_streaming_output(output, "chat", tool_calls),
|
||||
usage_stats=usage_stats,
|
||||
latency=latency,
|
||||
distinct_id=posthog_distinct_id,
|
||||
trace_id=posthog_trace_id,
|
||||
properties=posthog_properties,
|
||||
privacy_mode=posthog_privacy_mode,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._client._ph_client, event_data)
|
||||
|
||||
|
||||
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,658 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from posthog.ai.types import TokenUsage
|
||||
|
||||
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,
|
||||
merge_usage_stats,
|
||||
with_privacy_mode,
|
||||
)
|
||||
from posthog.ai.openai.openai_converter import (
|
||||
extract_openai_usage_from_chunk,
|
||||
extract_openai_content_from_chunk,
|
||||
extract_openai_tool_calls_from_chunk,
|
||||
accumulate_openai_tool_calls,
|
||||
format_openai_streaming_output,
|
||||
)
|
||||
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: TokenUsage = TokenUsage()
|
||||
final_content = []
|
||||
model_from_response: Optional[str] = None
|
||||
response = await self._original.create(**kwargs)
|
||||
|
||||
async def async_generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal final_content # noqa: F824
|
||||
nonlocal model_from_response
|
||||
|
||||
try:
|
||||
async for chunk in response:
|
||||
# Extract model from response object in chunk (for stored prompts)
|
||||
if hasattr(chunk, "response") and chunk.response:
|
||||
if model_from_response is None and hasattr(
|
||||
chunk.response, "model"
|
||||
):
|
||||
model_from_response = chunk.response.model
|
||||
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
|
||||
|
||||
if chunk_usage:
|
||||
merge_usage_stats(usage_stats, chunk_usage)
|
||||
|
||||
# Extract content from chunk
|
||||
content = extract_openai_content_from_chunk(chunk, "responses")
|
||||
|
||||
if content is not None:
|
||||
final_content.append(content)
|
||||
|
||||
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),
|
||||
model_from_response,
|
||||
)
|
||||
|
||||
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: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
model_from_response: Optional[str] = None,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
# Use model from kwargs, fallback to model from response
|
||||
model = kwargs.get("model") or model_from_response or "unknown"
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": 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,
|
||||
format_openai_streaming_output(output, "responses"),
|
||||
),
|
||||
"$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 {}),
|
||||
}
|
||||
|
||||
# Add web search count if present
|
||||
web_search_count = usage_stats.get("web_search_count")
|
||||
if (
|
||||
web_search_count is not None
|
||||
and isinstance(web_search_count, int)
|
||||
and web_search_count > 0
|
||||
):
|
||||
event_properties["$ai_web_search_count"] = web_search_count
|
||||
|
||||
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: TokenUsage = TokenUsage()
|
||||
accumulated_content = []
|
||||
accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
|
||||
model_from_response: Optional[str] = None
|
||||
|
||||
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
|
||||
nonlocal accumulated_tool_calls
|
||||
nonlocal model_from_response
|
||||
|
||||
try:
|
||||
async for chunk in response:
|
||||
# Extract model from chunk (Chat Completions chunks have model field)
|
||||
if model_from_response is None and hasattr(chunk, "model"):
|
||||
model_from_response = chunk.model
|
||||
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
|
||||
if chunk_usage:
|
||||
merge_usage_stats(usage_stats, chunk_usage)
|
||||
|
||||
# Extract content from chunk
|
||||
content = extract_openai_content_from_chunk(chunk, "chat")
|
||||
if content is not None:
|
||||
accumulated_content.append(content)
|
||||
|
||||
# Extract and accumulate tool calls from chunk
|
||||
chunk_tool_calls = extract_openai_tool_calls_from_chunk(chunk)
|
||||
if chunk_tool_calls:
|
||||
accumulate_openai_tool_calls(
|
||||
accumulated_tool_calls, chunk_tool_calls
|
||||
)
|
||||
|
||||
yield chunk
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
|
||||
# Convert accumulated tool calls dict to list
|
||||
tool_calls_list = (
|
||||
list(accumulated_tool_calls.values())
|
||||
if accumulated_tool_calls
|
||||
else None
|
||||
)
|
||||
|
||||
await self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
accumulated_content,
|
||||
tool_calls_list,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
model_from_response,
|
||||
)
|
||||
|
||||
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: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
model_from_response: Optional[str] = None,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
# Use model from kwargs, fallback to model from response
|
||||
model = kwargs.get("model") or model_from_response or "unknown"
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": 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,
|
||||
format_openai_streaming_output(output, "chat", tool_calls),
|
||||
),
|
||||
"$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 {}),
|
||||
}
|
||||
|
||||
# Add web search count if present
|
||||
web_search_count = usage_stats.get("web_search_count")
|
||||
|
||||
if (
|
||||
web_search_count is not None
|
||||
and isinstance(web_search_count, int)
|
||||
and web_search_count > 0
|
||||
):
|
||||
event_properties["$ai_web_search_count"] = web_search_count
|
||||
|
||||
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: TokenUsage = TokenUsage()
|
||||
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
usage_stats = TokenUsage(
|
||||
input_tokens=getattr(response.usage, "prompt_tokens", 0),
|
||||
output_tokens=getattr(response.usage, "completion_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("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
|
||||
|
||||
# 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,741 @@
|
||||
"""
|
||||
OpenAI-specific conversion utilities.
|
||||
|
||||
This module handles the conversion of OpenAI API responses and inputs
|
||||
into standardized formats for PostHog tracking. It supports both
|
||||
Chat Completions API and Responses API formats.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from posthog.ai.types import (
|
||||
FormattedContentItem,
|
||||
FormattedFunctionCall,
|
||||
FormattedImageContent,
|
||||
FormattedMessage,
|
||||
FormattedTextContent,
|
||||
TokenUsage,
|
||||
)
|
||||
|
||||
|
||||
def format_openai_response(response: Any) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format an OpenAI response into standardized message format.
|
||||
|
||||
Handles both Chat Completions API and Responses API formats.
|
||||
|
||||
Args:
|
||||
response: The response object from OpenAI API
|
||||
|
||||
Returns:
|
||||
List of formatted messages with role and content
|
||||
"""
|
||||
|
||||
output: List[FormattedMessage] = []
|
||||
|
||||
if response is None:
|
||||
return output
|
||||
|
||||
# Handle Chat Completions response format
|
||||
if hasattr(response, "choices"):
|
||||
content: List[FormattedContentItem] = []
|
||||
role = "assistant"
|
||||
|
||||
for choice in response.choices:
|
||||
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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Handle audio output (gpt-4o-audio-preview)
|
||||
if hasattr(choice.message, "audio") and choice.message.audio:
|
||||
# Convert Pydantic model to dict to capture all fields from OpenAI
|
||||
audio_dict = choice.message.audio.model_dump()
|
||||
content.append({"type": "audio", **audio_dict})
|
||||
|
||||
if content:
|
||||
output.append(
|
||||
{
|
||||
"role": role,
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
|
||||
# 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")
|
||||
):
|
||||
image_content: FormattedImageContent = {
|
||||
"type": "image",
|
||||
"image": content_item.image_url,
|
||||
}
|
||||
content.append(image_content)
|
||||
|
||||
elif hasattr(item, "content"):
|
||||
text_content = {"type": "text", "text": str(item.content)}
|
||||
content.append(text_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:
|
||||
output.append(
|
||||
{
|
||||
"role": role,
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def format_openai_input(
|
||||
messages: Optional[List[Dict[str, Any]]] = None, input_data: Optional[Any] = None
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format OpenAI input messages.
|
||||
|
||||
Handles both messages parameter (Chat Completions) and input parameter (Responses API).
|
||||
|
||||
Args:
|
||||
messages: List of message dictionaries for Chat Completions API
|
||||
input_data: Input data for Responses API
|
||||
|
||||
Returns:
|
||||
List of formatted messages
|
||||
"""
|
||||
|
||||
formatted_messages: List[FormattedMessage] = []
|
||||
|
||||
# Handle Chat Completions API format
|
||||
if messages is not None:
|
||||
for msg in messages:
|
||||
formatted_messages.append(
|
||||
{
|
||||
"role": msg.get("role", "user"),
|
||||
"content": msg.get("content", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# Handle Responses API format
|
||||
if input_data is not None:
|
||||
if isinstance(input_data, list):
|
||||
for item in input_data:
|
||||
role = "user"
|
||||
content = ""
|
||||
|
||||
if isinstance(item, dict):
|
||||
role = item.get("role", "user")
|
||||
content = item.get("content", "")
|
||||
|
||||
elif isinstance(item, str):
|
||||
content = item
|
||||
|
||||
else:
|
||||
content = str(item)
|
||||
|
||||
formatted_messages.append({"role": role, "content": content})
|
||||
|
||||
elif isinstance(input_data, str):
|
||||
formatted_messages.append({"role": "user", "content": input_data})
|
||||
|
||||
else:
|
||||
formatted_messages.append({"role": "user", "content": str(input_data)})
|
||||
|
||||
return formatted_messages
|
||||
|
||||
|
||||
def extract_openai_tools(kwargs: Dict[str, Any]) -> Optional[Any]:
|
||||
"""
|
||||
Extract tool definitions from OpenAI API kwargs.
|
||||
|
||||
Args:
|
||||
kwargs: Keyword arguments passed to OpenAI API
|
||||
|
||||
Returns:
|
||||
Tool definitions if present, None otherwise
|
||||
"""
|
||||
|
||||
# Check for tools parameter (newer API)
|
||||
if "tools" in kwargs:
|
||||
return kwargs["tools"]
|
||||
|
||||
# Check for functions parameter (older API)
|
||||
if "functions" in kwargs:
|
||||
return kwargs["functions"]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_openai_streaming_content(
|
||||
accumulated_content: str, tool_calls: Optional[List[Dict[str, Any]]] = None
|
||||
) -> List[FormattedContentItem]:
|
||||
"""
|
||||
Format content from OpenAI streaming response.
|
||||
|
||||
Used by streaming handlers to format accumulated content.
|
||||
|
||||
Args:
|
||||
accumulated_content: Accumulated text content from streaming
|
||||
tool_calls: Optional list of tool calls accumulated during streaming
|
||||
|
||||
Returns:
|
||||
List of formatted content items
|
||||
"""
|
||||
formatted: List[FormattedContentItem] = []
|
||||
|
||||
# Add text content if present
|
||||
if accumulated_content:
|
||||
text_content: FormattedTextContent = {
|
||||
"type": "text",
|
||||
"text": accumulated_content,
|
||||
}
|
||||
formatted.append(text_content)
|
||||
|
||||
# Add tool calls if present
|
||||
if tool_calls:
|
||||
for tool_call in tool_calls:
|
||||
function_call: FormattedFunctionCall = {
|
||||
"type": "function",
|
||||
"id": tool_call.get("id"),
|
||||
"function": tool_call.get("function", {}),
|
||||
}
|
||||
formatted.append(function_call)
|
||||
|
||||
return formatted
|
||||
|
||||
|
||||
def extract_openai_web_search_count(response: Any) -> int:
|
||||
"""
|
||||
Extract web search count from OpenAI response.
|
||||
|
||||
Uses a two-tier detection strategy:
|
||||
1. Priority 1 (exact count): Check for output[].type == "web_search_call" (Responses API)
|
||||
2. Priority 2 (binary detection): Check for various web search indicators:
|
||||
- Root-level citations, search_results, or usage.search_context_size (Perplexity)
|
||||
- Annotations with type "url_citation" in choices/output (including delta for streaming)
|
||||
|
||||
Args:
|
||||
response: The response from OpenAI API
|
||||
|
||||
Returns:
|
||||
Number of web search requests (exact count or binary 1/0)
|
||||
"""
|
||||
|
||||
# Priority 1: Check for exact count in Responses API output
|
||||
if hasattr(response, "output"):
|
||||
web_search_count = 0
|
||||
|
||||
for item in response.output:
|
||||
if hasattr(item, "type") and item.type == "web_search_call":
|
||||
web_search_count += 1
|
||||
|
||||
web_search_count = max(0, web_search_count)
|
||||
|
||||
if web_search_count > 0:
|
||||
return web_search_count
|
||||
|
||||
# Priority 2: Binary detection (returns 1 or 0)
|
||||
|
||||
# Check root-level indicators (Perplexity)
|
||||
if hasattr(response, "citations"):
|
||||
citations = getattr(response, "citations")
|
||||
|
||||
if citations and len(citations) > 0:
|
||||
return 1
|
||||
|
||||
if hasattr(response, "search_results"):
|
||||
search_results = getattr(response, "search_results")
|
||||
|
||||
if search_results and len(search_results) > 0:
|
||||
return 1
|
||||
|
||||
if hasattr(response, "usage") and hasattr(response.usage, "search_context_size"):
|
||||
if response.usage.search_context_size:
|
||||
return 1
|
||||
|
||||
# Check for url_citation annotations in choices (Chat Completions)
|
||||
if hasattr(response, "choices"):
|
||||
for choice in response.choices:
|
||||
# Check message.annotations (non-streaming or final chunk)
|
||||
if hasattr(choice, "message") and hasattr(choice.message, "annotations"):
|
||||
annotations = choice.message.annotations
|
||||
|
||||
if annotations:
|
||||
for annotation in annotations:
|
||||
# Support both dict and object formats
|
||||
annotation_type = (
|
||||
annotation.get("type")
|
||||
if isinstance(annotation, dict)
|
||||
else getattr(annotation, "type", None)
|
||||
)
|
||||
|
||||
if annotation_type == "url_citation":
|
||||
return 1
|
||||
|
||||
# Check delta.annotations (streaming chunks)
|
||||
if hasattr(choice, "delta") and hasattr(choice.delta, "annotations"):
|
||||
annotations = choice.delta.annotations
|
||||
|
||||
if annotations:
|
||||
for annotation in annotations:
|
||||
# Support both dict and object formats
|
||||
annotation_type = (
|
||||
annotation.get("type")
|
||||
if isinstance(annotation, dict)
|
||||
else getattr(annotation, "type", None)
|
||||
)
|
||||
|
||||
if annotation_type == "url_citation":
|
||||
return 1
|
||||
|
||||
# Check for url_citation annotations in output (Responses API)
|
||||
if hasattr(response, "output"):
|
||||
for item in response.output:
|
||||
if hasattr(item, "content") and isinstance(item.content, list):
|
||||
for content_item in item.content:
|
||||
if hasattr(content_item, "annotations"):
|
||||
annotations = content_item.annotations
|
||||
|
||||
if annotations:
|
||||
for annotation in annotations:
|
||||
# Support both dict and object formats
|
||||
annotation_type = (
|
||||
annotation.get("type")
|
||||
if isinstance(annotation, dict)
|
||||
else getattr(annotation, "type", None)
|
||||
)
|
||||
|
||||
if annotation_type == "url_citation":
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def extract_openai_usage_from_response(response: Any) -> TokenUsage:
|
||||
"""
|
||||
Extract usage statistics from a full OpenAI response (non-streaming).
|
||||
Handles both Chat Completions and Responses API.
|
||||
|
||||
Args:
|
||||
response: The complete response from OpenAI API
|
||||
|
||||
Returns:
|
||||
TokenUsage with standardized usage statistics
|
||||
"""
|
||||
if not hasattr(response, "usage"):
|
||||
return TokenUsage(input_tokens=0, output_tokens=0)
|
||||
|
||||
cached_tokens = 0
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
reasoning_tokens = 0
|
||||
|
||||
# Responses API format
|
||||
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 format
|
||||
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
|
||||
if hasattr(response.usage, "completion_tokens_details") and hasattr(
|
||||
response.usage.completion_tokens_details, "reasoning_tokens"
|
||||
):
|
||||
reasoning_tokens = response.usage.completion_tokens_details.reasoning_tokens
|
||||
|
||||
result = TokenUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
|
||||
if cached_tokens > 0:
|
||||
result["cache_read_input_tokens"] = cached_tokens
|
||||
if reasoning_tokens > 0:
|
||||
result["reasoning_tokens"] = reasoning_tokens
|
||||
|
||||
web_search_count = extract_openai_web_search_count(response)
|
||||
if web_search_count > 0:
|
||||
result["web_search_count"] = web_search_count
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def extract_openai_usage_from_chunk(
|
||||
chunk: Any, provider_type: str = "chat"
|
||||
) -> TokenUsage:
|
||||
"""
|
||||
Extract usage statistics from an OpenAI streaming chunk.
|
||||
|
||||
Handles both Chat Completions and Responses API formats.
|
||||
|
||||
Args:
|
||||
chunk: Streaming chunk from OpenAI API
|
||||
provider_type: Either "chat" or "responses" to handle different API formats
|
||||
|
||||
Returns:
|
||||
Dictionary of usage statistics
|
||||
"""
|
||||
|
||||
usage: TokenUsage = TokenUsage()
|
||||
|
||||
if provider_type == "chat":
|
||||
# Extract web search count from the chunk before checking for usage
|
||||
# Web search indicators (citations, annotations) can appear on any chunk,
|
||||
# not just those with usage data
|
||||
web_search_count = extract_openai_web_search_count(chunk)
|
||||
if web_search_count > 0:
|
||||
usage["web_search_count"] = web_search_count
|
||||
|
||||
if not hasattr(chunk, "usage") or not chunk.usage:
|
||||
return usage
|
||||
|
||||
# Chat Completions API uses prompt_tokens and completion_tokens
|
||||
# Standardize to input_tokens and output_tokens
|
||||
usage["input_tokens"] = getattr(chunk.usage, "prompt_tokens", 0)
|
||||
usage["output_tokens"] = getattr(chunk.usage, "completion_tokens", 0)
|
||||
|
||||
# Handle cached tokens
|
||||
if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
|
||||
chunk.usage.prompt_tokens_details, "cached_tokens"
|
||||
):
|
||||
usage["cache_read_input_tokens"] = (
|
||||
chunk.usage.prompt_tokens_details.cached_tokens
|
||||
)
|
||||
|
||||
# Handle reasoning tokens
|
||||
if hasattr(chunk.usage, "completion_tokens_details") and hasattr(
|
||||
chunk.usage.completion_tokens_details, "reasoning_tokens"
|
||||
):
|
||||
usage["reasoning_tokens"] = (
|
||||
chunk.usage.completion_tokens_details.reasoning_tokens
|
||||
)
|
||||
|
||||
elif provider_type == "responses":
|
||||
# For Responses API, usage is only in chunk.response.usage for completed events
|
||||
if hasattr(chunk, "type") and chunk.type == "response.completed":
|
||||
if (
|
||||
hasattr(chunk, "response")
|
||||
and hasattr(chunk.response, "usage")
|
||||
and chunk.response.usage
|
||||
):
|
||||
response_usage = chunk.response.usage
|
||||
usage["input_tokens"] = getattr(response_usage, "input_tokens", 0)
|
||||
usage["output_tokens"] = getattr(response_usage, "output_tokens", 0)
|
||||
|
||||
# Handle cached tokens
|
||||
if hasattr(response_usage, "input_tokens_details") and hasattr(
|
||||
response_usage.input_tokens_details, "cached_tokens"
|
||||
):
|
||||
usage["cache_read_input_tokens"] = (
|
||||
response_usage.input_tokens_details.cached_tokens
|
||||
)
|
||||
|
||||
# Handle reasoning tokens
|
||||
if hasattr(response_usage, "output_tokens_details") and hasattr(
|
||||
response_usage.output_tokens_details, "reasoning_tokens"
|
||||
):
|
||||
usage["reasoning_tokens"] = (
|
||||
response_usage.output_tokens_details.reasoning_tokens
|
||||
)
|
||||
|
||||
# Extract web search count from the complete response
|
||||
if hasattr(chunk, "response"):
|
||||
web_search_count = extract_openai_web_search_count(chunk.response)
|
||||
if web_search_count > 0:
|
||||
usage["web_search_count"] = web_search_count
|
||||
|
||||
return usage
|
||||
|
||||
|
||||
def extract_openai_content_from_chunk(
|
||||
chunk: Any, provider_type: str = "chat"
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Extract content from an OpenAI streaming chunk.
|
||||
|
||||
Handles both Chat Completions and Responses API formats.
|
||||
|
||||
Args:
|
||||
chunk: Streaming chunk from OpenAI API
|
||||
provider_type: Either "chat" or "responses" to handle different API formats
|
||||
|
||||
Returns:
|
||||
Text content if present, None otherwise
|
||||
"""
|
||||
|
||||
if provider_type == "chat":
|
||||
# Chat Completions API format
|
||||
if (
|
||||
hasattr(chunk, "choices")
|
||||
and chunk.choices
|
||||
and len(chunk.choices) > 0
|
||||
and chunk.choices[0].delta
|
||||
and chunk.choices[0].delta.content
|
||||
):
|
||||
return chunk.choices[0].delta.content
|
||||
|
||||
elif provider_type == "responses":
|
||||
# Responses API format
|
||||
if hasattr(chunk, "type") and chunk.type == "response.completed":
|
||||
if hasattr(chunk, "response") and chunk.response:
|
||||
res = chunk.response
|
||||
if res.output and len(res.output) > 0:
|
||||
# Return the full output for responses
|
||||
return res.output[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_openai_tool_calls_from_chunk(chunk: Any) -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Extract tool calls from an OpenAI streaming chunk.
|
||||
|
||||
Args:
|
||||
chunk: Streaming chunk from OpenAI API
|
||||
|
||||
Returns:
|
||||
List of tool call deltas if present, None otherwise
|
||||
"""
|
||||
if (
|
||||
hasattr(chunk, "choices")
|
||||
and chunk.choices
|
||||
and len(chunk.choices) > 0
|
||||
and chunk.choices[0].delta
|
||||
and hasattr(chunk.choices[0].delta, "tool_calls")
|
||||
and chunk.choices[0].delta.tool_calls
|
||||
):
|
||||
tool_calls = []
|
||||
for tool_call in chunk.choices[0].delta.tool_calls:
|
||||
tc_dict = {
|
||||
"index": getattr(tool_call, "index", None),
|
||||
}
|
||||
|
||||
if hasattr(tool_call, "id") and tool_call.id:
|
||||
tc_dict["id"] = tool_call.id
|
||||
|
||||
if hasattr(tool_call, "type") and tool_call.type:
|
||||
tc_dict["type"] = tool_call.type
|
||||
|
||||
if hasattr(tool_call, "function") and tool_call.function:
|
||||
function_dict = {}
|
||||
if hasattr(tool_call.function, "name") and tool_call.function.name:
|
||||
function_dict["name"] = tool_call.function.name
|
||||
if (
|
||||
hasattr(tool_call.function, "arguments")
|
||||
and tool_call.function.arguments
|
||||
):
|
||||
function_dict["arguments"] = tool_call.function.arguments
|
||||
tc_dict["function"] = function_dict
|
||||
|
||||
tool_calls.append(tc_dict)
|
||||
return tool_calls
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def accumulate_openai_tool_calls(
|
||||
accumulated_tool_calls: Dict[int, Dict[str, Any]],
|
||||
chunk_tool_calls: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""
|
||||
Accumulate tool calls from streaming chunks.
|
||||
|
||||
OpenAI sends tool calls incrementally:
|
||||
- First chunk has id, type, function.name and partial function.arguments
|
||||
- Subsequent chunks have more function.arguments
|
||||
|
||||
Args:
|
||||
accumulated_tool_calls: Dictionary mapping index to accumulated tool call data
|
||||
chunk_tool_calls: List of tool call deltas from current chunk
|
||||
"""
|
||||
for tool_call_delta in chunk_tool_calls:
|
||||
index = tool_call_delta.get("index")
|
||||
if index is None:
|
||||
continue
|
||||
|
||||
# Initialize tool call if first time seeing this index
|
||||
if index not in accumulated_tool_calls:
|
||||
accumulated_tool_calls[index] = {
|
||||
"id": "",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "",
|
||||
"arguments": "",
|
||||
},
|
||||
}
|
||||
|
||||
# Update with new data from delta
|
||||
tc = accumulated_tool_calls[index]
|
||||
|
||||
if "id" in tool_call_delta and tool_call_delta["id"]:
|
||||
tc["id"] = tool_call_delta["id"]
|
||||
|
||||
if "type" in tool_call_delta and tool_call_delta["type"]:
|
||||
tc["type"] = tool_call_delta["type"]
|
||||
|
||||
if "function" in tool_call_delta:
|
||||
func_delta = tool_call_delta["function"]
|
||||
if "name" in func_delta and func_delta["name"]:
|
||||
tc["function"]["name"] = func_delta["name"]
|
||||
if "arguments" in func_delta and func_delta["arguments"]:
|
||||
# Arguments are sent incrementally, concatenate them
|
||||
tc["function"]["arguments"] += func_delta["arguments"]
|
||||
|
||||
|
||||
def format_openai_streaming_output(
|
||||
accumulated_content: Any,
|
||||
provider_type: str = "chat",
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Format the final output from OpenAI streaming.
|
||||
|
||||
Args:
|
||||
accumulated_content: Accumulated content from streaming (string for chat, list for responses)
|
||||
provider_type: Either "chat" or "responses" to handle different API formats
|
||||
tool_calls: Optional list of accumulated tool calls
|
||||
|
||||
Returns:
|
||||
List of formatted messages
|
||||
"""
|
||||
|
||||
if provider_type == "chat":
|
||||
content_items: List[FormattedContentItem] = []
|
||||
|
||||
# Add text content if present
|
||||
if isinstance(accumulated_content, str) and accumulated_content:
|
||||
content_items.append({"type": "text", "text": accumulated_content})
|
||||
elif isinstance(accumulated_content, list):
|
||||
# If it's a list of strings, join them
|
||||
text = "".join(str(item) for item in accumulated_content if item)
|
||||
if text:
|
||||
content_items.append({"type": "text", "text": text})
|
||||
|
||||
# Add tool calls if present
|
||||
if tool_calls:
|
||||
for tool_call in tool_calls:
|
||||
if "function" in tool_call:
|
||||
function_call: FormattedFunctionCall = {
|
||||
"type": "function",
|
||||
"id": tool_call.get("id", ""),
|
||||
"function": tool_call["function"],
|
||||
}
|
||||
content_items.append(function_call)
|
||||
|
||||
# Return formatted message with content
|
||||
if content_items:
|
||||
return [{"role": "assistant", "content": content_items}]
|
||||
else:
|
||||
# Empty response
|
||||
return [{"role": "assistant", "content": []}]
|
||||
|
||||
elif provider_type == "responses":
|
||||
# Responses API: accumulated_content is a list of output items
|
||||
if isinstance(accumulated_content, list) and accumulated_content:
|
||||
# The output is already formatted, just return it
|
||||
return accumulated_content
|
||||
elif isinstance(accumulated_content, str):
|
||||
return [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": accumulated_content}],
|
||||
}
|
||||
]
|
||||
|
||||
# Fallback for any other format
|
||||
return [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": str(accumulated_content)}],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def format_openai_streaming_input(
|
||||
kwargs: Dict[str, Any], api_type: str = "chat"
|
||||
) -> Any:
|
||||
"""
|
||||
Format OpenAI streaming input based on API type.
|
||||
|
||||
Args:
|
||||
kwargs: Keyword arguments passed to OpenAI API
|
||||
api_type: Either "chat" or "responses"
|
||||
|
||||
Returns:
|
||||
Formatted input ready for PostHog tracking
|
||||
"""
|
||||
from posthog.ai.utils import merge_system_prompt
|
||||
|
||||
return merge_system_prompt(kwargs, "openai")
|
||||
@@ -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,248 @@
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
REDACTED_IMAGE_PLACEHOLDER = "[base64 image redacted]"
|
||||
|
||||
|
||||
def _is_multimodal_enabled() -> bool:
|
||||
"""Check if multimodal capture is enabled via environment variable."""
|
||||
return os.environ.get("_INTERNAL_LLMA_MULTIMODAL", "").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
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 _is_multimodal_enabled():
|
||||
return value
|
||||
|
||||
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"]),
|
||||
},
|
||||
}
|
||||
|
||||
if item.get("type") == "audio" and "data" in item:
|
||||
if _is_multimodal_enabled():
|
||||
return item
|
||||
return {**item, "data": REDACTED_IMAGE_PLACEHOLDER}
|
||||
|
||||
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 _is_multimodal_enabled():
|
||||
return item
|
||||
|
||||
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"]
|
||||
):
|
||||
return {
|
||||
**item,
|
||||
"source": {
|
||||
**item["source"],
|
||||
"data": REDACTED_IMAGE_PLACEHOLDER,
|
||||
},
|
||||
}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_gemini_part(part: Any) -> Any:
|
||||
if _is_multimodal_enabled():
|
||||
return part
|
||||
|
||||
if not isinstance(part, dict):
|
||||
return part
|
||||
|
||||
if (
|
||||
"inline_data" in part
|
||||
and isinstance(part["inline_data"], dict)
|
||||
and "data" in part["inline_data"]
|
||||
):
|
||||
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"]
|
||||
):
|
||||
if _is_multimodal_enabled():
|
||||
return item
|
||||
|
||||
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,125 @@
|
||||
"""
|
||||
Common type definitions for PostHog AI SDK.
|
||||
|
||||
These types are used for formatting messages and responses across different AI providers
|
||||
(Anthropic, OpenAI, Gemini, etc.) to ensure consistency in tracking and data structure.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, TypedDict, Union
|
||||
|
||||
|
||||
class FormattedTextContent(TypedDict):
|
||||
"""Formatted text content item."""
|
||||
|
||||
type: str # Literal["text"]
|
||||
text: str
|
||||
|
||||
|
||||
class FormattedFunctionCall(TypedDict, total=False):
|
||||
"""Formatted function/tool call content item."""
|
||||
|
||||
type: str # Literal["function"]
|
||||
id: Optional[str]
|
||||
function: Dict[str, Any] # Contains 'name' and 'arguments'
|
||||
|
||||
|
||||
class FormattedImageContent(TypedDict):
|
||||
"""Formatted image content item."""
|
||||
|
||||
type: str # Literal["image"]
|
||||
image: str
|
||||
|
||||
|
||||
# Union type for all formatted content items
|
||||
FormattedContentItem = Union[
|
||||
FormattedTextContent,
|
||||
FormattedFunctionCall,
|
||||
FormattedImageContent,
|
||||
Dict[str, Any], # Fallback for unknown content types
|
||||
]
|
||||
|
||||
|
||||
class FormattedMessage(TypedDict):
|
||||
"""
|
||||
Standardized message format for PostHog tracking.
|
||||
|
||||
Used across all providers to ensure consistent message structure
|
||||
when sending events to PostHog.
|
||||
"""
|
||||
|
||||
role: str
|
||||
content: Union[str, List[FormattedContentItem], Any]
|
||||
|
||||
|
||||
class TokenUsage(TypedDict, total=False):
|
||||
"""
|
||||
Token usage information for AI model responses.
|
||||
|
||||
Different providers may populate different fields.
|
||||
"""
|
||||
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cache_read_input_tokens: Optional[int]
|
||||
cache_creation_input_tokens: Optional[int]
|
||||
reasoning_tokens: Optional[int]
|
||||
web_search_count: Optional[int]
|
||||
|
||||
|
||||
class ProviderResponse(TypedDict, total=False):
|
||||
"""
|
||||
Standardized provider response format.
|
||||
|
||||
Used for consistent response formatting across all providers.
|
||||
"""
|
||||
|
||||
messages: List[FormattedMessage]
|
||||
usage: TokenUsage
|
||||
error: Optional[str]
|
||||
|
||||
|
||||
class StreamingContentBlock(TypedDict, total=False):
|
||||
"""
|
||||
Content block used during streaming to accumulate content.
|
||||
|
||||
Used for tracking text and function calls as they stream in.
|
||||
"""
|
||||
|
||||
type: str # "text" or "function"
|
||||
text: Optional[str]
|
||||
id: Optional[str]
|
||||
function: Optional[Dict[str, Any]]
|
||||
|
||||
|
||||
class ToolInProgress(TypedDict):
|
||||
"""
|
||||
Tracks a tool/function call being accumulated during streaming.
|
||||
|
||||
Used by Anthropic to accumulate JSON input for tools.
|
||||
"""
|
||||
|
||||
block: StreamingContentBlock
|
||||
input_string: str
|
||||
|
||||
|
||||
class StreamingEventData(TypedDict):
|
||||
"""
|
||||
Standardized data for streaming events across all providers.
|
||||
|
||||
This type ensures consistent data structure when capturing streaming events,
|
||||
with all provider-specific formatting already completed.
|
||||
"""
|
||||
|
||||
provider: str # "openai", "anthropic", "gemini"
|
||||
model: str
|
||||
base_url: str
|
||||
kwargs: Dict[str, Any] # Original kwargs for tool extraction and special handling
|
||||
formatted_input: Any # Provider-formatted input ready for tracking
|
||||
formatted_output: Any # Provider-formatted output ready for tracking
|
||||
usage_stats: TokenUsage
|
||||
latency: float
|
||||
distinct_id: Optional[str]
|
||||
trace_id: Optional[str]
|
||||
properties: Optional[Dict[str, Any]]
|
||||
privacy_mode: bool
|
||||
groups: Optional[Dict[str, Any]]
|
||||
@@ -0,0 +1,589 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, List, Optional, cast
|
||||
|
||||
from posthog.client import Client as PostHogClient
|
||||
from posthog.ai.types import FormattedMessage, StreamingEventData, TokenUsage
|
||||
from posthog.ai.sanitization import (
|
||||
sanitize_openai,
|
||||
sanitize_anthropic,
|
||||
sanitize_gemini,
|
||||
sanitize_langchain,
|
||||
)
|
||||
|
||||
|
||||
def merge_usage_stats(
|
||||
target: TokenUsage, source: TokenUsage, mode: str = "incremental"
|
||||
) -> None:
|
||||
"""
|
||||
Merge streaming usage statistics into target dict, handling None values.
|
||||
|
||||
Supports two modes:
|
||||
- "incremental": Add source values to target (for APIs that report new tokens)
|
||||
- "cumulative": Replace target with source values (for APIs that report totals)
|
||||
|
||||
Args:
|
||||
target: Dictionary to update with usage stats
|
||||
source: TokenUsage that may contain None values
|
||||
mode: Either "incremental" or "cumulative"
|
||||
"""
|
||||
if mode == "incremental":
|
||||
# Add new values to existing totals
|
||||
source_input = source.get("input_tokens")
|
||||
if source_input is not None:
|
||||
current = target.get("input_tokens") or 0
|
||||
target["input_tokens"] = current + source_input
|
||||
|
||||
source_output = source.get("output_tokens")
|
||||
if source_output is not None:
|
||||
current = target.get("output_tokens") or 0
|
||||
target["output_tokens"] = current + source_output
|
||||
|
||||
source_cache_read = source.get("cache_read_input_tokens")
|
||||
if source_cache_read is not None:
|
||||
current = target.get("cache_read_input_tokens") or 0
|
||||
target["cache_read_input_tokens"] = current + source_cache_read
|
||||
|
||||
source_cache_creation = source.get("cache_creation_input_tokens")
|
||||
if source_cache_creation is not None:
|
||||
current = target.get("cache_creation_input_tokens") or 0
|
||||
target["cache_creation_input_tokens"] = current + source_cache_creation
|
||||
|
||||
source_reasoning = source.get("reasoning_tokens")
|
||||
if source_reasoning is not None:
|
||||
current = target.get("reasoning_tokens") or 0
|
||||
target["reasoning_tokens"] = current + source_reasoning
|
||||
|
||||
source_web_search = source.get("web_search_count")
|
||||
if source_web_search is not None:
|
||||
current = target.get("web_search_count") or 0
|
||||
target["web_search_count"] = max(current, source_web_search)
|
||||
|
||||
elif mode == "cumulative":
|
||||
# Replace with latest values (already cumulative)
|
||||
if source.get("input_tokens") is not None:
|
||||
target["input_tokens"] = source["input_tokens"]
|
||||
if source.get("output_tokens") is not None:
|
||||
target["output_tokens"] = source["output_tokens"]
|
||||
if source.get("cache_read_input_tokens") is not None:
|
||||
target["cache_read_input_tokens"] = source["cache_read_input_tokens"]
|
||||
if source.get("cache_creation_input_tokens") is not None:
|
||||
target["cache_creation_input_tokens"] = source[
|
||||
"cache_creation_input_tokens"
|
||||
]
|
||||
if source.get("reasoning_tokens") is not None:
|
||||
target["reasoning_tokens"] = source["reasoning_tokens"]
|
||||
if source.get("web_search_count") is not None:
|
||||
target["web_search_count"] = source["web_search_count"]
|
||||
|
||||
else:
|
||||
raise ValueError(f"Invalid mode: {mode}. Must be 'incremental' or 'cumulative'")
|
||||
|
||||
|
||||
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) -> TokenUsage:
|
||||
"""
|
||||
Extract usage statistics from response based on provider.
|
||||
Delegates to provider-specific converter functions.
|
||||
"""
|
||||
if provider == "anthropic":
|
||||
from posthog.ai.anthropic.anthropic_converter import (
|
||||
extract_anthropic_usage_from_response,
|
||||
)
|
||||
|
||||
return extract_anthropic_usage_from_response(response)
|
||||
elif provider == "openai":
|
||||
from posthog.ai.openai.openai_converter import (
|
||||
extract_openai_usage_from_response,
|
||||
)
|
||||
|
||||
return extract_openai_usage_from_response(response)
|
||||
elif provider == "gemini":
|
||||
from posthog.ai.gemini.gemini_converter import (
|
||||
extract_gemini_usage_from_response,
|
||||
)
|
||||
|
||||
return extract_gemini_usage_from_response(response)
|
||||
|
||||
return TokenUsage(input_tokens=0, output_tokens=0)
|
||||
|
||||
|
||||
def format_response(response, provider: str):
|
||||
"""
|
||||
Format a regular (non-streaming) response.
|
||||
"""
|
||||
if provider == "anthropic":
|
||||
from posthog.ai.anthropic.anthropic_converter import format_anthropic_response
|
||||
|
||||
return format_anthropic_response(response)
|
||||
elif provider == "openai":
|
||||
from posthog.ai.openai.openai_converter import format_openai_response
|
||||
|
||||
return format_openai_response(response)
|
||||
elif provider == "gemini":
|
||||
from posthog.ai.gemini.gemini_converter import format_gemini_response
|
||||
|
||||
return format_gemini_response(response)
|
||||
return []
|
||||
|
||||
|
||||
def extract_available_tool_calls(provider: str, kwargs: Dict[str, Any]):
|
||||
"""
|
||||
Extract available tool calls for the given provider.
|
||||
"""
|
||||
if provider == "anthropic":
|
||||
from posthog.ai.anthropic.anthropic_converter import extract_anthropic_tools
|
||||
|
||||
return extract_anthropic_tools(kwargs)
|
||||
elif provider == "gemini":
|
||||
from posthog.ai.gemini.gemini_converter import extract_gemini_tools
|
||||
|
||||
return extract_gemini_tools(kwargs)
|
||||
elif provider == "openai":
|
||||
from posthog.ai.openai.openai_converter import extract_openai_tools
|
||||
|
||||
return extract_openai_tools(kwargs)
|
||||
return None
|
||||
|
||||
|
||||
def merge_system_prompt(
|
||||
kwargs: Dict[str, Any], provider: str
|
||||
) -> List[FormattedMessage]:
|
||||
"""
|
||||
Merge system prompts and format messages for the given provider.
|
||||
"""
|
||||
if provider == "anthropic":
|
||||
from posthog.ai.anthropic.anthropic_converter import format_anthropic_input
|
||||
|
||||
messages = kwargs.get("messages") or []
|
||||
system = kwargs.get("system")
|
||||
return format_anthropic_input(messages, system)
|
||||
elif provider == "gemini":
|
||||
from posthog.ai.gemini.gemini_converter import format_gemini_input_with_system
|
||||
|
||||
contents = kwargs.get("contents", [])
|
||||
config = kwargs.get("config")
|
||||
return format_gemini_input_with_system(contents, config)
|
||||
elif provider == "openai":
|
||||
from posthog.ai.openai.openai_converter import format_openai_input
|
||||
|
||||
# For OpenAI, handle both Chat Completions and Responses API
|
||||
messages_param = kwargs.get("messages")
|
||||
input_param = kwargs.get("input")
|
||||
|
||||
# Get base formatted messages
|
||||
messages = format_openai_input(messages_param, input_param)
|
||||
|
||||
# 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:
|
||||
system_msg = cast(
|
||||
FormattedMessage,
|
||||
{"role": "system", "content": kwargs.get("system")},
|
||||
)
|
||||
messages = [system_msg] + 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
|
||||
instruction_msg = cast(
|
||||
FormattedMessage,
|
||||
{"role": "system", "content": kwargs.get("instructions")},
|
||||
)
|
||||
messages = [instruction_msg] + messages
|
||||
|
||||
return messages
|
||||
|
||||
# Default case - return empty list
|
||||
return []
|
||||
|
||||
|
||||
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: str,
|
||||
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: TokenUsage = TokenUsage()
|
||||
error_params: Dict[str, Any] = {}
|
||||
|
||||
try:
|
||||
response = call_method(**kwargs)
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
http_status = getattr(
|
||||
exc, "status_code", 0
|
||||
) # default to 0 becuase its likely an SDK error
|
||||
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)
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": provider,
|
||||
"$ai_model": kwargs.get("model") or getattr(response, "model", None),
|
||||
"$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
|
||||
|
||||
cache_read = usage.get("cache_read_input_tokens")
|
||||
if cache_read is not None and cache_read > 0:
|
||||
event_properties["$ai_cache_read_input_tokens"] = cache_read
|
||||
|
||||
cache_creation = usage.get("cache_creation_input_tokens")
|
||||
if cache_creation is not None and cache_creation > 0:
|
||||
event_properties["$ai_cache_creation_input_tokens"] = cache_creation
|
||||
|
||||
reasoning = usage.get("reasoning_tokens")
|
||||
if reasoning is not None and reasoning > 0:
|
||||
event_properties["$ai_reasoning_tokens"] = reasoning
|
||||
|
||||
web_search_count = usage.get("web_search_count")
|
||||
if web_search_count is not None and web_search_count > 0:
|
||||
event_properties["$ai_web_search_count"] = web_search_count
|
||||
|
||||
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: str,
|
||||
call_async_method: Callable[..., Any],
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
start_time = time.time()
|
||||
response = None
|
||||
error = None
|
||||
http_status = 200
|
||||
usage: TokenUsage = TokenUsage()
|
||||
error_params: Dict[str, Any] = {}
|
||||
|
||||
try:
|
||||
response = await call_async_method(**kwargs)
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
http_status = getattr(
|
||||
exc, "status_code", 0
|
||||
) # default to 0 because its likely an SDK error
|
||||
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)
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": provider,
|
||||
"$ai_model": kwargs.get("model") or getattr(response, "model", None),
|
||||
"$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
|
||||
|
||||
cache_read = usage.get("cache_read_input_tokens")
|
||||
if cache_read is not None and cache_read > 0:
|
||||
event_properties["$ai_cache_read_input_tokens"] = cache_read
|
||||
|
||||
cache_creation = usage.get("cache_creation_input_tokens")
|
||||
if cache_creation is not None and cache_creation > 0:
|
||||
event_properties["$ai_cache_creation_input_tokens"] = cache_creation
|
||||
|
||||
reasoning = usage.get("reasoning_tokens")
|
||||
if reasoning is not None and reasoning > 0:
|
||||
event_properties["$ai_reasoning_tokens"] = reasoning
|
||||
|
||||
web_search_count = usage.get("web_search_count")
|
||||
if web_search_count is not None and web_search_count > 0:
|
||||
event_properties["$ai_web_search_count"] = web_search_count
|
||||
|
||||
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":
|
||||
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
|
||||
|
||||
|
||||
def capture_streaming_event(
|
||||
ph_client: PostHogClient,
|
||||
event_data: StreamingEventData,
|
||||
):
|
||||
"""
|
||||
Unified streaming event capture for all LLM providers.
|
||||
|
||||
This function handles the common logic for capturing streaming events across all providers.
|
||||
All provider-specific formatting should be done BEFORE calling this function.
|
||||
|
||||
The function handles:
|
||||
- Building PostHog event properties
|
||||
- Extracting and adding tools based on provider
|
||||
- Applying privacy mode
|
||||
- Adding special token fields (cache, reasoning)
|
||||
- Provider-specific fields (e.g., OpenAI instructions)
|
||||
- Sending the event to PostHog
|
||||
|
||||
Args:
|
||||
ph_client: PostHog client instance
|
||||
event_data: Standardized streaming event data containing all necessary information
|
||||
"""
|
||||
trace_id = event_data.get("trace_id") or str(uuid.uuid4())
|
||||
|
||||
# Build base event properties
|
||||
event_properties = {
|
||||
"$ai_provider": event_data["provider"],
|
||||
"$ai_model": event_data["model"],
|
||||
"$ai_model_parameters": get_model_params(event_data["kwargs"]),
|
||||
"$ai_input": with_privacy_mode(
|
||||
ph_client,
|
||||
event_data["privacy_mode"],
|
||||
event_data["formatted_input"],
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
ph_client,
|
||||
event_data["privacy_mode"],
|
||||
event_data["formatted_output"],
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": event_data["usage_stats"].get("input_tokens", 0),
|
||||
"$ai_output_tokens": event_data["usage_stats"].get("output_tokens", 0),
|
||||
"$ai_latency": event_data["latency"],
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_base_url": str(event_data["base_url"]),
|
||||
**(event_data.get("properties") or {}),
|
||||
}
|
||||
|
||||
# Extract and add tools based on provider
|
||||
available_tools = extract_available_tool_calls(
|
||||
event_data["provider"],
|
||||
event_data["kwargs"],
|
||||
)
|
||||
if available_tools:
|
||||
event_properties["$ai_tools"] = available_tools
|
||||
|
||||
# Add optional token fields
|
||||
# For Anthropic, always include cache fields even if 0 (backward compatibility)
|
||||
# For others, only include if present and non-zero
|
||||
if event_data["provider"] == "anthropic":
|
||||
# Anthropic always includes cache fields
|
||||
cache_read = event_data["usage_stats"].get("cache_read_input_tokens", 0)
|
||||
cache_creation = event_data["usage_stats"].get("cache_creation_input_tokens", 0)
|
||||
event_properties["$ai_cache_read_input_tokens"] = cache_read
|
||||
event_properties["$ai_cache_creation_input_tokens"] = cache_creation
|
||||
else:
|
||||
# Other providers only include if non-zero
|
||||
optional_token_fields = [
|
||||
"cache_read_input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
"reasoning_tokens",
|
||||
]
|
||||
|
||||
for field in optional_token_fields:
|
||||
value = event_data["usage_stats"].get(field)
|
||||
if value is not None and isinstance(value, int) and value > 0:
|
||||
event_properties[f"$ai_{field}"] = value
|
||||
|
||||
# Add web search count if present (all providers)
|
||||
web_search_count = event_data["usage_stats"].get("web_search_count")
|
||||
if (
|
||||
web_search_count is not None
|
||||
and isinstance(web_search_count, int)
|
||||
and web_search_count > 0
|
||||
):
|
||||
event_properties["$ai_web_search_count"] = web_search_count
|
||||
|
||||
# Handle provider-specific fields
|
||||
if (
|
||||
event_data["provider"] == "openai"
|
||||
and event_data["kwargs"].get("instructions") is not None
|
||||
):
|
||||
event_properties["$ai_instructions"] = with_privacy_mode(
|
||||
ph_client,
|
||||
event_data["privacy_mode"],
|
||||
event_data["kwargs"]["instructions"],
|
||||
)
|
||||
|
||||
if event_data.get("distinct_id") is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
# Send event to PostHog
|
||||
if hasattr(ph_client, "capture"):
|
||||
ph_client.capture(
|
||||
distinct_id=event_data.get("distinct_id") or trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=event_data.get("groups"),
|
||||
)
|
||||
@@ -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]
|
||||
+1803
-319
File diff suppressed because it is too large
Load Diff
+19
-6
@@ -1,9 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from threading import Thread
|
||||
|
||||
import backoff
|
||||
import monotonic
|
||||
|
||||
from posthog.request import APIError, DatetimeSerializer, batch_post
|
||||
|
||||
@@ -36,6 +36,7 @@ class Consumer(Thread):
|
||||
gzip=False,
|
||||
retries=10,
|
||||
timeout=15,
|
||||
historical_migration=False,
|
||||
):
|
||||
"""Create a consumer thread."""
|
||||
Thread.__init__(self)
|
||||
@@ -55,6 +56,7 @@ class Consumer(Thread):
|
||||
self.running = True
|
||||
self.retries = retries
|
||||
self.timeout = timeout
|
||||
self.historical_migration = historical_migration
|
||||
|
||||
def run(self):
|
||||
"""Runs the consumer."""
|
||||
@@ -94,18 +96,20 @@ 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())
|
||||
if item_size > MAX_MSG_SIZE:
|
||||
self.log.error("Item exceeds 900kib limit, dropping. (%s)", str(item))
|
||||
self.log.error(
|
||||
"Item exceeds 900kib limit, dropping. (%s)", str(item)
|
||||
)
|
||||
continue
|
||||
items.append(item)
|
||||
total_size += item_size
|
||||
@@ -132,8 +136,17 @@ class Consumer(Thread):
|
||||
# retry on all other errors (eg. network)
|
||||
return False
|
||||
|
||||
@backoff.on_exception(backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception)
|
||||
@backoff.on_exception(
|
||||
backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception
|
||||
)
|
||||
def send_request():
|
||||
batch_post(self.api_key, 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,365 @@
|
||||
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] = {}
|
||||
self.capture_exception_code_variables: Optional[bool] = None
|
||||
self.code_variables_mask_patterns: Optional[list] = None
|
||||
self.code_variables_ignore_patterns: Optional[list] = None
|
||||
|
||||
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 set_capture_exception_code_variables(self, enabled: bool):
|
||||
self.capture_exception_code_variables = enabled
|
||||
|
||||
def set_code_variables_mask_patterns(self, mask_patterns: list):
|
||||
self.code_variables_mask_patterns = mask_patterns
|
||||
|
||||
def set_code_variables_ignore_patterns(self, ignore_patterns: list):
|
||||
self.code_variables_ignore_patterns = ignore_patterns
|
||||
|
||||
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
|
||||
|
||||
def get_capture_exception_code_variables(self) -> Optional[bool]:
|
||||
if self.capture_exception_code_variables is not None:
|
||||
return self.capture_exception_code_variables
|
||||
if self.parent is not None and not self.fresh:
|
||||
return self.parent.get_capture_exception_code_variables()
|
||||
return None
|
||||
|
||||
def get_code_variables_mask_patterns(self) -> Optional[list]:
|
||||
if self.code_variables_mask_patterns is not None:
|
||||
return self.code_variables_mask_patterns
|
||||
if self.parent is not None and not self.fresh:
|
||||
return self.parent.get_code_variables_mask_patterns()
|
||||
return None
|
||||
|
||||
def get_code_variables_ignore_patterns(self) -> Optional[list]:
|
||||
if self.code_variables_ignore_patterns is not None:
|
||||
return self.code_variables_ignore_patterns
|
||||
if self.parent is not None and not self.fresh:
|
||||
return self.parent.get_code_variables_ignore_patterns()
|
||||
return None
|
||||
|
||||
|
||||
_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
|
||||
|
||||
|
||||
def set_capture_exception_code_variables_context(enabled: bool) -> None:
|
||||
"""
|
||||
Set whether code variables are captured for the current context.
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
current_context.set_capture_exception_code_variables(enabled)
|
||||
|
||||
|
||||
def set_code_variables_mask_patterns_context(mask_patterns: list) -> None:
|
||||
"""
|
||||
Variable names matching these patterns will be masked with *** when capturing code variables.
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
current_context.set_code_variables_mask_patterns(mask_patterns)
|
||||
|
||||
|
||||
def set_code_variables_ignore_patterns_context(ignore_patterns: list) -> None:
|
||||
"""
|
||||
Variable names matching these patterns will be ignored completely when capturing code variables.
|
||||
"""
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
current_context.set_code_variables_ignore_patterns(ignore_patterns)
|
||||
|
||||
|
||||
def get_capture_exception_code_variables_context() -> Optional[bool]:
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
return current_context.get_capture_exception_code_variables()
|
||||
return None
|
||||
|
||||
|
||||
def get_code_variables_mask_patterns_context() -> Optional[list]:
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
return current_context.get_code_variables_mask_patterns()
|
||||
return None
|
||||
|
||||
|
||||
def get_code_variables_ignore_patterns_context() -> Optional[list]:
|
||||
current_context = _get_current_context()
|
||||
if current_context:
|
||||
return current_context.get_code_variables_ignore_patterns()
|
||||
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}")
|
||||
File diff suppressed because it is too large
Load Diff
+336
-47
@@ -7,22 +7,38 @@ 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
|
||||
|
||||
|
||||
class RequiresServerEvaluation(Exception):
|
||||
"""
|
||||
Raised when feature flag evaluation requires server-side data that is not
|
||||
available locally (e.g., static cohorts, experience continuity).
|
||||
|
||||
This error should propagate immediately to trigger API fallback, unlike
|
||||
InconclusiveMatchError which allows trying other conditions.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
||||
# Given the same distinct_id and key, it'll always return the same float. These floats are
|
||||
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
||||
# we can do _hash(key, distinct_id) < 0.2
|
||||
def _hash(key, distinct_id, salt=""):
|
||||
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__
|
||||
@@ -39,57 +55,251 @@ def get_matching_variant(flag, distinct_id):
|
||||
def variant_lookup_table(feature_flag):
|
||||
lookup_table = []
|
||||
value_min = 0
|
||||
multivariates = ((feature_flag.get("filters") or {}).get("multivariate") or {}).get("variants") or []
|
||||
multivariates = ((feature_flag.get("filters") or {}).get("multivariate") or {}).get(
|
||||
"variants"
|
||||
) or []
|
||||
for variant in multivariates:
|
||||
value_max = value_min + variant["rollout_percentage"] / 100
|
||||
lookup_table.append({"value_min": value_min, "value_max": value_max, "key": variant["key"]})
|
||||
lookup_table.append(
|
||||
{"value_min": value_min, "value_max": value_max, "key": variant["key"]}
|
||||
)
|
||||
value_min = value_max
|
||||
return lookup_table
|
||||
|
||||
|
||||
def match_feature_flag_properties(flag, distinct_id, properties, cohort_properties=None):
|
||||
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:
|
||||
for condition in 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):
|
||||
if is_condition_match(
|
||||
flag,
|
||||
distinct_id,
|
||||
condition,
|
||||
properties,
|
||||
cohort_properties,
|
||||
flags_by_key,
|
||||
evaluation_cache,
|
||||
):
|
||||
variant_override = condition.get("variant")
|
||||
# Some filters can be explicitly set to null, which require accessing variants like so
|
||||
flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get("variants") or []
|
||||
if variant_override and variant_override in [variant["key"] for variant in flag_variants]:
|
||||
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 RequiresServerEvaluation:
|
||||
# Static cohort or other missing server-side data - must fallback to API
|
||||
raise
|
||||
except InconclusiveMatchError:
|
||||
# Evaluation error (bad regex, invalid date, missing property, etc.)
|
||||
# Track that we had an inconclusive match, but try other conditions
|
||||
is_inconclusive = True
|
||||
|
||||
if is_inconclusive:
|
||||
raise InconclusiveMatchError("Can't determine if feature flag is enabled or not with given properties")
|
||||
raise InconclusiveMatchError(
|
||||
"Can't determine if feature flag is enabled or not with given properties"
|
||||
)
|
||||
|
||||
# We can only return False when either all conditions are False, or
|
||||
# no condition was inconclusive.
|
||||
return False
|
||||
|
||||
|
||||
def is_condition_match(feature_flag, distinct_id, condition, properties, cohort_properties):
|
||||
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)
|
||||
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:
|
||||
@@ -98,7 +308,9 @@ def is_condition_match(feature_flag, distinct_id, condition, properties, cohort_
|
||||
if rollout_percentage is None:
|
||||
return True
|
||||
|
||||
if rollout_percentage is not None and _hash(feature_flag["key"], distinct_id) > (rollout_percentage / 100):
|
||||
if rollout_percentage is not None and _hash(feature_flag["key"], distinct_id) > (
|
||||
rollout_percentage / 100
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -112,19 +324,26 @@ def match_property(property, property_values) -> bool:
|
||||
value = property.get("value")
|
||||
|
||||
if key not in property_values:
|
||||
raise InconclusiveMatchError("can't match properties without a given property value")
|
||||
raise InconclusiveMatchError(
|
||||
"can't match properties without a given property value"
|
||||
)
|
||||
|
||||
if operator == "is_not_set":
|
||||
raise InconclusiveMatchError("can't match properties with operator is_not_set")
|
||||
|
||||
override_value = property_values[key]
|
||||
|
||||
if (operator not in NONE_VALUES_ALLOWED_OPERATORS) and override_value is None:
|
||||
return False
|
||||
|
||||
if operator in ("exact", "is_not"):
|
||||
|
||||
def compute_exact_match(value, override_value):
|
||||
if isinstance(value, list):
|
||||
return str(override_value).lower() in [str(val).lower() for val in value]
|
||||
return str(value).lower() == str(override_value).lower()
|
||||
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)
|
||||
@@ -135,16 +354,22 @@ def match_property(property, property_values) -> bool:
|
||||
return key in property_values
|
||||
|
||||
if operator == "icontains":
|
||||
return str(value).lower() in str(override_value).lower()
|
||||
return utils.str_icontains(override_value, value)
|
||||
|
||||
if operator == "not_icontains":
|
||||
return str(value).lower() not in str(override_value).lower()
|
||||
return not utils.str_icontains(override_value, value)
|
||||
|
||||
if operator == "regex":
|
||||
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is not None
|
||||
return (
|
||||
is_valid_regex(str(value))
|
||||
and re.compile(str(value)).search(str(override_value)) is not None
|
||||
)
|
||||
|
||||
if operator == "not_regex":
|
||||
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is None
|
||||
return (
|
||||
is_valid_regex(str(value))
|
||||
and re.compile(str(value)).search(str(override_value)) is None
|
||||
)
|
||||
|
||||
if operator in ("gt", "gte", "lt", "lte"):
|
||||
# :TRICKY: We adjust comparison based on the override value passed in,
|
||||
@@ -175,27 +400,31 @@ def match_property(property, property_values) -> bool:
|
||||
else:
|
||||
return compare(str(override_value), str(value), operator)
|
||||
|
||||
if operator in ["is_date_before", "is_date_after", "is_relative_date_before", "is_relative_date_after"]:
|
||||
if operator in ["is_date_before", "is_date_after"]:
|
||||
try:
|
||||
if operator in ["is_relative_date_before", "is_relative_date_after"]:
|
||||
parsed_date = relative_date_parse_for_feature_flag_matching(str(value))
|
||||
else:
|
||||
parsed_date = relative_date_parse_for_feature_flag_matching(str(value))
|
||||
|
||||
if not parsed_date:
|
||||
parsed_date = parser.parse(str(value))
|
||||
parsed_date = convert_to_datetime_aware(parsed_date)
|
||||
except Exception as e:
|
||||
raise InconclusiveMatchError("The date set on the flag is not a valid format") from e
|
||||
raise InconclusiveMatchError(
|
||||
"The date set on the flag is not a valid format"
|
||||
) from e
|
||||
|
||||
if not parsed_date:
|
||||
raise InconclusiveMatchError("The date set on the flag is not a valid format")
|
||||
raise InconclusiveMatchError(
|
||||
"The date set on the flag is not a valid format"
|
||||
)
|
||||
|
||||
if isinstance(override_value, datetime.datetime):
|
||||
override_date = convert_to_datetime_aware(override_value)
|
||||
if operator in ("is_date_before", "is_relative_date_before"):
|
||||
if operator == "is_date_before":
|
||||
return override_date < parsed_date
|
||||
else:
|
||||
return override_date > parsed_date
|
||||
elif isinstance(override_value, datetime.date):
|
||||
if operator in ("is_date_before", "is_relative_date_before"):
|
||||
if operator == "is_date_before":
|
||||
return override_value < parsed_date.date()
|
||||
else:
|
||||
return override_value > parsed_date.date()
|
||||
@@ -203,20 +432,29 @@ def match_property(property, property_values) -> bool:
|
||||
try:
|
||||
override_date = parser.parse(override_value)
|
||||
override_date = convert_to_datetime_aware(override_date)
|
||||
if operator in ("is_date_before", "is_relative_date_before"):
|
||||
if operator == "is_date_before":
|
||||
return override_date < parsed_date
|
||||
else:
|
||||
return override_date > parsed_date
|
||||
except Exception:
|
||||
raise InconclusiveMatchError("The date provided is not a valid format")
|
||||
else:
|
||||
raise InconclusiveMatchError("The date provided must be a string or date object")
|
||||
raise InconclusiveMatchError(
|
||||
"The date provided must be a string or date object"
|
||||
)
|
||||
|
||||
# if we get here, we don't know how to handle the operator
|
||||
raise InconclusiveMatchError(f"Unknown operator {operator}")
|
||||
|
||||
|
||||
def match_cohort(property, property_values, cohort_properties) -> bool:
|
||||
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": {
|
||||
@@ -228,13 +466,29 @@ def match_cohort(property, property_values, cohort_properties) -> bool:
|
||||
# }
|
||||
cohort_id = str(property.get("value"))
|
||||
if cohort_id not in cohort_properties:
|
||||
raise InconclusiveMatchError("can't match cohort without a given cohort property value")
|
||||
raise RequiresServerEvaluation(
|
||||
f"cohort {cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation"
|
||||
)
|
||||
|
||||
property_group = cohort_properties[cohort_id]
|
||||
return match_property_group(property_group, property_values, cohort_properties)
|
||||
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) -> bool:
|
||||
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
|
||||
|
||||
@@ -251,7 +505,14 @@ def match_property_group(property_group, property_values, cohort_properties) ->
|
||||
# a nested property group
|
||||
for prop in properties:
|
||||
try:
|
||||
matches = match_property_group(prop, property_values, cohort_properties)
|
||||
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
|
||||
@@ -259,12 +520,17 @@ def match_property_group(property_group, property_values, cohort_properties) ->
|
||||
# OR group
|
||||
if matches:
|
||||
return True
|
||||
except RequiresServerEvaluation:
|
||||
# Immediately propagate - this condition requires server-side data
|
||||
raise
|
||||
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")
|
||||
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"
|
||||
|
||||
@@ -272,7 +538,23 @@ def match_property_group(property_group, property_values, cohort_properties) ->
|
||||
for prop in properties:
|
||||
try:
|
||||
if prop.get("type") == "cohort":
|
||||
matches = match_cohort(prop, property_values, cohort_properties)
|
||||
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)
|
||||
|
||||
@@ -290,19 +572,26 @@ def match_property_group(property_group, property_values, cohort_properties) ->
|
||||
return True
|
||||
if not matches and negation:
|
||||
return True
|
||||
except RequiresServerEvaluation:
|
||||
# Immediately propagate - this condition requires server-side data
|
||||
raise
|
||||
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")
|
||||
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])$"
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Flag Definition Cache Provider interface for multi-worker environments.
|
||||
|
||||
EXPERIMENTAL: This API may change in future minor version bumps.
|
||||
|
||||
This module provides an interface for external caching of feature flag definitions,
|
||||
enabling multi-worker environments (Kubernetes, load-balanced servers, serverless
|
||||
functions) to share flag definitions and reduce API calls.
|
||||
|
||||
Usage:
|
||||
|
||||
from posthog import Posthog
|
||||
from posthog.flag_definition_cache import FlagDefinitionCacheProvider
|
||||
|
||||
cache = RedisFlagDefinitionCache(redis_client, "my-team")
|
||||
posthog = Posthog(
|
||||
"<project_api_key>",
|
||||
personal_api_key="<personal_api_key>",
|
||||
flag_definition_cache_provider=cache,
|
||||
)
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
from typing_extensions import Required, TypedDict
|
||||
|
||||
|
||||
class FlagDefinitionCacheData(TypedDict):
|
||||
"""
|
||||
Data structure for cached flag definitions.
|
||||
|
||||
Attributes:
|
||||
flags: List of feature flag definition dictionaries from the API.
|
||||
group_type_mapping: Mapping of group type indices to group names.
|
||||
cohorts: Dictionary of cohort definitions for local evaluation.
|
||||
"""
|
||||
|
||||
flags: Required[List[Dict[str, Any]]]
|
||||
group_type_mapping: Required[Dict[str, str]]
|
||||
cohorts: Required[Dict[str, Any]]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FlagDefinitionCacheProvider(Protocol):
|
||||
"""
|
||||
Interface for external caching of feature flag definitions.
|
||||
|
||||
Enables multi-worker environments to share flag definitions, reducing API
|
||||
calls while ensuring all workers have consistent data.
|
||||
|
||||
EXPERIMENTAL: This API may change in future minor version bumps.
|
||||
|
||||
The four methods handle the complete lifecycle of flag definition caching:
|
||||
|
||||
1. `should_fetch_flag_definitions()` - Called before each poll to determine
|
||||
if this worker should fetch new definitions. Use for distributed lock
|
||||
coordination to ensure only one worker fetches at a time.
|
||||
|
||||
2. `get_flag_definitions()` - Called when `should_fetch_flag_definitions()`
|
||||
returns False. Returns cached definitions if available.
|
||||
|
||||
3. `on_flag_definitions_received()` - Called after successfully fetching
|
||||
new definitions from the API. Store the data in your external cache
|
||||
and release any locks.
|
||||
|
||||
4. `shutdown()` - Called when the PostHog client shuts down. Release any
|
||||
distributed locks and clean up resources.
|
||||
|
||||
Error Handling:
|
||||
All methods are wrapped in try/except. Errors will be logged but will
|
||||
never break flag evaluation. On error:
|
||||
- `should_fetch_flag_definitions()` errors default to fetching (fail-safe)
|
||||
- `get_flag_definitions()` errors fall back to API fetch
|
||||
- `on_flag_definitions_received()` errors are logged but flags remain in memory
|
||||
- `shutdown()` errors are logged but shutdown continues
|
||||
"""
|
||||
|
||||
def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]:
|
||||
"""
|
||||
Retrieve cached flag definitions.
|
||||
|
||||
Returns:
|
||||
Cached flag definitions if available and valid, None otherwise.
|
||||
Returning None will trigger a fetch from the API if this worker
|
||||
has no flags loaded yet.
|
||||
"""
|
||||
...
|
||||
|
||||
def should_fetch_flag_definitions(self) -> bool:
|
||||
"""
|
||||
Determine whether this instance should fetch new flag definitions.
|
||||
|
||||
Use this for distributed lock coordination. Only one worker should
|
||||
return True to avoid thundering herd problems. A typical implementation
|
||||
uses a distributed lock (e.g., Redis SETNX) that expires after the
|
||||
poll interval.
|
||||
|
||||
Returns:
|
||||
True if this instance should fetch from the API, False otherwise.
|
||||
When False, the client will call `get_flag_definitions()` to
|
||||
retrieve cached data instead.
|
||||
"""
|
||||
...
|
||||
|
||||
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
|
||||
"""
|
||||
Called after successfully receiving new flag definitions from PostHog.
|
||||
|
||||
Use this to store the data in your external cache and release any
|
||||
distributed locks acquired in `should_fetch_flag_definitions()`.
|
||||
|
||||
Args:
|
||||
data: The flag definitions to cache, containing flags,
|
||||
group_type_mapping, and cohorts.
|
||||
"""
|
||||
...
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""
|
||||
Called when the PostHog client shuts down.
|
||||
|
||||
Use this to release any distributed locks and clean up resources.
|
||||
This method is called even if `should_fetch_flag_definitions()`
|
||||
returned False, so implementations should handle the case where
|
||||
no lock was acquired.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,319 @@
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from posthog import contexts
|
||||
from posthog.client import Client
|
||||
|
||||
try:
|
||||
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
|
||||
except ImportError:
|
||||
# Fallback for older Django versions without asgiref
|
||||
import asyncio
|
||||
|
||||
iscoroutinefunction = asyncio.iscoroutinefunction
|
||||
|
||||
# No-op fallback for markcoroutinefunction
|
||||
# Older Django versions without asgiref typically don't support async middleware anyway
|
||||
def markcoroutinefunction(func):
|
||||
return func
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.http import HttpRequest, HttpResponse # noqa: F401
|
||||
from typing import Callable, Dict, Any, Optional, Union, Awaitable # 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.
|
||||
|
||||
This middleware is hybrid-capable: it supports both WSGI (sync) and ASGI (async) Django applications. The middleware
|
||||
detects at initialization whether the next middleware in the chain is async or sync, and adapts its behavior accordingly.
|
||||
This ensures compatibility with both pure sync and pure async middleware chains, as well as mixed chains in ASGI mode.
|
||||
"""
|
||||
|
||||
sync_capable = True
|
||||
async_capable = True
|
||||
|
||||
def __init__(self, get_response):
|
||||
# type: (Union[Callable[[HttpRequest], HttpResponse], Callable[[HttpRequest], Awaitable[HttpResponse]]]) -> None
|
||||
self.get_response = get_response
|
||||
self._is_coroutine = iscoroutinefunction(get_response)
|
||||
|
||||
# Mark this instance as a coroutine function if get_response is async
|
||||
# This is required for Django to correctly detect async middleware
|
||||
if self._is_coroutine:
|
||||
markcoroutinefunction(self)
|
||||
|
||||
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]
|
||||
"""Extract tags from request in sync context."""
|
||||
user_id, user_email = self.extract_request_user(request)
|
||||
return self._build_tags(request, user_id, user_email)
|
||||
|
||||
def _build_tags(self, request, user_id, user_email):
|
||||
# type: (HttpRequest, Optional[str], Optional[str]) -> Dict[str, Any]
|
||||
"""
|
||||
Build tags dict from request and user info.
|
||||
|
||||
Centralized tag extraction logic used by both sync and async paths.
|
||||
"""
|
||||
tags = {}
|
||||
|
||||
# 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):
|
||||
# type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
|
||||
"""Extract user ID and email from request in sync context."""
|
||||
user = getattr(request, "user", None)
|
||||
return self._resolve_user_details(user)
|
||||
|
||||
async def aextract_tags(self, request):
|
||||
# type: (HttpRequest) -> Dict[str, Any]
|
||||
"""
|
||||
Async version of extract_tags for use in async request handling.
|
||||
|
||||
Uses await request.auser() instead of request.user to avoid
|
||||
SynchronousOnlyOperation in async context.
|
||||
|
||||
Follows Django's naming convention for async methods (auser, asave, etc.).
|
||||
"""
|
||||
user_id, user_email = await self.aextract_request_user(request)
|
||||
return self._build_tags(request, user_id, user_email)
|
||||
|
||||
async def aextract_request_user(self, request):
|
||||
# type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
|
||||
"""
|
||||
Async version of extract_request_user for use in async request handling.
|
||||
|
||||
Uses await request.auser() instead of request.user to avoid
|
||||
SynchronousOnlyOperation in async context.
|
||||
|
||||
Follows Django's naming convention for async methods (auser, asave, etc.).
|
||||
"""
|
||||
auser = getattr(request, "auser", None)
|
||||
if callable(auser):
|
||||
try:
|
||||
user = await auser()
|
||||
return self._resolve_user_details(user)
|
||||
except Exception:
|
||||
# If auser() fails, return empty - don't break the request
|
||||
# Real errors (permissions, broken auth) will be logged by Django
|
||||
return None, None
|
||||
|
||||
# Fallback for test requests without auser
|
||||
return None, None
|
||||
|
||||
def _resolve_user_details(self, user):
|
||||
# type: (Any) -> tuple[Optional[str], Optional[str]]
|
||||
"""
|
||||
Extract user ID and email from a user object.
|
||||
|
||||
Handles both authenticated and unauthenticated users, as well as
|
||||
legacy Django where is_authenticated was a method.
|
||||
"""
|
||||
user_id = None
|
||||
email = None
|
||||
|
||||
if user is None:
|
||||
return user_id, email
|
||||
|
||||
# Handle is_authenticated (property in modern Django, method in legacy)
|
||||
is_authenticated = getattr(user, "is_authenticated", False)
|
||||
if callable(is_authenticated):
|
||||
is_authenticated = is_authenticated()
|
||||
|
||||
if not is_authenticated:
|
||||
return user_id, email
|
||||
|
||||
# Extract user primary key
|
||||
user_pk = getattr(user, "pk", None)
|
||||
if user_pk is not None:
|
||||
user_id = str(user_pk)
|
||||
|
||||
# Extract user email
|
||||
user_email = getattr(user, "email", None)
|
||||
if user_email:
|
||||
email = str(user_email)
|
||||
|
||||
return user_id, email
|
||||
|
||||
def __call__(self, request):
|
||||
# type: (HttpRequest) -> Union[HttpResponse, Awaitable[HttpResponse]]
|
||||
"""
|
||||
Unified entry point for both sync and async request handling.
|
||||
|
||||
When sync_capable and async_capable are both True, Django passes requests
|
||||
without conversion. This method detects the mode and routes accordingly.
|
||||
"""
|
||||
if self._is_coroutine:
|
||||
return self.__acall__(request)
|
||||
else:
|
||||
# Synchronous path
|
||||
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)
|
||||
|
||||
async def __acall__(self, request):
|
||||
# type: (HttpRequest) -> Awaitable[HttpResponse]
|
||||
"""
|
||||
Asynchronous entry point for async request handling.
|
||||
|
||||
This method is called when the middleware chain is async.
|
||||
Uses aextract_tags() which calls request.auser() to avoid
|
||||
SynchronousOnlyOperation when accessing user in async context.
|
||||
"""
|
||||
if self.request_filter and not self.request_filter(request):
|
||||
return await self.get_response(request)
|
||||
|
||||
with contexts.new_context(self.capture_exceptions, client=self.client):
|
||||
for k, v in (await self.aextract_tags(request)).items():
|
||||
contexts.tag(k, v)
|
||||
|
||||
return await self.get_response(request)
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
# type: (HttpRequest, Exception) -> None
|
||||
"""
|
||||
Process exceptions from views and downstream middleware.
|
||||
|
||||
Django calls this WHILE still inside the context created by __call__,
|
||||
so request tags have already been extracted and set. This method just
|
||||
needs to capture the exception directly.
|
||||
|
||||
Django converts view exceptions into responses before they propagate through
|
||||
the middleware stack, so the context manager in __call__/__acall__ never sees them.
|
||||
|
||||
Note: Django's process_exception is always synchronous, even for async views.
|
||||
"""
|
||||
if self.request_filter and not self.request_filter(request):
|
||||
return
|
||||
|
||||
if not self.capture_exceptions:
|
||||
return
|
||||
|
||||
# Context and tags already set by __call__ or __acall__
|
||||
# Just capture the exception
|
||||
if self.client:
|
||||
self.client.capture_exception(exception)
|
||||
else:
|
||||
from posthog import capture_exception
|
||||
|
||||
capture_exception(exception)
|
||||
+292
-16
@@ -1,33 +1,199 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from gzip import GzipFile
|
||||
from io import BytesIO
|
||||
from typing import Any, Optional, Union
|
||||
from typing import Any, List, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
from dateutil.tz import tzutc
|
||||
from requests.adapters import HTTPAdapter # type: ignore[import-untyped]
|
||||
from urllib3.connection import HTTPConnection
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from posthog.utils import remove_trailing_slash
|
||||
from posthog.version import VERSION
|
||||
|
||||
_session = requests.sessions.Session()
|
||||
SocketOptions = List[Tuple[int, int, Union[int, bytes]]]
|
||||
|
||||
DEFAULT_HOST = "https://app.posthog.com"
|
||||
KEEPALIVE_IDLE_SECONDS = 60
|
||||
KEEPALIVE_INTERVAL_SECONDS = 60
|
||||
KEEPALIVE_PROBE_COUNT = 3
|
||||
|
||||
# TCP keepalive probes idle connections to prevent them from being dropped.
|
||||
# SO_KEEPALIVE is cross-platform, but timing options vary:
|
||||
# - Linux: TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT
|
||||
# - macOS: only SO_KEEPALIVE (uses system defaults)
|
||||
# - Windows: TCP_KEEPIDLE, TCP_KEEPINTVL (since Windows 10 1709)
|
||||
KEEP_ALIVE_SOCKET_OPTIONS: SocketOptions = list(
|
||||
HTTPConnection.default_socket_options
|
||||
) + [
|
||||
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
|
||||
]
|
||||
for attr, value in [
|
||||
("TCP_KEEPIDLE", KEEPALIVE_IDLE_SECONDS),
|
||||
("TCP_KEEPINTVL", KEEPALIVE_INTERVAL_SECONDS),
|
||||
("TCP_KEEPCNT", KEEPALIVE_PROBE_COUNT),
|
||||
]:
|
||||
if hasattr(socket, attr):
|
||||
KEEP_ALIVE_SOCKET_OPTIONS.append((socket.SOL_TCP, getattr(socket, attr), value))
|
||||
|
||||
# Status codes that indicate transient server errors worth retrying
|
||||
RETRY_STATUS_FORCELIST = [408, 500, 502, 503, 504]
|
||||
|
||||
|
||||
def _mask_tokens_in_url(url: str) -> str:
|
||||
"""Mask token values in URLs for safe logging, keeping first 10 chars visible."""
|
||||
return re.sub(r"(token=)([^&]{10})[^&]*", r"\1\2...", url)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetResponse:
|
||||
"""Response from a GET request with ETag support."""
|
||||
|
||||
data: Any
|
||||
etag: Optional[str] = None
|
||||
not_modified: bool = False
|
||||
|
||||
|
||||
class HTTPAdapterWithSocketOptions(HTTPAdapter):
|
||||
"""HTTPAdapter with configurable socket options."""
|
||||
|
||||
def __init__(self, *args, socket_options: Optional[SocketOptions] = None, **kwargs):
|
||||
self.socket_options = socket_options
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def init_poolmanager(self, *args, **kwargs):
|
||||
if self.socket_options is not None:
|
||||
kwargs["socket_options"] = self.socket_options
|
||||
super().init_poolmanager(*args, **kwargs)
|
||||
|
||||
|
||||
def _build_session(socket_options: Optional[SocketOptions] = None) -> requests.Session:
|
||||
"""Build a session for general requests (batch, decide, etc.)."""
|
||||
adapter = HTTPAdapterWithSocketOptions(
|
||||
max_retries=Retry(
|
||||
total=2,
|
||||
connect=2,
|
||||
read=2,
|
||||
),
|
||||
socket_options=socket_options,
|
||||
)
|
||||
session = requests.Session()
|
||||
session.mount("https://", adapter)
|
||||
return session
|
||||
|
||||
|
||||
def _build_flags_session(
|
||||
socket_options: Optional[SocketOptions] = None,
|
||||
) -> requests.Session:
|
||||
"""
|
||||
Build a session for feature flag requests with POST retries.
|
||||
|
||||
Feature flag requests are idempotent (read-only), so retrying POST
|
||||
requests is safe. This session retries on transient server errors
|
||||
(408, 5xx) and network failures with exponential backoff
|
||||
(0.5s, 1s delays between retries).
|
||||
"""
|
||||
adapter = HTTPAdapterWithSocketOptions(
|
||||
max_retries=Retry(
|
||||
total=2,
|
||||
connect=2,
|
||||
read=2,
|
||||
backoff_factor=0.5,
|
||||
status_forcelist=RETRY_STATUS_FORCELIST,
|
||||
allowed_methods=["POST"],
|
||||
),
|
||||
socket_options=socket_options,
|
||||
)
|
||||
session = requests.Session()
|
||||
session.mount("https://", adapter)
|
||||
return session
|
||||
|
||||
|
||||
_session = _build_session()
|
||||
_flags_session = _build_flags_session()
|
||||
_socket_options: Optional[SocketOptions] = None
|
||||
_pooling_enabled = True
|
||||
|
||||
|
||||
def _get_session() -> requests.Session:
|
||||
if _pooling_enabled:
|
||||
return _session
|
||||
return _build_session(_socket_options)
|
||||
|
||||
|
||||
def _get_flags_session() -> requests.Session:
|
||||
if _pooling_enabled:
|
||||
return _flags_session
|
||||
return _build_flags_session(_socket_options)
|
||||
|
||||
|
||||
def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
|
||||
"""
|
||||
Configure socket options for all HTTP connections.
|
||||
|
||||
Example:
|
||||
from posthog import set_socket_options
|
||||
set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
|
||||
"""
|
||||
global _session, _flags_session, _socket_options
|
||||
if socket_options == _socket_options:
|
||||
return
|
||||
_socket_options = socket_options
|
||||
_session = _build_session(socket_options)
|
||||
_flags_session = _build_flags_session(socket_options)
|
||||
|
||||
|
||||
def enable_keep_alive() -> None:
|
||||
"""Enable TCP keepalive to prevent idle connections from being dropped."""
|
||||
set_socket_options(KEEP_ALIVE_SOCKET_OPTIONS)
|
||||
|
||||
|
||||
def disable_connection_reuse() -> None:
|
||||
"""Disable connection reuse, creating a fresh connection for each request."""
|
||||
global _pooling_enabled
|
||||
_pooling_enabled = False
|
||||
|
||||
|
||||
US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
|
||||
EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"
|
||||
DEFAULT_HOST = US_INGESTION_ENDPOINT
|
||||
USER_AGENT = "posthog-python/" + VERSION
|
||||
|
||||
|
||||
def determine_server_host(host: Optional[str]) -> str:
|
||||
"""Determines the server host to use."""
|
||||
host_or_default = host or DEFAULT_HOST
|
||||
trimmed_host = remove_trailing_slash(host_or_default)
|
||||
if trimmed_host in ("https://app.posthog.com", "https://us.posthog.com"):
|
||||
return US_INGESTION_ENDPOINT
|
||||
elif trimmed_host == "https://eu.posthog.com":
|
||||
return EU_INGESTION_ENDPOINT
|
||||
else:
|
||||
return host_or_default
|
||||
|
||||
|
||||
def post(
|
||||
api_key: str, host: Optional[str] = None, path=None, gzip: bool = False, timeout: int = 15, **kwargs
|
||||
api_key: str,
|
||||
host: Optional[str] = None,
|
||||
path=None,
|
||||
gzip: bool = False,
|
||||
timeout: int = 15,
|
||||
session: Optional[requests.Session] = None,
|
||||
**kwargs,
|
||||
) -> requests.Response:
|
||||
"""Post the `kwargs` to the API"""
|
||||
log = logging.getLogger("posthog")
|
||||
body = kwargs
|
||||
body["sentAt"] = datetime.utcnow().replace(tzinfo=tzutc()).isoformat()
|
||||
body["sentAt"] = datetime.now(tz=tzutc()).isoformat()
|
||||
url = remove_trailing_slash(host or DEFAULT_HOST) + path
|
||||
body["api_key"] = api_key
|
||||
data = json.dumps(body, cls=DatetimeSerializer)
|
||||
log.debug("making request: %s", data)
|
||||
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"
|
||||
@@ -38,7 +204,9 @@ def post(
|
||||
gz.write(data.encode("utf-8"))
|
||||
data = buf.getvalue()
|
||||
|
||||
res = _session.post(url, data=data, headers=headers, timeout=timeout)
|
||||
res = (session or _get_session()).post(
|
||||
url, data=data, headers=headers, timeout=timeout
|
||||
)
|
||||
|
||||
if res.status_code == 200:
|
||||
log.debug("data uploaded successfully")
|
||||
@@ -52,7 +220,21 @@ def _process_response(
|
||||
log = logging.getLogger("posthog")
|
||||
if res.status_code == 200:
|
||||
log.debug(success_message)
|
||||
return res.json() if return_json else res
|
||||
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)
|
||||
@@ -61,24 +243,108 @@ def _process_response(
|
||||
raise APIError(res.status_code, res.text)
|
||||
|
||||
|
||||
def decide(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any:
|
||||
def decide(
|
||||
api_key: str,
|
||||
host: Optional[str] = None,
|
||||
gzip: bool = False,
|
||||
timeout: int = 15,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
"""Post the `kwargs to the decide API endpoint"""
|
||||
res = post(api_key, host, "/decide/?v=3", gzip, timeout, **kwargs)
|
||||
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 with automatic retries."""
|
||||
res = post(
|
||||
api_key,
|
||||
host,
|
||||
"/flags/?v=2",
|
||||
gzip,
|
||||
timeout,
|
||||
session=_get_flags_session(),
|
||||
**kwargs,
|
||||
)
|
||||
return _process_response(
|
||||
res, success_message="Feature flags evaluated successfully"
|
||||
)
|
||||
|
||||
|
||||
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"""
|
||||
response = get(
|
||||
personal_api_key,
|
||||
f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}",
|
||||
host,
|
||||
timeout,
|
||||
)
|
||||
return response.data
|
||||
|
||||
|
||||
def batch_post(
|
||||
api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs
|
||||
api_key: str,
|
||||
host: Optional[str] = None,
|
||||
gzip: bool = False,
|
||||
timeout: int = 15,
|
||||
**kwargs,
|
||||
) -> requests.Response:
|
||||
"""Post the `kwargs` to the batch API endpoint for events"""
|
||||
res = post(api_key, host, "/batch/", gzip, timeout, **kwargs)
|
||||
return _process_response(res, success_message="data uploaded successfully", return_json=False)
|
||||
return _process_response(
|
||||
res, success_message="data uploaded successfully", return_json=False
|
||||
)
|
||||
|
||||
|
||||
def get(api_key: str, url: str, host: Optional[str] = None, timeout: Optional[int] = None) -> requests.Response:
|
||||
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")
|
||||
def get(
|
||||
api_key: str,
|
||||
url: str,
|
||||
host: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
etag: Optional[str] = None,
|
||||
) -> GetResponse:
|
||||
"""
|
||||
Make a GET request with optional ETag support.
|
||||
|
||||
If an etag is provided, sends If-None-Match header. Returns GetResponse with:
|
||||
- not_modified=True and data=None if server returns 304
|
||||
- not_modified=False and data=response if server returns 200
|
||||
"""
|
||||
log = logging.getLogger("posthog")
|
||||
full_url = remove_trailing_slash(host or DEFAULT_HOST) + url
|
||||
headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}
|
||||
|
||||
if etag:
|
||||
headers["If-None-Match"] = etag
|
||||
|
||||
res = _get_session().get(full_url, headers=headers, timeout=timeout)
|
||||
|
||||
masked_url = _mask_tokens_in_url(full_url)
|
||||
|
||||
# Handle 304 Not Modified
|
||||
if res.status_code == 304:
|
||||
log.debug(f"GET {masked_url} returned 304 Not Modified")
|
||||
response_etag = res.headers.get("ETag")
|
||||
return GetResponse(data=None, etag=response_etag or etag, not_modified=True)
|
||||
|
||||
# Handle normal response
|
||||
data = _process_response(
|
||||
res, success_message=f"GET {masked_url} completed successfully"
|
||||
)
|
||||
response_etag = res.headers.get("ETag")
|
||||
return GetResponse(data=data, etag=response_etag, not_modified=False)
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
@@ -91,6 +357,16 @@ class APIError(Exception):
|
||||
return msg.format(self.message, self.status)
|
||||
|
||||
|
||||
class QuotaLimitError(APIError):
|
||||
pass
|
||||
|
||||
|
||||
# Re-export requests exceptions for use in client.py
|
||||
# This keeps all requests library imports centralized in this module
|
||||
RequestsTimeout = requests.exceptions.Timeout
|
||||
RequestsConnectionError = requests.exceptions.ConnectionError
|
||||
|
||||
|
||||
class DatetimeSerializer(json.JSONEncoder):
|
||||
def default(self, obj: Any):
|
||||
if isinstance(obj, (date, datetime)):
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
POSTHOG_ID_TAG = "posthog_distinct_id"
|
||||
@@ -1,28 +0,0 @@
|
||||
from django.conf import settings
|
||||
from sentry_sdk import configure_scope
|
||||
|
||||
from posthog.sentry import POSTHOG_ID_TAG
|
||||
|
||||
GET_DISTINCT_ID = getattr(settings, "POSTHOG_DJANGO", {}).get("distinct_id")
|
||||
|
||||
|
||||
def get_distinct_id(request):
|
||||
if not GET_DISTINCT_ID:
|
||||
return None
|
||||
try:
|
||||
return GET_DISTINCT_ID(request)
|
||||
except: # noqa: E722
|
||||
return None
|
||||
|
||||
|
||||
class PosthogDistinctIdMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
with configure_scope() as scope:
|
||||
distinct_id = get_distinct_id(request)
|
||||
if distinct_id:
|
||||
scope.set_tag(POSTHOG_ID_TAG, distinct_id)
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
@@ -1,52 +0,0 @@
|
||||
from sentry_sdk._types import MYPY
|
||||
from sentry_sdk.hub import Hub
|
||||
from sentry_sdk.integrations import Integration
|
||||
from sentry_sdk.scope import add_global_event_processor
|
||||
from sentry_sdk.utils import Dsn
|
||||
|
||||
import posthog
|
||||
from posthog.request import DEFAULT_HOST
|
||||
from posthog.sentry import POSTHOG_ID_TAG
|
||||
|
||||
if MYPY:
|
||||
from typing import Optional # noqa: F401
|
||||
|
||||
from sentry_sdk._types import Event, Hint # noqa: F401
|
||||
|
||||
|
||||
class PostHogIntegration(Integration):
|
||||
identifier = "posthog-python"
|
||||
organization = None # The Sentry organization, used to send a direct link from PostHog to Sentry
|
||||
project_id = None # The Sentry project id, used to send a direct link from PostHog to Sentry
|
||||
prefix = "https://sentry.io/organizations/" # URL of a hosted sentry instance (default: https://sentry.io/organizations/)
|
||||
|
||||
@staticmethod
|
||||
def setup_once():
|
||||
@add_global_event_processor
|
||||
def processor(event, hint):
|
||||
# type: (Event, Optional[Hint]) -> Optional[Event]
|
||||
if Hub.current.get_integration(PostHogIntegration) is not None:
|
||||
if event.get("level") != "error":
|
||||
return event
|
||||
|
||||
if event.get("tags", {}).get(POSTHOG_ID_TAG):
|
||||
posthog_distinct_id = event["tags"][POSTHOG_ID_TAG]
|
||||
event["tags"]["PostHog URL"] = f"{posthog.host or DEFAULT_HOST}/person/{posthog_distinct_id}"
|
||||
|
||||
properties = {
|
||||
"$sentry_event_id": event["event_id"],
|
||||
"$sentry_exception": event["exception"],
|
||||
}
|
||||
|
||||
if PostHogIntegration.organization:
|
||||
project_id = PostHogIntegration.project_id or (
|
||||
not not Hub.current.client.dsn and Dsn(Hub.current.client.dsn).project_id
|
||||
)
|
||||
if project_id:
|
||||
properties[
|
||||
"$sentry_url"
|
||||
] = f"{PostHogIntegration.prefix}{PostHogIntegration.organization}/issues/?project={project_id}&query={event['event_id']}"
|
||||
|
||||
posthog.capture(posthog_distinct_id, "$exception", properties)
|
||||
|
||||
return event
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,853 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from google import genai as google_genai
|
||||
|
||||
from posthog.ai.gemini import AsyncClient
|
||||
|
||||
GEMINI_AVAILABLE = True
|
||||
except ImportError:
|
||||
GEMINI_AVAILABLE = False
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.skipif(
|
||||
not GEMINI_AVAILABLE, reason="Google Gemini package is not available"
|
||||
),
|
||||
pytest.mark.asyncio,
|
||||
]
|
||||
|
||||
|
||||
@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
|
||||
# Ensure cache and reasoning tokens are not present (not MagicMock)
|
||||
mock_usage.cached_content_token_count = 0
|
||||
mock_usage.thoughts_token_count = 0
|
||||
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 google-genai Client with async support"""
|
||||
with patch.object(google_genai, "Client") as mock_client_class:
|
||||
mock_client_instance = MagicMock()
|
||||
mock_models = MagicMock()
|
||||
mock_aio = MagicMock()
|
||||
mock_aio_models = MagicMock()
|
||||
|
||||
mock_client_instance.models = mock_models
|
||||
mock_client_instance.aio = mock_aio
|
||||
mock_aio.models = mock_aio_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_usage.cached_content_token_count = 0
|
||||
mock_usage.thoughts_token_count = 0
|
||||
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."
|
||||
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
|
||||
mock_function_part = MagicMock()
|
||||
mock_function_part.function_call = mock_function_call
|
||||
type(mock_function_part).function_call = mock_function_part.function_call
|
||||
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
|
||||
|
||||
|
||||
async def test_async_client_basic_generation(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test the async Client/AsyncModels API structure"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = await 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
|
||||
|
||||
|
||||
async def test_async_client_streaming_with_generate_content_stream(
|
||||
mock_client, mock_google_genai_client
|
||||
):
|
||||
"""Test the async generate_content_stream method"""
|
||||
|
||||
async 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_usage1.cached_content_token_count = 0
|
||||
mock_usage1.thoughts_token_count = 0
|
||||
mock_chunk1.usage_metadata = mock_usage1
|
||||
yield mock_chunk1
|
||||
|
||||
mock_chunk2 = MagicMock()
|
||||
mock_chunk2.text = "world!"
|
||||
mock_usage2 = MagicMock()
|
||||
mock_usage2.prompt_token_count = 10
|
||||
mock_usage2.candidates_token_count = 10
|
||||
mock_usage2.cached_content_token_count = 0
|
||||
mock_usage2.thoughts_token_count = 0
|
||||
mock_chunk2.usage_metadata = mock_usage2
|
||||
yield mock_chunk2
|
||||
|
||||
# Mock the async generate_content_stream method
|
||||
mock_google_genai_client.aio.models.generate_content_stream = AsyncMock(
|
||||
return_value=mock_streaming_response()
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = await 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 = []
|
||||
async for chunk in response:
|
||||
chunks.append(chunk)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def test_async_client_streaming_with_tools(mock_client, mock_google_genai_client):
|
||||
"""Test that tools are captured in async streaming mode"""
|
||||
|
||||
async def mock_streaming_response():
|
||||
mock_chunk1 = MagicMock()
|
||||
mock_chunk1.text = "I'll check "
|
||||
mock_usage1 = MagicMock()
|
||||
mock_usage1.prompt_token_count = 15
|
||||
mock_usage1.candidates_token_count = 5
|
||||
mock_usage1.cached_content_token_count = 0
|
||||
mock_usage1.thoughts_token_count = 0
|
||||
mock_chunk1.usage_metadata = mock_usage1
|
||||
yield mock_chunk1
|
||||
|
||||
mock_chunk2 = MagicMock()
|
||||
mock_chunk2.text = "the weather"
|
||||
mock_usage2 = MagicMock()
|
||||
mock_usage2.prompt_token_count = 15
|
||||
mock_usage2.candidates_token_count = 10
|
||||
mock_usage2.cached_content_token_count = 0
|
||||
mock_usage2.thoughts_token_count = 0
|
||||
mock_chunk2.usage_metadata = mock_usage2
|
||||
yield mock_chunk2
|
||||
|
||||
# Mock the async generate_content_stream method
|
||||
mock_google_genai_client.aio.models.generate_content_stream = AsyncMock(
|
||||
return_value=mock_streaming_response()
|
||||
)
|
||||
|
||||
client = AsyncClient(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 = await client.models.generate_content_stream(
|
||||
model="gemini-2.0-flash",
|
||||
contents=["What's the weather in SF?"],
|
||||
config=mock_config,
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"feature": "streaming_with_tools"},
|
||||
)
|
||||
|
||||
chunks = []
|
||||
async for chunk in response:
|
||||
chunks.append(chunk)
|
||||
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0].text == "I'll check "
|
||||
assert chunks[1].text == "the weather"
|
||||
|
||||
# Check that the streaming event was captured with tools
|
||||
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"] == 15
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["feature"] == "streaming_with_tools"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
# Verify that tools are captured in the $ai_tools property in streaming mode
|
||||
assert props["$ai_tools"] == [mock_tool]
|
||||
|
||||
|
||||
async def test_async_client_groups(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test groups functionality with async Client API"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
await 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"}
|
||||
|
||||
|
||||
async def test_async_client_privacy_mode_local(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test local privacy mode with async Client API"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
await 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
|
||||
|
||||
|
||||
async def test_async_client_privacy_mode_global(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test global privacy mode with async Client API"""
|
||||
mock_client.privacy_mode = True
|
||||
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
await 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
|
||||
|
||||
|
||||
async def test_async_client_different_input_formats(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test different input formats with async Client API"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
# Test string input
|
||||
await 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 Gemini-specific format with parts array
|
||||
mock_client.reset_mock()
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=[{"role": "user", "parts": [{"text": "hey"}]}],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] == [
|
||||
{"role": "user", "content": [{"type": "text", "text": "hey"}]}
|
||||
]
|
||||
|
||||
# Test multiple parts in the parts array
|
||||
mock_client.reset_mock()
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=[{"role": "user", "parts": [{"text": "Hello "}, {"text": "world"}]}],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello "},
|
||||
{"type": "text", "text": "world"},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
# Test list input with string
|
||||
mock_client.capture.reset_mock()
|
||||
await client.models.generate_content(
|
||||
model="gemini-2.0-flash", contents=["List item"], 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"}]
|
||||
|
||||
|
||||
async def test_async_client_model_parameters(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test model parameters with async Client API"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
await 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
|
||||
|
||||
|
||||
async def test_async_client_default_settings(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test async client with default PostHog settings"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(
|
||||
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
|
||||
await 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"
|
||||
|
||||
|
||||
async def test_async_client_override_defaults(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test overriding async client defaults per call"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response
|
||||
)
|
||||
|
||||
client = AsyncClient(
|
||||
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
|
||||
await 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
|
||||
|
||||
|
||||
async def test_async_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.aio.models.generate_content = AsyncMock(
|
||||
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
|
||||
AsyncClient(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
async def test_async_api_key_mode(mock_client, mock_google_genai_client):
|
||||
"""Test API key authentication mode with async client"""
|
||||
|
||||
# Create async client with just API key (traditional mode)
|
||||
AsyncClient(
|
||||
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")
|
||||
|
||||
|
||||
async def test_async_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 with async"""
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_gemini_response_with_function_calls
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = await 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
|
||||
|
||||
|
||||
async def test_async_cache_and_reasoning_tokens(mock_client, mock_google_genai_client):
|
||||
"""Test that cache and reasoning tokens are properly extracted with async"""
|
||||
# Create a mock response with cache and reasoning tokens
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Test response with cache"
|
||||
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.prompt_token_count = 100
|
||||
mock_usage.candidates_token_count = 50
|
||||
mock_usage.cached_content_token_count = 30 # Cache tokens
|
||||
mock_usage.thoughts_token_count = 10 # Reasoning tokens
|
||||
mock_response.usage_metadata = mock_usage
|
||||
|
||||
# Mock candidates
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.text = "Test response with cache"
|
||||
mock_response.candidates = [mock_candidate]
|
||||
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content(
|
||||
model="gemini-2.5-pro",
|
||||
contents="Test with cache",
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
assert response == mock_response
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
# Check that all token types are present
|
||||
assert props["$ai_input_tokens"] == 100
|
||||
assert props["$ai_output_tokens"] == 50
|
||||
assert props["$ai_cache_read_input_tokens"] == 30
|
||||
assert props["$ai_reasoning_tokens"] == 10
|
||||
|
||||
|
||||
async def test_async_streaming_cache_and_reasoning_tokens(
|
||||
mock_client, mock_google_genai_client
|
||||
):
|
||||
"""Test that cache and reasoning tokens are properly extracted in async streaming"""
|
||||
|
||||
async def mock_streaming_response():
|
||||
# Create mock chunks with cache and reasoning tokens
|
||||
chunk1 = MagicMock()
|
||||
chunk1.text = "Hello "
|
||||
chunk1_usage = MagicMock()
|
||||
chunk1_usage.prompt_token_count = 100
|
||||
chunk1_usage.candidates_token_count = 5
|
||||
chunk1_usage.cached_content_token_count = 30 # Cache tokens
|
||||
chunk1_usage.thoughts_token_count = 0
|
||||
chunk1.usage_metadata = chunk1_usage
|
||||
yield chunk1
|
||||
|
||||
chunk2 = MagicMock()
|
||||
chunk2.text = "world!"
|
||||
chunk2_usage = MagicMock()
|
||||
chunk2_usage.prompt_token_count = 100
|
||||
chunk2_usage.candidates_token_count = 10
|
||||
chunk2_usage.cached_content_token_count = 30 # Same cache tokens
|
||||
chunk2_usage.thoughts_token_count = 5 # Reasoning tokens
|
||||
chunk2.usage_metadata = chunk2_usage
|
||||
yield chunk2
|
||||
|
||||
mock_google_genai_client.aio.models.generate_content_stream = AsyncMock(
|
||||
return_value=mock_streaming_response()
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content_stream(
|
||||
model="gemini-2.5-pro",
|
||||
contents="Test streaming with cache",
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
# Consume the stream
|
||||
result = []
|
||||
async for chunk in response:
|
||||
result.append(chunk)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
# Check PostHog capture was called
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
# Check that all token types are present (should use final chunk's usage)
|
||||
assert props["$ai_input_tokens"] == 100
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["$ai_cache_read_input_tokens"] == 30
|
||||
assert props["$ai_reasoning_tokens"] == 5
|
||||
|
||||
|
||||
async def test_async_web_search_grounding(mock_client, mock_google_genai_client):
|
||||
"""Test async web search detection via grounding_metadata."""
|
||||
|
||||
# Create mock response with grounding metadata
|
||||
mock_response = MagicMock()
|
||||
|
||||
# Mock usage metadata
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.prompt_token_count = 60
|
||||
mock_usage.candidates_token_count = 40
|
||||
mock_usage.cached_content_token_count = 0
|
||||
mock_usage.thoughts_token_count = 0
|
||||
mock_response.usage_metadata = mock_usage
|
||||
|
||||
# Mock grounding metadata
|
||||
mock_grounding_chunk = MagicMock()
|
||||
mock_grounding_chunk.uri = "https://example.com"
|
||||
|
||||
mock_grounding_metadata = MagicMock()
|
||||
mock_grounding_metadata.grounding_chunks = [mock_grounding_chunk]
|
||||
|
||||
# Mock text part
|
||||
mock_text_part = MagicMock()
|
||||
mock_text_part.text = "According to search results..."
|
||||
type(mock_text_part).text = mock_text_part.text
|
||||
|
||||
# Mock content with parts
|
||||
mock_content = MagicMock()
|
||||
mock_content.parts = [mock_text_part]
|
||||
|
||||
# Mock candidate with grounding metadata
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.content = mock_content
|
||||
mock_candidate.grounding_metadata = mock_grounding_metadata
|
||||
type(mock_candidate).grounding_metadata = mock_candidate.grounding_metadata
|
||||
|
||||
mock_response.candidates = [mock_candidate]
|
||||
mock_response.text = "According to search results..."
|
||||
|
||||
# Mock the async generate_content method
|
||||
mock_google_genai_client.aio.models.generate_content = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
response = await client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents="What's the latest news?",
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
assert response == mock_response
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
# Verify web search count is detected (binary for grounding)
|
||||
assert props["$ai_web_search_count"] == 1
|
||||
assert props["$ai_input_tokens"] == 60
|
||||
assert props["$ai_output_tokens"] == 40
|
||||
|
||||
|
||||
async def test_async_streaming_with_web_search(mock_client, mock_google_genai_client):
|
||||
"""Test that web search count is properly captured in async streaming mode."""
|
||||
|
||||
async def mock_streaming_response():
|
||||
# Create chunk 1 with grounding metadata
|
||||
mock_chunk1 = MagicMock()
|
||||
mock_chunk1.text = "According to "
|
||||
|
||||
mock_usage1 = MagicMock()
|
||||
mock_usage1.prompt_token_count = 30
|
||||
mock_usage1.candidates_token_count = 5
|
||||
mock_usage1.cached_content_token_count = 0
|
||||
mock_usage1.thoughts_token_count = 0
|
||||
mock_chunk1.usage_metadata = mock_usage1
|
||||
|
||||
# Add grounding metadata to first chunk
|
||||
mock_grounding_chunk = MagicMock()
|
||||
mock_grounding_chunk.uri = "https://example.com"
|
||||
|
||||
mock_grounding_metadata = MagicMock()
|
||||
mock_grounding_metadata.grounding_chunks = [mock_grounding_chunk]
|
||||
|
||||
mock_candidate1 = MagicMock()
|
||||
mock_candidate1.grounding_metadata = mock_grounding_metadata
|
||||
type(mock_candidate1).grounding_metadata = mock_candidate1.grounding_metadata
|
||||
|
||||
mock_chunk1.candidates = [mock_candidate1]
|
||||
yield mock_chunk1
|
||||
|
||||
# Create chunk 2
|
||||
mock_chunk2 = MagicMock()
|
||||
mock_chunk2.text = "search results..."
|
||||
|
||||
mock_usage2 = MagicMock()
|
||||
mock_usage2.prompt_token_count = 30
|
||||
mock_usage2.candidates_token_count = 15
|
||||
mock_usage2.cached_content_token_count = 0
|
||||
mock_usage2.thoughts_token_count = 0
|
||||
mock_chunk2.usage_metadata = mock_usage2
|
||||
|
||||
mock_candidate2 = MagicMock()
|
||||
mock_chunk2.candidates = [mock_candidate2]
|
||||
yield mock_chunk2
|
||||
|
||||
# Mock the async generate_content_stream method
|
||||
mock_google_genai_client.aio.models.generate_content_stream = AsyncMock(
|
||||
return_value=mock_streaming_response()
|
||||
)
|
||||
|
||||
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = await client.models.generate_content_stream(
|
||||
model="gemini-2.5-flash",
|
||||
contents="What's the latest news?",
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
chunks = []
|
||||
async for chunk in response:
|
||||
chunks.append(chunk)
|
||||
|
||||
assert len(chunks) == 2
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
# Verify web search count is detected (binary for grounding)
|
||||
assert props["$ai_web_search_count"] == 1
|
||||
assert props["$ai_input_tokens"] == 30
|
||||
assert props["$ai_output_tokens"] == 15
|
||||
@@ -0,0 +1,5 @@
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("langchain_core")
|
||||
pytest.importorskip("langchain_community")
|
||||
pytest.importorskip("langgraph")
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,522 @@
|
||||
import os
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
class TestAIMultipartRequest(unittest.TestCase):
|
||||
"""Test that _INTERNAL_LLMA_MULTIMODAL environment variable controls sanitization."""
|
||||
|
||||
def tearDown(self):
|
||||
# Clean up environment variable after each test
|
||||
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
|
||||
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
|
||||
|
||||
def test_multimodal_disabled_redacts_images(self):
|
||||
"""When _INTERNAL_LLMA_MULTIMODAL is not set, images should be redacted."""
|
||||
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
|
||||
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, REDACTED_IMAGE_PLACEHOLDER)
|
||||
|
||||
def test_multimodal_enabled_preserves_images(self):
|
||||
"""When _INTERNAL_LLMA_MULTIMODAL is true, images should be preserved."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, base64_image)
|
||||
|
||||
def test_multimodal_enabled_with_1(self):
|
||||
"""_INTERNAL_LLMA_MULTIMODAL=1 should enable multimodal."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "1"
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, base64_image)
|
||||
|
||||
def test_multimodal_enabled_with_yes(self):
|
||||
"""_INTERNAL_LLMA_MULTIMODAL=yes should enable multimodal."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "yes"
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, base64_image)
|
||||
|
||||
def test_multimodal_false_redacts_images(self):
|
||||
"""_INTERNAL_LLMA_MULTIMODAL=false should still redact."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "false"
|
||||
|
||||
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
result = redact_base64_data_url(base64_image)
|
||||
self.assertEqual(result, REDACTED_IMAGE_PLACEHOLDER)
|
||||
|
||||
def test_anthropic_multimodal_enabled(self):
|
||||
"""Anthropic images should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": "base64data",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_anthropic(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["source"]["data"], "base64data")
|
||||
|
||||
def test_gemini_multimodal_enabled(self):
|
||||
"""Gemini images should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"parts": [
|
||||
{"inline_data": {"mime_type": "image/jpeg", "data": "base64data"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_gemini(input_data)
|
||||
self.assertEqual(result[0]["parts"][0]["inline_data"]["data"], "base64data")
|
||||
|
||||
def test_langchain_anthropic_style_multimodal_enabled(self):
|
||||
"""LangChain Anthropic-style images should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"data": "base64data"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_langchain(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["source"]["data"], "base64data")
|
||||
|
||||
def test_openai_audio_redacted_by_default(self):
|
||||
"""OpenAI audio should be redacted when _INTERNAL_LLMA_MULTIMODAL is not set."""
|
||||
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
|
||||
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "audio", "data": "base64audiodata", "id": "audio_123"}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["data"], REDACTED_IMAGE_PLACEHOLDER)
|
||||
self.assertEqual(result[0]["content"][0]["id"], "audio_123")
|
||||
|
||||
def test_openai_audio_preserved_with_flag(self):
|
||||
"""OpenAI audio should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "audio", "data": "base64audiodata", "id": "audio_123"}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["data"], "base64audiodata")
|
||||
|
||||
def test_gemini_audio_redacted_by_default(self):
|
||||
"""Gemini audio should be redacted when _INTERNAL_LLMA_MULTIMODAL is not set."""
|
||||
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
|
||||
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"inline_data": {
|
||||
"mime_type": "audio/L16;codec=pcm;rate=24000",
|
||||
"data": "base64audiodata",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_gemini(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["parts"][0]["inline_data"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_gemini_audio_preserved_with_flag(self):
|
||||
"""Gemini audio should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
|
||||
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
|
||||
|
||||
input_data = [
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"inline_data": {
|
||||
"mime_type": "audio/L16;codec=pcm;rate=24000",
|
||||
"data": "base64audiodata",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_gemini(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["parts"][0]["inline_data"]["data"], "base64audiodata"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Tests for system prompt capture across all LLM providers.
|
||||
|
||||
This test suite ensures that system prompts are correctly captured in analytics
|
||||
regardless of how they're passed to the providers:
|
||||
- As first message in messages/contents array (standard format)
|
||||
- As separate system parameter (Anthropic, OpenAI)
|
||||
- As instructions parameter (OpenAI Responses API)
|
||||
- As system_instruction parameter (Gemini)
|
||||
"""
|
||||
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestSystemPromptCapture(unittest.TestCase):
|
||||
"""Test system prompt capture for all providers."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.test_system_prompt = "You are a helpful AI assistant."
|
||||
self.test_user_message = "Hello, how are you?"
|
||||
self.test_response = "I'm doing well, thank you!"
|
||||
|
||||
# Create mock PostHog client
|
||||
self.client = MagicMock()
|
||||
self.client.privacy_mode = False
|
||||
|
||||
def _assert_system_prompt_captured(self, captured_input):
|
||||
"""Helper to assert system prompt is correctly captured."""
|
||||
self.assertEqual(
|
||||
len(captured_input), 2, "Should have 2 messages (system + user)"
|
||||
)
|
||||
self.assertEqual(
|
||||
captured_input[0]["role"], "system", "First message should be system"
|
||||
)
|
||||
self.assertEqual(
|
||||
captured_input[0]["content"],
|
||||
self.test_system_prompt,
|
||||
"System content should match",
|
||||
)
|
||||
self.assertEqual(
|
||||
captured_input[1]["role"], "user", "Second message should be user"
|
||||
)
|
||||
self.assertEqual(
|
||||
captured_input[1]["content"],
|
||||
self.test_user_message,
|
||||
"User content should match",
|
||||
)
|
||||
|
||||
# OpenAI Tests
|
||||
def test_openai_messages_array_system_prompt(self):
|
||||
"""Test OpenAI with system prompt in messages array."""
|
||||
try:
|
||||
from posthog.ai.openai import OpenAI
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
except ImportError:
|
||||
self.skipTest("OpenAI package not available")
|
||||
|
||||
mock_response = ChatCompletion(
|
||||
id="test",
|
||||
model="gpt-4",
|
||||
object="chat.completion",
|
||||
created=int(time.time()),
|
||||
choices=[
|
||||
Choice(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
content=self.test_response, role="assistant"
|
||||
),
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
completion_tokens=10, prompt_tokens=20, total_tokens=30
|
||||
),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"openai.resources.chat.completions.Completions.create",
|
||||
return_value=mock_response,
|
||||
):
|
||||
client = OpenAI(posthog_client=self.client, api_key="test")
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": self.test_system_prompt},
|
||||
{"role": "user", "content": self.test_user_message},
|
||||
]
|
||||
|
||||
client.chat.completions.create(
|
||||
model="gpt-4", messages=messages, posthog_distinct_id="test-user"
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.client.capture.call_args_list), 1)
|
||||
properties = self.client.capture.call_args_list[0][1]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
|
||||
def test_openai_separate_system_parameter(self):
|
||||
"""Test OpenAI with system prompt as separate parameter."""
|
||||
try:
|
||||
from posthog.ai.openai import OpenAI
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
except ImportError:
|
||||
self.skipTest("OpenAI package not available")
|
||||
|
||||
mock_response = ChatCompletion(
|
||||
id="test",
|
||||
model="gpt-4",
|
||||
object="chat.completion",
|
||||
created=int(time.time()),
|
||||
choices=[
|
||||
Choice(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
content=self.test_response, role="assistant"
|
||||
),
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
completion_tokens=10, prompt_tokens=20, total_tokens=30
|
||||
),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"openai.resources.chat.completions.Completions.create",
|
||||
return_value=mock_response,
|
||||
):
|
||||
client = OpenAI(posthog_client=self.client, api_key="test")
|
||||
|
||||
messages = [{"role": "user", "content": self.test_user_message}]
|
||||
|
||||
client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=messages,
|
||||
system=self.test_system_prompt,
|
||||
posthog_distinct_id="test-user",
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.client.capture.call_args_list), 1)
|
||||
properties = self.client.capture.call_args_list[0][1]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
|
||||
def test_openai_streaming_system_parameter(self):
|
||||
"""Test OpenAI streaming with system parameter."""
|
||||
try:
|
||||
from posthog.ai.openai import OpenAI
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChoiceChunk
|
||||
from openai.types.chat.chat_completion_chunk import ChoiceDelta
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
except ImportError:
|
||||
self.skipTest("OpenAI package not available")
|
||||
|
||||
chunk1 = ChatCompletionChunk(
|
||||
id="test",
|
||||
model="gpt-4",
|
||||
object="chat.completion.chunk",
|
||||
created=int(time.time()),
|
||||
choices=[
|
||||
ChoiceChunk(
|
||||
finish_reason=None,
|
||||
index=0,
|
||||
delta=ChoiceDelta(content="Hello", role="assistant"),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
chunk2 = ChatCompletionChunk(
|
||||
id="test",
|
||||
model="gpt-4",
|
||||
object="chat.completion.chunk",
|
||||
created=int(time.time()),
|
||||
choices=[
|
||||
ChoiceChunk(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
delta=ChoiceDelta(content=" there!", role=None),
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
completion_tokens=10, prompt_tokens=20, total_tokens=30
|
||||
),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"openai.resources.chat.completions.Completions.create",
|
||||
return_value=[chunk1, chunk2],
|
||||
):
|
||||
client = OpenAI(posthog_client=self.client, api_key="test")
|
||||
|
||||
messages = [{"role": "user", "content": self.test_user_message}]
|
||||
|
||||
response_generator = client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=messages,
|
||||
system=self.test_system_prompt,
|
||||
stream=True,
|
||||
posthog_distinct_id="test-user",
|
||||
)
|
||||
|
||||
list(response_generator) # Consume generator
|
||||
|
||||
self.assertEqual(len(self.client.capture.call_args_list), 1)
|
||||
properties = self.client.capture.call_args_list[0][1]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
|
||||
# Anthropic Tests
|
||||
def test_anthropic_messages_array_system_prompt(self):
|
||||
"""Test Anthropic with system prompt in messages array."""
|
||||
try:
|
||||
from posthog.ai.anthropic import Anthropic
|
||||
except ImportError:
|
||||
self.skipTest("Anthropic package not available")
|
||||
|
||||
with patch("anthropic.resources.messages.Messages.create") as mock_create:
|
||||
mock_response = MagicMock()
|
||||
mock_response.usage.input_tokens = 20
|
||||
mock_response.usage.output_tokens = 10
|
||||
mock_response.usage.cache_read_input_tokens = None
|
||||
mock_response.usage.cache_creation_input_tokens = None
|
||||
mock_create.return_value = mock_response
|
||||
|
||||
client = Anthropic(posthog_client=self.client, api_key="test")
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": self.test_system_prompt},
|
||||
{"role": "user", "content": self.test_user_message},
|
||||
]
|
||||
|
||||
client.messages.create(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
messages=messages,
|
||||
posthog_distinct_id="test-user",
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.client.capture.call_args_list), 1)
|
||||
properties = self.client.capture.call_args_list[0][1]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
|
||||
def test_anthropic_separate_system_parameter(self):
|
||||
"""Test Anthropic with system prompt as separate parameter."""
|
||||
try:
|
||||
from posthog.ai.anthropic import Anthropic
|
||||
except ImportError:
|
||||
self.skipTest("Anthropic package not available")
|
||||
|
||||
with patch("anthropic.resources.messages.Messages.create") as mock_create:
|
||||
mock_response = MagicMock()
|
||||
mock_response.usage.input_tokens = 20
|
||||
mock_response.usage.output_tokens = 10
|
||||
mock_response.usage.cache_read_input_tokens = None
|
||||
mock_response.usage.cache_creation_input_tokens = None
|
||||
mock_create.return_value = mock_response
|
||||
|
||||
client = Anthropic(posthog_client=self.client, api_key="test")
|
||||
|
||||
messages = [{"role": "user", "content": self.test_user_message}]
|
||||
|
||||
client.messages.create(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
messages=messages,
|
||||
system=self.test_system_prompt,
|
||||
posthog_distinct_id="test-user",
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.client.capture.call_args_list), 1)
|
||||
properties = self.client.capture.call_args_list[0][1]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
|
||||
# Gemini Tests
|
||||
def test_gemini_contents_array_system_prompt(self):
|
||||
"""Test Gemini with system prompt in contents array."""
|
||||
try:
|
||||
from posthog.ai.gemini import Client
|
||||
except ImportError:
|
||||
self.skipTest("Gemini package not available")
|
||||
|
||||
with patch("google.genai.Client") as mock_genai_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.candidates = [MagicMock()]
|
||||
mock_response.candidates[0].content.parts = [MagicMock()]
|
||||
mock_response.candidates[0].content.parts[0].text = self.test_response
|
||||
mock_response.usage_metadata.prompt_token_count = 20
|
||||
mock_response.usage_metadata.candidates_token_count = 10
|
||||
mock_response.usage_metadata.cached_content_token_count = None
|
||||
mock_response.usage_metadata.thoughts_token_count = None
|
||||
|
||||
mock_client_instance = MagicMock()
|
||||
mock_models_instance = MagicMock()
|
||||
mock_models_instance.generate_content.return_value = mock_response
|
||||
mock_client_instance.models = mock_models_instance
|
||||
mock_genai_class.return_value = mock_client_instance
|
||||
|
||||
client = Client(posthog_client=self.client, api_key="test")
|
||||
|
||||
contents = [
|
||||
{"role": "system", "content": self.test_system_prompt},
|
||||
{"role": "user", "content": self.test_user_message},
|
||||
]
|
||||
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=contents,
|
||||
posthog_distinct_id="test-user",
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.client.capture.call_args_list), 1)
|
||||
properties = self.client.capture.call_args_list[0][1]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
|
||||
def test_gemini_system_instruction_parameter(self):
|
||||
"""Test Gemini with system_instruction in config parameter."""
|
||||
try:
|
||||
from posthog.ai.gemini import Client
|
||||
except ImportError:
|
||||
self.skipTest("Gemini package not available")
|
||||
|
||||
with patch("google.genai.Client") as mock_genai_class:
|
||||
mock_response = MagicMock()
|
||||
mock_response.candidates = [MagicMock()]
|
||||
mock_response.candidates[0].content.parts = [MagicMock()]
|
||||
mock_response.candidates[0].content.parts[0].text = self.test_response
|
||||
mock_response.usage_metadata.prompt_token_count = 20
|
||||
mock_response.usage_metadata.candidates_token_count = 10
|
||||
mock_response.usage_metadata.cached_content_token_count = None
|
||||
mock_response.usage_metadata.thoughts_token_count = None
|
||||
|
||||
mock_client_instance = MagicMock()
|
||||
mock_models_instance = MagicMock()
|
||||
mock_models_instance.generate_content.return_value = mock_response
|
||||
mock_client_instance.models = mock_models_instance
|
||||
mock_genai_class.return_value = mock_client_instance
|
||||
|
||||
client = Client(posthog_client=self.client, api_key="test")
|
||||
|
||||
contents = [{"role": "user", "content": self.test_user_message}]
|
||||
config = {"system_instruction": self.test_system_prompt}
|
||||
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash",
|
||||
contents=contents,
|
||||
config=config,
|
||||
posthog_distinct_id="test-user",
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.client.capture.call_args_list), 1)
|
||||
properties = self.client.capture.call_args_list[0][1]["properties"]
|
||||
self._assert_system_prompt_captured(properties["$ai_input"])
|
||||
@@ -0,0 +1,773 @@
|
||||
from posthog.contexts import (
|
||||
new_context,
|
||||
get_context_session_id,
|
||||
get_context_distinct_id,
|
||||
)
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
import asyncio
|
||||
|
||||
# Configure Django settings before importing middleware
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
if not settings.configured:
|
||||
settings.configure(
|
||||
DEBUG=True,
|
||||
SECRET_KEY="test-secret-key",
|
||||
INSTALLED_APPS=[],
|
||||
MIDDLEWARE=[],
|
||||
)
|
||||
django.setup()
|
||||
|
||||
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,
|
||||
get_response=None,
|
||||
):
|
||||
"""Helper to create middleware instance with mock Django settings"""
|
||||
if get_response is None:
|
||||
get_response = Mock()
|
||||
|
||||
with patch("django.conf.settings") as mock_settings:
|
||||
# Configure mock settings
|
||||
mock_settings.POSTHOG_MW_EXTRA_TAGS = extra_tags
|
||||
mock_settings.POSTHOG_MW_REQUEST_FILTER = request_filter
|
||||
mock_settings.POSTHOG_MW_TAG_MAP = tag_map
|
||||
mock_settings.POSTHOG_MW_CAPTURE_EXCEPTIONS = capture_exceptions
|
||||
mock_settings.POSTHOG_MW_CLIENT = None
|
||||
|
||||
# Make hasattr work correctly
|
||||
def mock_hasattr(obj, name):
|
||||
return name in [
|
||||
"POSTHOG_MW_EXTRA_TAGS",
|
||||
"POSTHOG_MW_REQUEST_FILTER",
|
||||
"POSTHOG_MW_TAG_MAP",
|
||||
"POSTHOG_MW_CAPTURE_EXCEPTIONS",
|
||||
"POSTHOG_MW_CLIENT",
|
||||
]
|
||||
|
||||
with patch("builtins.hasattr", side_effect=mock_hasattr):
|
||||
middleware = PosthogContextMiddleware(get_response)
|
||||
|
||||
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")
|
||||
|
||||
def test_process_exception_called_during_view_exception(self):
|
||||
"""
|
||||
Unit test verifying process_exception captures exceptions per Django's contract.
|
||||
|
||||
Since this is a library test (no Django runtime), we simulate how Django
|
||||
would invoke our middleware in production:
|
||||
1. Middleware.__call__ creates context with request tags
|
||||
2. View raises exception inside get_response
|
||||
3. Django's BaseHandler catches it, calls process_exception, returns error response
|
||||
4. Exception never propagates to middleware's context manager
|
||||
|
||||
We manually call process_exception to simulate Django's behavior - this is
|
||||
the only way to test the hook without a full Django integration test.
|
||||
"""
|
||||
mock_client = Mock()
|
||||
view_exception = ValueError("View raised this error")
|
||||
error_response = Mock(status_code=500)
|
||||
|
||||
def mock_get_response(request):
|
||||
# Simulate Django's exception handling: catches view exception,
|
||||
# calls process_exception hook if it exists, returns error response
|
||||
if hasattr(middleware, "process_exception"):
|
||||
middleware.process_exception(request, view_exception)
|
||||
return error_response
|
||||
|
||||
middleware = self.create_middleware(get_response=mock_get_response)
|
||||
middleware.client = mock_client
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-DISTINCT-ID": "test-user"},
|
||||
method="POST",
|
||||
path="/api/endpoint",
|
||||
)
|
||||
response = middleware(request)
|
||||
|
||||
self.assertEqual(response.status_code, 500)
|
||||
mock_client.capture_exception.assert_called_once_with(view_exception)
|
||||
|
||||
def test_process_exception_respects_capture_exceptions_false(self):
|
||||
"""Verify process_exception respects capture_exceptions=False setting"""
|
||||
mock_client = Mock()
|
||||
view_exception = ValueError("Should not be captured")
|
||||
|
||||
def mock_get_response(request):
|
||||
if hasattr(middleware, "process_exception"):
|
||||
middleware.process_exception(request, view_exception)
|
||||
return Mock(status_code=500)
|
||||
|
||||
middleware = self.create_middleware(
|
||||
capture_exceptions=False, get_response=mock_get_response
|
||||
)
|
||||
middleware.client = mock_client
|
||||
|
||||
request = MockRequest()
|
||||
middleware(request)
|
||||
|
||||
mock_client.capture_exception.assert_not_called()
|
||||
|
||||
def test_process_exception_respects_request_filter(self):
|
||||
"""Verify process_exception respects request_filter setting"""
|
||||
mock_client = Mock()
|
||||
view_exception = ValueError("Should be filtered")
|
||||
|
||||
def mock_get_response(request):
|
||||
if hasattr(middleware, "process_exception"):
|
||||
middleware.process_exception(request, view_exception)
|
||||
return Mock(status_code=500)
|
||||
|
||||
middleware = self.create_middleware(
|
||||
request_filter=lambda req: False,
|
||||
capture_exceptions=True,
|
||||
get_response=mock_get_response,
|
||||
)
|
||||
middleware.client = mock_client
|
||||
|
||||
request = MockRequest()
|
||||
middleware(request)
|
||||
|
||||
mock_client.capture_exception.assert_not_called()
|
||||
|
||||
|
||||
class TestPosthogContextMiddlewareSync(unittest.TestCase):
|
||||
"""Test synchronous middleware behavior"""
|
||||
|
||||
def test_sync_middleware_call(self):
|
||||
"""Test that sync middleware correctly processes requests"""
|
||||
mock_response = Mock()
|
||||
get_response = Mock(return_value=mock_response)
|
||||
|
||||
# Create middleware with sync get_response
|
||||
middleware = PosthogContextMiddleware(get_response)
|
||||
|
||||
# Verify sync mode detected
|
||||
self.assertFalse(middleware._is_coroutine)
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "test-session"},
|
||||
method="GET",
|
||||
path="/test",
|
||||
)
|
||||
|
||||
with new_context():
|
||||
response = middleware(request)
|
||||
|
||||
# Verify response returned
|
||||
self.assertEqual(response, mock_response)
|
||||
get_response.assert_called_once_with(request)
|
||||
|
||||
def test_sync_middleware_with_filter(self):
|
||||
"""Test sync middleware respects request filter"""
|
||||
mock_response = Mock()
|
||||
get_response = Mock(return_value=mock_response)
|
||||
|
||||
# Create middleware with request filter that filters all requests
|
||||
def request_filter(req):
|
||||
return False
|
||||
|
||||
middleware = PosthogContextMiddleware.__new__(PosthogContextMiddleware)
|
||||
middleware.get_response = get_response
|
||||
middleware._is_coroutine = False
|
||||
middleware.request_filter = request_filter
|
||||
middleware.capture_exceptions = True
|
||||
middleware.client = None
|
||||
|
||||
request = MockRequest()
|
||||
|
||||
# Should skip context creation and return response directly
|
||||
response = middleware(request)
|
||||
self.assertEqual(response, mock_response)
|
||||
get_response.assert_called_once_with(request)
|
||||
|
||||
def test_view_exceptions_only_captured_via_process_exception(self):
|
||||
"""
|
||||
Demonstrates that process_exception is required to capture view exceptions.
|
||||
|
||||
In production Django, view exceptions don't propagate to middleware's context
|
||||
manager because Django's BaseHandler catches them first and converts them to
|
||||
error responses. Django provides the exception via process_exception hook instead.
|
||||
|
||||
This unit test proves:
|
||||
1. Context manager in __call__ never sees view exceptions (Django intercepts)
|
||||
2. Only process_exception can capture them
|
||||
3. Without process_exception, exceptions are silently lost (v6.7.5 regression)
|
||||
|
||||
We manually call process_exception to verify the hook works - in production,
|
||||
Django's BaseHandler would call it when a view raises.
|
||||
"""
|
||||
mock_client = Mock()
|
||||
get_response = Mock(return_value=Mock(status_code=500))
|
||||
|
||||
middleware = PosthogContextMiddleware(get_response)
|
||||
middleware.client = mock_client
|
||||
|
||||
def get_response_simulating_django(request):
|
||||
# Simulates Django behavior: view exception converted to error response,
|
||||
# never propagates to middleware's context manager
|
||||
return Mock(status_code=500)
|
||||
|
||||
middleware._sync_get_response = get_response_simulating_django
|
||||
|
||||
request = MockRequest()
|
||||
|
||||
response = middleware(request)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
# Context manager didn't capture anything - exception was intercepted by Django
|
||||
mock_client.capture_exception.assert_not_called()
|
||||
|
||||
# Verify process_exception hook exists and captures exceptions when called
|
||||
if hasattr(middleware, "process_exception"):
|
||||
exception = ValueError("View error")
|
||||
middleware.process_exception(request, exception)
|
||||
mock_client.capture_exception.assert_called_once_with(exception)
|
||||
else:
|
||||
self.fail(
|
||||
"process_exception missing - view exceptions will not be captured!"
|
||||
)
|
||||
|
||||
|
||||
class TestPosthogContextMiddlewareAsync(unittest.TestCase):
|
||||
"""Test asynchronous middleware behavior"""
|
||||
|
||||
def test_async_middleware_detection(self):
|
||||
"""Test that async get_response is correctly detected"""
|
||||
|
||||
async def async_get_response(request):
|
||||
return Mock()
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
|
||||
# Verify async mode detected
|
||||
self.assertTrue(middleware._is_coroutine)
|
||||
|
||||
def test_async_middleware_call(self):
|
||||
"""Test that async middleware correctly processes requests"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
async def async_get_response(request):
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "async-session"},
|
||||
method="POST",
|
||||
path="/async-test",
|
||||
)
|
||||
|
||||
with new_context():
|
||||
# Call should return the coroutine from __acall__
|
||||
result = middleware(request)
|
||||
|
||||
# Verify it's a coroutine
|
||||
self.assertTrue(asyncio.iscoroutine(result))
|
||||
|
||||
# Await the result
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_with_filter(self):
|
||||
"""Test async middleware respects request filter"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
async def async_get_response(request):
|
||||
return mock_response
|
||||
|
||||
# Properly initialize middleware
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
# Override request filter after initialization
|
||||
middleware.request_filter = lambda req: False
|
||||
|
||||
request = MockRequest()
|
||||
|
||||
# Should skip context creation and return response directly
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_context_propagation(self):
|
||||
"""Test that async middleware properly propagates context"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
async def async_get_response(request):
|
||||
# Verify context is available during async processing
|
||||
session_id = get_context_session_id()
|
||||
self.assertEqual(session_id, "async-session-123")
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "async-session-123"},
|
||||
method="GET",
|
||||
)
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
await result
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_exception_capture(self):
|
||||
"""Test that async middleware captures exceptions during request processing"""
|
||||
|
||||
async def run_test():
|
||||
mock_client = Mock()
|
||||
|
||||
# Make async_get_response raise an exception
|
||||
async def raise_exception(request):
|
||||
raise ValueError("Async test exception")
|
||||
|
||||
# Properly initialize middleware
|
||||
middleware = PosthogContextMiddleware(raise_exception)
|
||||
middleware.client = mock_client # Override with mock client
|
||||
|
||||
request = MockRequest()
|
||||
|
||||
# Should capture exception and re-raise
|
||||
with self.assertRaises(ValueError):
|
||||
result = middleware(request)
|
||||
await result
|
||||
|
||||
# Verify exception was captured by middleware
|
||||
mock_client.capture_exception.assert_called_once()
|
||||
captured_exception = mock_client.capture_exception.call_args[0][0]
|
||||
self.assertIsInstance(captured_exception, ValueError)
|
||||
self.assertEqual(str(captured_exception), "Async test exception")
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_with_authenticated_user(self):
|
||||
"""
|
||||
Test that async middleware correctly extracts user info in async context.
|
||||
|
||||
Django's request.user is a SimpleLazyObject that defers DB access.
|
||||
In async context, accessing it directly raises SynchronousOnlyOperation.
|
||||
The middleware should use request.auser() instead.
|
||||
|
||||
This tests the fix for issue #355.
|
||||
"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
mock_user = Mock()
|
||||
mock_user.is_authenticated = True
|
||||
mock_user.pk = 123
|
||||
mock_user.email = "test@example.com"
|
||||
|
||||
async def async_get_response(request):
|
||||
# Verify user info was extracted and set as distinct_id
|
||||
distinct_id = get_context_distinct_id()
|
||||
self.assertEqual(distinct_id, "123")
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
|
||||
)
|
||||
|
||||
# Mock auser() to return authenticated user
|
||||
async def mock_auser():
|
||||
return mock_user
|
||||
|
||||
request.auser = mock_auser
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_with_unauthenticated_user(self):
|
||||
"""
|
||||
Test that async middleware handles unauthenticated users correctly.
|
||||
"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
mock_user = Mock()
|
||||
mock_user.is_authenticated = False # Not authenticated
|
||||
|
||||
async def async_get_response(request):
|
||||
# Verify no distinct_id was set (no user)
|
||||
distinct_id = get_context_distinct_id()
|
||||
self.assertIsNone(distinct_id)
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
|
||||
)
|
||||
|
||||
async def mock_auser():
|
||||
return mock_user
|
||||
|
||||
request.auser = mock_auser
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_without_user_attribute(self):
|
||||
"""
|
||||
Test that async middleware handles requests without user attribute (no auth middleware).
|
||||
"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
async def async_get_response(request):
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
middleware.client = Mock()
|
||||
|
||||
# Request without auser method (no auth middleware)
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
|
||||
)
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_with_extra_tags(self):
|
||||
"""
|
||||
Test that async middleware works with extra_tags callback.
|
||||
"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
def extra_tags_callback(request):
|
||||
# Simple sync callback - should work
|
||||
return {"custom_tag": "custom_value"}
|
||||
|
||||
async def async_get_response(request):
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
middleware.extra_tags = extra_tags_callback
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
|
||||
)
|
||||
|
||||
# Mock auser for no user
|
||||
async def mock_auser():
|
||||
return None
|
||||
|
||||
request.auser = mock_auser
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_with_tag_map(self):
|
||||
"""
|
||||
Test that async middleware works with tag_map callback.
|
||||
"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
|
||||
def tag_map_callback(tags):
|
||||
# Simple sync callback - should work
|
||||
tags["mapped"] = "yes"
|
||||
return tags
|
||||
|
||||
async def async_get_response(request):
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
middleware.tag_map = tag_map_callback
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
|
||||
)
|
||||
|
||||
# Mock auser for no user
|
||||
async def mock_auser():
|
||||
return None
|
||||
|
||||
request.auser = mock_auser
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_async_middleware_user_extraction_with_all_headers(self):
|
||||
"""
|
||||
Test async middleware extracts all request info correctly.
|
||||
"""
|
||||
|
||||
async def run_test():
|
||||
mock_response = Mock()
|
||||
mock_user = Mock()
|
||||
mock_user.is_authenticated = True
|
||||
mock_user.pk = 456
|
||||
mock_user.email = "async@test.com"
|
||||
|
||||
async def async_get_response(request):
|
||||
# Verify all context was set correctly
|
||||
distinct_id = get_context_distinct_id()
|
||||
session_id = get_context_session_id()
|
||||
self.assertEqual(distinct_id, "456")
|
||||
self.assertEqual(session_id, "async-sess-123")
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
middleware.client = Mock()
|
||||
|
||||
request = MockRequest(
|
||||
headers={
|
||||
"X-POSTHOG-SESSION-ID": "async-sess-123",
|
||||
"X-Forwarded-For": "192.168.1.1",
|
||||
"User-Agent": "TestAgent/1.0",
|
||||
},
|
||||
method="POST",
|
||||
path="/api/test",
|
||||
)
|
||||
|
||||
async def mock_auser():
|
||||
return mock_user
|
||||
|
||||
request.auser = mock_auser
|
||||
|
||||
with new_context():
|
||||
result = middleware(request)
|
||||
response = await result
|
||||
self.assertEqual(response, mock_response)
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
|
||||
class TestPosthogContextMiddlewareHybrid(unittest.TestCase):
|
||||
"""Test hybrid middleware behavior with mixed sync/async chains"""
|
||||
|
||||
def test_hybrid_flags_set(self):
|
||||
"""Test that both capability flags are set"""
|
||||
self.assertTrue(PosthogContextMiddleware.sync_capable)
|
||||
self.assertTrue(PosthogContextMiddleware.async_capable)
|
||||
|
||||
def test_sync_to_async_routing(self):
|
||||
"""Test that __call__ routes to __acall__ when async"""
|
||||
|
||||
async def run_test():
|
||||
async def async_get_response(request):
|
||||
return Mock()
|
||||
|
||||
middleware = PosthogContextMiddleware(async_get_response)
|
||||
|
||||
# Verify routing happens
|
||||
request = MockRequest()
|
||||
result = middleware(request)
|
||||
|
||||
# Should be a coroutine from __acall__
|
||||
self.assertTrue(asyncio.iscoroutine(result))
|
||||
await result # Clean up
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
def test_sync_path_direct_return(self):
|
||||
"""Test that sync path returns directly without coroutine"""
|
||||
mock_response = Mock()
|
||||
|
||||
def sync_get_response(request):
|
||||
return mock_response
|
||||
|
||||
middleware = PosthogContextMiddleware(sync_get_response)
|
||||
|
||||
request = MockRequest()
|
||||
result = middleware(request)
|
||||
|
||||
# Should NOT be a coroutine
|
||||
self.assertFalse(asyncio.iscoroutine(result))
|
||||
self.assertEqual(result, mock_response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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")
|
||||
+2245
-484
File diff suppressed because it is too large
Load Diff
@@ -58,7 +58,11 @@ class TestConsumer(unittest.TestCase):
|
||||
with mock.patch("posthog.consumer.batch_post") as mock_post:
|
||||
consumer.start()
|
||||
for i in range(0, 3):
|
||||
track = {"type": "track", "event": "python event %d" % i, "distinct_id": "distinct_id"}
|
||||
track = {
|
||||
"type": "track",
|
||||
"event": "python event %d" % i,
|
||||
"distinct_id": "distinct_id",
|
||||
}
|
||||
q.put(track)
|
||||
time.sleep(flush_interval * 1.1)
|
||||
self.assertEqual(mock_post.call_count, 3)
|
||||
@@ -69,11 +73,17 @@ class TestConsumer(unittest.TestCase):
|
||||
q = Queue()
|
||||
flush_interval = 0.5
|
||||
flush_at = 10
|
||||
consumer = Consumer(q, TEST_API_KEY, flush_at=flush_at, flush_interval=flush_interval)
|
||||
consumer = Consumer(
|
||||
q, TEST_API_KEY, flush_at=flush_at, flush_interval=flush_interval
|
||||
)
|
||||
with mock.patch("posthog.consumer.batch_post") as mock_post:
|
||||
consumer.start()
|
||||
for i in range(0, flush_at * 2):
|
||||
track = {"type": "track", "event": "python event %d" % i, "distinct_id": "distinct_id"}
|
||||
track = {
|
||||
"type": "track",
|
||||
"event": "python event %d" % i,
|
||||
"distinct_id": "distinct_id",
|
||||
}
|
||||
q.put(track)
|
||||
time.sleep(flush_interval * 1.1)
|
||||
self.assertEqual(mock_post.call_count, 2)
|
||||
@@ -91,8 +101,14 @@ class TestConsumer(unittest.TestCase):
|
||||
|
||||
mock_post.call_count = 0
|
||||
|
||||
with mock.patch("posthog.consumer.batch_post", mock.Mock(side_effect=mock_post)):
|
||||
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
|
||||
with mock.patch(
|
||||
"posthog.consumer.batch_post", mock.Mock(side_effect=mock_post)
|
||||
):
|
||||
track = {
|
||||
"type": "track",
|
||||
"event": "python event",
|
||||
"distinct_id": "distinct_id",
|
||||
}
|
||||
# request() should succeed if the number of exceptions raised is
|
||||
# less than the retries paramater.
|
||||
if exception_count <= consumer.retries:
|
||||
@@ -107,7 +123,8 @@ class TestConsumer(unittest.TestCase):
|
||||
self.assertEqual(exc, expected_exception)
|
||||
else:
|
||||
self.fail(
|
||||
"request() should raise an exception if still failing after %d retries" % consumer.retries
|
||||
"request() should raise an exception if still failing after %d retries"
|
||||
% consumer.retries
|
||||
)
|
||||
|
||||
def test_request_retry(self):
|
||||
@@ -148,7 +165,12 @@ class TestConsumer(unittest.TestCase):
|
||||
properties = {}
|
||||
for n in range(0, 500):
|
||||
properties[str(n)] = "one_long_property_value_to_build_a_big_event"
|
||||
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id", "properties": properties}
|
||||
track = {
|
||||
"type": "track",
|
||||
"event": "python event",
|
||||
"distinct_id": "distinct_id",
|
||||
"properties": properties,
|
||||
}
|
||||
msg_size = len(json.dumps(track).encode())
|
||||
# Let's capture 8MB of data to trigger two batches
|
||||
n_msgs = int(8_000_000 / msg_size)
|
||||
@@ -158,10 +180,15 @@ class TestConsumer(unittest.TestCase):
|
||||
res.status_code = 200
|
||||
request_size = len(data.encode())
|
||||
# Batches close after the first message bringing it bigger than BATCH_SIZE_LIMIT, let's add 10% of margin
|
||||
self.assertTrue(request_size < (5 * 1024 * 1024) * 1.1, "batch size (%d) higher than limit" % request_size)
|
||||
self.assertTrue(
|
||||
request_size < (5 * 1024 * 1024) * 1.1,
|
||||
"batch size (%d) higher than limit" % request_size,
|
||||
)
|
||||
return res
|
||||
|
||||
with mock.patch("posthog.request._session.post", side_effect=mock_post_fn) as mock_post:
|
||||
with mock.patch(
|
||||
"posthog.request._session.post", side_effect=mock_post_fn
|
||||
) as mock_post:
|
||||
consumer.start()
|
||||
for _ in range(0, n_msgs + 2):
|
||||
q.put(track)
|
||||
|
||||
@@ -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,452 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_excepthook(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
from posthog import Posthog
|
||||
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
|
||||
|
||||
# frame_value = "LOL"
|
||||
|
||||
1/0
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output
|
||||
|
||||
assert b"ZeroDivisionError" in output
|
||||
assert b"LOL" in output
|
||||
assert b"DEBUG:posthog:data uploaded successfully" in output
|
||||
assert (
|
||||
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
|
||||
in output
|
||||
)
|
||||
|
||||
|
||||
def test_code_variables_capture(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
import os
|
||||
from posthog import Posthog
|
||||
|
||||
class UnserializableObject:
|
||||
pass
|
||||
|
||||
posthog = Posthog(
|
||||
'phc_x',
|
||||
host='https://eu.i.posthog.com',
|
||||
debug=True,
|
||||
enable_exception_autocapture=True,
|
||||
capture_exception_code_variables=True,
|
||||
project_root=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
|
||||
def trigger_error():
|
||||
my_string = "hello world"
|
||||
my_number = 42
|
||||
my_bool = True
|
||||
my_dict = {"name": "test", "value": 123}
|
||||
my_sensitive_dict = {
|
||||
"safe_key": "safe_value",
|
||||
"password": "secret123", # key matches pattern -> should be masked
|
||||
"other_key": "contains_password_here", # value matches pattern -> should be masked
|
||||
}
|
||||
my_nested_dict = {
|
||||
"level1": {
|
||||
"level2": {
|
||||
"api_key": "nested_secret", # deeply nested key matches
|
||||
"data": "contains_token_here", # deeply nested value matches
|
||||
"safe": "visible",
|
||||
}
|
||||
}
|
||||
}
|
||||
my_list = ["safe_item", "has_password_inside", "another_safe"]
|
||||
my_tuple = ("tuple_safe", "secret_in_value", "tuple_also_safe")
|
||||
my_list_of_dicts = [
|
||||
{"id": 1, "password": "list_dict_secret"},
|
||||
{"id": 2, "value": "safe_value"},
|
||||
]
|
||||
my_obj = UnserializableObject()
|
||||
my_password = "secret123" # Should be masked by default (name matches)
|
||||
my_innocent_var = "contains_password_here" # Should be masked by default (value matches)
|
||||
__should_be_ignored = "hidden" # Should be ignored by default
|
||||
|
||||
1/0 # Trigger exception
|
||||
|
||||
def intermediate_function():
|
||||
request_id = "abc-123"
|
||||
user_count = 100
|
||||
is_active = True
|
||||
|
||||
trigger_error()
|
||||
|
||||
def process_data():
|
||||
batch_size = 50
|
||||
retry_count = 3
|
||||
|
||||
intermediate_function()
|
||||
|
||||
process_data()
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
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"code_variables" in output
|
||||
|
||||
# Variables from trigger_error frame
|
||||
assert b"'my_string': 'hello world'" in output
|
||||
assert b"'my_number': 42" in output
|
||||
assert b"'my_bool': 'True'" in output
|
||||
assert b'"my_dict": "{\\"name\\": \\"test\\", \\"value\\": 123}"' in output
|
||||
assert (
|
||||
b'{\\"safe_key\\": \\"safe_value\\", \\"password\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"other_key\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\"}'
|
||||
in output
|
||||
)
|
||||
assert (
|
||||
b'{\\"level1\\": {\\"level2\\": {\\"api_key\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"data\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"safe\\": \\"visible\\"}}}'
|
||||
in output
|
||||
)
|
||||
assert (
|
||||
b'[\\"safe_item\\", \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"another_safe\\"]'
|
||||
in output
|
||||
)
|
||||
assert (
|
||||
b'[\\"tuple_safe\\", \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"tuple_also_safe\\"]'
|
||||
in output
|
||||
)
|
||||
assert (
|
||||
b'[{\\"id\\": 1, \\"password\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\"}, {\\"id\\": 2, \\"value\\": \\"safe_value\\"}]'
|
||||
in output
|
||||
)
|
||||
assert b"<__main__.UnserializableObject object at" in output
|
||||
assert b"'my_password': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
|
||||
assert (
|
||||
b"'my_innocent_var': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
|
||||
)
|
||||
assert b"'__should_be_ignored':" not in output
|
||||
|
||||
# Variables from intermediate_function frame
|
||||
assert b"'request_id': 'abc-123'" in output
|
||||
assert b"'user_count': 100" in output
|
||||
assert b"'is_active': 'True'" in output
|
||||
|
||||
# Variables from process_data frame
|
||||
assert b"'batch_size': 50" in output
|
||||
assert b"'retry_count': 3" in output
|
||||
|
||||
|
||||
def test_code_variables_context_override(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
import os
|
||||
import posthog
|
||||
from posthog import Posthog
|
||||
|
||||
posthog_client = Posthog(
|
||||
'phc_x',
|
||||
host='https://eu.i.posthog.com',
|
||||
debug=True,
|
||||
enable_exception_autocapture=True,
|
||||
capture_exception_code_variables=False,
|
||||
project_root=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
|
||||
def process_data():
|
||||
bank = "should_be_masked"
|
||||
__dunder_var = "should_be_visible"
|
||||
|
||||
1/0
|
||||
|
||||
with posthog.new_context(client=posthog_client):
|
||||
posthog.set_capture_exception_code_variables_context(True)
|
||||
posthog.set_code_variables_mask_patterns_context([r"(?i).*bank.*"])
|
||||
posthog.set_code_variables_ignore_patterns_context([])
|
||||
|
||||
process_data()
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
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"code_variables" in output
|
||||
assert b"'bank': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
|
||||
assert b"'__dunder_var': 'should_be_visible'" in output
|
||||
|
||||
|
||||
def test_code_variables_size_limiter(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
import os
|
||||
from posthog import Posthog
|
||||
|
||||
posthog = Posthog(
|
||||
'phc_x',
|
||||
host='https://eu.i.posthog.com',
|
||||
debug=True,
|
||||
enable_exception_autocapture=True,
|
||||
capture_exception_code_variables=True,
|
||||
project_root=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
|
||||
def trigger_error():
|
||||
var_a = "a" * 2000
|
||||
var_b = "b" * 2000
|
||||
var_c = "c" * 2000
|
||||
var_d = "d" * 2000
|
||||
var_e = "e" * 2000
|
||||
var_f = "f" * 2000
|
||||
var_g = "g" * 2000
|
||||
|
||||
1/0
|
||||
|
||||
def intermediate_function():
|
||||
var_h = "h" * 2000
|
||||
var_i = "i" * 2000
|
||||
var_j = "j" * 2000
|
||||
var_k = "k" * 2000
|
||||
var_l = "l" * 2000
|
||||
var_m = "m" * 2000
|
||||
var_n = "n" * 2000
|
||||
|
||||
trigger_error()
|
||||
|
||||
def process_data():
|
||||
var_o = "o" * 2000
|
||||
var_p = "p" * 2000
|
||||
var_q = "q" * 2000
|
||||
var_r = "r" * 2000
|
||||
var_s = "s" * 2000
|
||||
var_t = "t" * 2000
|
||||
var_u = "u" * 2000
|
||||
|
||||
intermediate_function()
|
||||
|
||||
process_data()
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output.decode("utf-8")
|
||||
|
||||
assert "ZeroDivisionError" in output
|
||||
assert "code_variables" in output
|
||||
|
||||
captured_vars = []
|
||||
for var_name in [
|
||||
"var_a",
|
||||
"var_b",
|
||||
"var_c",
|
||||
"var_d",
|
||||
"var_e",
|
||||
"var_f",
|
||||
"var_g",
|
||||
"var_h",
|
||||
"var_i",
|
||||
"var_j",
|
||||
"var_k",
|
||||
"var_l",
|
||||
"var_m",
|
||||
"var_n",
|
||||
"var_o",
|
||||
"var_p",
|
||||
"var_q",
|
||||
"var_r",
|
||||
"var_s",
|
||||
"var_t",
|
||||
"var_u",
|
||||
]:
|
||||
if f"'{var_name}'" in output:
|
||||
captured_vars.append(var_name)
|
||||
|
||||
assert len(captured_vars) > 0
|
||||
assert len(captured_vars) < 21
|
||||
|
||||
|
||||
def test_code_variables_disabled_capture(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
import os
|
||||
from posthog import Posthog
|
||||
|
||||
posthog = Posthog(
|
||||
'phc_x',
|
||||
host='https://eu.i.posthog.com',
|
||||
debug=True,
|
||||
enable_exception_autocapture=True,
|
||||
capture_exception_code_variables=False,
|
||||
project_root=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
|
||||
def trigger_error():
|
||||
my_string = "hello world"
|
||||
my_number = 42
|
||||
my_bool = True
|
||||
|
||||
1/0
|
||||
|
||||
trigger_error()
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output.decode("utf-8")
|
||||
|
||||
assert "ZeroDivisionError" in output
|
||||
assert "'code_variables':" not in output
|
||||
assert '"code_variables":' not in output
|
||||
assert "'my_string'" not in output
|
||||
assert "'my_number'" not in output
|
||||
|
||||
|
||||
def test_code_variables_enabled_then_disabled_in_context(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
import os
|
||||
import posthog
|
||||
from posthog import Posthog
|
||||
|
||||
posthog_client = Posthog(
|
||||
'phc_x',
|
||||
host='https://eu.i.posthog.com',
|
||||
debug=True,
|
||||
enable_exception_autocapture=True,
|
||||
capture_exception_code_variables=True,
|
||||
project_root=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
|
||||
def process_data():
|
||||
my_var = "should not be captured"
|
||||
important_value = 123
|
||||
|
||||
1/0
|
||||
|
||||
with posthog.new_context(client=posthog_client):
|
||||
posthog.set_capture_exception_code_variables_context(False)
|
||||
|
||||
process_data()
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output.decode("utf-8")
|
||||
|
||||
assert "ZeroDivisionError" in output
|
||||
assert "'code_variables':" not in output
|
||||
assert '"code_variables":' not in output
|
||||
assert "'my_var'" not in output
|
||||
assert "'important_value'" not in output
|
||||
|
||||
|
||||
def test_code_variables_repr_fallback(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
from posthog import Posthog
|
||||
|
||||
class CustomReprClass:
|
||||
def __repr__(self):
|
||||
return '<CustomReprClass: custom representation>'
|
||||
|
||||
posthog = Posthog(
|
||||
'phc_x',
|
||||
host='https://eu.i.posthog.com',
|
||||
debug=True,
|
||||
enable_exception_autocapture=True,
|
||||
capture_exception_code_variables=True,
|
||||
project_root=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
|
||||
def trigger_error():
|
||||
my_regex = re.compile(r'\\d+')
|
||||
my_datetime = datetime(2024, 1, 15, 10, 30, 45)
|
||||
my_timedelta = timedelta(days=5, hours=3)
|
||||
my_decimal = Decimal('123.456')
|
||||
my_fraction = Fraction(3, 4)
|
||||
my_set = {1, 2, 3}
|
||||
my_frozenset = frozenset([4, 5, 6])
|
||||
my_bytes = b'hello bytes'
|
||||
my_bytearray = bytearray(b'mutable bytes')
|
||||
my_memoryview = memoryview(b'memory view')
|
||||
my_complex = complex(3, 4)
|
||||
my_range = range(10)
|
||||
my_custom = CustomReprClass()
|
||||
my_lambda = lambda x: x * 2
|
||||
my_function = trigger_error
|
||||
|
||||
1/0
|
||||
|
||||
trigger_error()
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as excinfo:
|
||||
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
|
||||
|
||||
output = excinfo.value.output.decode("utf-8")
|
||||
|
||||
assert "ZeroDivisionError" in output
|
||||
assert "code_variables" in output
|
||||
|
||||
assert "re.compile(" in output and "\\\\d+" in output
|
||||
assert "datetime.datetime(2024, 1, 15, 10, 30, 45)" in output
|
||||
assert "datetime.timedelta(days=5, seconds=10800)" in output
|
||||
assert "Decimal('123.456')" in output
|
||||
assert "Fraction(3, 4)" in output
|
||||
assert "{1, 2, 3}" in output
|
||||
assert "frozenset({4, 5, 6})" in output
|
||||
assert "b'hello bytes'" in output
|
||||
assert "bytearray(b'mutable bytes')" in output
|
||||
assert "<memory at" in output
|
||||
assert "(3+4j)" in output
|
||||
assert "range(0, 10)" in output
|
||||
assert "<CustomReprClass: custom representation>" in output
|
||||
assert "<lambda>" in output
|
||||
assert "<function trigger_error at" 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,883 @@
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
from posthog.types import (
|
||||
FeatureFlag,
|
||||
FeatureFlagError,
|
||||
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"""
|
||||
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,
|
||||
)
|
||||
# Verify error property is NOT present on successful evaluation
|
||||
captured_properties = patch_capture.call_args[1]["properties"]
|
||||
self.assertNotIn("$feature_flag_error", captured_properties)
|
||||
|
||||
@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,
|
||||
)
|
||||
# Verify error property is NOT present on successful evaluation
|
||||
captured_properties = patch_capture.call_args[1]["properties"]
|
||||
self.assertNotIn("$feature_flag_error", captured_properties)
|
||||
|
||||
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,
|
||||
)
|
||||
# Verify error property is NOT present on successful evaluation
|
||||
captured_properties = patch_capture.call_args[1]["properties"]
|
||||
self.assertNotIn("$feature_flag_error", captured_properties)
|
||||
|
||||
@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,
|
||||
)
|
||||
# Verify error property is NOT present on successful evaluation
|
||||
captured_properties = patch_capture.call_args[1]["properties"]
|
||||
self.assertNotIn("$feature_flag_error", captured_properties)
|
||||
|
||||
@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,
|
||||
"$feature_flag_error": FeatureFlagError.FLAG_MISSING,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_with_errors_while_computing_flags(
|
||||
self, patch_capture, patch_flags
|
||||
):
|
||||
"""Test that errors_while_computing_flags is included in the $feature_flag_called event.
|
||||
|
||||
When the server returns errorsWhileComputingFlags=true, it indicates that there
|
||||
was an error computing one or more flags. We include this in the event so users
|
||||
can identify and debug flag evaluation issues.
|
||||
"""
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"my-flag": {
|
||||
"key": "my-flag",
|
||||
"enabled": True,
|
||||
"variant": None,
|
||||
"reason": {"description": "Matched condition set 1"},
|
||||
"metadata": {"id": 1, "version": 1, "payload": None},
|
||||
},
|
||||
},
|
||||
"requestId": "test-request-id-789",
|
||||
"errorsWhileComputingFlags": True,
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": True,
|
||||
"$feature_flag_request_id": "test-request-id-789",
|
||||
"$feature_flag_reason": "Matched condition set 1",
|
||||
"$feature_flag_id": 1,
|
||||
"$feature_flag_version": 1,
|
||||
"$feature_flag_error": FeatureFlagError.ERRORS_WHILE_COMPUTING,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_flag_not_in_response(
|
||||
self, patch_capture, patch_flags
|
||||
):
|
||||
"""Test that when a flag is not in the API response, we capture flag_missing error.
|
||||
|
||||
This happens when a flag doesn't exist or the user doesn't match any conditions.
|
||||
"""
|
||||
patch_flags.return_value = {
|
||||
"flags": {
|
||||
"other-flag": {
|
||||
"key": "other-flag",
|
||||
"enabled": True,
|
||||
"variant": None,
|
||||
"reason": {"description": "Matched condition set 1"},
|
||||
"metadata": {"id": 1, "version": 1, "payload": None},
|
||||
},
|
||||
},
|
||||
"requestId": "test-request-id-456",
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"missing-flag", "some-distinct-id"
|
||||
)
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "missing-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/missing-flag": None,
|
||||
"$feature_flag_request_id": "test-request-id-456",
|
||||
"$feature_flag_error": FeatureFlagError.FLAG_MISSING,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_errors_computing_and_flag_missing(
|
||||
self, patch_capture, patch_flags
|
||||
):
|
||||
"""Test that both errors are reported when errorsWhileComputingFlags=true AND flag is missing.
|
||||
|
||||
This can happen when the server encounters errors computing flags AND the requested
|
||||
flag is not in the response. Both conditions should be reported for debugging.
|
||||
"""
|
||||
patch_flags.return_value = {
|
||||
"flags": {}, # Flag is missing
|
||||
"requestId": "test-request-id-999",
|
||||
"errorsWhileComputingFlags": True, # But errors also occurred
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"missing-flag", "some-distinct-id"
|
||||
)
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "missing-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/missing-flag": None,
|
||||
"$feature_flag_request_id": "test-request-id-999",
|
||||
"$feature_flag_error": f"{FeatureFlagError.ERRORS_WHILE_COMPUTING},{FeatureFlagError.FLAG_MISSING}",
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_unknown_error(self, patch_capture, patch_flags):
|
||||
"""Test that unexpected exceptions are captured as unknown_error."""
|
||||
patch_flags.side_effect = Exception("Unexpected error")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": None,
|
||||
"$feature_flag_error": FeatureFlagError.UNKNOWN_ERROR,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_timeout_error(self, patch_capture, patch_flags):
|
||||
"""Test that timeout errors are captured specifically."""
|
||||
from posthog.request import RequestsTimeout
|
||||
|
||||
patch_flags.side_effect = RequestsTimeout("Request timed out")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": None,
|
||||
"$feature_flag_error": FeatureFlagError.TIMEOUT,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_connection_error(self, patch_capture, patch_flags):
|
||||
"""Test that connection errors are captured specifically."""
|
||||
from posthog.request import RequestsConnectionError
|
||||
|
||||
patch_flags.side_effect = RequestsConnectionError("Connection refused")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": None,
|
||||
"$feature_flag_error": FeatureFlagError.CONNECTION_ERROR,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_api_error(self, patch_capture, patch_flags):
|
||||
"""Test that API errors include the status code."""
|
||||
from posthog.request import APIError
|
||||
|
||||
patch_flags.side_effect = APIError(500, "Internal server error")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": None,
|
||||
"$feature_flag_error": FeatureFlagError.api_error(500),
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_get_feature_flag_result_quota_limited(self, patch_capture, patch_flags):
|
||||
"""Test that quota limit errors are captured specifically."""
|
||||
from posthog.request import QuotaLimitError
|
||||
|
||||
patch_flags.side_effect = QuotaLimitError(429, "Rate limit exceeded")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": None,
|
||||
"$feature_flag_error": FeatureFlagError.QUOTA_LIMITED,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
|
||||
class TestFeatureFlagErrorWithStaleCacheFallback(unittest.TestCase):
|
||||
"""Tests for stale cache fallback behavior when flag evaluation fails.
|
||||
|
||||
When the PostHog API is unavailable (timeout, connection error, etc.), the SDK
|
||||
falls back to stale cached flag values if available. These tests verify that:
|
||||
1. The stale cached value is returned when an error occurs
|
||||
2. The $feature_flag_error property is still set (for debugging)
|
||||
3. The response reflects the cached value, not None
|
||||
"""
|
||||
|
||||
def set_fail(self, e, batch):
|
||||
"""Mark the failure handler"""
|
||||
self.failed = True
|
||||
|
||||
def setUp(self):
|
||||
self.failed = False
|
||||
# Create client with memory-based flag cache enabled
|
||||
self.client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
on_error=self.set_fail,
|
||||
flag_fallback_cache_url="memory://local/?ttl=300&size=10000",
|
||||
)
|
||||
|
||||
def _populate_stale_cache(self, distinct_id, flag_key, flag_result):
|
||||
"""Pre-populate the flag cache with a value that will be used for stale fallback."""
|
||||
self.client.flag_cache.set_cached_flag(
|
||||
distinct_id,
|
||||
flag_key,
|
||||
flag_result,
|
||||
flag_definition_version=self.client.flag_definition_version,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_timeout_error_returns_stale_cached_value(self, patch_capture, patch_flags):
|
||||
"""Test that timeout errors return stale cached value when available."""
|
||||
from posthog.request import RequestsTimeout
|
||||
|
||||
# Pre-populate cache with a flag result
|
||||
cached_result = FeatureFlagResult.from_value_and_payload(
|
||||
"my-flag", "cached-variant", '{"from": "cache"}'
|
||||
)
|
||||
self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
|
||||
|
||||
# Simulate timeout error
|
||||
patch_flags.side_effect = RequestsTimeout("Request timed out")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
# Should return the stale cached value
|
||||
self.assertIsNotNone(flag_result)
|
||||
self.assertEqual(flag_result.variant, "cached-variant")
|
||||
self.assertEqual(flag_result.payload, {"from": "cache"})
|
||||
|
||||
# Error should still be tracked for debugging
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": "cached-variant",
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": "cached-variant",
|
||||
"$feature_flag_payload": {"from": "cache"},
|
||||
"$feature_flag_error": FeatureFlagError.TIMEOUT,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_connection_error_returns_stale_cached_value(
|
||||
self, patch_capture, patch_flags
|
||||
):
|
||||
"""Test that connection errors return stale cached value when available."""
|
||||
from posthog.request import RequestsConnectionError
|
||||
|
||||
# Pre-populate cache with a boolean flag result
|
||||
cached_result = FeatureFlagResult.from_value_and_payload("my-flag", True, None)
|
||||
self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
|
||||
|
||||
# Simulate connection error
|
||||
patch_flags.side_effect = RequestsConnectionError("Connection refused")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
# Should return the stale cached value
|
||||
self.assertIsNotNone(flag_result)
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertIsNone(flag_result.variant)
|
||||
|
||||
# Error should still be tracked
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": True,
|
||||
"$feature_flag_error": FeatureFlagError.CONNECTION_ERROR,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_api_error_returns_stale_cached_value(self, patch_capture, patch_flags):
|
||||
"""Test that API errors return stale cached value when available."""
|
||||
from posthog.request import APIError
|
||||
|
||||
# Pre-populate cache
|
||||
cached_result = FeatureFlagResult.from_value_and_payload(
|
||||
"my-flag", "control", None
|
||||
)
|
||||
self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
|
||||
|
||||
# Simulate API error
|
||||
patch_flags.side_effect = APIError(503, "Service unavailable")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
# Should return the stale cached value
|
||||
self.assertIsNotNone(flag_result)
|
||||
self.assertEqual(flag_result.variant, "control")
|
||||
|
||||
# Error should still be tracked with status code
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": "control",
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": "control",
|
||||
"$feature_flag_error": FeatureFlagError.api_error(503),
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
|
||||
@mock.patch("posthog.client.flags")
|
||||
@mock.patch.object(Client, "capture")
|
||||
def test_error_without_cache_returns_none(self, patch_capture, patch_flags):
|
||||
"""Test that errors return None when no stale cache is available."""
|
||||
from posthog.request import RequestsTimeout
|
||||
|
||||
# Do NOT populate cache - no fallback available
|
||||
|
||||
patch_flags.side_effect = RequestsTimeout("Request timed out")
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
|
||||
|
||||
# Should return None since no cache available
|
||||
self.assertIsNone(flag_result)
|
||||
|
||||
# Error should still be tracked
|
||||
patch_capture.assert_called_with(
|
||||
"$feature_flag_called",
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "my-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
"$feature/my-flag": None,
|
||||
"$feature_flag_error": FeatureFlagError.TIMEOUT,
|
||||
},
|
||||
groups={},
|
||||
disable_geoip=None,
|
||||
)
|
||||
+2618
-446
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,612 @@
|
||||
"""
|
||||
Tests for FlagDefinitionCacheProvider functionality.
|
||||
|
||||
These tests follow the patterns from the TypeScript implementation in posthog-js/packages/node.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import unittest
|
||||
from typing import Optional
|
||||
from unittest import mock
|
||||
|
||||
from posthog.client import Client
|
||||
from posthog.flag_definition_cache import (
|
||||
FlagDefinitionCacheData,
|
||||
FlagDefinitionCacheProvider,
|
||||
)
|
||||
from posthog.request import GetResponse
|
||||
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
||||
|
||||
|
||||
class MockCacheProvider:
|
||||
"""A mock implementation of FlagDefinitionCacheProvider for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.stored_data: Optional[FlagDefinitionCacheData] = None
|
||||
self.should_fetch_return_value = True
|
||||
self.get_call_count = 0
|
||||
self.should_fetch_call_count = 0
|
||||
self.on_received_call_count = 0
|
||||
self.shutdown_call_count = 0
|
||||
self.should_fetch_error: Optional[Exception] = None
|
||||
self.get_error: Optional[Exception] = None
|
||||
self.on_received_error: Optional[Exception] = None
|
||||
self.shutdown_error: Optional[Exception] = None
|
||||
|
||||
def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]:
|
||||
self.get_call_count += 1
|
||||
if self.get_error:
|
||||
raise self.get_error
|
||||
return self.stored_data
|
||||
|
||||
def should_fetch_flag_definitions(self) -> bool:
|
||||
self.should_fetch_call_count += 1
|
||||
if self.should_fetch_error:
|
||||
raise self.should_fetch_error
|
||||
return self.should_fetch_return_value
|
||||
|
||||
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
|
||||
self.on_received_call_count += 1
|
||||
if self.on_received_error:
|
||||
raise self.on_received_error
|
||||
self.stored_data = data
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.shutdown_call_count += 1
|
||||
if self.shutdown_error:
|
||||
raise self.shutdown_error
|
||||
|
||||
|
||||
class TestFlagDefinitionCacheProvider(unittest.TestCase):
|
||||
"""Tests for the FlagDefinitionCacheProvider protocol."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Prevent real HTTP requests
|
||||
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 setUp(self):
|
||||
self.cache_provider = MockCacheProvider()
|
||||
self.sample_flags_data: FlagDefinitionCacheData = {
|
||||
"flags": [
|
||||
{"key": "test-flag", "active": True, "filters": {}},
|
||||
{"key": "another-flag", "active": False, "filters": {}},
|
||||
],
|
||||
"group_type_mapping": {"0": "company", "1": "project"},
|
||||
"cohorts": {"1": {"properties": []}},
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
# Ensure client cleanup
|
||||
pass
|
||||
|
||||
def _create_client_with_cache(self) -> Client:
|
||||
"""Create a client with the mock cache provider."""
|
||||
return Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
personal_api_key="test-personal-key",
|
||||
flag_definition_cache_provider=self.cache_provider,
|
||||
sync_mode=True,
|
||||
enable_local_evaluation=False, # Disable poller for tests
|
||||
)
|
||||
|
||||
|
||||
class TestCacheInitialization(TestFlagDefinitionCacheProvider):
|
||||
"""Tests for cache initialization behavior."""
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_uses_cached_data_when_should_fetch_returns_false(self, mock_get):
|
||||
"""When should_fetch returns False and cache has data, use cached data."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = self.sample_flags_data
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should not call API
|
||||
mock_get.assert_not_called()
|
||||
|
||||
# Should have called cache methods
|
||||
self.assertEqual(self.cache_provider.should_fetch_call_count, 1)
|
||||
self.assertEqual(self.cache_provider.get_call_count, 1)
|
||||
|
||||
# Flags should be loaded from cache
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
self.assertEqual(client.feature_flags[0]["key"], "test-flag")
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_fetches_from_api_when_should_fetch_returns_true(self, mock_get):
|
||||
"""When should_fetch returns True, fetch from API."""
|
||||
self.cache_provider.should_fetch_return_value = True
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should call API
|
||||
mock_get.assert_called_once()
|
||||
|
||||
# Should have called should_fetch but not get
|
||||
self.assertEqual(self.cache_provider.should_fetch_call_count, 1)
|
||||
self.assertEqual(self.cache_provider.get_call_count, 0)
|
||||
|
||||
# Should have called on_received to store in cache
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 1)
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_emergency_fallback_when_cache_empty_and_no_flags(self, mock_get):
|
||||
"""When should_fetch=False but cache is empty and no flags loaded, fetch anyway."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = None # Empty cache
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should call API due to emergency fallback
|
||||
mock_get.assert_called_once()
|
||||
|
||||
# Should have called on_received
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 1)
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_preserves_existing_flags_when_cache_returns_none(self, mock_get):
|
||||
"""When cache returns None but client has flags, preserve existing flags."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = None # Empty cache
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
|
||||
# Pre-load flags (simulating a previous successful fetch)
|
||||
client.feature_flags = self.sample_flags_data["flags"]
|
||||
client.group_type_mapping = self.sample_flags_data["group_type_mapping"]
|
||||
client.cohorts = self.sample_flags_data["cohorts"]
|
||||
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should NOT call API since we already have flags
|
||||
mock_get.assert_not_called()
|
||||
|
||||
# Existing flags should be preserved
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
self.assertEqual(client.feature_flags[0]["key"], "test-flag")
|
||||
|
||||
client.join()
|
||||
|
||||
|
||||
class TestFetchCoordination(TestFlagDefinitionCacheProvider):
|
||||
"""Tests for fetch coordination between workers."""
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_calls_should_fetch_before_each_poll(self, mock_get):
|
||||
"""should_fetch_flag_definitions is called before each poll cycle."""
|
||||
self.cache_provider.should_fetch_return_value = True
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
|
||||
# First poll
|
||||
client._load_feature_flags()
|
||||
self.assertEqual(self.cache_provider.should_fetch_call_count, 1)
|
||||
|
||||
# Second poll
|
||||
client._load_feature_flags()
|
||||
self.assertEqual(self.cache_provider.should_fetch_call_count, 2)
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_does_not_call_on_received_when_fetch_skipped(self, mock_get):
|
||||
"""on_flag_definitions_received is NOT called when fetch is skipped."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = self.sample_flags_data
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should not call on_received since we didn't fetch
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 0)
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_stores_data_in_cache_after_api_fetch(self, mock_get):
|
||||
"""on_flag_definitions_received receives the fetched data."""
|
||||
self.cache_provider.should_fetch_return_value = True
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should have stored data in cache
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 1)
|
||||
self.assertIsNotNone(self.cache_provider.stored_data)
|
||||
self.assertEqual(len(self.cache_provider.stored_data["flags"]), 2)
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_304_not_modified_does_not_update_cache(self, mock_get):
|
||||
"""When API returns 304 Not Modified, cache should not be updated."""
|
||||
self.cache_provider.should_fetch_return_value = True
|
||||
|
||||
# First fetch to populate flags and ETag
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Verify initial fetch worked
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 1)
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
|
||||
# Second fetch returns 304 Not Modified
|
||||
mock_get.return_value = GetResponse(
|
||||
data=None, etag="test-etag", not_modified=True
|
||||
)
|
||||
|
||||
client._load_feature_flags()
|
||||
|
||||
# API was called twice
|
||||
self.assertEqual(mock_get.call_count, 2)
|
||||
|
||||
# should_fetch was called twice
|
||||
self.assertEqual(self.cache_provider.should_fetch_call_count, 2)
|
||||
|
||||
# on_received should NOT be called again (304 = no new data)
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 1)
|
||||
|
||||
# Flags should still be present
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
|
||||
client.join()
|
||||
|
||||
|
||||
class TestErrorHandling(TestFlagDefinitionCacheProvider):
|
||||
"""Tests for error handling in cache provider operations."""
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_should_fetch_error_defaults_to_fetching(self, mock_get):
|
||||
"""When should_fetch throws an error, default to fetching from API."""
|
||||
self.cache_provider.should_fetch_error = Exception("Lock acquisition failed")
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should still fetch from API
|
||||
mock_get.assert_called_once()
|
||||
|
||||
# Flags should be loaded
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_get_error_falls_back_to_api_fetch(self, mock_get):
|
||||
"""When get_flag_definitions throws an error, fetch from API."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.get_error = Exception("Cache read failed")
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should fall back to API
|
||||
mock_get.assert_called_once()
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_on_received_error_keeps_flags_in_memory(self, mock_get):
|
||||
"""When on_flag_definitions_received throws, flags are still in memory."""
|
||||
self.cache_provider.should_fetch_return_value = True
|
||||
self.cache_provider.on_received_error = Exception("Cache write failed")
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Flags should still be loaded in memory despite cache error
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
self.assertEqual(client.feature_flags[0]["key"], "test-flag")
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_shutdown_error_is_logged_but_continues(self, mock_get):
|
||||
"""When shutdown throws an error, it's logged but shutdown continues."""
|
||||
self.cache_provider.shutdown_error = Exception("Lock release failed")
|
||||
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should not raise when joining
|
||||
client.join()
|
||||
|
||||
# Shutdown was called
|
||||
self.assertEqual(self.cache_provider.shutdown_call_count, 1)
|
||||
|
||||
|
||||
class TestShutdownLifecycle(TestFlagDefinitionCacheProvider):
|
||||
"""Tests for shutdown lifecycle."""
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_shutdown_calls_cache_provider_shutdown(self, mock_get):
|
||||
"""Client shutdown calls cache provider shutdown."""
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Shutdown
|
||||
client.join()
|
||||
|
||||
self.assertEqual(self.cache_provider.shutdown_call_count, 1)
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_shutdown_called_even_without_fetching(self, mock_get):
|
||||
"""Shutdown is called even when cache was used instead of fetching."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = self.sample_flags_data
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
client.join()
|
||||
|
||||
# Shutdown should still be called
|
||||
self.assertEqual(self.cache_provider.shutdown_call_count, 1)
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_multiple_join_calls_only_shutdown_once(self, mock_get):
|
||||
"""Calling join() multiple times should only call cache provider shutdown once."""
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Call join multiple times
|
||||
client.join()
|
||||
client.join()
|
||||
client.join()
|
||||
|
||||
# Shutdown should be called each time (current behavior - no guard)
|
||||
# This test documents the current behavior
|
||||
self.assertGreaterEqual(self.cache_provider.shutdown_call_count, 1)
|
||||
|
||||
|
||||
class TestBackwardCompatibility(TestFlagDefinitionCacheProvider):
|
||||
"""Tests for backward compatibility without cache provider."""
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_works_without_cache_provider(self, mock_get):
|
||||
"""Client works normally without a cache provider configured."""
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
# Create client without cache provider
|
||||
client = Client(
|
||||
FAKE_TEST_API_KEY,
|
||||
personal_api_key="test-personal-key",
|
||||
sync_mode=True,
|
||||
enable_local_evaluation=False,
|
||||
)
|
||||
client._load_feature_flags()
|
||||
|
||||
# Should fetch from API
|
||||
mock_get.assert_called_once()
|
||||
|
||||
# Flags should be loaded
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
|
||||
client.join()
|
||||
|
||||
|
||||
class TestDataIntegrity(TestFlagDefinitionCacheProvider):
|
||||
"""Tests for data integrity between cache and client state."""
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_cached_flags_available_for_evaluation(self, mock_get):
|
||||
"""Flags loaded from cache are available for local evaluation."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = {
|
||||
"flags": [
|
||||
{
|
||||
"key": "test-flag",
|
||||
"active": True,
|
||||
"filters": {
|
||||
"groups": [
|
||||
{
|
||||
"properties": [],
|
||||
"rollout_percentage": 100,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
}
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
# Flag should be accessible
|
||||
self.assertEqual(len(client.feature_flags), 1)
|
||||
self.assertEqual(client.feature_flags_by_key["test-flag"]["key"], "test-flag")
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_group_type_mapping_loaded_from_cache(self, mock_get):
|
||||
"""Group type mapping is correctly loaded from cache."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = self.sample_flags_data
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
self.assertEqual(client.group_type_mapping["0"], "company")
|
||||
self.assertEqual(client.group_type_mapping["1"], "project")
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_cohorts_loaded_from_cache(self, mock_get):
|
||||
"""Cohorts are correctly loaded from cache."""
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
self.cache_provider.stored_data = self.sample_flags_data
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
client._load_feature_flags()
|
||||
|
||||
self.assertIn("1", client.cohorts)
|
||||
|
||||
client.join()
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_cache_updated_when_api_returns_new_data(self, mock_get):
|
||||
"""State transition: cache has old data -> API returns new -> cache updated."""
|
||||
# Start with old cached data
|
||||
old_flags_data: FlagDefinitionCacheData = {
|
||||
"flags": [{"key": "old-flag", "active": True, "filters": {}}],
|
||||
"group_type_mapping": {},
|
||||
"cohorts": {},
|
||||
}
|
||||
self.cache_provider.stored_data = old_flags_data
|
||||
self.cache_provider.should_fetch_return_value = False
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
|
||||
# First load from cache
|
||||
client._load_feature_flags()
|
||||
self.assertEqual(client.feature_flags[0]["key"], "old-flag")
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 0)
|
||||
|
||||
# Now trigger API fetch with new data
|
||||
self.cache_provider.should_fetch_return_value = True
|
||||
new_flags_data: FlagDefinitionCacheData = {
|
||||
"flags": [{"key": "new-flag", "active": True, "filters": {}}],
|
||||
"group_type_mapping": {"0": "company"},
|
||||
"cohorts": {"1": {"properties": []}},
|
||||
}
|
||||
mock_get.return_value = GetResponse(
|
||||
data=new_flags_data, etag="new-etag", not_modified=False
|
||||
)
|
||||
|
||||
client._load_feature_flags()
|
||||
|
||||
# Verify new flags loaded
|
||||
self.assertEqual(client.feature_flags[0]["key"], "new-flag")
|
||||
self.assertEqual(client.group_type_mapping["0"], "company")
|
||||
|
||||
# Verify cache was updated
|
||||
self.assertEqual(self.cache_provider.on_received_call_count, 1)
|
||||
self.assertEqual(self.cache_provider.stored_data["flags"][0]["key"], "new-flag")
|
||||
|
||||
client.join()
|
||||
|
||||
|
||||
class TestConcurrency(TestFlagDefinitionCacheProvider):
|
||||
"""Tests for thread safety and concurrent access."""
|
||||
|
||||
@mock.patch("posthog.client.get")
|
||||
def test_concurrent_load_feature_flags_is_thread_safe(self, mock_get):
|
||||
"""Multiple threads calling _load_feature_flags should not cause errors."""
|
||||
mock_get.return_value = GetResponse(
|
||||
data=self.sample_flags_data, etag="test-etag", not_modified=False
|
||||
)
|
||||
|
||||
client = self._create_client_with_cache()
|
||||
errors = []
|
||||
|
||||
def load_flags():
|
||||
try:
|
||||
client._load_feature_flags()
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
# Launch 5 threads concurrently
|
||||
threads = [threading.Thread(target=load_flags) for _ in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Should complete without errors
|
||||
self.assertEqual(len(errors), 0, f"Unexpected errors: {errors}")
|
||||
|
||||
# Flags should be loaded
|
||||
self.assertIsNotNone(client.feature_flags)
|
||||
self.assertEqual(len(client.feature_flags), 2)
|
||||
|
||||
client.join()
|
||||
|
||||
|
||||
class TestProtocolCompliance(unittest.TestCase):
|
||||
"""Tests for Protocol compliance."""
|
||||
|
||||
def test_mock_provider_is_protocol_instance(self):
|
||||
"""MockCacheProvider satisfies FlagDefinitionCacheProvider protocol."""
|
||||
provider = MockCacheProvider()
|
||||
self.assertIsInstance(provider, FlagDefinitionCacheProvider)
|
||||
|
||||
def test_incomplete_provider_is_not_protocol_instance(self):
|
||||
"""Class missing methods is not a FlagDefinitionCacheProvider."""
|
||||
|
||||
class IncompleteProvider:
|
||||
def get_flag_definitions(self):
|
||||
return None
|
||||
|
||||
provider = IncompleteProvider()
|
||||
self.assertNotIsInstance(provider, FlagDefinitionCacheProvider)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7,31 +7,19 @@ class TestModule(unittest.TestCase):
|
||||
posthog = None
|
||||
|
||||
def _assert_enqueue_result(self, result):
|
||||
self.assertEqual(type(result[0]), bool)
|
||||
self.assertEqual(type(result[1]), dict)
|
||||
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)
|
||||
self.posthog = Posthog(
|
||||
"testsecret", host="http://localhost:8000", on_error=self.failed
|
||||
)
|
||||
|
||||
def test_track(self):
|
||||
res = self.posthog.capture("distinct_id", "python module event")
|
||||
self._assert_enqueue_result(res)
|
||||
self.posthog.flush()
|
||||
|
||||
def test_identify(self):
|
||||
res = self.posthog.identify("distinct_id", {"email": "user@email.com"})
|
||||
res = self.posthog.capture("python module event", distinct_id="distinct_id")
|
||||
self._assert_enqueue_result(res)
|
||||
self.posthog.flush()
|
||||
|
||||
@@ -40,9 +28,5 @@ class TestModule(unittest.TestCase):
|
||||
self._assert_enqueue_result(res)
|
||||
self.posthog.flush()
|
||||
|
||||
def test_page(self):
|
||||
self.posthog.page("distinct_id", "https://posthog.com/contact")
|
||||
self.posthog.flush()
|
||||
|
||||
def test_flush(self):
|
||||
self.posthog.flush()
|
||||
|
||||
@@ -2,22 +2,83 @@ import json
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
|
||||
import mock
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from posthog.request import DatetimeSerializer, batch_post
|
||||
import posthog.request as request_module
|
||||
from posthog.request import (
|
||||
APIError,
|
||||
DatetimeSerializer,
|
||||
GetResponse,
|
||||
KEEP_ALIVE_SOCKET_OPTIONS,
|
||||
QuotaLimitError,
|
||||
_mask_tokens_in_url,
|
||||
batch_post,
|
||||
decide,
|
||||
determine_server_host,
|
||||
disable_connection_reuse,
|
||||
enable_keep_alive,
|
||||
flags,
|
||||
get,
|
||||
set_socket_options,
|
||||
)
|
||||
from posthog.test.test_utils import TEST_API_KEY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, expected",
|
||||
[
|
||||
# Token with params after - masks keeping first 10 chars
|
||||
(
|
||||
"https://example.com/api/flags?token=phc_abc123xyz789&send_cohorts",
|
||||
"https://example.com/api/flags?token=phc_abc123...&send_cohorts",
|
||||
),
|
||||
# Token at end of URL
|
||||
(
|
||||
"https://example.com/api/flags?token=phc_abc123xyz789",
|
||||
"https://example.com/api/flags?token=phc_abc123...",
|
||||
),
|
||||
# No token - unchanged
|
||||
(
|
||||
"https://example.com/api/flags?other=value",
|
||||
"https://example.com/api/flags?other=value",
|
||||
),
|
||||
# Short token (<10 chars) - unchanged
|
||||
(
|
||||
"https://example.com/api/flags?token=short",
|
||||
"https://example.com/api/flags?token=short",
|
||||
),
|
||||
# Exactly 10 char token - gets ellipsis
|
||||
(
|
||||
"https://example.com/api/flags?token=1234567890",
|
||||
"https://example.com/api/flags?token=1234567890...",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_mask_tokens_in_url(url, expected):
|
||||
assert _mask_tokens_in_url(url) == expected
|
||||
|
||||
|
||||
class TestRequests(unittest.TestCase):
|
||||
def test_valid_request(self):
|
||||
res = batch_post(TEST_API_KEY, batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}])
|
||||
res = batch_post(
|
||||
TEST_API_KEY,
|
||||
batch=[
|
||||
{"distinct_id": "distinct_id", "event": "python event", "type": "track"}
|
||||
],
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_invalid_request_error(self):
|
||||
self.assertRaises(Exception, batch_post, "testsecret", "https://t.posthog.com", False, "[{]")
|
||||
self.assertRaises(
|
||||
Exception, batch_post, "testsecret", "https://t.posthog.com", False, "[{]"
|
||||
)
|
||||
|
||||
def test_invalid_host(self):
|
||||
self.assertRaises(Exception, batch_post, "testsecret", "t.posthog.com/", batch=[])
|
||||
self.assertRaises(
|
||||
Exception, batch_post, "testsecret", "t.posthog.com/", batch=[]
|
||||
)
|
||||
|
||||
def test_datetime_serialization(self):
|
||||
data = {"created": datetime(2012, 3, 4, 5, 6, 7, 891011)}
|
||||
@@ -33,12 +94,573 @@ class TestRequests(unittest.TestCase):
|
||||
|
||||
def test_should_not_timeout(self):
|
||||
res = batch_post(
|
||||
TEST_API_KEY, batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=15
|
||||
TEST_API_KEY,
|
||||
batch=[
|
||||
{"distinct_id": "distinct_id", "event": "python event", "type": "track"}
|
||||
],
|
||||
timeout=15,
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_should_timeout(self):
|
||||
with self.assertRaises(requests.ReadTimeout):
|
||||
batch_post(
|
||||
"key", batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=0.0001
|
||||
"key",
|
||||
batch=[
|
||||
{
|
||||
"distinct_id": "distinct_id",
|
||||
"event": "python event",
|
||||
"type": "track",
|
||||
}
|
||||
],
|
||||
timeout=0.0001,
|
||||
)
|
||||
|
||||
def test_quota_limited_response(self):
|
||||
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})
|
||||
|
||||
|
||||
class TestGet(unittest.TestCase):
|
||||
"""Unit tests for the get() function HTTP-level behavior."""
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_returns_data_and_etag(self, mock_get):
|
||||
"""Test that get() returns GetResponse with data and etag from headers."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers["ETag"] = '"abc123"'
|
||||
mock_response._content = json.dumps({"flags": [{"key": "test-flag"}]}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = get("api_key", "/test-url", host="https://example.com")
|
||||
|
||||
self.assertIsInstance(response, GetResponse)
|
||||
self.assertEqual(response.data, {"flags": [{"key": "test-flag"}]})
|
||||
self.assertEqual(response.etag, '"abc123"')
|
||||
self.assertFalse(response.not_modified)
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_sends_if_none_match_header_when_etag_provided(self, mock_get):
|
||||
"""Test that If-None-Match header is sent when etag parameter is provided."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers["ETag"] = '"new-etag"'
|
||||
mock_response._content = json.dumps({"flags": []}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("api_key", "/test-url", host="https://example.com", etag='"previous-etag"')
|
||||
|
||||
call_kwargs = mock_get.call_args[1]
|
||||
self.assertEqual(call_kwargs["headers"]["If-None-Match"], '"previous-etag"')
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_does_not_send_if_none_match_when_no_etag(self, mock_get):
|
||||
"""Test that If-None-Match header is not sent when no etag provided."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({"flags": []}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("api_key", "/test-url", host="https://example.com")
|
||||
|
||||
call_kwargs = mock_get.call_args[1]
|
||||
self.assertNotIn("If-None-Match", call_kwargs["headers"])
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_handles_304_not_modified(self, mock_get):
|
||||
"""Test that 304 Not Modified response returns not_modified=True with no data."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 304
|
||||
mock_response.headers["ETag"] = '"unchanged-etag"'
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = get(
|
||||
"api_key", "/test-url", host="https://example.com", etag='"unchanged-etag"'
|
||||
)
|
||||
|
||||
self.assertIsInstance(response, GetResponse)
|
||||
self.assertIsNone(response.data)
|
||||
self.assertEqual(response.etag, '"unchanged-etag"')
|
||||
self.assertTrue(response.not_modified)
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_304_without_etag_header_uses_request_etag(self, mock_get):
|
||||
"""Test that 304 response without ETag header falls back to request etag."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 304
|
||||
# Server doesn't return ETag header on 304
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = get(
|
||||
"api_key", "/test-url", host="https://example.com", etag='"original-etag"'
|
||||
)
|
||||
|
||||
self.assertTrue(response.not_modified)
|
||||
self.assertEqual(response.etag, '"original-etag"')
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_200_without_etag_header(self, mock_get):
|
||||
"""Test that 200 response without ETag header returns None for etag."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({"flags": []}).encode("utf-8")
|
||||
# No ETag header
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = get("api_key", "/test-url", host="https://example.com")
|
||||
|
||||
self.assertFalse(response.not_modified)
|
||||
self.assertIsNone(response.etag)
|
||||
self.assertEqual(response.data, {"flags": []})
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_error_response_raises_api_error(self, mock_get):
|
||||
"""Test that error responses raise APIError."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 401
|
||||
mock_response._content = json.dumps({"detail": "Unauthorized"}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with self.assertRaises(APIError) as ctx:
|
||||
get("bad_key", "/test-url", host="https://example.com")
|
||||
|
||||
self.assertEqual(ctx.exception.status, 401)
|
||||
self.assertEqual(ctx.exception.message, "Unauthorized")
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_sends_authorization_header(self, mock_get):
|
||||
"""Test that Authorization header is sent with Bearer token."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("my-api-key", "/test-url", host="https://example.com")
|
||||
|
||||
call_kwargs = mock_get.call_args[1]
|
||||
self.assertEqual(call_kwargs["headers"]["Authorization"], "Bearer my-api-key")
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_sends_user_agent_header(self, mock_get):
|
||||
"""Test that User-Agent header is sent."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("api_key", "/test-url", host="https://example.com")
|
||||
|
||||
call_kwargs = mock_get.call_args[1]
|
||||
self.assertIn("User-Agent", call_kwargs["headers"])
|
||||
self.assertTrue(
|
||||
call_kwargs["headers"]["User-Agent"].startswith("posthog-python/")
|
||||
)
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_passes_timeout(self, mock_get):
|
||||
"""Test that timeout parameter is passed to the request."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("api_key", "/test-url", host="https://example.com", timeout=30)
|
||||
|
||||
call_kwargs = mock_get.call_args[1]
|
||||
self.assertEqual(call_kwargs["timeout"], 30)
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_constructs_full_url(self, mock_get):
|
||||
"""Test that host and url are combined correctly."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("api_key", "/api/flags", host="https://example.com")
|
||||
|
||||
call_args = mock_get.call_args[0]
|
||||
self.assertEqual(call_args[0], "https://example.com/api/flags")
|
||||
|
||||
@mock.patch("posthog.request._session.get")
|
||||
def test_get_removes_trailing_slash_from_host(self, mock_get):
|
||||
"""Test that trailing slash is removed from host."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps({}).encode("utf-8")
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
get("api_key", "/api/flags", host="https://example.com/")
|
||||
|
||||
call_args = mock_get.call_args[0]
|
||||
self.assertEqual(call_args[0], "https://example.com/api/flags")
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
def test_enable_keep_alive_sets_socket_options():
|
||||
try:
|
||||
enable_keep_alive()
|
||||
from posthog.request import _session
|
||||
|
||||
adapter = _session.get_adapter("https://example.com")
|
||||
assert adapter.socket_options == KEEP_ALIVE_SOCKET_OPTIONS
|
||||
finally:
|
||||
set_socket_options(None)
|
||||
|
||||
|
||||
def test_set_socket_options_clears_with_none():
|
||||
try:
|
||||
enable_keep_alive()
|
||||
set_socket_options(None)
|
||||
from posthog.request import _session
|
||||
|
||||
adapter = _session.get_adapter("https://example.com")
|
||||
assert adapter.socket_options is None
|
||||
finally:
|
||||
set_socket_options(None)
|
||||
|
||||
|
||||
def test_disable_connection_reuse_creates_fresh_sessions():
|
||||
try:
|
||||
disable_connection_reuse()
|
||||
session1 = request_module._get_session()
|
||||
session2 = request_module._get_session()
|
||||
assert session1 is not session2
|
||||
finally:
|
||||
request_module._pooling_enabled = True
|
||||
|
||||
|
||||
def test_set_socket_options_is_idempotent():
|
||||
try:
|
||||
enable_keep_alive()
|
||||
session1 = request_module._session
|
||||
enable_keep_alive()
|
||||
session2 = request_module._session
|
||||
assert session1 is session2
|
||||
finally:
|
||||
set_socket_options(None)
|
||||
|
||||
|
||||
class TestFlagsSession(unittest.TestCase):
|
||||
"""Tests for flags session configuration."""
|
||||
|
||||
def test_retry_status_forcelist_excludes_rate_limits(self):
|
||||
"""Verify 429 (rate limit) is NOT retried - need to wait, not hammer."""
|
||||
from posthog.request import RETRY_STATUS_FORCELIST
|
||||
|
||||
self.assertNotIn(429, RETRY_STATUS_FORCELIST)
|
||||
|
||||
def test_retry_status_forcelist_excludes_quota_errors(self):
|
||||
"""Verify 402 (payment required/quota) is NOT retried - won't resolve."""
|
||||
from posthog.request import RETRY_STATUS_FORCELIST
|
||||
|
||||
self.assertNotIn(402, RETRY_STATUS_FORCELIST)
|
||||
|
||||
@mock.patch("posthog.request._get_flags_session")
|
||||
def test_flags_uses_flags_session(self, mock_get_flags_session):
|
||||
"""flags() uses the dedicated flags session, not the general session."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{
|
||||
"featureFlags": {"test-flag": True},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
mock_session = mock.MagicMock()
|
||||
mock_session.post.return_value = mock_response
|
||||
mock_get_flags_session.return_value = mock_session
|
||||
|
||||
result = flags("test-key", "https://test.posthog.com", distinct_id="user123")
|
||||
|
||||
self.assertEqual(result["featureFlags"]["test-flag"], True)
|
||||
mock_get_flags_session.assert_called_once()
|
||||
mock_session.post.assert_called_once()
|
||||
|
||||
@mock.patch("posthog.request._get_flags_session")
|
||||
def test_flags_no_retry_on_quota_limit(self, mock_get_flags_session):
|
||||
"""flags() raises QuotaLimitError without retrying (at application level)."""
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{
|
||||
"quotaLimited": ["feature_flags"],
|
||||
"featureFlags": {},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
mock_session = mock.MagicMock()
|
||||
mock_session.post.return_value = mock_response
|
||||
mock_get_flags_session.return_value = mock_session
|
||||
|
||||
with self.assertRaises(QuotaLimitError):
|
||||
flags("test-key", "https://test.posthog.com", distinct_id="user123")
|
||||
|
||||
# QuotaLimitError is raised after response is received, not retried
|
||||
self.assertEqual(mock_session.post.call_count, 1)
|
||||
|
||||
|
||||
class TestFlagsSessionNetworkRetries(unittest.TestCase):
|
||||
"""Tests for network failure retries in the flags session."""
|
||||
|
||||
def test_flags_session_retry_config_includes_connection_errors(self):
|
||||
"""
|
||||
Verify that the flags session is configured to retry on connection errors.
|
||||
|
||||
The urllib3 Retry adapter with connect=2 and read=2 automatically
|
||||
retries on network-level failures (DNS failures, connection refused,
|
||||
connection reset, etc.) up to 2 times each.
|
||||
"""
|
||||
from posthog.request import _build_flags_session
|
||||
|
||||
session = _build_flags_session()
|
||||
|
||||
# Get the adapter for https://
|
||||
adapter = session.get_adapter("https://test.posthog.com")
|
||||
|
||||
# Verify retry configuration
|
||||
retry = adapter.max_retries
|
||||
self.assertEqual(retry.total, 2, "Should have 2 total retries")
|
||||
self.assertEqual(retry.connect, 2, "Should retry connection errors twice")
|
||||
self.assertEqual(retry.read, 2, "Should retry read errors twice")
|
||||
self.assertIn("POST", retry.allowed_methods, "Should allow POST retries")
|
||||
|
||||
def test_flags_session_retries_on_server_errors(self):
|
||||
"""
|
||||
Verify that transient server errors (5xx) trigger retries.
|
||||
|
||||
This tests the status_forcelist configuration which specifies
|
||||
which HTTP status codes should trigger a retry.
|
||||
"""
|
||||
from posthog.request import _build_flags_session, RETRY_STATUS_FORCELIST
|
||||
|
||||
session = _build_flags_session()
|
||||
adapter = session.get_adapter("https://test.posthog.com")
|
||||
retry = adapter.max_retries
|
||||
|
||||
# Verify the status codes that trigger retries
|
||||
self.assertEqual(
|
||||
set(retry.status_forcelist),
|
||||
set(RETRY_STATUS_FORCELIST),
|
||||
"Should retry on transient server errors",
|
||||
)
|
||||
|
||||
# Verify specific codes are included
|
||||
self.assertIn(500, retry.status_forcelist)
|
||||
self.assertIn(502, retry.status_forcelist)
|
||||
self.assertIn(503, retry.status_forcelist)
|
||||
self.assertIn(504, retry.status_forcelist)
|
||||
|
||||
# Verify rate limits and quota errors are NOT retried
|
||||
self.assertNotIn(429, retry.status_forcelist)
|
||||
self.assertNotIn(402, retry.status_forcelist)
|
||||
|
||||
def test_flags_session_has_backoff(self):
|
||||
"""
|
||||
Verify that retries use exponential backoff to avoid thundering herd.
|
||||
"""
|
||||
from posthog.request import _build_flags_session
|
||||
|
||||
session = _build_flags_session()
|
||||
adapter = session.get_adapter("https://test.posthog.com")
|
||||
retry = adapter.max_retries
|
||||
|
||||
self.assertEqual(
|
||||
retry.backoff_factor,
|
||||
0.5,
|
||||
"Should use 0.5s backoff factor (0.5s, 1s delays)",
|
||||
)
|
||||
|
||||
|
||||
class TestFlagsSessionRetryIntegration(unittest.TestCase):
|
||||
"""Integration tests that verify actual retry behavior with a local server."""
|
||||
|
||||
def test_retries_on_503_then_succeeds(self):
|
||||
"""
|
||||
Verify that 503 errors trigger retries and eventually succeed.
|
||||
|
||||
Uses a local HTTP server that fails twice with 503, then succeeds.
|
||||
This tests the full retry flow including backoff timing.
|
||||
"""
|
||||
import threading
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from socketserver import ThreadingMixIn
|
||||
from urllib3.util.retry import Retry
|
||||
from posthog.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
|
||||
|
||||
request_count = 0
|
||||
|
||||
class RetryTestHandler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def do_POST(self):
|
||||
nonlocal request_count
|
||||
request_count += 1
|
||||
|
||||
# Read and discard request body to prevent connection issues
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
if content_length > 0:
|
||||
self.rfile.read(content_length)
|
||||
|
||||
if request_count <= 2:
|
||||
self.send_response(503)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
body = b'{"error": "Service unavailable"}'
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
body = (
|
||||
b'{"featureFlags": {"test": true}, "featureFlagPayloads": {}}'
|
||||
)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass # Suppress logging
|
||||
|
||||
# Use ThreadingMixIn for cleaner shutdown
|
||||
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
# Start server on a random available port
|
||||
server = ThreadedHTTPServer(("127.0.0.1", 0), RetryTestHandler)
|
||||
port = server.server_address[1]
|
||||
server_thread = threading.Thread(target=server.serve_forever)
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
|
||||
try:
|
||||
# Build session with same retry config as _build_flags_session
|
||||
# but mounted on http:// for local testing
|
||||
adapter = HTTPAdapterWithSocketOptions(
|
||||
max_retries=Retry(
|
||||
total=2,
|
||||
connect=2,
|
||||
read=2,
|
||||
backoff_factor=0.01, # Fast backoff for testing
|
||||
status_forcelist=RETRY_STATUS_FORCELIST,
|
||||
allowed_methods=["POST"],
|
||||
),
|
||||
)
|
||||
session = requests.Session()
|
||||
session.mount("http://", adapter)
|
||||
|
||||
response = session.post(
|
||||
f"http://127.0.0.1:{port}/flags/?v=2",
|
||||
json={"distinct_id": "user123"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
# Should succeed on 3rd attempt
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(request_count, 3) # 1 initial + 2 retries
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
def test_connection_errors_are_retried(self):
|
||||
"""
|
||||
Verify that connection errors (no server) trigger retries.
|
||||
|
||||
Binds a socket to get a guaranteed available port, then closes it
|
||||
so connection attempts fail with ConnectionError.
|
||||
"""
|
||||
import socket
|
||||
import time
|
||||
from urllib3.util.retry import Retry
|
||||
from posthog.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
|
||||
|
||||
# Get an available port by binding then closing a socket
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close() # Port is now available but nothing is listening
|
||||
|
||||
adapter = HTTPAdapterWithSocketOptions(
|
||||
max_retries=Retry(
|
||||
total=2,
|
||||
connect=2,
|
||||
read=2,
|
||||
backoff_factor=0.05, # Very fast for testing
|
||||
status_forcelist=RETRY_STATUS_FORCELIST,
|
||||
allowed_methods=["POST"],
|
||||
),
|
||||
)
|
||||
session = requests.Session()
|
||||
session.mount("http://", adapter)
|
||||
|
||||
start = time.time()
|
||||
with self.assertRaises(requests.exceptions.ConnectionError):
|
||||
session.post(
|
||||
f"http://127.0.0.1:{port}/flags/?v=2",
|
||||
json={"distinct_id": "user123"},
|
||||
timeout=1,
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
|
||||
# With 3 attempts and backoff, should take more than instant
|
||||
# but less than timeout (confirms retries happened)
|
||||
self.assertGreater(elapsed, 0.05, "Should have some delay from retries")
|
||||
|
||||
@@ -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"}')
|
||||
+234
-34
@@ -1,29 +1,49 @@
|
||||
import sys
|
||||
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())
|
||||
self.assertTrue(utils.is_naive(now))
|
||||
self.assertFalse(utils.is_naive(utcnow))
|
||||
|
||||
fixed = utils.guess_timezone(now)
|
||||
self.assertFalse(utils.is_naive(fixed))
|
||||
assert utils.is_naive(fixed) is False
|
||||
|
||||
shouldnt_be_edited = utils.guess_timezone(utcnow)
|
||||
self.assertEqual(utcnow, shouldnt_be_edited)
|
||||
assert utcnow == shouldnt_be_edited
|
||||
|
||||
def test_clean(self):
|
||||
simple = {
|
||||
@@ -50,52 +70,232 @@ class TestUtils(unittest.TestCase):
|
||||
pre_clean_keys = combined.keys()
|
||||
|
||||
utils.clean(combined)
|
||||
self.assertEqual(combined.keys(), pre_clean_keys)
|
||||
assert combined.keys() == pre_clean_keys
|
||||
|
||||
# test UUID separately, as the UUID object doesn't equal its string representation according to Python
|
||||
self.assertEqual(utils.clean(UUID("12345678123456781234567812345678")), "12345678-1234-5678-1234-567812345678")
|
||||
assert (
|
||||
utils.clean(UUID("12345678123456781234567812345678"))
|
||||
== "12345678-1234-5678-1234-567812345678"
|
||||
)
|
||||
|
||||
def test_clean_with_dates(self):
|
||||
dict_with_dates = {
|
||||
"birthdate": date(1980, 1, 1),
|
||||
"registration": datetime.utcnow(),
|
||||
"registration": datetime.now(tz=tzutc()),
|
||||
}
|
||||
self.assertEqual(dict_with_dates, utils.clean(dict_with_dates))
|
||||
assert dict_with_dates == utils.clean(dict_with_dates)
|
||||
|
||||
def test_bytes(self):
|
||||
if six.PY3:
|
||||
item = bytes(10)
|
||||
else:
|
||||
item = bytearray(10)
|
||||
|
||||
item = bytes(10)
|
||||
utils.clean(item)
|
||||
assert utils.clean(item) == "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
|
||||
def test_clean_fn(self):
|
||||
cleaned = utils.clean({"fn": lambda x: x, "number": 4})
|
||||
self.assertEqual(cleaned["number"], 4)
|
||||
# TODO: fixme, different behavior on python 2 and 3
|
||||
if "fn" in cleaned:
|
||||
self.assertEqual(cleaned["fn"], None)
|
||||
assert cleaned == {"fn": None, "number": 4}
|
||||
|
||||
def test_remove_slash(self):
|
||||
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io/"))
|
||||
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io"))
|
||||
@parameterized.expand(
|
||||
[
|
||||
("http://posthog.io/", "http://posthog.io"),
|
||||
("http://posthog.io", "http://posthog.io"),
|
||||
("https://example.com/path/", "https://example.com/path"),
|
||||
("https://example.com/path", "https://example.com/path"),
|
||||
]
|
||||
)
|
||||
def test_remove_slash(self, input_url, expected_url):
|
||||
assert expected_url == utils.remove_trailing_slash(input_url)
|
||||
|
||||
def test_clean_pydantic(self):
|
||||
class ModelV2(BaseModel):
|
||||
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,
|
||||
}
|
||||
# Pydantic V1 is not compatible with Python 3.14+
|
||||
if sys.version_info < (3, 14):
|
||||
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 TestSizeLimitedDict(unittest.TestCase):
|
||||
def test_size_limited_dict(self):
|
||||
size = 10
|
||||
values = utils.SizeLimitedDict(size, lambda _: -1)
|
||||
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
|
||||
)
|
||||
|
||||
for i in range(100):
|
||||
values[i] = i
|
||||
def test_cache_basic_operations(self):
|
||||
distinct_id = "user123"
|
||||
flag_key = "test-flag"
|
||||
flag_version = 1
|
||||
|
||||
self.assertEqual(values[i], i)
|
||||
self.assertEqual(len(values), i % size + 1)
|
||||
# Test cache miss
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
||||
assert result is None
|
||||
|
||||
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))
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
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]]
|
||||
evaluatedAt: Optional[int]
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
class FeatureFlagError:
|
||||
"""Error type constants for the $feature_flag_error property.
|
||||
|
||||
These values are sent in analytics events to track flag evaluation failures.
|
||||
They should not be changed without considering impact on existing dashboards
|
||||
and queries that filter on these values.
|
||||
|
||||
Error values:
|
||||
ERRORS_WHILE_COMPUTING: Server returned errorsWhileComputingFlags=true
|
||||
FLAG_MISSING: Requested flag not in API response
|
||||
QUOTA_LIMITED: Rate/quota limit exceeded
|
||||
TIMEOUT: Request timed out
|
||||
CONNECTION_ERROR: Network connectivity issue
|
||||
UNKNOWN_ERROR: Unexpected exceptions
|
||||
|
||||
For API errors with status codes, use the api_error() method which returns
|
||||
a string like "api_error_500".
|
||||
"""
|
||||
|
||||
ERRORS_WHILE_COMPUTING = "errors_while_computing_flags"
|
||||
FLAG_MISSING = "flag_missing"
|
||||
QUOTA_LIMITED = "quota_limited"
|
||||
TIMEOUT = "timeout"
|
||||
CONNECTION_ERROR = "connection_error"
|
||||
UNKNOWN_ERROR = "unknown_error"
|
||||
|
||||
@staticmethod
|
||||
def api_error(status: Union[int, str]) -> str:
|
||||
"""Generate API error string with status code.
|
||||
|
||||
Args:
|
||||
status: HTTP status code from the API error
|
||||
|
||||
Returns:
|
||||
Error string like "api_error_500"
|
||||
"""
|
||||
return f"api_error_{status}"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user