Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
440a5b2f13 | ||
|
|
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 |
@@ -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
|
||||
+55
-23
@@ -3,44 +3,38 @@ 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.11
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
with:
|
||||
python-version: 3.11.11
|
||||
|
||||
- uses: actions/cache@v3
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
enable-cache: true
|
||||
pyproject-file: 'pyproject.toml'
|
||||
|
||||
- name: Install dev dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install -e .[dev]
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra dev
|
||||
|
||||
- name: Check formatting with black
|
||||
- name: Check formatting with ruff
|
||||
run: |
|
||||
black --check .
|
||||
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
flake8 posthog --ignore E501,W503
|
||||
|
||||
- name: Check import order with isort
|
||||
run: |
|
||||
isort --check-only .
|
||||
ruff format --check .
|
||||
|
||||
- name: Check types with mypy
|
||||
run: |
|
||||
@@ -54,19 +48,57 @@ jobs:
|
||||
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install requirements.txt dependencies with pip
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
enable-cache: true
|
||||
pyproject-file: 'pyproject.toml'
|
||||
|
||||
- name: Install test dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install -e .[test]
|
||||
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra test
|
||||
|
||||
- name: Run posthog tests
|
||||
run: |
|
||||
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,48 @@
|
||||
name: "Generate References"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
docs-generation:
|
||||
name: Generate references
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.POSTHOG_BOT_PAT }}
|
||||
|
||||
- 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,43 +0,0 @@
|
||||
name: "Release"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "posthog/version.py"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Publish release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@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,57 @@
|
||||
name: "Release"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- "posthog/version.py"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Publish release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.POSTHOG_BOT_PAT }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
|
||||
with:
|
||||
python-version: 3.11.11
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
|
||||
with:
|
||||
enable-cache: true
|
||||
pyproject-file: 'pyproject.toml'
|
||||
|
||||
- name: Detect version
|
||||
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare for building release
|
||||
run: uv sync --extra dev
|
||||
|
||||
- name: Push releases to PyPI
|
||||
run: uv run make release && uv run make release_analytics
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_PAT }}
|
||||
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
|
||||
@@ -17,3 +17,6 @@ posthog-analytics
|
||||
.coverage
|
||||
pyrightconfig.json
|
||||
.env
|
||||
.DS_Store
|
||||
posthog-python-references.json
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"actionabilityScore": 0.15,
|
||||
"context": "The task description is too vague to evaluate. The repository already has a comprehensive, mature test suite with 11,000+ LOC of tests across pytest/unittest, async testing, AI integrations, and Django middleware. Without clarity on what new tests should be created, I cannot assess actionability.",
|
||||
"keyFiles": [],
|
||||
"blockers": [
|
||||
"Task description is just the word 'test' - completely unclear what should be tested",
|
||||
"Repository already has extensive existing test coverage - unclear if task is to expand, fix, or reorganize tests",
|
||||
"No specification of what modules, features, or behaviors need testing"
|
||||
],
|
||||
"questions": [
|
||||
{
|
||||
"id": "q1",
|
||||
"question": "What specific modules, features, or code areas need new tests?",
|
||||
"options": [
|
||||
"Expand existing test coverage for a specific module (e.g., add tests for uncovered edge cases in consumer.py, request.py, or size-limited dict)",
|
||||
"Create tests for a new feature or integration that's missing coverage",
|
||||
"Reorganize or improve existing tests (e.g., add more async tests, increase parameterization, improve mocking strategy)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "q2",
|
||||
"question": "What is the goal or context for this test creation?",
|
||||
"options": [
|
||||
"Increase code coverage percentage to meet a specific threshold target",
|
||||
"Improve test reliability or add missing test patterns (e.g., more parameterized tests, better async coverage)",
|
||||
"Test a recently added feature or bug fix that lacks test coverage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "q3",
|
||||
"question": "Are there specific test requirements or constraints?",
|
||||
"options": [
|
||||
"Follow existing patterns (pytest/unittest, use mock.patch, AsyncMock, freezegun as needed)",
|
||||
"Introduce new testing tools or frameworks (e.g., switch to pure pytest fixtures, add property-based testing with hypothesis)",
|
||||
"Focus on specific test categories (unit tests, integration tests, performance tests, or AI-specific tests)"
|
||||
]
|
||||
}
|
||||
],
|
||||
"answered": false
|
||||
}
|
||||
@@ -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"})
|
||||
```
|
||||
+245
@@ -1,3 +1,248 @@
|
||||
# 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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
lint:
|
||||
pylint --rcfile=.pylintrc --reports=y --exit-zero analytics | tee pylint.out
|
||||
flake8 --max-complexity=10 --statistics analytics > flake8.out || true
|
||||
uvx ruff format
|
||||
|
||||
test:
|
||||
coverage run -m pytest
|
||||
@@ -17,22 +16,42 @@ release_analytics:
|
||||
rm -rf posthoganalytics
|
||||
mkdir posthoganalytics
|
||||
cp -r posthog/* posthoganalytics/
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthog /from posthoganalytics /g' {} \;
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthog\./from posthoganalytics\./g' {} \;
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \;
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \;
|
||||
find ./posthoganalytics -name "*.bak" -delete
|
||||
rm -rf posthog
|
||||
python setup_analytics.py sdist bdist_wheel
|
||||
twine upload dist/*
|
||||
mkdir posthog
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthoganalytics /from posthog /g' {} \;
|
||||
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthoganalytics\./from posthog\./g' {} \;
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics /from posthog /g' {} \;
|
||||
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics\./from posthog\./g' {} \;
|
||||
find ./posthoganalytics -name "*.bak" -delete
|
||||
cp -r posthoganalytics/* posthog/
|
||||
rm -rf posthoganalytics
|
||||
rm -f pyproject.toml
|
||||
cp pyproject.toml.backup pyproject.toml
|
||||
rm -f pyproject.toml.backup
|
||||
|
||||
e2e_test:
|
||||
.buildscripts/e2e.sh
|
||||
|
||||
django_example:
|
||||
python -m pip install -e ".[sentry]"
|
||||
cd sentry_django_example && python manage.py runserver 8080
|
||||
prep_local:
|
||||
rm -rf ../posthog-python-local
|
||||
mkdir ../posthog-python-local
|
||||
cp -r . ../posthog-python-local/
|
||||
cd ../posthog-python-local && rm -rf dist build posthoganalytics .git
|
||||
cd ../posthog-python-local && mkdir posthoganalytics
|
||||
cd ../posthog-python-local && cp -r posthog/* posthoganalytics/
|
||||
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \;
|
||||
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \;
|
||||
cd ../posthog-python-local && find ./posthoganalytics -name "*.bak" -delete
|
||||
cd ../posthog-python-local && rm -rf posthog
|
||||
cd ../posthog-python-local && sed -i.bak 's/from version import VERSION/from posthoganalytics.version import VERSION/' setup_analytics.py
|
||||
cd ../posthog-python-local && rm setup_analytics.py.bak
|
||||
cd ../posthog-python-local && sed -i.bak 's/"posthog"/"posthoganalytics"/' setup.py
|
||||
cd ../posthog-python-local && rm setup.py.bak
|
||||
cd ../posthog-python-local && python -c "import setup_analytics" 2>/dev/null || true
|
||||
@echo "Local copy created at ../posthog-python-local"
|
||||
@echo "Install with: pip install -e ../posthog-python-local"
|
||||
|
||||
.PHONY: test lint release e2e_test
|
||||
.PHONY: test lint release e2e_test prep_local
|
||||
|
||||
@@ -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,40 +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")
|
||||
* or `uv venv env`
|
||||
We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
|
||||
|
||||
1. Run `uv venv env` (creates virtual environment called "env")
|
||||
* or `python3 -m venv env`
|
||||
2. Run `source env/bin/activate` (activates the virtual environment)
|
||||
3. Run `python3 -m pip install -e ".[test]"` (installs the package in develop mode, along with test dependencies)
|
||||
* or `uv pip install -e ".[test]"`
|
||||
4. Run `make test`
|
||||
3. Run `uv sync --extra dev --extra test` (installs the package in develop mode, along with test dependencies)
|
||||
* or `pip install -e ".[dev,test]"`
|
||||
4. you have to run `pre-commit install` to have auto linting pre commit
|
||||
5. Run `make test`
|
||||
1. To run a specific test do `pytest -k test_no_api_key`
|
||||
|
||||
## PostHog recommends `uv` so...
|
||||
|
||||
```bash
|
||||
uv python install 3.9.19
|
||||
uv python pin 3.9.19
|
||||
uv venv
|
||||
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/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()
|
||||
@@ -6,9 +6,7 @@ set_source_and_root_dir
|
||||
ensure_virtual_env
|
||||
|
||||
if [[ "$1" == "--check" ]]; then
|
||||
black --check .
|
||||
isort --check-only .
|
||||
ruff format --check .
|
||||
else
|
||||
black .
|
||||
isort .
|
||||
ruff format .
|
||||
fi
|
||||
+484
-89
@@ -1,107 +1,502 @@
|
||||
# PostHog Python library example
|
||||
import argparse
|
||||
#
|
||||
# This script demonstrates various PostHog Python SDK capabilities including:
|
||||
# - Basic event capture and user identification
|
||||
# - Feature flag local evaluation
|
||||
# - Feature flag payloads
|
||||
# - Context management and tagging
|
||||
#
|
||||
# Setup:
|
||||
# 1. Copy .env.example to .env and fill in your PostHog credentials
|
||||
# 2. Run this script and choose from the interactive menu
|
||||
|
||||
import os
|
||||
|
||||
import posthog
|
||||
|
||||
# Add argument parsing
|
||||
parser = argparse.ArgumentParser(description="PostHog Python library example")
|
||||
parser.add_argument(
|
||||
"--flag", default="person-on-events-enabled", help="Feature flag key to check (default: person-on-events-enabled)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
posthog.debug = True
|
||||
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())
|
||||
|
||||
# You can find this key on the /setup page in PostHog
|
||||
posthog.project_api_key = "phc_gtWmTq3Pgl06u4sZY3TRcoQfp42yfuXHKoe8ZVSR6Kh"
|
||||
posthog.personal_api_key = "phx_fiRCOQkTA3o2ePSdLrFDAILLHjMu2Mv52vUi8MNruIm"
|
||||
|
||||
# Where you host PostHog, with no trailing /.
|
||||
# You can remove this line if you're using posthog.com
|
||||
posthog.host = "http://localhost:8000"
|
||||
posthog.poll_interval = 10
|
||||
# Load .env file if it exists
|
||||
load_env_file()
|
||||
|
||||
print(
|
||||
posthog.feature_enabled(
|
||||
args.flag, # Use the flag from command line arguments
|
||||
"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",
|
||||
}
|
||||
},
|
||||
# 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 credentials are provided
|
||||
if not project_key or not personal_api_key:
|
||||
print("❌ Missing PostHog credentials!")
|
||||
print(
|
||||
" Please set POSTHOG_PROJECT_API_KEY and POSTHOG_PERSONAL_API_KEY environment variables"
|
||||
)
|
||||
print(" or copy .env.example to .env and fill in your values")
|
||||
exit(1)
|
||||
|
||||
# Test authentication before proceeding
|
||||
print("🔑 Testing PostHog authentication...")
|
||||
|
||||
try:
|
||||
# Configure PostHog with credentials
|
||||
posthog.debug = False # Keep quiet during auth test
|
||||
posthog.api_key = project_key
|
||||
posthog.project_api_key = project_key
|
||||
posthog.personal_api_key = personal_api_key
|
||||
posthog.host = host
|
||||
posthog.poll_interval = 10
|
||||
|
||||
# Test by attempting to get feature flags (this validates both keys)
|
||||
# This will fail if credentials are invalid
|
||||
test_flags = posthog.get_all_flags("test_user", only_evaluate_locally=True)
|
||||
|
||||
# If we get here without exception, credentials work
|
||||
print("✅ Authentication successful!")
|
||||
print(f" Project API Key: {project_key[:9]}...")
|
||||
print(" Personal API Key: [REDACTED]")
|
||||
print(f" Host: {host}\n\n")
|
||||
|
||||
except Exception as e:
|
||||
print("❌ Authentication failed!")
|
||||
print(f" Error: {e}")
|
||||
print("\n Please check your credentials:")
|
||||
print(" - POSTHOG_PROJECT_API_KEY: Project API key from PostHog settings")
|
||||
print(
|
||||
" - POSTHOG_PERSONAL_API_KEY: Personal API key (required for local evaluation)"
|
||||
)
|
||||
print(" - POSTHOG_HOST: Your PostHog instance URL")
|
||||
exit(1)
|
||||
|
||||
# Display menu and get user choice
|
||||
print("🚀 PostHog Python SDK Demo - Choose an example to run:\n")
|
||||
print("1. Identify and capture examples")
|
||||
print("2. Feature flag local evaluation examples")
|
||||
print("3. Feature flag payload examples")
|
||||
print("4. Flag dependencies examples")
|
||||
print("5. Context management and tagging examples")
|
||||
print("6. Run all examples")
|
||||
print("7. Exit")
|
||||
choice = input("\nEnter your choice (1-7): ").strip()
|
||||
|
||||
if choice == "1":
|
||||
print("\n" + "=" * 60)
|
||||
print("IDENTIFY AND CAPTURE EXAMPLES")
|
||||
print("=" * 60)
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
# Capture an event
|
||||
print("📊 Capturing events...")
|
||||
posthog.capture(
|
||||
"event",
|
||||
distinct_id="distinct_id",
|
||||
properties={"property1": "value", "property2": "value"},
|
||||
send_feature_flags=True,
|
||||
)
|
||||
|
||||
# Alias a previous distinct id with a new one
|
||||
print("🔗 Creating alias...")
|
||||
posthog.alias("distinct_id", "new_distinct_id")
|
||||
|
||||
posthog.capture(
|
||||
"event2",
|
||||
distinct_id="new_distinct_id",
|
||||
properties={"property1": "value", "property2": "value"},
|
||||
)
|
||||
posthog.capture(
|
||||
"event-with-groups",
|
||||
distinct_id="new_distinct_id",
|
||||
properties={"property1": "value", "property2": "value"},
|
||||
groups={"company": "id:5"},
|
||||
)
|
||||
|
||||
# Add properties to the person
|
||||
print("👤 Identifying user...")
|
||||
posthog.set(
|
||||
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
|
||||
)
|
||||
|
||||
# Add properties to a group
|
||||
print("🏢 Identifying group...")
|
||||
posthog.group_identify("company", "id:5", {"employees": 11})
|
||||
|
||||
# Properties set only once to the person
|
||||
print("🔒 Setting properties once...")
|
||||
posthog.set_once(
|
||||
distinct_id="new_distinct_id", properties={"self_serve_signup": True}
|
||||
)
|
||||
|
||||
# This will not change the property (because it was already set)
|
||||
posthog.set_once(
|
||||
distinct_id="new_distinct_id", properties={"self_serve_signup": False}
|
||||
)
|
||||
|
||||
print("🔄 Updating properties...")
|
||||
posthog.set(distinct_id="new_distinct_id", properties={"current_browser": "Chrome"})
|
||||
posthog.set(
|
||||
distinct_id="new_distinct_id", properties={"current_browser": "Firefox"}
|
||||
)
|
||||
|
||||
elif choice == "2":
|
||||
print("\n" + "=" * 60)
|
||||
print("FEATURE FLAG LOCAL EVALUATION EXAMPLES")
|
||||
print("=" * 60)
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
print("🏁 Testing basic feature flags...")
|
||||
print(
|
||||
f"beta-feature for 'distinct_id': {posthog.feature_enabled('beta-feature', 'distinct_id')}"
|
||||
)
|
||||
print(
|
||||
f"beta-feature for 'new_distinct_id': {posthog.feature_enabled('beta-feature', 'new_distinct_id')}"
|
||||
)
|
||||
print(
|
||||
f"beta-feature with groups: {posthog.feature_enabled('beta-feature-groups', 'distinct_id', groups={'company': 'id:5'})}"
|
||||
)
|
||||
|
||||
print("\n🌍 Testing location-based flags...")
|
||||
# Assume test-flag has `City Name = Sydney` as a person property set
|
||||
print(
|
||||
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
|
||||
)
|
||||
|
||||
print(
|
||||
f"Sydney user (local only): {posthog.feature_enabled('test-flag', 'distinct_id_random_22', person_properties={'$geoip_city_name': 'Sydney'}, only_evaluate_locally=True)}"
|
||||
)
|
||||
|
||||
print("\n📋 Getting all flags...")
|
||||
print(f"All flags: {posthog.get_all_flags('distinct_id_random_22')}")
|
||||
print(
|
||||
f"All flags (local): {posthog.get_all_flags('distinct_id_random_22', only_evaluate_locally=True)}"
|
||||
)
|
||||
print(
|
||||
f"All flags with properties: {posthog.get_all_flags('distinct_id_random_22', person_properties={'$geoip_city_name': 'Sydney'}, only_evaluate_locally=True)}"
|
||||
)
|
||||
|
||||
elif choice == "3":
|
||||
print("\n" + "=" * 60)
|
||||
print("FEATURE FLAG PAYLOAD EXAMPLES")
|
||||
print("=" * 60)
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
print("📦 Testing feature flag payloads...")
|
||||
print(
|
||||
f"beta-feature payload: {posthog.get_feature_flag_payload('beta-feature', 'distinct_id')}"
|
||||
)
|
||||
print(
|
||||
f"All flags and payloads: {posthog.get_all_flags_and_payloads('distinct_id')}"
|
||||
)
|
||||
print(
|
||||
f"Remote config payload: {posthog.get_remote_config_payload('encrypted_payload_flag_key')}"
|
||||
)
|
||||
|
||||
# Get feature flag result with all details (enabled, variant, payload, key, reason)
|
||||
print("\n🔍 Getting detailed flag result...")
|
||||
result = posthog.get_feature_flag_result("beta-feature", "distinct_id")
|
||||
if result:
|
||||
print(f"Flag key: {result.key}")
|
||||
print(f"Flag enabled: {result.enabled}")
|
||||
print(f"Variant: {result.variant}")
|
||||
print(f"Payload: {result.payload}")
|
||||
print(f"Reason: {result.reason}")
|
||||
# get_value() returns the variant if it exists, otherwise the enabled value
|
||||
print(f"Value (variant or enabled): {result.get_value()}")
|
||||
|
||||
elif choice == "4":
|
||||
print("\n" + "=" * 60)
|
||||
print("FLAG DEPENDENCIES EXAMPLES")
|
||||
print("=" * 60)
|
||||
print("🔗 Testing flag dependencies with local evaluation...")
|
||||
print(
|
||||
" Flag structure: 'test-flag-dependency' depends on 'beta-feature' being enabled"
|
||||
)
|
||||
print("")
|
||||
print("📋 Required setup (if 'test-flag-dependency' doesn't exist):")
|
||||
print(" 1. Create feature flag 'beta-feature':")
|
||||
print(" - Condition: email contains '@example.com'")
|
||||
print(" - Rollout: 100%")
|
||||
print(" 2. Create feature flag 'test-flag-dependency':")
|
||||
print(" - Condition: flag 'beta-feature' is enabled")
|
||||
print(" - Rollout: 100%")
|
||||
print("")
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
# Test @example.com user (should satisfy dependency if flags exist)
|
||||
result1 = posthog.feature_enabled(
|
||||
"test-flag-dependency",
|
||||
"example_user",
|
||||
person_properties={"email": "user@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
)
|
||||
print(f"✅ @example.com user (test-flag-dependency): {result1}")
|
||||
|
||||
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
print(posthog.get_remote_config_payload("encrypted_payload_flag_key"))
|
||||
beta2 = posthog.feature_enabled(
|
||||
"beta-feature",
|
||||
"regular_user",
|
||||
person_properties={"email": "user@other.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
print(f"📊 Beta feature comparison - @example.com: {beta1}, regular: {beta2}")
|
||||
|
||||
print("\n🎯 Results Summary:")
|
||||
print(
|
||||
f" - Flag dependencies evaluated locally: {'✅ YES' if result1 != result2 else '❌ NO'}"
|
||||
)
|
||||
print(" - Zero API calls needed: ✅ YES (all evaluated locally)")
|
||||
print(" - Python SDK supports flag dependencies: ✅ YES")
|
||||
|
||||
print("\n" + "-" * 60)
|
||||
print("PRODUCTION-STYLE MULTIVARIATE DEPENDENCY CHAIN")
|
||||
print("-" * 60)
|
||||
print("🔗 Testing complex multivariate flag dependencies...")
|
||||
print(
|
||||
" Structure: multivariate-root-flag -> multivariate-intermediate-flag -> multivariate-leaf-flag"
|
||||
)
|
||||
print("")
|
||||
print("📋 Required setup (if flags don't exist):")
|
||||
print(
|
||||
" 1. Create 'multivariate-leaf-flag' with fruit variants (pineapple, mango, papaya, kiwi)"
|
||||
)
|
||||
print(" - pineapple: email = 'pineapple@example.com'")
|
||||
print(" - mango: email = 'mango@example.com'")
|
||||
print(
|
||||
" 2. Create 'multivariate-intermediate-flag' with color variants (blue, red)"
|
||||
)
|
||||
print(" - blue: depends on multivariate-leaf-flag = 'pineapple'")
|
||||
print(" - red: depends on multivariate-leaf-flag = 'mango'")
|
||||
print(
|
||||
" 3. Create 'multivariate-root-flag' with show variants (breaking-bad, the-wire)"
|
||||
)
|
||||
print(" - breaking-bad: depends on multivariate-intermediate-flag = 'blue'")
|
||||
print(" - the-wire: depends on multivariate-intermediate-flag = 'red'")
|
||||
print("")
|
||||
|
||||
# Test pineapple -> blue -> breaking-bad chain
|
||||
dependent_result3 = posthog.get_feature_flag(
|
||||
"multivariate-root-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": "pineapple@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
if str(dependent_result3) != "breaking-bad":
|
||||
print(
|
||||
f" ❌ Something went wrong evaluating 'multivariate-root-flag' with pineapple@example.com. Expected 'breaking-bad', got '{dependent_result3}'"
|
||||
)
|
||||
else:
|
||||
print("✅ 'multivariate-root-flag' with email pineapple@example.com succeeded")
|
||||
|
||||
# Test mango -> red -> the-wire chain
|
||||
dependent_result4 = posthog.get_feature_flag(
|
||||
"multivariate-root-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": "mango@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
if str(dependent_result4) != "the-wire":
|
||||
print(
|
||||
f" ❌ Something went wrong evaluating multivariate-root-flag with mango@example.com. Expected 'the-wire', got '{dependent_result4}'"
|
||||
)
|
||||
else:
|
||||
print("✅ 'multivariate-root-flag' with email mango@example.com succeeded")
|
||||
|
||||
# Show the complete chain evaluation
|
||||
print("\n🔍 Complete dependency chain evaluation:")
|
||||
for email, expected_chain in [
|
||||
("pineapple@example.com", ["pineapple", "blue", "breaking-bad"]),
|
||||
("mango@example.com", ["mango", "red", "the-wire"]),
|
||||
]:
|
||||
leaf = posthog.get_feature_flag(
|
||||
"multivariate-leaf-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": email},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
intermediate = posthog.get_feature_flag(
|
||||
"multivariate-intermediate-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": email},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
root = posthog.get_feature_flag(
|
||||
"multivariate-root-flag",
|
||||
"regular_user",
|
||||
person_properties={"email": email},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
|
||||
actual_chain = [str(leaf), str(intermediate), str(root)]
|
||||
chain_success = actual_chain == expected_chain
|
||||
|
||||
print(f" 📧 {email}:")
|
||||
print(f" Expected: {' -> '.join(map(str, expected_chain))}")
|
||||
print(f" Actual: {' -> '.join(map(str, actual_chain))}")
|
||||
print(f" Status: {'✅ SUCCESS' if chain_success else '❌ FAILED'}")
|
||||
|
||||
print("\n🎯 Multivariate Chain Summary:")
|
||||
print(" - Complex dependency chains: ✅ SUPPORTED")
|
||||
print(" - Multivariate flag dependencies: ✅ SUPPORTED")
|
||||
print(" - Local evaluation of chains: ✅ WORKING")
|
||||
|
||||
elif choice == "5":
|
||||
print("\n" + "=" * 60)
|
||||
print("CONTEXT MANAGEMENT AND TAGGING EXAMPLES")
|
||||
print("=" * 60)
|
||||
|
||||
posthog.debug = True
|
||||
|
||||
print("🏷️ Testing context management...")
|
||||
print(
|
||||
"You can add tags to a context, and these are automatically added to any events captured within that context."
|
||||
)
|
||||
|
||||
# You can enter a new context using a with statement. Any exceptions thrown in the context will be captured,
|
||||
# and tagged with the context tags. Other events captured will also be tagged with the context tags. By default,
|
||||
# the new context inherits tags from the parent context.
|
||||
try:
|
||||
with posthog.new_context():
|
||||
posthog.tag("transaction_id", "abc123")
|
||||
posthog.tag("some_arbitrary_value", {"tags": "can be dicts"})
|
||||
|
||||
# This event will be captured with the tags set above
|
||||
posthog.capture("order_processed")
|
||||
print("✅ Event captured with inherited context tags")
|
||||
# This exception will be captured with the tags set above
|
||||
# raise Exception("Order processing failed")
|
||||
except Exception as e:
|
||||
print(f"Exception captured: {e}")
|
||||
|
||||
# Use fresh=True to start with a clean context (no inherited tags)
|
||||
try:
|
||||
with posthog.new_context(fresh=True):
|
||||
posthog.tag("session_id", "xyz789")
|
||||
# Only session_id tag will be present, no inherited tags
|
||||
posthog.capture("session_event")
|
||||
print("✅ Event captured with fresh context tags")
|
||||
# raise Exception("Session handling failed")
|
||||
except Exception as e:
|
||||
print(f"Exception captured: {e}")
|
||||
|
||||
# You can also use the `@posthog.scoped()` decorator to enter a new context.
|
||||
# By default, it inherits tags from the parent context
|
||||
@posthog.scoped()
|
||||
def process_order(order_id):
|
||||
posthog.tag("order_id", order_id)
|
||||
posthog.capture("order_step_completed")
|
||||
print(f"✅ Order {order_id} processed with scoped context")
|
||||
# Exception will be captured and tagged automatically
|
||||
# raise Exception("Order processing failed")
|
||||
|
||||
# Use fresh=True to start with a clean context (no inherited tags)
|
||||
@posthog.scoped(fresh=True)
|
||||
def process_payment(payment_id):
|
||||
posthog.tag("payment_id", payment_id)
|
||||
posthog.capture("payment_processed")
|
||||
print(f"✅ Payment {payment_id} processed with fresh scoped context")
|
||||
# Only payment_id tag will be present, no inherited tags
|
||||
# raise Exception("Payment processing failed")
|
||||
|
||||
process_order("12345")
|
||||
process_payment("67890")
|
||||
|
||||
elif choice == "6":
|
||||
print("\n🔄 Running all examples...")
|
||||
|
||||
# Run example 1
|
||||
print(f"\n{'🔸' * 20} IDENTIFY AND CAPTURE {'🔸' * 20}")
|
||||
posthog.debug = True
|
||||
print("📊 Capturing events...")
|
||||
posthog.capture(
|
||||
"event",
|
||||
distinct_id="distinct_id",
|
||||
properties={"property1": "value", "property2": "value"},
|
||||
send_feature_flags=True,
|
||||
)
|
||||
print("🔗 Creating alias...")
|
||||
posthog.alias("distinct_id", "new_distinct_id")
|
||||
print("👤 Identifying user...")
|
||||
posthog.set(
|
||||
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
|
||||
)
|
||||
|
||||
# Run example 2
|
||||
print(f"\n{'🔸' * 20} FEATURE FLAGS {'🔸' * 20}")
|
||||
print("🏁 Testing basic feature flags...")
|
||||
print(f"beta-feature: {posthog.feature_enabled('beta-feature', 'distinct_id')}")
|
||||
print(
|
||||
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
|
||||
)
|
||||
|
||||
# Run example 3
|
||||
print(f"\n{'🔸' * 20} PAYLOADS {'🔸' * 20}")
|
||||
print("📦 Testing payloads...")
|
||||
print(f"Payload: {posthog.get_feature_flag_payload('beta-feature', 'distinct_id')}")
|
||||
|
||||
# Run example 4
|
||||
print(f"\n{'🔸' * 20} FLAG DEPENDENCIES {'🔸' * 20}")
|
||||
print("🔗 Testing flag dependencies...")
|
||||
result1 = posthog.feature_enabled(
|
||||
"test-flag-dependency",
|
||||
"demo_user",
|
||||
person_properties={"email": "user@example.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
result2 = posthog.feature_enabled(
|
||||
"test-flag-dependency",
|
||||
"demo_user2",
|
||||
person_properties={"email": "user@other.com"},
|
||||
only_evaluate_locally=True,
|
||||
)
|
||||
print(f"✅ @example.com user: {result1}, regular user: {result2}")
|
||||
|
||||
# Run example 5
|
||||
print(f"\n{'🔸' * 20} CONTEXT MANAGEMENT {'🔸' * 20}")
|
||||
print("🏷️ Testing context management...")
|
||||
with posthog.new_context():
|
||||
posthog.tag("demo_run", "all_examples")
|
||||
posthog.capture("demo_completed")
|
||||
print("✅ Demo completed with context tags")
|
||||
|
||||
elif choice == "7":
|
||||
print("👋 Goodbye!")
|
||||
posthog.shutdown()
|
||||
exit()
|
||||
|
||||
else:
|
||||
print("❌ Invalid choice. Please run again and select 1-7.")
|
||||
posthog.shutdown()
|
||||
exit()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ Example completed!")
|
||||
print("=" * 60)
|
||||
|
||||
posthog.shutdown()
|
||||
|
||||
@@ -0,0 +1,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
|
||||
+8
-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
|
||||
@@ -16,13 +17,12 @@ Including another URLconf
|
||||
|
||||
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" },
|
||||
]
|
||||
+16
-17
@@ -1,7 +1,6 @@
|
||||
posthog/utils.py:0: error: Library stubs not installed for "six" [import-untyped]
|
||||
posthog/utils.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
|
||||
posthog/utils.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/utils.py:0: error: Argument 1 to "join" of "str" has incompatible type "AttributeError"; expected "Iterable[str]" [arg-type]
|
||||
posthog/request.py:0: error: Library stubs not installed for "requests" [import-untyped]
|
||||
posthog/request.py:0: note: Hint: "python3 -m pip install types-requests"
|
||||
posthog/request.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
|
||||
@@ -23,19 +22,19 @@ posthog/client.py:0: error: Library stubs not installed for "six" [import-untyp
|
||||
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: Item "None" of "Any | None" has no attribute "get" [union-attr]
|
||||
simulator.py:0: error: Unexpected keyword argument "anonymous_id" for "capture" [call-arg]
|
||||
posthog/__init__.py:0: note: "capture" defined here
|
||||
simulator.py:0: error: Unexpected keyword argument "anonymous_id" for "identify" [call-arg]
|
||||
posthog/__init__.py:0: note: "identify" defined here
|
||||
simulator.py:0: error: Unexpected keyword argument "traits" for "identify" [call-arg]
|
||||
posthog/__init__.py:0: note: "identify" defined here
|
||||
example.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/sentry/posthog_integration.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/ai/utils.py:0: error: Need type annotation for "output" (hint: "output: list[<type>] = ...") [var-annotated]
|
||||
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
|
||||
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
|
||||
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
|
||||
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
|
||||
sentry_django_example/sentry_django_example/settings.py:0: error: Need type annotation for "ALLOWED_HOSTS" (hint: "ALLOWED_HOSTS: list[<type>] = ...") [var-annotated]
|
||||
sentry_django_example/sentry_django_example/settings.py:0: error: Incompatible types in assignment (expression has type "str", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | list[Any]", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Any, Any]", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: "None" has no attribute "__iter__" (not iterable) [attr-defined]
|
||||
posthog/client.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | dict[Any, Any]", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | dict[Any, Any]", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Never, Never]", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Never, Never]", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: Right operand of "and" is never evaluated [unreachable]
|
||||
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Poller", variable has type "None") [assignment]
|
||||
posthog/client.py:0: error: "None" has no attribute "start" [attr-defined]
|
||||
posthog/client.py:0: error: "None" has no attribute "get" [attr-defined]
|
||||
posthog/client.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/client.py:0: error: Statement is unreachable [unreachable]
|
||||
posthog/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]
|
||||
|
||||
@@ -9,7 +9,7 @@ check_untyped_defs = True
|
||||
warn_unreachable = True
|
||||
strict_equality = True
|
||||
ignore_missing_imports = True
|
||||
exclude = env/.*|venv/.*
|
||||
exclude = env/.*|venv/.*|build/.*
|
||||
|
||||
[mypy-django.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
+508
-326
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,17 @@
|
||||
from .anthropic import Anthropic
|
||||
from .anthropic_async import AsyncAnthropic
|
||||
from .anthropic_providers import AnthropicBedrock, AnthropicVertex, AsyncAnthropicBedrock, AsyncAnthropicVertex
|
||||
from .anthropic_providers import (
|
||||
AnthropicBedrock,
|
||||
AnthropicVertex,
|
||||
AsyncAnthropicBedrock,
|
||||
AsyncAnthropicVertex,
|
||||
)
|
||||
from .anthropic_converter import (
|
||||
format_anthropic_response,
|
||||
format_anthropic_input,
|
||||
extract_anthropic_tools,
|
||||
format_anthropic_streaming_content,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Anthropic",
|
||||
@@ -9,4 +20,8 @@ __all__ = [
|
||||
"AsyncAnthropicBedrock",
|
||||
"AnthropicVertex",
|
||||
"AsyncAnthropicVertex",
|
||||
"format_anthropic_response",
|
||||
"format_anthropic_input",
|
||||
"extract_anthropic_tools",
|
||||
"format_anthropic_streaming_content",
|
||||
]
|
||||
|
||||
@@ -2,14 +2,29 @@ try:
|
||||
import anthropic
|
||||
from anthropic.resources import Messages
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError("Please install the Anthropic SDK to use this feature: 'pip install anthropic'")
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the Anthropic SDK to use this feature: 'pip install anthropic'"
|
||||
)
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from posthog.ai.utils import call_llm_and_track_usage, get_model_params, merge_system_prompt, with_privacy_mode
|
||||
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):
|
||||
@@ -19,14 +34,14 @@ class Anthropic(anthropic.Anthropic):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
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
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = WrappedMessages(self)
|
||||
|
||||
|
||||
@@ -53,6 +68,7 @@ class WrappedMessages(Messages):
|
||||
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())
|
||||
|
||||
@@ -110,35 +126,66 @@ class WrappedMessages(Messages):
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
|
||||
accumulated_content = []
|
||||
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 # noqa: F824
|
||||
nonlocal accumulated_content
|
||||
nonlocal content_blocks
|
||||
nonlocal tools_in_progress
|
||||
nonlocal current_text_block
|
||||
|
||||
try:
|
||||
for event in response:
|
||||
if hasattr(event, "usage") and event.usage:
|
||||
usage_stats = {
|
||||
k: getattr(event.usage, k, 0)
|
||||
for k in [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
]
|
||||
}
|
||||
# Extract usage stats from event
|
||||
event_usage = extract_anthropic_usage_from_event(event)
|
||||
merge_usage_stats(usage_stats, event_usage)
|
||||
|
||||
if hasattr(event, "content") and event.content:
|
||||
accumulated_content.append(event.content)
|
||||
# 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
|
||||
output = "".join(accumulated_content)
|
||||
|
||||
self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
@@ -149,7 +196,8 @@ class WrappedMessages(Messages):
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
content_blocks,
|
||||
accumulated_content,
|
||||
)
|
||||
|
||||
return generator()
|
||||
@@ -162,45 +210,39 @@ class WrappedMessages(Messages):
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
output: str,
|
||||
content_blocks: List[StreamingContentBlock],
|
||||
accumulated_content: str,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
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
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "anthropic",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
merge_system_prompt(kwargs, "anthropic"),
|
||||
# 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
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
[{"content": output, "role": "assistant"}],
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
|
||||
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
|
||||
"$ai_cache_creation_input_tokens": usage_stats.get("cache_creation_input_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._client._ph_client, event_data)
|
||||
|
||||
@@ -2,13 +2,28 @@ try:
|
||||
import anthropic
|
||||
from anthropic.resources import AsyncMessages
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError("Please install the Anthropic SDK to use this feature: 'pip install anthropic'")
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the Anthropic SDK to use this feature: 'pip install anthropic'"
|
||||
)
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from posthog.ai.utils import call_llm_and_track_usage_async, get_model_params, merge_system_prompt, with_privacy_mode
|
||||
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
|
||||
|
||||
|
||||
@@ -19,14 +34,14 @@ class AsyncAnthropic(anthropic.AsyncAnthropic):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
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
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = AsyncWrappedMessages(self)
|
||||
|
||||
|
||||
@@ -53,6 +68,7 @@ class AsyncWrappedMessages(AsyncMessages):
|
||||
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())
|
||||
|
||||
@@ -110,35 +126,66 @@ class AsyncWrappedMessages(AsyncMessages):
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
|
||||
accumulated_content = []
|
||||
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 # noqa: F824
|
||||
nonlocal accumulated_content
|
||||
nonlocal content_blocks
|
||||
nonlocal tools_in_progress
|
||||
nonlocal current_text_block
|
||||
|
||||
try:
|
||||
async for event in response:
|
||||
if hasattr(event, "usage") and event.usage:
|
||||
usage_stats = {
|
||||
k: getattr(event.usage, k, 0)
|
||||
for k in [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
]
|
||||
}
|
||||
# Extract usage stats from event
|
||||
event_usage = extract_anthropic_usage_from_event(event)
|
||||
merge_usage_stats(usage_stats, event_usage)
|
||||
|
||||
if hasattr(event, "content") and event.content:
|
||||
accumulated_content.append(event.content)
|
||||
# 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
|
||||
output = "".join(accumulated_content)
|
||||
|
||||
await self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
@@ -149,7 +196,8 @@ class AsyncWrappedMessages(AsyncMessages):
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
content_blocks,
|
||||
accumulated_content,
|
||||
)
|
||||
|
||||
return generator()
|
||||
@@ -162,45 +210,39 @@ class AsyncWrappedMessages(AsyncMessages):
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
output: str,
|
||||
content_blocks: List[StreamingContentBlock],
|
||||
accumulated_content: str,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
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
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "anthropic",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
merge_system_prompt(kwargs, "anthropic"),
|
||||
# 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
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
[{"content": output, "role": "assistant"}],
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
|
||||
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
|
||||
"$ai_cache_creation_input_tokens": usage_stats.get("cache_creation_input_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
# 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}],
|
||||
}
|
||||
]
|
||||
@@ -1,11 +1,16 @@
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError("Please install the Anthropic SDK to use this feature: 'pip install anthropic'")
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the Anthropic SDK to use this feature: 'pip install anthropic'"
|
||||
)
|
||||
|
||||
from 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):
|
||||
@@ -15,9 +20,9 @@ class AnthropicBedrock(anthropic.AnthropicBedrock):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = WrappedMessages(self)
|
||||
|
||||
|
||||
@@ -28,9 +33,9 @@ class AsyncAnthropicBedrock(anthropic.AsyncAnthropicBedrock):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = AsyncWrappedMessages(self)
|
||||
|
||||
|
||||
@@ -41,9 +46,9 @@ class AnthropicVertex(anthropic.AnthropicVertex):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = WrappedMessages(self)
|
||||
|
||||
|
||||
@@ -54,7 +59,7 @@ class AsyncAnthropicVertex(anthropic.AsyncAnthropicVertex):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
self.messages = AsyncWrappedMessages(self)
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
from .gemini import Client
|
||||
from .gemini_converter import (
|
||||
format_gemini_input,
|
||||
format_gemini_response,
|
||||
extract_gemini_tools,
|
||||
)
|
||||
|
||||
|
||||
# Create a genai-like module for perfect drop-in replacement
|
||||
@@ -8,4 +13,10 @@ class _GenAI:
|
||||
|
||||
genai = _GenAI()
|
||||
|
||||
__all__ = ["Client", "genai"]
|
||||
__all__ = [
|
||||
"Client",
|
||||
"genai",
|
||||
"format_gemini_input",
|
||||
"format_gemini_response",
|
||||
"extract_gemini_tools",
|
||||
]
|
||||
|
||||
+168
-84
@@ -3,12 +3,28 @@ 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'")
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the Google Gemini SDK to use this feature: 'pip install google-genai'"
|
||||
)
|
||||
|
||||
from posthog.ai.utils import call_llm_and_track_usage, get_model_params, with_privacy_mode
|
||||
from posthog 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
|
||||
|
||||
|
||||
@@ -30,9 +46,17 @@ class Client:
|
||||
)
|
||||
"""
|
||||
|
||||
_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,
|
||||
@@ -42,7 +66,13 @@ class Client:
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable
|
||||
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)
|
||||
@@ -50,12 +80,21 @@ class Client:
|
||||
posthog_groups: Default groups for all calls (can be overridden per call)
|
||||
**kwargs: Additional arguments (for future compatibility)
|
||||
"""
|
||||
if posthog_client is None:
|
||||
|
||||
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,
|
||||
posthog_client=posthog_client,
|
||||
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,
|
||||
@@ -74,6 +113,12 @@ class Models:
|
||||
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,
|
||||
@@ -83,7 +128,13 @@ class Models:
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable
|
||||
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
|
||||
@@ -91,10 +142,11 @@ class Models:
|
||||
posthog_groups: Default groups for all calls
|
||||
**kwargs: Additional arguments (for future compatibility)
|
||||
"""
|
||||
if posthog_client is None:
|
||||
raise ValueError("posthog_client is required for PostHog tracking")
|
||||
|
||||
self._ph_client = posthog_client
|
||||
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
|
||||
@@ -102,16 +154,46 @@ class Models:
|
||||
self._default_privacy_mode = posthog_privacy_mode
|
||||
self._default_groups = posthog_groups
|
||||
|
||||
# Handle API key - try parameter first, then environment variables
|
||||
if api_key is None:
|
||||
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("API_KEY")
|
||||
# Build genai.Client arguments
|
||||
client_args: Dict[str, Any] = {}
|
||||
|
||||
if api_key is None:
|
||||
raise ValueError(
|
||||
"API key must be provided either as parameter or via GOOGLE_API_KEY/API_KEY environment variable"
|
||||
)
|
||||
# Add Vertex AI parameters if provided
|
||||
if vertexai is not None:
|
||||
client_args["vertexai"] = vertexai
|
||||
|
||||
self._client = genai.Client(api_key=api_key)
|
||||
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(
|
||||
@@ -123,13 +205,23 @@ class Models:
|
||||
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
|
||||
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)
|
||||
|
||||
@@ -165,9 +257,16 @@ class Models:
|
||||
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
|
||||
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}
|
||||
@@ -197,7 +296,7 @@ class Models:
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
|
||||
usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
|
||||
accumulated_content = []
|
||||
|
||||
kwargs_without_stream = {"model": model, "contents": contents, **kwargs}
|
||||
@@ -208,21 +307,24 @@ class Models:
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
try:
|
||||
for chunk in response:
|
||||
if hasattr(chunk, "usage_metadata") and chunk.usage_metadata:
|
||||
usage_stats = {
|
||||
"input_tokens": getattr(chunk.usage_metadata, "prompt_token_count", 0),
|
||||
"output_tokens": getattr(chunk.usage_metadata, "candidates_token_count", 0),
|
||||
}
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_gemini_usage_from_chunk(chunk)
|
||||
|
||||
if hasattr(chunk, "text") and chunk.text:
|
||||
accumulated_content.append(chunk.text)
|
||||
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
|
||||
output = "".join(accumulated_content)
|
||||
|
||||
self._capture_streaming_event(
|
||||
model,
|
||||
@@ -235,7 +337,7 @@ class Models:
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
accumulated_content,
|
||||
)
|
||||
|
||||
return generator()
|
||||
@@ -250,63 +352,39 @@ class Models:
|
||||
privacy_mode: bool,
|
||||
groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
output: str,
|
||||
output: Any,
|
||||
):
|
||||
if trace_id is None:
|
||||
trace_id = str(uuid.uuid4())
|
||||
# Prepare standardized event data
|
||||
formatted_input = self._format_input(contents, **kwargs)
|
||||
sanitized_input = sanitize_gemini(formatted_input)
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "gemini",
|
||||
"$ai_model": model,
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._ph_client,
|
||||
privacy_mode,
|
||||
self._format_input(contents),
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._ph_client,
|
||||
privacy_mode,
|
||||
[{"content": output, "role": "assistant"}],
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_base_url": self._base_url,
|
||||
**(properties or {}),
|
||||
}
|
||||
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,
|
||||
)
|
||||
|
||||
if distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._ph_client, event_data)
|
||||
|
||||
if hasattr(self._ph_client, "capture"):
|
||||
self._ph_client.capture(
|
||||
distinct_id=distinct_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=groups,
|
||||
)
|
||||
|
||||
def _format_input(self, contents):
|
||||
def _format_input(self, contents, **kwargs):
|
||||
"""Format input contents for PostHog tracking"""
|
||||
if isinstance(contents, str):
|
||||
return [{"role": "user", "content": contents}]
|
||||
elif isinstance(contents, list):
|
||||
formatted = []
|
||||
for item in contents:
|
||||
if isinstance(item, str):
|
||||
formatted.append({"role": "user", "content": item})
|
||||
elif hasattr(item, "text"):
|
||||
formatted.append({"role": "user", "content": item.text})
|
||||
else:
|
||||
formatted.append({"role": "user", "content": str(item)})
|
||||
return formatted
|
||||
else:
|
||||
return [{"role": "user", "content": str(contents)}]
|
||||
|
||||
# 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,
|
||||
@@ -320,8 +398,14 @@ class Models:
|
||||
**kwargs: Any,
|
||||
):
|
||||
# Merge PostHog parameters
|
||||
distinct_id, trace_id, properties, privacy_mode, groups = self._merge_posthog_params(
|
||||
posthog_distinct_id, posthog_trace_id, posthog_properties, posthog_privacy_mode, posthog_groups
|
||||
distinct_id, trace_id, properties, privacy_mode, groups = (
|
||||
self._merge_posthog_params(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
)
|
||||
)
|
||||
|
||||
return self._generate_content_streaming(
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
"""
|
||||
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 _extract_text_from_parts(parts: List[Any]) -> str:
|
||||
"""
|
||||
Extract and concatenate text from a parts array.
|
||||
|
||||
Args:
|
||||
parts: List of parts that may contain text content
|
||||
|
||||
Returns:
|
||||
Concatenated text from all parts
|
||||
"""
|
||||
|
||||
content_parts = []
|
||||
|
||||
for part in parts:
|
||||
if isinstance(part, dict) and "text" in part:
|
||||
content_parts.append(part["text"])
|
||||
|
||||
elif isinstance(part, str):
|
||||
content_parts.append(part)
|
||||
|
||||
elif hasattr(part, "text"):
|
||||
# Get the text attribute value
|
||||
text_value = getattr(part, "text", "")
|
||||
content_parts.append(text_value if text_value else str(part))
|
||||
|
||||
else:
|
||||
content_parts.append(str(part))
|
||||
|
||||
return "".join(content_parts)
|
||||
|
||||
|
||||
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 = _extract_text_from_parts(item["parts"])
|
||||
return {"role": item.get("role", "user"), "content": content}
|
||||
|
||||
# Handle dict with content field
|
||||
if "content" in item:
|
||||
content = item["content"]
|
||||
|
||||
if isinstance(content, list):
|
||||
# If content is a list, extract text from it
|
||||
content = _extract_text_from_parts(content)
|
||||
|
||||
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 = _extract_text_from_parts(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}
|
||||
|
||||
# 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 = _extract_text_from_parts(content)
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
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": ""}]}]
|
||||
@@ -1,8 +1,11 @@
|
||||
try:
|
||||
import langchain # noqa: F401
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError("Please install LangChain to use this feature: 'pip install langchain'")
|
||||
raise ModuleNotFoundError(
|
||||
"Please install LangChain to use this feature: 'pip install langchain'"
|
||||
)
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
@@ -12,21 +15,35 @@ from typing import (
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from uuid import UUID
|
||||
|
||||
from langchain.callbacks.base import BaseCallbackHandler
|
||||
from langchain.schema.agent import AgentAction, AgentFinish
|
||||
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
|
||||
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 default_client
|
||||
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")
|
||||
@@ -73,10 +90,10 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
The PostHog LLM observability callback handler for LangChain.
|
||||
"""
|
||||
|
||||
_client: Client
|
||||
_ph_client: Client
|
||||
"""PostHog client instance."""
|
||||
|
||||
_distinct_id: Optional[Union[str, int, float, UUID]]
|
||||
_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]]
|
||||
@@ -104,7 +121,7 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
self,
|
||||
client: Optional[Client] = None,
|
||||
*,
|
||||
distinct_id: Optional[Union[str, int, float, UUID]] = 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,
|
||||
@@ -119,10 +136,7 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
privacy_mode: Whether to redact the input and output of the trace.
|
||||
groups: Optional additional PostHog groups to use for the trace.
|
||||
"""
|
||||
posthog_client = client or default_client
|
||||
if posthog_client is None:
|
||||
raise ValueError("PostHog client is required")
|
||||
self._client = posthog_client
|
||||
self._ph_client = client or setup()
|
||||
self._distinct_id = distinct_id
|
||||
self._trace_id = trace_id
|
||||
self._properties = properties or {}
|
||||
@@ -143,7 +157,9 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
):
|
||||
self._log_debug_event("on_chain_start", run_id, parent_run_id, inputs=inputs)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_trace_or_span_metadata(serialized, inputs, run_id, parent_run_id, **kwargs)
|
||||
self._set_trace_or_span_metadata(
|
||||
serialized, inputs, run_id, parent_run_id, **kwargs
|
||||
)
|
||||
|
||||
def on_chain_end(
|
||||
self,
|
||||
@@ -176,9 +192,13 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._log_debug_event("on_chat_model_start", run_id, parent_run_id, messages=messages)
|
||||
self._log_debug_event(
|
||||
"on_chat_model_start", run_id, parent_run_id, messages=messages
|
||||
)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
input = [_convert_message_to_dict(message) for row in messages for message in row]
|
||||
input = [
|
||||
_convert_message_to_dict(message) for row in messages for message in row
|
||||
]
|
||||
self._set_llm_metadata(serialized, run_id, input, **kwargs)
|
||||
|
||||
def on_llm_start(
|
||||
@@ -216,7 +236,9 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
"""
|
||||
The callback works for both streaming and non-streaming runs. For streaming runs, the chain must set `stream_usage=True` in the LLM.
|
||||
"""
|
||||
self._log_debug_event("on_llm_end", run_id, parent_run_id, response=response, kwargs=kwargs)
|
||||
self._log_debug_event(
|
||||
"on_llm_end", run_id, parent_run_id, response=response, kwargs=kwargs
|
||||
)
|
||||
self._pop_run_and_capture_generation(run_id, parent_run_id, response)
|
||||
|
||||
def on_llm_error(
|
||||
@@ -240,9 +262,13 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
self._log_debug_event("on_tool_start", run_id, parent_run_id, input_str=input_str)
|
||||
self._log_debug_event(
|
||||
"on_tool_start", run_id, parent_run_id, input_str=input_str
|
||||
)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_trace_or_span_metadata(serialized, input_str, run_id, parent_run_id, **kwargs)
|
||||
self._set_trace_or_span_metadata(
|
||||
serialized, input_str, run_id, parent_run_id, **kwargs
|
||||
)
|
||||
|
||||
def on_tool_end(
|
||||
self,
|
||||
@@ -279,7 +305,9 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
) -> Any:
|
||||
self._log_debug_event("on_retriever_start", run_id, parent_run_id, query=query)
|
||||
self._set_parent_of_run(run_id, parent_run_id)
|
||||
self._set_trace_or_span_metadata(serialized, query, run_id, parent_run_id, **kwargs)
|
||||
self._set_trace_or_span_metadata(
|
||||
serialized, query, run_id, parent_run_id, **kwargs
|
||||
)
|
||||
|
||||
def on_retriever_end(
|
||||
self,
|
||||
@@ -289,7 +317,9 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
parent_run_id: Optional[UUID] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
self._log_debug_event("on_retriever_end", run_id, parent_run_id, documents=documents)
|
||||
self._log_debug_event(
|
||||
"on_retriever_end", run_id, parent_run_id, documents=documents
|
||||
)
|
||||
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, documents)
|
||||
|
||||
def on_retriever_error(
|
||||
@@ -364,7 +394,9 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
):
|
||||
default_name = "trace" if parent_run_id is None else "span"
|
||||
run_name = _get_langchain_run_name(serialized, **kwargs) or default_name
|
||||
self._runs[run_id] = SpanMetadata(name=run_name, input=input, start_time=time.time(), end_time=None)
|
||||
self._runs[run_id] = SpanMetadata(
|
||||
name=run_name, input=input, start_time=time.time(), end_time=None
|
||||
)
|
||||
|
||||
def _set_llm_metadata(
|
||||
self,
|
||||
@@ -376,7 +408,9 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
**kwargs,
|
||||
):
|
||||
run_name = _get_langchain_run_name(serialized, **kwargs) or "generation"
|
||||
generation = GenerationMetadata(name=run_name, input=messages, start_time=time.time(), end_time=None)
|
||||
generation = GenerationMetadata(
|
||||
name=run_name, input=messages, start_time=time.time(), end_time=None
|
||||
)
|
||||
if isinstance(invocation_params, dict):
|
||||
generation.model_params = get_model_params(invocation_params)
|
||||
if tools := invocation_params.get("tools"):
|
||||
@@ -410,7 +444,9 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
return run_id
|
||||
return trace_id
|
||||
|
||||
def _get_parent_run_id(self, trace_id: Any, run_id: UUID, parent_run_id: Optional[UUID]):
|
||||
def _get_parent_run_id(
|
||||
self, trace_id: Any, run_id: UUID, parent_run_id: Optional[UUID]
|
||||
):
|
||||
"""
|
||||
Replace the parent run ID with the trace ID for second level runs when a custom trace ID is set.
|
||||
"""
|
||||
@@ -418,14 +454,18 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
return trace_id
|
||||
return parent_run_id
|
||||
|
||||
def _pop_run_and_capture_trace_or_span(self, run_id: UUID, parent_run_id: Optional[UUID], outputs: Any):
|
||||
def _pop_run_and_capture_trace_or_span(
|
||||
self, run_id: UUID, parent_run_id: Optional[UUID], outputs: Any
|
||||
):
|
||||
trace_id = self._get_trace_id(run_id)
|
||||
self._pop_parent_of_run(run_id)
|
||||
run = self._pop_run_metadata(run_id)
|
||||
if not run:
|
||||
return
|
||||
if isinstance(run, GenerationMetadata):
|
||||
log.warning(f"Run {run_id} is a generation, but attempted to be captured as a trace or span.")
|
||||
log.warning(
|
||||
f"Run {run_id} is a generation, but attempted to be captured as a trace or span."
|
||||
)
|
||||
return
|
||||
self._capture_trace_or_span(
|
||||
trace_id,
|
||||
@@ -446,10 +486,13 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
event_name = "$ai_trace" if parent_run_id is None else "$ai_span"
|
||||
event_properties = {
|
||||
"$ai_trace_id": trace_id,
|
||||
"$ai_input_state": with_privacy_mode(self._client, self._privacy_mode, run.input),
|
||||
"$ai_input_state": with_privacy_mode(
|
||||
self._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
|
||||
@@ -460,12 +503,14 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
event_properties["$ai_error"] = _stringify_exception(outputs)
|
||||
event_properties["$ai_is_error"] = True
|
||||
elif outputs is not None:
|
||||
event_properties["$ai_output_state"] = with_privacy_mode(self._client, self._privacy_mode, outputs)
|
||||
event_properties["$ai_output_state"] = with_privacy_mode(
|
||||
self._ph_client, self._privacy_mode, outputs
|
||||
)
|
||||
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
self._client.capture(
|
||||
self._ph_client.capture(
|
||||
distinct_id=self._distinct_id or run_id,
|
||||
event=event_name,
|
||||
properties=event_properties,
|
||||
@@ -484,7 +529,9 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
if not run:
|
||||
return
|
||||
if not isinstance(run, GenerationMetadata):
|
||||
log.warning(f"Run {run_id} is not a generation, but attempted to be captured as a generation.")
|
||||
log.warning(
|
||||
f"Run {run_id} is not a generation, but attempted to be captured as a generation."
|
||||
)
|
||||
return
|
||||
self._capture_generation(
|
||||
trace_id,
|
||||
@@ -510,17 +557,17 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
"$ai_provider": run.provider,
|
||||
"$ai_model": run.model,
|
||||
"$ai_model_parameters": run.model_params,
|
||||
"$ai_input": with_privacy_mode(self._client, self._privacy_mode, run.input),
|
||||
"$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 run.tools:
|
||||
event_properties["$ai_tools"] = with_privacy_mode(
|
||||
self._client,
|
||||
self._privacy_mode,
|
||||
run.tools,
|
||||
)
|
||||
event_properties["$ai_tools"] = run.tools
|
||||
|
||||
if isinstance(output, BaseException):
|
||||
event_properties["$ai_http_status"] = _get_http_status(output)
|
||||
@@ -528,9 +575,14 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
event_properties["$ai_is_error"] = True
|
||||
else:
|
||||
# Add usage
|
||||
input_tokens, output_tokens = _parse_usage(output)
|
||||
event_properties["$ai_input_tokens"] = input_tokens
|
||||
event_properties["$ai_output_tokens"] = output_tokens
|
||||
usage = _parse_usage(output)
|
||||
event_properties["$ai_input_tokens"] = usage.input_tokens
|
||||
event_properties["$ai_output_tokens"] = usage.output_tokens
|
||||
event_properties["$ai_cache_creation_input_tokens"] = (
|
||||
usage.cache_write_tokens
|
||||
)
|
||||
event_properties["$ai_cache_read_input_tokens"] = usage.cache_read_tokens
|
||||
event_properties["$ai_reasoning_tokens"] = usage.reasoning_tokens
|
||||
|
||||
# Generation results
|
||||
generation_result = output.generations[-1]
|
||||
@@ -540,8 +592,13 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
for generation in generation_result
|
||||
]
|
||||
else:
|
||||
completions = [_extract_raw_esponse(generation) for generation in generation_result]
|
||||
event_properties["$ai_output_choices"] = with_privacy_mode(self._client, self._privacy_mode, completions)
|
||||
completions = [
|
||||
_extract_raw_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)
|
||||
@@ -549,7 +606,7 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
if self._distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
|
||||
self._client.capture(
|
||||
self._ph_client.capture(
|
||||
distinct_id=self._distinct_id or trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
@@ -568,7 +625,7 @@ class CallbackHandler(BaseCallbackHandler):
|
||||
)
|
||||
|
||||
|
||||
def _extract_raw_esponse(last_response):
|
||||
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() != "":
|
||||
@@ -581,12 +638,35 @@ def _extract_raw_esponse(last_response):
|
||||
return ""
|
||||
|
||||
|
||||
def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
|
||||
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):
|
||||
@@ -599,12 +679,24 @@ def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
|
||||
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],
|
||||
) -> Tuple[Union[int, None], Union[int, None]]:
|
||||
usage: Union[BaseModel, dict],
|
||||
) -> ModelUsage:
|
||||
if isinstance(usage, BaseModel):
|
||||
usage = usage.__dict__
|
||||
|
||||
@@ -612,15 +704,23 @@ def _parse_usage_model(
|
||||
# https://pypi.org/project/langchain-anthropic/ (works also for Bedrock-Anthropic)
|
||||
("input_tokens", "input"),
|
||||
("output_tokens", "output"),
|
||||
("cache_creation_input_tokens", "cache_write"),
|
||||
("cache_read_input_tokens", "cache_read"),
|
||||
# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/get-token-count
|
||||
("prompt_token_count", "input"),
|
||||
("candidates_token_count", "output"),
|
||||
("cached_content_token_count", "cache_read"),
|
||||
("thoughts_token_count", "reasoning"),
|
||||
# Bedrock: https://docs.aws.amazon.com/bedrock/latest/userguide/monitoring-cw.html#runtime-cloudwatch-metrics
|
||||
("inputTokenCount", "input"),
|
||||
("outputTokenCount", "output"),
|
||||
("cacheCreationInputTokenCount", "cache_write"),
|
||||
("cacheReadInputTokenCount", "cache_read"),
|
||||
# Bedrock Anthropic
|
||||
("prompt_tokens", "input"),
|
||||
("completion_tokens", "output"),
|
||||
("cache_creation_input_tokens", "cache_write"),
|
||||
("cache_read_input_tokens", "cache_read"),
|
||||
# langchain-ibm https://pypi.org/project/langchain-ibm/
|
||||
("input_token_count", "input"),
|
||||
("generated_token_count", "output"),
|
||||
@@ -631,18 +731,59 @@ def _parse_usage_model(
|
||||
if model_key in usage:
|
||||
captured_count = usage[model_key]
|
||||
final_count = (
|
||||
sum(captured_count) if isinstance(captured_count, list) else captured_count
|
||||
sum(captured_count)
|
||||
if isinstance(captured_count, list)
|
||||
else captured_count
|
||||
) # For Bedrock, the token count is a list when streamed
|
||||
|
||||
parsed_usage[type_key] = final_count
|
||||
|
||||
return parsed_usage.get("input"), parsed_usage.get("output")
|
||||
# Caching (OpenAI & langchain 0.3.9+)
|
||||
if "input_token_details" in usage and isinstance(
|
||||
usage["input_token_details"], dict
|
||||
):
|
||||
parsed_usage["cache_write"] = usage["input_token_details"].get("cache_creation")
|
||||
parsed_usage["cache_read"] = usage["input_token_details"].get("cache_read")
|
||||
|
||||
# Reasoning (OpenAI & langchain 0.3.9+)
|
||||
if "output_token_details" in usage and isinstance(
|
||||
usage["output_token_details"], dict
|
||||
):
|
||||
parsed_usage["reasoning"] = usage["output_token_details"].get("reasoning")
|
||||
|
||||
field_mapping = {
|
||||
"input": "input_tokens",
|
||||
"output": "output_tokens",
|
||||
"cache_write": "cache_write_tokens",
|
||||
"cache_read": "cache_read_tokens",
|
||||
"reasoning": "reasoning_tokens",
|
||||
}
|
||||
normalized_usage = ModelUsage(
|
||||
**{
|
||||
dataclass_key: parsed_usage.get(mapped_key) or 0
|
||||
for mapped_key, dataclass_key in field_mapping.items()
|
||||
},
|
||||
)
|
||||
# In LangChain, input_tokens is the sum of input and cache read tokens.
|
||||
# Our cost calculation expects them to be separate, for Anthropic.
|
||||
if 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):
|
||||
def _parse_usage(response: LLMResult) -> ModelUsage:
|
||||
# langchain-anthropic uses the usage field
|
||||
llm_usage_keys = ["token_usage", "usage"]
|
||||
llm_usage: Tuple[Union[int, None], Union[int, None]] = (None, None)
|
||||
llm_usage: ModelUsage = ModelUsage(
|
||||
input_tokens=None,
|
||||
output_tokens=None,
|
||||
cache_write_tokens=None,
|
||||
cache_read_tokens=None,
|
||||
reasoning_tokens=None,
|
||||
)
|
||||
|
||||
if response.llm_output is not None:
|
||||
for key in llm_usage_keys:
|
||||
if response.llm_output.get(key):
|
||||
@@ -656,8 +797,12 @@ def _parse_usage(response: LLMResult):
|
||||
break
|
||||
|
||||
for generation_chunk in generation:
|
||||
if generation_chunk.generation_info and ("usage_metadata" in generation_chunk.generation_info):
|
||||
llm_usage = _parse_usage_model(generation_chunk.generation_info["usage_metadata"])
|
||||
if generation_chunk.generation_info and (
|
||||
"usage_metadata" in generation_chunk.generation_info
|
||||
):
|
||||
llm_usage = _parse_usage_model(
|
||||
generation_chunk.generation_info["usage_metadata"]
|
||||
)
|
||||
break
|
||||
|
||||
message_chunk = getattr(generation_chunk, "message", {})
|
||||
@@ -669,13 +814,19 @@ def _parse_usage(response: LLMResult):
|
||||
else None
|
||||
)
|
||||
bedrock_titan_usage = (
|
||||
response_metadata.get("amazon-bedrock-invocationMetrics", None) # for Bedrock-Titan
|
||||
response_metadata.get(
|
||||
"amazon-bedrock-invocationMetrics", None
|
||||
) # for Bedrock-Titan
|
||||
if isinstance(response_metadata, dict)
|
||||
else None
|
||||
)
|
||||
ollama_usage = getattr(message_chunk, "usage_metadata", None) # for Ollama
|
||||
ollama_usage = getattr(
|
||||
message_chunk, "usage_metadata", None
|
||||
) # for Ollama
|
||||
|
||||
chunk_usage = bedrock_anthropic_usage or bedrock_titan_usage or ollama_usage
|
||||
chunk_usage = (
|
||||
bedrock_anthropic_usage or bedrock_titan_usage or ollama_usage
|
||||
)
|
||||
if chunk_usage:
|
||||
llm_usage = _parse_usage_model(chunk_usage)
|
||||
break
|
||||
@@ -691,7 +842,9 @@ def _get_http_status(error: BaseException) -> int:
|
||||
return status_code
|
||||
|
||||
|
||||
def _get_langchain_run_name(serialized: Optional[Dict[str, Any]], **kwargs: Any) -> Optional[str]:
|
||||
def _get_langchain_run_name(
|
||||
serialized: Optional[Dict[str, Any]], **kwargs: Any
|
||||
) -> Optional[str]:
|
||||
"""Retrieve the name of a serialized LangChain runnable.
|
||||
|
||||
The prioritization for the determination of the run name is as follows:
|
||||
|
||||
@@ -1,5 +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"]
|
||||
__all__ = [
|
||||
"OpenAI",
|
||||
"AsyncOpenAI",
|
||||
"AzureOpenAI",
|
||||
"AsyncAzureOpenAI",
|
||||
"format_openai_response",
|
||||
"format_openai_input",
|
||||
"extract_openai_tools",
|
||||
"format_openai_streaming_content",
|
||||
]
|
||||
|
||||
+158
-149
@@ -2,13 +2,30 @@ 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'")
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the OpenAI SDK to use this feature: 'pip install openai'"
|
||||
)
|
||||
|
||||
from posthog.ai.utils import call_llm_and_track_usage, get_model_params, with_privacy_mode
|
||||
from posthog.ai.utils import (
|
||||
call_llm_and_track_usage,
|
||||
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):
|
||||
@@ -18,16 +35,16 @@ class OpenAI(openai.OpenAI):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
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.
|
||||
posthog_client: If provided, events will be captured via this client instead of the global `posthog`.
|
||||
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
|
||||
"""
|
||||
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
@@ -105,7 +122,7 @@ class WrappedResponses:
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
usage_stats: TokenUsage = TokenUsage()
|
||||
final_content = []
|
||||
response = self._original.create(**kwargs)
|
||||
|
||||
@@ -115,31 +132,17 @@ class WrappedResponses:
|
||||
|
||||
try:
|
||||
for chunk in response:
|
||||
if hasattr(chunk, "type") and chunk.type == "response.completed":
|
||||
res = chunk.response
|
||||
if res.output and len(res.output) > 0:
|
||||
final_content.append(res.output[0])
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
|
||||
|
||||
if hasattr(chunk, "usage") and chunk.usage:
|
||||
usage_stats = {
|
||||
k: getattr(chunk.usage, k, 0)
|
||||
for k in [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
]
|
||||
}
|
||||
if chunk_usage:
|
||||
merge_usage_stats(usage_stats, chunk_usage)
|
||||
|
||||
# Add support for cached tokens
|
||||
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
|
||||
chunk.usage.output_tokens_details, "reasoning_tokens"
|
||||
):
|
||||
usage_stats["reasoning_tokens"] = chunk.usage.output_tokens_details.reasoning_tokens
|
||||
# Extract content from chunk
|
||||
content = extract_openai_content_from_chunk(chunk, "responses")
|
||||
|
||||
if hasattr(chunk.usage, "input_tokens_details") and hasattr(
|
||||
chunk.usage.input_tokens_details, "cached_tokens"
|
||||
):
|
||||
usage_stats["cache_read_input_tokens"] = chunk.usage.input_tokens_details.cached_tokens
|
||||
if content is not None:
|
||||
final_content.append(content)
|
||||
|
||||
yield chunk
|
||||
|
||||
@@ -157,6 +160,7 @@ class WrappedResponses:
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
None, # Responses API doesn't have tools
|
||||
)
|
||||
|
||||
return generator()
|
||||
@@ -169,52 +173,76 @@ class WrappedResponses:
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
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
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("input")),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
output,
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
|
||||
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
|
||||
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
# Prepare standardized event data
|
||||
formatted_input = format_openai_streaming_input(kwargs, "responses")
|
||||
sanitized_input = sanitize_openai_response(formatted_input)
|
||||
|
||||
if tool_calls:
|
||||
event_properties["$ai_tools"] = with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
tool_calls,
|
||||
)
|
||||
event_data = StreamingEventData(
|
||||
provider="openai",
|
||||
model=kwargs.get("model", "unknown"),
|
||||
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,
|
||||
)
|
||||
|
||||
if posthog_distinct_id is None:
|
||||
event_properties["$process_person_profile"] = False
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._client._ph_client, event_data)
|
||||
|
||||
if hasattr(self._client._ph_client, "capture"):
|
||||
self._client._ph_client.capture(
|
||||
distinct_id=posthog_distinct_id or posthog_trace_id,
|
||||
event="$ai_generation",
|
||||
properties=event_properties,
|
||||
groups=posthog_groups,
|
||||
)
|
||||
def parse(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in PostHog.
|
||||
|
||||
Args:
|
||||
posthog_distinct_id: Optional ID to associate with the usage event.
|
||||
posthog_trace_id: Optional trace UUID for linking events.
|
||||
posthog_properties: Optional dictionary of extra properties to include in the event.
|
||||
posthog_privacy_mode: Whether to anonymize the input and output.
|
||||
posthog_groups: Optional dictionary of groups to associate with the event.
|
||||
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
|
||||
|
||||
Returns:
|
||||
The response from OpenAI's responses.parse call.
|
||||
"""
|
||||
return call_llm_and_track_usage(
|
||||
posthog_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
self._original.parse,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class WrappedChat:
|
||||
@@ -289,9 +317,9 @@ class WrappedCompletions:
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
usage_stats: TokenUsage = TokenUsage()
|
||||
accumulated_content = []
|
||||
accumulated_tools = {}
|
||||
accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
|
||||
if "stream_options" not in kwargs:
|
||||
kwargs["stream_options"] = {}
|
||||
kwargs["stream_options"]["include_usage"] = True
|
||||
@@ -300,56 +328,42 @@ class WrappedCompletions:
|
||||
def generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
nonlocal accumulated_tools # noqa: F824
|
||||
nonlocal accumulated_tool_calls
|
||||
|
||||
try:
|
||||
for chunk in response:
|
||||
if hasattr(chunk, "usage") and chunk.usage:
|
||||
usage_stats = {
|
||||
k: getattr(chunk.usage, k, 0)
|
||||
for k in [
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
]
|
||||
}
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
|
||||
|
||||
# Add support for cached tokens
|
||||
if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
|
||||
chunk.usage.prompt_tokens_details, "cached_tokens"
|
||||
):
|
||||
usage_stats["cache_read_input_tokens"] = chunk.usage.prompt_tokens_details.cached_tokens
|
||||
if chunk_usage:
|
||||
merge_usage_stats(usage_stats, chunk_usage)
|
||||
|
||||
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
|
||||
chunk.usage.output_tokens_details, "reasoning_tokens"
|
||||
):
|
||||
usage_stats["reasoning_tokens"] = chunk.usage.output_tokens_details.reasoning_tokens
|
||||
# Extract content from chunk
|
||||
content = extract_openai_content_from_chunk(chunk, "chat")
|
||||
|
||||
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
|
||||
if chunk.choices[0].delta and chunk.choices[0].delta.content:
|
||||
content = chunk.choices[0].delta.content
|
||||
if content:
|
||||
accumulated_content.append(content)
|
||||
if content is not None:
|
||||
accumulated_content.append(content)
|
||||
|
||||
# Process tool calls
|
||||
tool_calls = getattr(chunk.choices[0].delta, "tool_calls", None)
|
||||
if tool_calls:
|
||||
for tool_call in tool_calls:
|
||||
index = tool_call.index
|
||||
if index not in accumulated_tools:
|
||||
accumulated_tools[index] = tool_call
|
||||
else:
|
||||
# Append arguments for existing tool calls
|
||||
if hasattr(tool_call, "function") and hasattr(tool_call.function, "arguments"):
|
||||
accumulated_tools[index].function.arguments += tool_call.function.arguments
|
||||
# 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
|
||||
output = "".join(accumulated_content)
|
||||
tools = list(accumulated_tools.values()) if accumulated_tools else None
|
||||
|
||||
# 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,
|
||||
@@ -359,8 +373,9 @@ class WrappedCompletions:
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
tools,
|
||||
accumulated_content,
|
||||
tool_calls_list,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
)
|
||||
|
||||
return generator()
|
||||
@@ -373,52 +388,41 @@ class WrappedCompletions:
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
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
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("messages")),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
[{"content": output, "role": "assistant"}],
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
|
||||
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
|
||||
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
"$ai_trace_id": posthog_trace_id,
|
||||
"$ai_base_url": str(self._client.base_url),
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
# Prepare standardized event data
|
||||
formatted_input = format_openai_streaming_input(kwargs, "chat")
|
||||
sanitized_input = sanitize_openai(formatted_input)
|
||||
|
||||
if tool_calls:
|
||||
event_properties["$ai_tools"] = with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
tool_calls,
|
||||
)
|
||||
event_data = StreamingEventData(
|
||||
provider="openai",
|
||||
model=kwargs.get("model", "unknown"),
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
# Use the common capture function
|
||||
capture_streaming_event(self._client._ph_client, event_data)
|
||||
|
||||
|
||||
class WrappedEmbeddings:
|
||||
@@ -455,6 +459,7 @@ class WrappedEmbeddings:
|
||||
Returns:
|
||||
The response from OpenAI's embeddings.create call.
|
||||
"""
|
||||
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
@@ -476,7 +481,11 @@ class WrappedEmbeddings:
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("input")),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
sanitize_openai_response(kwargs.get("input")),
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_latency": latency,
|
||||
|
||||
+164
-100
@@ -2,12 +2,31 @@ 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'")
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the OpenAI SDK to use this feature: 'pip install openai'"
|
||||
)
|
||||
|
||||
from posthog.ai.utils import call_llm_and_track_usage_async, get_model_params, with_privacy_mode
|
||||
from posthog 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
|
||||
|
||||
|
||||
@@ -18,7 +37,7 @@ class AsyncOpenAI(openai.AsyncOpenAI):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: OpenAI API key.
|
||||
@@ -26,8 +45,9 @@ class AsyncOpenAI(openai.AsyncOpenAI):
|
||||
of the global posthog.
|
||||
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
|
||||
"""
|
||||
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
@@ -58,6 +78,7 @@ class WrappedResponses:
|
||||
|
||||
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(
|
||||
@@ -105,7 +126,7 @@ class WrappedResponses:
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
usage_stats: TokenUsage = TokenUsage()
|
||||
final_content = []
|
||||
response = await self._original.create(**kwargs)
|
||||
|
||||
@@ -115,31 +136,17 @@ class WrappedResponses:
|
||||
|
||||
try:
|
||||
async for chunk in response:
|
||||
if hasattr(chunk, "type") and chunk.type == "response.completed":
|
||||
res = chunk.response
|
||||
if res.output and len(res.output) > 0:
|
||||
final_content.append(res.output[0])
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
|
||||
|
||||
if hasattr(chunk, "usage") and chunk.usage:
|
||||
usage_stats = {
|
||||
k: getattr(chunk.usage, k, 0)
|
||||
for k in [
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
]
|
||||
}
|
||||
if chunk_usage:
|
||||
merge_usage_stats(usage_stats, chunk_usage)
|
||||
|
||||
# Add support for cached tokens
|
||||
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
|
||||
chunk.usage.output_tokens_details, "reasoning_tokens"
|
||||
):
|
||||
usage_stats["reasoning_tokens"] = chunk.usage.output_tokens_details.reasoning_tokens
|
||||
# Extract content from chunk
|
||||
content = extract_openai_content_from_chunk(chunk, "responses")
|
||||
|
||||
if hasattr(chunk.usage, "input_tokens_details") and hasattr(
|
||||
chunk.usage.input_tokens_details, "cached_tokens"
|
||||
):
|
||||
usage_stats["cache_read_input_tokens"] = chunk.usage.input_tokens_details.cached_tokens
|
||||
if content is not None:
|
||||
final_content.append(content)
|
||||
|
||||
yield chunk
|
||||
|
||||
@@ -147,6 +154,7 @@ class WrappedResponses:
|
||||
end_time = time.time()
|
||||
latency = end_time - start_time
|
||||
output = final_content
|
||||
|
||||
await self._capture_streaming_event(
|
||||
posthog_distinct_id,
|
||||
posthog_trace_id,
|
||||
@@ -157,6 +165,7 @@ class WrappedResponses:
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
)
|
||||
|
||||
return async_generator()
|
||||
@@ -169,10 +178,10 @@ class WrappedResponses:
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
@@ -181,16 +190,22 @@ class WrappedResponses:
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("input")),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
sanitize_openai_response(kwargs.get("input")),
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
output,
|
||||
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_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,
|
||||
@@ -198,12 +213,17 @@ class WrappedResponses:
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
if tool_calls:
|
||||
event_properties["$ai_tools"] = with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
tool_calls,
|
||||
)
|
||||
# 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
|
||||
@@ -216,6 +236,42 @@ class WrappedResponses:
|
||||
groups=posthog_groups,
|
||||
)
|
||||
|
||||
async def parse(
|
||||
self,
|
||||
posthog_distinct_id: Optional[str] = None,
|
||||
posthog_trace_id: Optional[str] = None,
|
||||
posthog_properties: Optional[Dict[str, Any]] = None,
|
||||
posthog_privacy_mode: bool = False,
|
||||
posthog_groups: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in PostHog.
|
||||
|
||||
Args:
|
||||
posthog_distinct_id: Optional ID to associate with the usage event.
|
||||
posthog_trace_id: Optional trace UUID for linking events.
|
||||
posthog_properties: Optional dictionary of extra properties to include in the event.
|
||||
posthog_privacy_mode: Whether to anonymize the input and output.
|
||||
posthog_groups: Optional dictionary of groups to associate with the event.
|
||||
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
|
||||
|
||||
Returns:
|
||||
The response from OpenAI's responses.parse call.
|
||||
"""
|
||||
return await call_llm_and_track_usage_async(
|
||||
posthog_distinct_id,
|
||||
self._client._ph_client,
|
||||
"openai",
|
||||
posthog_trace_id,
|
||||
posthog_properties,
|
||||
posthog_privacy_mode,
|
||||
posthog_groups,
|
||||
self._client.base_url,
|
||||
self._original.parse,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class WrappedChat:
|
||||
"""Async wrapper for OpenAI chat that tracks usage in PostHog."""
|
||||
@@ -291,9 +347,9 @@ class WrappedCompletions:
|
||||
**kwargs: Any,
|
||||
):
|
||||
start_time = time.time()
|
||||
usage_stats: Dict[str, int] = {}
|
||||
usage_stats: TokenUsage = TokenUsage()
|
||||
accumulated_content = []
|
||||
accumulated_tools = {}
|
||||
accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
|
||||
|
||||
if "stream_options" not in kwargs:
|
||||
kwargs["stream_options"] = {}
|
||||
@@ -303,56 +359,40 @@ class WrappedCompletions:
|
||||
async def async_generator():
|
||||
nonlocal usage_stats
|
||||
nonlocal accumulated_content # noqa: F824
|
||||
nonlocal accumulated_tools # noqa: F824
|
||||
nonlocal accumulated_tool_calls
|
||||
|
||||
try:
|
||||
async for chunk in response:
|
||||
if hasattr(chunk, "usage") and chunk.usage:
|
||||
usage_stats = {
|
||||
k: getattr(chunk.usage, k, 0)
|
||||
for k in [
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
]
|
||||
}
|
||||
# Extract usage stats from chunk
|
||||
chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
|
||||
if chunk_usage:
|
||||
merge_usage_stats(usage_stats, chunk_usage)
|
||||
|
||||
# Add support for cached tokens
|
||||
if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
|
||||
chunk.usage.prompt_tokens_details, "cached_tokens"
|
||||
):
|
||||
usage_stats["cache_read_input_tokens"] = chunk.usage.prompt_tokens_details.cached_tokens
|
||||
# Extract content from chunk
|
||||
content = extract_openai_content_from_chunk(chunk, "chat")
|
||||
if content is not None:
|
||||
accumulated_content.append(content)
|
||||
|
||||
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
|
||||
chunk.usage.output_tokens_details, "reasoning_tokens"
|
||||
):
|
||||
usage_stats["reasoning_tokens"] = chunk.usage.output_tokens_details.reasoning_tokens
|
||||
|
||||
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
|
||||
if chunk.choices[0].delta and chunk.choices[0].delta.content:
|
||||
content = chunk.choices[0].delta.content
|
||||
if content:
|
||||
accumulated_content.append(content)
|
||||
|
||||
# Process tool calls
|
||||
tool_calls = getattr(chunk.choices[0].delta, "tool_calls", None)
|
||||
if tool_calls:
|
||||
for tool_call in tool_calls:
|
||||
index = tool_call.index
|
||||
if index not in accumulated_tools:
|
||||
accumulated_tools[index] = tool_call
|
||||
else:
|
||||
# Append arguments for existing tool calls
|
||||
if hasattr(tool_call, "function") and hasattr(tool_call.function, "arguments"):
|
||||
accumulated_tools[index].function.arguments += tool_call.function.arguments
|
||||
# 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
|
||||
output = "".join(accumulated_content)
|
||||
tools = list(accumulated_tools.values()) if accumulated_tools else None
|
||||
|
||||
# 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,
|
||||
@@ -362,8 +402,9 @@ class WrappedCompletions:
|
||||
kwargs,
|
||||
usage_stats,
|
||||
latency,
|
||||
output,
|
||||
tools,
|
||||
accumulated_content,
|
||||
tool_calls_list,
|
||||
extract_available_tool_calls("openai", kwargs),
|
||||
)
|
||||
|
||||
return async_generator()
|
||||
@@ -376,10 +417,11 @@ class WrappedCompletions:
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
kwargs: Dict[str, Any],
|
||||
usage_stats: Dict[str, int],
|
||||
usage_stats: TokenUsage,
|
||||
latency: float,
|
||||
output: Any,
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
@@ -388,16 +430,22 @@ class WrappedCompletions:
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("messages")),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
sanitize_openai(kwargs.get("messages")),
|
||||
),
|
||||
"$ai_output_choices": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
[{"content": output, "role": "assistant"}],
|
||||
format_openai_streaming_output(output, "chat", tool_calls),
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
|
||||
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
|
||||
"$ai_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,
|
||||
@@ -405,12 +453,18 @@ class WrappedCompletions:
|
||||
**(posthog_properties or {}),
|
||||
}
|
||||
|
||||
if tool_calls:
|
||||
event_properties["$ai_tools"] = with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
tool_calls,
|
||||
)
|
||||
# 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
|
||||
@@ -433,6 +487,7 @@ class WrappedEmbeddings:
|
||||
|
||||
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(
|
||||
@@ -458,6 +513,7 @@ class WrappedEmbeddings:
|
||||
Returns:
|
||||
The response from OpenAI's embeddings.create call.
|
||||
"""
|
||||
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
@@ -466,12 +522,13 @@ class WrappedEmbeddings:
|
||||
end_time = time.time()
|
||||
|
||||
# Extract usage statistics if available
|
||||
usage_stats = {}
|
||||
usage_stats: TokenUsage = TokenUsage()
|
||||
|
||||
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),
|
||||
}
|
||||
usage_stats = TokenUsage(
|
||||
input_tokens=getattr(response.usage, "prompt_tokens", 0),
|
||||
output_tokens=getattr(response.usage, "completion_tokens", 0),
|
||||
)
|
||||
|
||||
latency = end_time - start_time
|
||||
|
||||
@@ -479,9 +536,13 @@ class WrappedEmbeddings:
|
||||
event_properties = {
|
||||
"$ai_provider": "openai",
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("input")),
|
||||
"$ai_input": with_privacy_mode(
|
||||
self._client._ph_client,
|
||||
posthog_privacy_mode,
|
||||
sanitize_openai_response(kwargs.get("input")),
|
||||
),
|
||||
"$ai_http_status": 200,
|
||||
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
|
||||
"$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),
|
||||
@@ -512,6 +573,7 @@ class WrappedBeta:
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta object for any methods we don't explicitly handle."""
|
||||
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
@@ -528,6 +590,7 @@ class WrappedBetaChat:
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Fallback to original beta chat object for any methods we don't explicitly handle."""
|
||||
|
||||
return getattr(self._original, name)
|
||||
|
||||
@property
|
||||
@@ -544,6 +607,7 @@ class WrappedBetaCompletions:
|
||||
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,735 @@
|
||||
"""
|
||||
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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
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")
|
||||
@@ -1,14 +1,24 @@
|
||||
try:
|
||||
import openai
|
||||
except ImportError:
|
||||
raise ModuleNotFoundError("Please install the Open AI SDK to use this feature: 'pip install openai'")
|
||||
raise ModuleNotFoundError(
|
||||
"Please install the Open AI SDK to use this feature: 'pip install openai'"
|
||||
)
|
||||
|
||||
from posthog.ai.openai.openai import WrappedBeta, WrappedChat, WrappedEmbeddings, WrappedResponses
|
||||
from posthog.ai.openai.openai import (
|
||||
WrappedBeta,
|
||||
WrappedChat,
|
||||
WrappedEmbeddings,
|
||||
WrappedResponses,
|
||||
)
|
||||
from posthog.ai.openai.openai_async import WrappedBeta as AsyncWrappedBeta
|
||||
from posthog.ai.openai.openai_async import WrappedChat as AsyncWrappedChat
|
||||
from posthog.ai.openai.openai_async import WrappedEmbeddings as AsyncWrappedEmbeddings
|
||||
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):
|
||||
@@ -18,7 +28,7 @@ class AzureOpenAI(openai.AzureOpenAI):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: Azure OpenAI API key.
|
||||
@@ -27,7 +37,7 @@ class AzureOpenAI(openai.AzureOpenAI):
|
||||
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
@@ -56,7 +66,7 @@ class AsyncAzureOpenAI(openai.AsyncAzureOpenAI):
|
||||
|
||||
_ph_client: PostHogClient
|
||||
|
||||
def __init__(self, posthog_client: PostHogClient, **kwargs):
|
||||
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
api_key: Azure OpenAI API key.
|
||||
@@ -65,7 +75,7 @@ class AsyncAzureOpenAI(openai.AsyncAzureOpenAI):
|
||||
**openai_config: Any additional keyword args to set on Azure OpenAI (e.g. azure_endpoint="xxx").
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._ph_client = posthog_client
|
||||
self._ph_client = posthog_client or setup()
|
||||
|
||||
# Store original objects after parent initialization (only if they exist)
|
||||
self._original_chat = getattr(self, "chat", None)
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
REDACTED_IMAGE_PLACEHOLDER = "[base64 image redacted]"
|
||||
|
||||
|
||||
def is_base64_data_url(text: str) -> bool:
|
||||
return re.match(r"^data:([^;]+);base64,", text) is not None
|
||||
|
||||
|
||||
def is_valid_url(text: str) -> bool:
|
||||
try:
|
||||
result = urlparse(text)
|
||||
return bool(result.scheme and result.netloc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return text.startswith(("/", "./", "../"))
|
||||
|
||||
|
||||
def is_raw_base64(text: str) -> bool:
|
||||
if is_valid_url(text):
|
||||
return False
|
||||
|
||||
return len(text) > 20 and re.match(r"^[A-Za-z0-9+/]+=*$", text) is not None
|
||||
|
||||
|
||||
def redact_base64_data_url(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
if is_base64_data_url(value):
|
||||
return REDACTED_IMAGE_PLACEHOLDER
|
||||
|
||||
if is_raw_base64(value):
|
||||
return REDACTED_IMAGE_PLACEHOLDER
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def process_messages(messages: Any, transform_content_func) -> Any:
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
def process_content(content: Any) -> Any:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
|
||||
if not content:
|
||||
return content
|
||||
|
||||
if isinstance(content, list):
|
||||
return [transform_content_func(item) for item in content]
|
||||
|
||||
return transform_content_func(content)
|
||||
|
||||
def process_message(msg: Any) -> Any:
|
||||
if not isinstance(msg, dict) or "content" not in msg:
|
||||
return msg
|
||||
return {**msg, "content": process_content(msg["content"])}
|
||||
|
||||
if isinstance(messages, list):
|
||||
return [process_message(msg) for msg in messages]
|
||||
|
||||
return process_message(messages)
|
||||
|
||||
|
||||
def sanitize_openai_image(item: Any) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if (
|
||||
item.get("type") == "image_url"
|
||||
and isinstance(item.get("image_url"), dict)
|
||||
and "url" in item["image_url"]
|
||||
):
|
||||
return {
|
||||
**item,
|
||||
"image_url": {
|
||||
**item["image_url"],
|
||||
"url": redact_base64_data_url(item["image_url"]["url"]),
|
||||
},
|
||||
}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_openai_response_image(item: Any) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if item.get("type") == "input_image" and "image_url" in item:
|
||||
return {
|
||||
**item,
|
||||
"image_url": redact_base64_data_url(item["image_url"]),
|
||||
}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_anthropic_image(item: Any) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if (
|
||||
item.get("type") == "image"
|
||||
and isinstance(item.get("source"), dict)
|
||||
and item["source"].get("type") == "base64"
|
||||
and "data" in item["source"]
|
||||
):
|
||||
# For Anthropic, if the source type is "base64", we should always redact the data
|
||||
# The provider is explicitly telling us this is base64 data
|
||||
return {
|
||||
**item,
|
||||
"source": {
|
||||
**item["source"],
|
||||
"data": REDACTED_IMAGE_PLACEHOLDER,
|
||||
},
|
||||
}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_gemini_part(part: Any) -> Any:
|
||||
if not isinstance(part, dict):
|
||||
return part
|
||||
|
||||
if (
|
||||
"inline_data" in part
|
||||
and isinstance(part["inline_data"], dict)
|
||||
and "data" in part["inline_data"]
|
||||
):
|
||||
# For Gemini, the inline_data structure indicates base64 data
|
||||
# We should redact any string data in this context
|
||||
return {
|
||||
**part,
|
||||
"inline_data": {
|
||||
**part["inline_data"],
|
||||
"data": REDACTED_IMAGE_PLACEHOLDER,
|
||||
},
|
||||
}
|
||||
|
||||
return part
|
||||
|
||||
|
||||
def process_gemini_item(item: Any) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if "parts" in item and item["parts"]:
|
||||
parts = item["parts"]
|
||||
if isinstance(parts, list):
|
||||
parts = [sanitize_gemini_part(part) for part in parts]
|
||||
else:
|
||||
parts = sanitize_gemini_part(parts)
|
||||
|
||||
return {**item, "parts": parts}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_langchain_image(item: Any) -> Any:
|
||||
if not isinstance(item, dict):
|
||||
return item
|
||||
|
||||
if (
|
||||
item.get("type") == "image_url"
|
||||
and isinstance(item.get("image_url"), dict)
|
||||
and "url" in item["image_url"]
|
||||
):
|
||||
return {
|
||||
**item,
|
||||
"image_url": {
|
||||
**item["image_url"],
|
||||
"url": redact_base64_data_url(item["image_url"]["url"]),
|
||||
},
|
||||
}
|
||||
|
||||
if item.get("type") == "image" and "data" in item:
|
||||
return {**item, "data": redact_base64_data_url(item["data"])}
|
||||
|
||||
if (
|
||||
item.get("type") == "image"
|
||||
and isinstance(item.get("source"), dict)
|
||||
and "data" in item["source"]
|
||||
):
|
||||
# Anthropic style - raw base64 in structured format, always redact
|
||||
return {
|
||||
**item,
|
||||
"source": {
|
||||
**item["source"],
|
||||
"data": REDACTED_IMAGE_PLACEHOLDER,
|
||||
},
|
||||
}
|
||||
|
||||
if item.get("type") == "media" and "data" in item:
|
||||
return {**item, "data": redact_base64_data_url(item["data"])}
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def sanitize_openai(data: Any) -> Any:
|
||||
return process_messages(data, sanitize_openai_image)
|
||||
|
||||
|
||||
def sanitize_openai_response(data: Any) -> Any:
|
||||
return process_messages(data, sanitize_openai_response_image)
|
||||
|
||||
|
||||
def sanitize_anthropic(data: Any) -> Any:
|
||||
return process_messages(data, sanitize_anthropic_image)
|
||||
|
||||
|
||||
def sanitize_gemini(data: Any) -> Any:
|
||||
if not data:
|
||||
return data
|
||||
|
||||
if isinstance(data, list):
|
||||
return [process_gemini_item(item) for item in data]
|
||||
|
||||
return process_gemini_item(data)
|
||||
|
||||
|
||||
def sanitize_langchain(data: Any) -> Any:
|
||||
return process_messages(data, sanitize_langchain_image)
|
||||
@@ -0,0 +1,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]]
|
||||
+360
-267
@@ -1,10 +1,83 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from httpx import URL
|
||||
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]:
|
||||
@@ -29,274 +102,135 @@ def get_model_params(kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return model_params
|
||||
|
||||
|
||||
def get_usage(response, provider: str) -> Dict[str, Any]:
|
||||
def get_usage(response, provider: str) -> TokenUsage:
|
||||
"""
|
||||
Extract usage statistics from response based on provider.
|
||||
Delegates to provider-specific converter functions.
|
||||
"""
|
||||
if provider == "anthropic":
|
||||
return {
|
||||
"input_tokens": response.usage.input_tokens,
|
||||
"output_tokens": response.usage.output_tokens,
|
||||
"cache_read_input_tokens": response.usage.cache_read_input_tokens,
|
||||
"cache_creation_input_tokens": response.usage.cache_creation_input_tokens,
|
||||
}
|
||||
from posthog.ai.anthropic.anthropic_converter import (
|
||||
extract_anthropic_usage_from_response,
|
||||
)
|
||||
|
||||
return extract_anthropic_usage_from_response(response)
|
||||
elif provider == "openai":
|
||||
cached_tokens = 0
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
reasoning_tokens = 0
|
||||
from posthog.ai.openai.openai_converter import (
|
||||
extract_openai_usage_from_response,
|
||||
)
|
||||
|
||||
# responses api
|
||||
if hasattr(response.usage, "input_tokens"):
|
||||
input_tokens = response.usage.input_tokens
|
||||
if hasattr(response.usage, "output_tokens"):
|
||||
output_tokens = response.usage.output_tokens
|
||||
if hasattr(response.usage, "input_tokens_details") and hasattr(
|
||||
response.usage.input_tokens_details, "cached_tokens"
|
||||
):
|
||||
cached_tokens = response.usage.input_tokens_details.cached_tokens
|
||||
if hasattr(response.usage, "output_tokens_details") and hasattr(
|
||||
response.usage.output_tokens_details, "reasoning_tokens"
|
||||
):
|
||||
reasoning_tokens = response.usage.output_tokens_details.reasoning_tokens
|
||||
|
||||
# chat completions
|
||||
if hasattr(response.usage, "prompt_tokens"):
|
||||
input_tokens = response.usage.prompt_tokens
|
||||
if hasattr(response.usage, "completion_tokens"):
|
||||
output_tokens = response.usage.completion_tokens
|
||||
if hasattr(response.usage, "prompt_tokens_details") and hasattr(
|
||||
response.usage.prompt_tokens_details, "cached_tokens"
|
||||
):
|
||||
cached_tokens = response.usage.prompt_tokens_details.cached_tokens
|
||||
|
||||
return {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"cache_read_input_tokens": cached_tokens,
|
||||
"reasoning_tokens": reasoning_tokens,
|
||||
}
|
||||
return extract_openai_usage_from_response(response)
|
||||
elif provider == "gemini":
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
from posthog.ai.gemini.gemini_converter import (
|
||||
extract_gemini_usage_from_response,
|
||||
)
|
||||
|
||||
if hasattr(response, "usage_metadata") and response.usage_metadata:
|
||||
input_tokens = getattr(response.usage_metadata, "prompt_token_count", 0)
|
||||
output_tokens = getattr(response.usage_metadata, "candidates_token_count", 0)
|
||||
return extract_gemini_usage_from_response(response)
|
||||
|
||||
return {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"cache_read_input_tokens": 0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
}
|
||||
return {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
}
|
||||
return TokenUsage(input_tokens=0, output_tokens=0)
|
||||
|
||||
|
||||
def format_response(response, provider: str):
|
||||
"""
|
||||
Format a regular (non-streaming) response.
|
||||
"""
|
||||
output = []
|
||||
if response is None:
|
||||
return output
|
||||
if provider == "anthropic":
|
||||
return format_response_anthropic(response)
|
||||
from posthog.ai.anthropic.anthropic_converter import format_anthropic_response
|
||||
|
||||
return format_anthropic_response(response)
|
||||
elif provider == "openai":
|
||||
return format_response_openai(response)
|
||||
from posthog.ai.openai.openai_converter import format_openai_response
|
||||
|
||||
return format_openai_response(response)
|
||||
elif provider == "gemini":
|
||||
return format_response_gemini(response)
|
||||
return output
|
||||
from posthog.ai.gemini.gemini_converter import format_gemini_response
|
||||
|
||||
return format_gemini_response(response)
|
||||
return []
|
||||
|
||||
|
||||
def format_response_anthropic(response):
|
||||
output = []
|
||||
for choice in response.content:
|
||||
if choice.text:
|
||||
output.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": choice.text,
|
||||
}
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def format_response_openai(response):
|
||||
output = []
|
||||
if hasattr(response, "choices"):
|
||||
for choice in response.choices:
|
||||
# Handle Chat Completions response format
|
||||
if hasattr(choice, "message") and choice.message and choice.message.content:
|
||||
output.append(
|
||||
{
|
||||
"content": choice.message.content,
|
||||
"role": choice.message.role,
|
||||
}
|
||||
)
|
||||
# Handle Responses API format
|
||||
if hasattr(response, "output"):
|
||||
for item in response.output:
|
||||
if item.type == "message":
|
||||
# Extract text content from the content list
|
||||
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")
|
||||
):
|
||||
output.append(
|
||||
{
|
||||
"content": content_item.text,
|
||||
"role": item.role,
|
||||
}
|
||||
)
|
||||
elif hasattr(content_item, "text"):
|
||||
output.append(
|
||||
{
|
||||
"content": content_item.text,
|
||||
"role": item.role,
|
||||
}
|
||||
)
|
||||
elif (
|
||||
hasattr(content_item, "type")
|
||||
and content_item.type == "input_image"
|
||||
and hasattr(content_item, "image_url")
|
||||
):
|
||||
output.append(
|
||||
{
|
||||
"content": {
|
||||
"type": "image",
|
||||
"image": content_item.image_url,
|
||||
},
|
||||
"role": item.role,
|
||||
}
|
||||
)
|
||||
else:
|
||||
output.append(
|
||||
{
|
||||
"content": item.content,
|
||||
"role": item.role,
|
||||
}
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def format_response_gemini(response):
|
||||
output = []
|
||||
if hasattr(response, "candidates") and response.candidates:
|
||||
for candidate in response.candidates:
|
||||
if hasattr(candidate, "content") and candidate.content:
|
||||
content_text = ""
|
||||
if hasattr(candidate.content, "parts") and candidate.content.parts:
|
||||
for part in candidate.content.parts:
|
||||
if hasattr(part, "text") and part.text:
|
||||
content_text += part.text
|
||||
if content_text:
|
||||
output.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": content_text,
|
||||
}
|
||||
)
|
||||
elif hasattr(candidate, "text") and candidate.text:
|
||||
output.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": candidate.text,
|
||||
}
|
||||
)
|
||||
elif hasattr(response, "text") and response.text:
|
||||
output.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": response.text,
|
||||
}
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def format_tool_calls(response, provider: str):
|
||||
def extract_available_tool_calls(provider: str, kwargs: Dict[str, Any]):
|
||||
"""
|
||||
Extract available tool calls for the given provider.
|
||||
"""
|
||||
if provider == "anthropic":
|
||||
if hasattr(response, "tools") and response.tools and len(response.tools) > 0:
|
||||
return response.tools
|
||||
elif provider == "openai":
|
||||
# Handle both Chat Completions and Responses API
|
||||
if hasattr(response, "choices") and response.choices:
|
||||
# Check for tool_calls in message (Chat Completions format)
|
||||
if (
|
||||
hasattr(response.choices[0], "message")
|
||||
and hasattr(response.choices[0].message, "tool_calls")
|
||||
and response.choices[0].message.tool_calls
|
||||
):
|
||||
return response.choices[0].message.tool_calls
|
||||
from posthog.ai.anthropic.anthropic_converter import extract_anthropic_tools
|
||||
|
||||
# Check for tool_calls directly in response (Responses API format)
|
||||
if hasattr(response.choices[0], "tool_calls") and response.choices[0].tool_calls:
|
||||
return response.choices[0].tool_calls
|
||||
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):
|
||||
messages: List[Dict[str, Any]] = []
|
||||
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 []
|
||||
if kwargs.get("system") is None:
|
||||
return messages
|
||||
return [{"role": "system", "content": kwargs.get("system")}] + messages
|
||||
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", [])
|
||||
if isinstance(contents, str):
|
||||
return [{"role": "user", "content": contents}]
|
||||
elif isinstance(contents, list):
|
||||
formatted = []
|
||||
for item in contents:
|
||||
if isinstance(item, str):
|
||||
formatted.append({"role": "user", "content": item})
|
||||
elif hasattr(item, "text"):
|
||||
formatted.append({"role": "user", "content": item.text})
|
||||
else:
|
||||
formatted.append({"role": "user", "content": str(item)})
|
||||
return formatted
|
||||
else:
|
||||
return [{"role": "user", "content": str(contents)}]
|
||||
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
|
||||
if kwargs.get("messages") is not None:
|
||||
messages = list(kwargs.get("messages", []))
|
||||
# For OpenAI, handle both Chat Completions and Responses API
|
||||
messages_param = kwargs.get("messages")
|
||||
input_param = kwargs.get("input")
|
||||
|
||||
if kwargs.get("input") is not None:
|
||||
input_data = kwargs.get("input")
|
||||
if isinstance(input_data, list):
|
||||
messages.extend(input_data)
|
||||
else:
|
||||
messages.append({"role": "user", "content": input_data})
|
||||
# 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:
|
||||
messages = [{"role": "system", "content": kwargs.get("system")}] + messages
|
||||
# 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)
|
||||
# For Responses API, add instructions to the system prompt if provided
|
||||
if kwargs.get("instructions") is not None:
|
||||
# Find the system message if it exists
|
||||
system_idx = next(
|
||||
(i for i, msg in enumerate(messages) if msg.get("role") == "system"),
|
||||
None,
|
||||
)
|
||||
|
||||
if system_idx is not None:
|
||||
# Append instructions to existing system message
|
||||
system_content = messages[system_idx].get("content", "")
|
||||
messages[system_idx]["content"] = f"{system_content}\n\n{kwargs.get('instructions')}"
|
||||
else:
|
||||
# Create a new system message with instructions
|
||||
messages = [{"role": "system", "content": kwargs.get("instructions")}] + messages
|
||||
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
|
||||
return messages
|
||||
|
||||
# Default case - return empty list
|
||||
return []
|
||||
|
||||
|
||||
def call_llm_and_track_usage(
|
||||
@@ -307,7 +241,7 @@ def call_llm_and_track_usage(
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
base_url: URL,
|
||||
base_url: str,
|
||||
call_method: Callable[..., Any],
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
@@ -319,14 +253,16 @@ def call_llm_and_track_usage(
|
||||
response = None
|
||||
error = None
|
||||
http_status = 200
|
||||
usage: Dict[str, Any] = {}
|
||||
error_params: Dict[str, any] = {}
|
||||
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
|
||||
http_status = getattr(
|
||||
exc, "status_code", 0
|
||||
) # default to 0 becuase its likely an SDK error
|
||||
error_params = {
|
||||
"$ai_is_error": True,
|
||||
"$ai_error": exc.__str__(),
|
||||
@@ -338,16 +274,22 @@ def call_llm_and_track_usage(
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
if response and (hasattr(response, "usage") or (provider == "gemini" and hasattr(response, "usage_metadata"))):
|
||||
if response and (
|
||||
hasattr(response, "usage")
|
||||
or (provider == "gemini" and hasattr(response, "usage_metadata"))
|
||||
):
|
||||
usage = get_usage(response, provider)
|
||||
|
||||
messages = merge_system_prompt(kwargs, provider)
|
||||
sanitized_messages = sanitize_messages(messages, provider)
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": provider,
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(ph_client, posthog_privacy_mode, messages),
|
||||
"$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)
|
||||
),
|
||||
@@ -361,18 +303,26 @@ def call_llm_and_track_usage(
|
||||
**(error_params or {}),
|
||||
}
|
||||
|
||||
tool_calls = format_tool_calls(response, provider)
|
||||
if tool_calls:
|
||||
event_properties["$ai_tools"] = with_privacy_mode(ph_client, posthog_privacy_mode, tool_calls)
|
||||
available_tool_calls = extract_available_tool_calls(provider, kwargs)
|
||||
|
||||
if usage.get("cache_read_input_tokens") is not None and usage.get("cache_read_input_tokens", 0) > 0:
|
||||
event_properties["$ai_cache_read_input_tokens"] = usage.get("cache_read_input_tokens", 0)
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_tool_calls
|
||||
|
||||
if usage.get("cache_creation_input_tokens") is not None and usage.get("cache_creation_input_tokens", 0) > 0:
|
||||
event_properties["$ai_cache_creation_input_tokens"] = usage.get("cache_creation_input_tokens", 0)
|
||||
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
|
||||
|
||||
if usage.get("reasoning_tokens") is not None and usage.get("reasoning_tokens", 0) > 0:
|
||||
event_properties["$ai_reasoning_tokens"] = usage.get("reasoning_tokens", 0)
|
||||
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
|
||||
@@ -406,7 +356,7 @@ async def call_llm_and_track_usage_async(
|
||||
posthog_properties: Optional[Dict[str, Any]],
|
||||
posthog_privacy_mode: bool,
|
||||
posthog_groups: Optional[Dict[str, Any]],
|
||||
base_url: URL,
|
||||
base_url: str,
|
||||
call_async_method: Callable[..., Any],
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
@@ -414,14 +364,16 @@ async def call_llm_and_track_usage_async(
|
||||
response = None
|
||||
error = None
|
||||
http_status = 200
|
||||
usage: Dict[str, Any] = {}
|
||||
error_params: Dict[str, any] = {}
|
||||
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
|
||||
http_status = getattr(
|
||||
exc, "status_code", 0
|
||||
) # default to 0 because its likely an SDK error
|
||||
error_params = {
|
||||
"$ai_is_error": True,
|
||||
"$ai_error": exc.__str__(),
|
||||
@@ -433,16 +385,22 @@ async def call_llm_and_track_usage_async(
|
||||
if posthog_trace_id is None:
|
||||
posthog_trace_id = str(uuid.uuid4())
|
||||
|
||||
if response and (hasattr(response, "usage") or (provider == "gemini" and hasattr(response, "usage_metadata"))):
|
||||
if response and (
|
||||
hasattr(response, "usage")
|
||||
or (provider == "gemini" and hasattr(response, "usage_metadata"))
|
||||
):
|
||||
usage = get_usage(response, provider)
|
||||
|
||||
messages = merge_system_prompt(kwargs, provider)
|
||||
sanitized_messages = sanitize_messages(messages, provider)
|
||||
|
||||
event_properties = {
|
||||
"$ai_provider": provider,
|
||||
"$ai_model": kwargs.get("model"),
|
||||
"$ai_model_parameters": get_model_params(kwargs),
|
||||
"$ai_input": with_privacy_mode(ph_client, posthog_privacy_mode, messages),
|
||||
"$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)
|
||||
),
|
||||
@@ -456,15 +414,26 @@ async def call_llm_and_track_usage_async(
|
||||
**(error_params or {}),
|
||||
}
|
||||
|
||||
tool_calls = format_tool_calls(response, provider)
|
||||
if tool_calls:
|
||||
event_properties["$ai_tools"] = with_privacy_mode(ph_client, posthog_privacy_mode, tool_calls)
|
||||
available_tool_calls = extract_available_tool_calls(provider, kwargs)
|
||||
|
||||
if usage.get("cache_read_input_tokens") is not None and usage.get("cache_read_input_tokens", 0) > 0:
|
||||
event_properties["$ai_cache_read_input_tokens"] = usage.get("cache_read_input_tokens", 0)
|
||||
if available_tool_calls:
|
||||
event_properties["$ai_tools"] = available_tool_calls
|
||||
|
||||
if usage.get("cache_creation_input_tokens") is not None and usage.get("cache_creation_input_tokens", 0) > 0:
|
||||
event_properties["$ai_cache_creation_input_tokens"] = usage.get("cache_creation_input_tokens", 0)
|
||||
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
|
||||
@@ -490,7 +459,131 @@ async def call_llm_and_track_usage_async(
|
||||
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]
|
||||
+1221
-467
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -107,7 +107,9 @@ class Consumer(Thread):
|
||||
item = queue.get(block=True, timeout=self.flush_interval - elapsed)
|
||||
item_size = len(json.dumps(item, cls=DatetimeSerializer).encode())
|
||||
if item_size > MAX_MSG_SIZE:
|
||||
self.log.error("Item exceeds 900kib limit, dropping. (%s)", str(item))
|
||||
self.log.error(
|
||||
"Item exceeds 900kib limit, dropping. (%s)", str(item)
|
||||
)
|
||||
continue
|
||||
items.append(item)
|
||||
total_size += item_size
|
||||
@@ -134,7 +136,9 @@ class Consumer(Thread):
|
||||
# retry on all other errors (eg. network)
|
||||
return False
|
||||
|
||||
@backoff.on_exception(backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception)
|
||||
@backoff.on_exception(
|
||||
backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception
|
||||
)
|
||||
def send_request():
|
||||
batch_post(
|
||||
self.api_key,
|
||||
|
||||
@@ -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
|
||||
@@ -6,45 +6,25 @@
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from posthog.client import Client
|
||||
|
||||
|
||||
class Integrations(str, Enum):
|
||||
Django = "django"
|
||||
|
||||
|
||||
class ExceptionCapture:
|
||||
# TODO: Add client side rate limiting to prevent spamming the server with exceptions
|
||||
|
||||
log = logging.getLogger("posthog")
|
||||
|
||||
def __init__(self, client: "Client", integrations: Optional[List[Integrations]] = None):
|
||||
def __init__(self, client: "Client"):
|
||||
self.client = client
|
||||
self.original_excepthook = sys.excepthook
|
||||
sys.excepthook = self.exception_handler
|
||||
threading.excepthook = self.thread_exception_handler
|
||||
self.enabled_integrations = []
|
||||
|
||||
for integration in integrations or []:
|
||||
# TODO: Maybe find a better way of enabling integrations
|
||||
# This is very annoying currently if we had to add any configuration per integration
|
||||
if integration == Integrations.Django:
|
||||
try:
|
||||
from posthog.exception_integrations.django import DjangoIntegration
|
||||
|
||||
enabled_integration = DjangoIntegration(self.exception_receiver)
|
||||
self.enabled_integrations.append(enabled_integration)
|
||||
except Exception as e:
|
||||
self.log.exception(f"Failed to enable Django integration: {e}")
|
||||
|
||||
def close(self):
|
||||
sys.excepthook = self.original_excepthook
|
||||
for integration in self.enabled_integrations:
|
||||
integration.uninstall()
|
||||
|
||||
def exception_handler(self, exc_type, exc_value, exc_traceback):
|
||||
# don't affect default behaviour.
|
||||
@@ -64,6 +44,6 @@ class ExceptionCapture:
|
||||
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)
|
||||
self.client.capture_exception(exception, distinct_id=distinct_id)
|
||||
except Exception as e:
|
||||
self.log.exception(f"Failed to capture exception: {e}")
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
class IntegrationEnablingError(Exception):
|
||||
"""
|
||||
The integration could not be enabled due to a user error like
|
||||
`django` not being installed for the `DjangoIntegration`.
|
||||
"""
|
||||
@@ -1,93 +0,0 @@
|
||||
# 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 re
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from posthog.exception_integrations import IntegrationEnablingError
|
||||
|
||||
try:
|
||||
from django import VERSION as DJANGO_VERSION
|
||||
from django.core import signals
|
||||
|
||||
except ImportError:
|
||||
raise IntegrationEnablingError("Django not installed")
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Dict # noqa: F401
|
||||
|
||||
from django.core.handlers.wsgi import WSGIRequest # noqa: F401
|
||||
|
||||
|
||||
class DjangoIntegration:
|
||||
# TODO: Abstract integrations one we have more and can see patterns
|
||||
"""
|
||||
Autocapture errors from a Django application.
|
||||
"""
|
||||
|
||||
identifier = "django"
|
||||
|
||||
def __init__(self, capture_exception_fn=None):
|
||||
|
||||
if DJANGO_VERSION < (4, 2):
|
||||
raise IntegrationEnablingError("Django 4.2 or newer is required.")
|
||||
|
||||
# TODO: Right now this seems too complicated / overkill for us, but seems like we can automatically plug in middlewares
|
||||
# which is great for users (they don't need to do this) and everything should just work.
|
||||
# We should consider this in the future, but for now we can just use the middleware and signals handlers.
|
||||
# See: https://github.com/getsentry/sentry-python/blob/269d96d6e9821122fbff280e6a26956e5ed03c0b/sentry_sdk/integrations/django/__init__.py
|
||||
|
||||
self.capture_exception_fn = capture_exception_fn
|
||||
|
||||
def _got_request_exception(request=None, **kwargs):
|
||||
# type: (WSGIRequest, **Any) -> None
|
||||
|
||||
extra_props = {}
|
||||
if request is not None:
|
||||
# get headers metadata
|
||||
extra_props = DjangoRequestExtractor(request).extract_person_data()
|
||||
|
||||
self.capture_exception_fn(sys.exc_info(), extra_props)
|
||||
|
||||
signals.got_request_exception.connect(_got_request_exception)
|
||||
|
||||
def uninstall(self):
|
||||
pass
|
||||
|
||||
|
||||
class DjangoRequestExtractor:
|
||||
|
||||
def __init__(self, request):
|
||||
# type: (Any) -> None
|
||||
self.request = request
|
||||
|
||||
def extract_person_data(self):
|
||||
headers = self.headers()
|
||||
|
||||
# Extract traceparent and tracestate headers
|
||||
traceparent = headers.get("traceparent")
|
||||
tracestate = headers.get("tracestate")
|
||||
|
||||
# Extract the distinct_id from tracestate
|
||||
distinct_id = None
|
||||
if tracestate:
|
||||
# TODO: Align on the format of the distinct_id in tracestate
|
||||
# We can't have comma or equals in header values here, so maybe we should base64 encode it?
|
||||
match = re.search(r"posthog-distinct-id=([^,]+)", tracestate)
|
||||
if match:
|
||||
distinct_id = match.group(1)
|
||||
|
||||
return {
|
||||
"distinct_id": distinct_id,
|
||||
"ip": headers.get("X-Forwarded-For"),
|
||||
"user_agent": headers.get("User-Agent"),
|
||||
"traceparent": traceparent,
|
||||
}
|
||||
|
||||
def headers(self):
|
||||
# type: () -> Dict[str, str]
|
||||
return dict(self.request.headers)
|
||||
+376
-174
@@ -5,12 +5,32 @@
|
||||
# 💖open source (under MIT License)
|
||||
# We want to keep payloads as similar to Sentry as possible for easy interoperability
|
||||
|
||||
import json
|
||||
import linecache
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from types import FrameType, TracebackType # noqa: F401
|
||||
from typing import ( # noqa: F401
|
||||
Any,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
TYPE_CHECKING,
|
||||
Pattern,
|
||||
)
|
||||
|
||||
from posthog.args import ExcInfo, ExceptionArg # noqa: F401
|
||||
|
||||
try:
|
||||
# Python 3.11
|
||||
@@ -22,78 +42,97 @@ except ImportError:
|
||||
|
||||
DEFAULT_MAX_VALUE_LENGTH = 1024
|
||||
|
||||
DEFAULT_CODE_VARIABLES_MASK_PATTERNS = [
|
||||
r"(?i).*password.*",
|
||||
r"(?i).*secret.*",
|
||||
r"(?i).*passwd.*",
|
||||
r"(?i).*pwd.*",
|
||||
r"(?i).*api_key.*",
|
||||
r"(?i).*apikey.*",
|
||||
r"(?i).*auth.*",
|
||||
r"(?i).*credentials.*",
|
||||
r"(?i).*privatekey.*",
|
||||
r"(?i).*private_key.*",
|
||||
r"(?i).*token.*",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = [r"^__.*"]
|
||||
|
||||
from types import FrameType, TracebackType
|
||||
from typing import ( # noqa: F401
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
Type,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
CODE_VARIABLES_REDACTED_VALUE = "$$_posthog_redacted_based_on_masking_rules_$$"
|
||||
|
||||
ExcInfo = Union[
|
||||
Tuple[Type[BaseException], BaseException, Optional[TracebackType]],
|
||||
Tuple[None, None, None],
|
||||
]
|
||||
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
|
||||
DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT = 20 * 1024
|
||||
|
||||
Event = TypedDict(
|
||||
"Event",
|
||||
{
|
||||
"breadcrumbs": Dict[Literal["values"], List[Dict[str, Any]]], # TODO: We can expand on this type
|
||||
"check_in_id": str,
|
||||
"contexts": Dict[str, Dict[str, object]],
|
||||
"dist": str,
|
||||
"duration": Optional[float],
|
||||
"environment": str,
|
||||
"errors": List[Dict[str, Any]], # TODO: We can expand on this type
|
||||
"event_id": str,
|
||||
"exception": Dict[Literal["values"], List[Dict[str, Any]]], # TODO: We can expand on this type
|
||||
# "extra": MutableMapping[str, object],
|
||||
# "fingerprint": List[str],
|
||||
"level": LogLevelStr,
|
||||
# "logentry": Mapping[str, object],
|
||||
"logger": str,
|
||||
# "measurements": Dict[str, MeasurementValue],
|
||||
"message": str,
|
||||
"modules": Dict[str, str],
|
||||
# "monitor_config": Mapping[str, object],
|
||||
"monitor_slug": Optional[str],
|
||||
"platform": Literal["python"],
|
||||
"profile": object, # Should be sentry_sdk.profiler.Profile, but we can't import that here due to circular imports
|
||||
"release": str,
|
||||
"request": Dict[str, object],
|
||||
# "sdk": Mapping[str, object],
|
||||
"server_name": str,
|
||||
"spans": List[Dict[str, object]],
|
||||
"stacktrace": Dict[str, object], # We access this key in the code, but I am unsure whether we ever set it
|
||||
"start_timestamp": datetime,
|
||||
"status": Optional[str],
|
||||
# "tags": MutableMapping[
|
||||
# str, str
|
||||
# ], # Tags must be less than 200 characters each
|
||||
"threads": Dict[Literal["values"], List[Dict[str, Any]]], # TODO: We can expand on this type
|
||||
"timestamp": Optional[datetime], # Must be set before sending the event
|
||||
"transaction": str,
|
||||
# "transaction_info": Mapping[str, Any], # TODO: We can expand on this type
|
||||
"type": Literal["check_in", "transaction"],
|
||||
"user": Dict[str, object],
|
||||
"_metrics_summary": Dict[str, object],
|
||||
},
|
||||
total=False,
|
||||
)
|
||||
|
||||
class VariableSizeLimiter:
|
||||
def __init__(self, max_size=DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT):
|
||||
self.max_size = max_size
|
||||
self.current_size = 0
|
||||
|
||||
def can_add(self, size):
|
||||
return self.current_size + size <= self.max_size
|
||||
|
||||
def add(self, size):
|
||||
self.current_size += size
|
||||
|
||||
def get_remaining_space(self):
|
||||
return self.max_size - self.current_size
|
||||
|
||||
|
||||
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
|
||||
|
||||
Event = TypedDict(
|
||||
"Event",
|
||||
{
|
||||
"breadcrumbs": Dict[
|
||||
Literal["values"], List[Dict[str, Any]]
|
||||
], # TODO: We can expand on this type
|
||||
"check_in_id": str,
|
||||
"contexts": Dict[str, Dict[str, object]],
|
||||
"dist": str,
|
||||
"duration": Optional[float],
|
||||
"environment": str,
|
||||
"errors": List[Dict[str, Any]], # TODO: We can expand on this type
|
||||
"event_id": str,
|
||||
"exception": Dict[
|
||||
Literal["values"], List[Dict[str, Any]]
|
||||
], # TODO: We can expand on this type
|
||||
# "extra": MutableMapping[str, object],
|
||||
# "fingerprint": List[str],
|
||||
"level": LogLevelStr,
|
||||
# "logentry": Mapping[str, object],
|
||||
"logger": str,
|
||||
# "measurements": Dict[str, MeasurementValue],
|
||||
"message": str,
|
||||
"modules": Dict[str, str],
|
||||
# "monitor_config": Mapping[str, object],
|
||||
"monitor_slug": Optional[str],
|
||||
"platform": Literal["python"],
|
||||
"profile": object,
|
||||
"release": str,
|
||||
"request": Dict[str, object],
|
||||
# "sdk": Mapping[str, object],
|
||||
"server_name": str,
|
||||
"spans": List[Dict[str, object]],
|
||||
"stacktrace": Dict[
|
||||
str, object
|
||||
], # We access this key in the code, but I am unsure whether we ever set it
|
||||
"start_timestamp": datetime,
|
||||
"status": Optional[str],
|
||||
# "tags": MutableMapping[
|
||||
# str, str
|
||||
# ], # Tags must be less than 200 characters each
|
||||
"threads": Dict[
|
||||
Literal["values"], List[Dict[str, Any]]
|
||||
], # TODO: We can expand on this type
|
||||
"timestamp": Optional[datetime], # Must be set before sending the event
|
||||
"transaction": str,
|
||||
# "transaction_info": Mapping[str, Any], # TODO: We can expand on this type
|
||||
"type": Literal["check_in", "transaction"],
|
||||
"user": Dict[str, object],
|
||||
"_metrics_summary": Dict[str, object],
|
||||
},
|
||||
total=False,
|
||||
)
|
||||
|
||||
|
||||
epoch = datetime(1970, 1, 1)
|
||||
@@ -129,9 +168,6 @@ def event_hint_with_exc_info(exc_info=None):
|
||||
class AnnotatedValue:
|
||||
"""
|
||||
Meta information for a data field in the event payload.
|
||||
This is to tell Relay that we have tampered with the fields value.
|
||||
See:
|
||||
https://github.com/getsentry/relay/blob/be12cd49a0f06ea932ed9b9f93a655de5d6ad6d1/relay-general/src/types/meta.rs#L407-L423
|
||||
"""
|
||||
|
||||
__slots__ = ("value", "metadata")
|
||||
@@ -273,7 +309,10 @@ def get_lines_from_file(
|
||||
upper_bound = min(lineno + 1 + context_lines, len(source))
|
||||
|
||||
try:
|
||||
pre_context = [strip_string(line.strip("\r\n"), max_length=max_length) for line in source[lower_bound:lineno]]
|
||||
pre_context = [
|
||||
strip_string(line.strip("\r\n"), max_length=max_length)
|
||||
for line in source[lower_bound:lineno]
|
||||
]
|
||||
context_line = strip_string(source[lineno].strip("\r\n"), max_length=max_length)
|
||||
post_context = [
|
||||
strip_string(line.strip("\r\n"), max_length=max_length)
|
||||
@@ -305,7 +344,9 @@ def get_source_context(
|
||||
loader = None
|
||||
lineno = tb_lineno - 1
|
||||
if lineno is not None and abs_path:
|
||||
return get_lines_from_file(abs_path, lineno, max_value_length, loader=loader, module=module)
|
||||
return get_lines_from_file(
|
||||
abs_path, lineno, max_value_length, loader=loader, module=module
|
||||
)
|
||||
return [], None, []
|
||||
|
||||
|
||||
@@ -342,7 +383,9 @@ def filename_for_module(module, abs_path):
|
||||
if not base_module_path:
|
||||
return abs_path
|
||||
|
||||
return abs_path.split(base_module_path.rsplit(os.sep, 2)[0], 1)[-1].lstrip(os.sep)
|
||||
return abs_path.split(base_module_path.rsplit(os.sep, 2)[0], 1)[-1].lstrip(
|
||||
os.sep
|
||||
)
|
||||
except Exception:
|
||||
return abs_path
|
||||
|
||||
@@ -350,12 +393,9 @@ def filename_for_module(module, abs_path):
|
||||
def serialize_frame(
|
||||
frame,
|
||||
tb_lineno=None,
|
||||
include_local_variables=True,
|
||||
include_source_context=True,
|
||||
max_value_length=None,
|
||||
custom_repr=None,
|
||||
):
|
||||
# type: (FrameType, Optional[int], bool, bool, Optional[int], Optional[Callable[..., Optional[str]]]) -> Dict[str, Any]
|
||||
# type: (FrameType, Optional[int], Optional[int]) -> Dict[str, Any]
|
||||
f_code = getattr(frame, "f_code", None)
|
||||
if not f_code:
|
||||
abs_path = None
|
||||
@@ -380,50 +420,13 @@ def serialize_frame(
|
||||
"lineno": tb_lineno,
|
||||
} # type: Dict[str, Any]
|
||||
|
||||
if include_source_context:
|
||||
rv["pre_context"], rv["context_line"], rv["post_context"] = get_source_context(
|
||||
frame, tb_lineno, max_value_length
|
||||
)
|
||||
|
||||
if include_local_variables:
|
||||
# TODO(nk): Sort out this current invalid import
|
||||
# from sentry_sdk.serializer import serialize
|
||||
|
||||
# rv["vars"] = serialize(
|
||||
# dict(frame.f_locals), is_vars=True, custom_repr=custom_repr
|
||||
# )
|
||||
pass
|
||||
rv["pre_context"], rv["context_line"], rv["post_context"] = get_source_context(
|
||||
frame, tb_lineno, max_value_length
|
||||
)
|
||||
|
||||
return rv
|
||||
|
||||
|
||||
def current_stacktrace(
|
||||
include_local_variables=True, # type: bool
|
||||
include_source_context=True, # type: bool
|
||||
max_value_length=None, # type: Optional[int]
|
||||
):
|
||||
# type: (...) -> Dict[str, Any]
|
||||
__tracebackhide__ = True
|
||||
frames = []
|
||||
|
||||
f = sys._getframe() # type: Optional[FrameType]
|
||||
while f is not None:
|
||||
if not should_hide_frame(f):
|
||||
frames.append(
|
||||
serialize_frame(
|
||||
f,
|
||||
include_local_variables=include_local_variables,
|
||||
include_source_context=include_source_context,
|
||||
max_value_length=max_value_length,
|
||||
)
|
||||
)
|
||||
f = f.f_back
|
||||
|
||||
frames.reverse()
|
||||
|
||||
return {"frames": frames, "type": "raw"}
|
||||
|
||||
|
||||
def get_errno(exc_value):
|
||||
# type: (BaseException) -> Optional[Any]
|
||||
return getattr(exc_value, "errno", None)
|
||||
@@ -431,14 +434,19 @@ def get_errno(exc_value):
|
||||
|
||||
def get_error_message(exc_value):
|
||||
# type: (Optional[BaseException]) -> str
|
||||
return getattr(exc_value, "message", "") or getattr(exc_value, "detail", "") or safe_str(exc_value)
|
||||
message = (
|
||||
getattr(exc_value, "message", "")
|
||||
or getattr(exc_value, "detail", "")
|
||||
or exc_value
|
||||
)
|
||||
|
||||
return safe_str(message)
|
||||
|
||||
|
||||
def single_exception_from_error_tuple(
|
||||
exc_type, # type: Optional[type]
|
||||
exc_value, # type: Optional[BaseException]
|
||||
tb, # type: Optional[TracebackType]
|
||||
client_options=None, # type: Optional[Dict[str, Any]]
|
||||
mechanism=None, # type: Optional[Dict[str, Any]]
|
||||
exception_id=None, # type: Optional[int]
|
||||
parent_id=None, # type: Optional[int]
|
||||
@@ -446,13 +454,12 @@ def single_exception_from_error_tuple(
|
||||
):
|
||||
# type: (...) -> Dict[str, Any]
|
||||
"""
|
||||
Creates a dict that goes into the events `exception.values` list and is ingestible by Sentry.
|
||||
|
||||
See the Exception Interface documentation for more details:
|
||||
https://develop.sentry.dev/sdk/event-payloads/exception/
|
||||
Creates a dict that goes into the events `exception.values` list
|
||||
"""
|
||||
exception_value = {} # type: Dict[str, Any]
|
||||
exception_value["mechanism"] = mechanism.copy() if mechanism else {"type": "generic", "handled": True}
|
||||
exception_value["mechanism"] = (
|
||||
mechanism.copy() if mechanism else {"type": "generic", "handled": True}
|
||||
)
|
||||
if exception_id is not None:
|
||||
exception_value["mechanism"]["exception_id"] = exception_id
|
||||
|
||||
@@ -462,7 +469,9 @@ def single_exception_from_error_tuple(
|
||||
errno = None
|
||||
|
||||
if errno is not None:
|
||||
exception_value["mechanism"].setdefault("meta", {}).setdefault("errno", {}).setdefault("number", errno)
|
||||
exception_value["mechanism"].setdefault("meta", {}).setdefault(
|
||||
"errno", {}
|
||||
).setdefault("number", errno)
|
||||
|
||||
if source is not None:
|
||||
exception_value["mechanism"]["source"] = source
|
||||
@@ -475,7 +484,9 @@ def single_exception_from_error_tuple(
|
||||
if is_root_exception and "type" not in exception_value["mechanism"]:
|
||||
exception_value["mechanism"]["type"] = "generic"
|
||||
|
||||
is_exception_group = BaseExceptionGroup is not None and isinstance(exc_value, BaseExceptionGroup)
|
||||
is_exception_group = BaseExceptionGroup is not None and isinstance(
|
||||
exc_value, BaseExceptionGroup
|
||||
)
|
||||
if is_exception_group:
|
||||
exception_value["mechanism"]["is_exception_group"] = True
|
||||
|
||||
@@ -483,25 +494,13 @@ def single_exception_from_error_tuple(
|
||||
exception_value["type"] = get_type_name(exc_type)
|
||||
exception_value["value"] = get_error_message(exc_value)
|
||||
|
||||
if client_options is None:
|
||||
include_local_variables = True
|
||||
include_source_context = True
|
||||
max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback
|
||||
custom_repr = None
|
||||
else:
|
||||
include_local_variables = client_options["include_local_variables"]
|
||||
include_source_context = client_options["include_source_context"]
|
||||
max_value_length = client_options["max_value_length"]
|
||||
custom_repr = client_options.get("custom_repr")
|
||||
max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback
|
||||
|
||||
frames = [
|
||||
serialize_frame(
|
||||
tb.tb_frame,
|
||||
tb_lineno=tb.tb_lineno,
|
||||
include_local_variables=include_local_variables,
|
||||
include_source_context=include_source_context,
|
||||
max_value_length=max_value_length,
|
||||
custom_repr=custom_repr,
|
||||
)
|
||||
for tb in iter_stacks(tb)
|
||||
]
|
||||
@@ -523,7 +522,11 @@ if HAS_CHAINED_EXCEPTIONS:
|
||||
seen_exceptions = []
|
||||
seen_exception_ids = set() # type: Set[int]
|
||||
|
||||
while exc_type is not None and exc_value is not None and id(exc_value) not in seen_exception_ids:
|
||||
while (
|
||||
exc_type is not None
|
||||
and exc_value is not None
|
||||
and id(exc_value) not in seen_exception_ids
|
||||
):
|
||||
yield exc_type, exc_value, tb
|
||||
|
||||
# Avoid hashing random types we don't know anything
|
||||
@@ -553,7 +556,6 @@ def exceptions_from_error(
|
||||
exc_type, # type: Optional[type]
|
||||
exc_value, # type: Optional[BaseException]
|
||||
tb, # type: Optional[TracebackType]
|
||||
client_options=None, # type: Optional[Dict[str, Any]]
|
||||
mechanism=None, # type: Optional[Dict[str, Any]]
|
||||
exception_id=0, # type: int
|
||||
parent_id=0, # type: int
|
||||
@@ -563,16 +565,12 @@ def exceptions_from_error(
|
||||
"""
|
||||
Creates the list of exceptions.
|
||||
This can include chained exceptions and exceptions from an ExceptionGroup.
|
||||
|
||||
See the Exception Interface documentation for more details:
|
||||
https://develop.sentry.dev/sdk/event-payloads/exception/
|
||||
"""
|
||||
|
||||
parent = single_exception_from_error_tuple(
|
||||
exc_type=exc_type,
|
||||
exc_value=exc_value,
|
||||
tb=tb,
|
||||
client_options=client_options,
|
||||
mechanism=mechanism,
|
||||
exception_id=exception_id,
|
||||
parent_id=parent_id,
|
||||
@@ -583,18 +581,23 @@ def exceptions_from_error(
|
||||
parent_id = exception_id
|
||||
exception_id += 1
|
||||
|
||||
should_supress_context = hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore
|
||||
should_supress_context = (
|
||||
hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore
|
||||
)
|
||||
if should_supress_context:
|
||||
# Add direct cause.
|
||||
# The field `__cause__` is set when raised with the exception (using the `from` keyword).
|
||||
exception_has_cause = exc_value and hasattr(exc_value, "__cause__") and exc_value.__cause__ is not None
|
||||
exception_has_cause = (
|
||||
exc_value
|
||||
and hasattr(exc_value, "__cause__")
|
||||
and exc_value.__cause__ is not None
|
||||
)
|
||||
if exception_has_cause:
|
||||
cause = exc_value.__cause__ # type: ignore
|
||||
(exception_id, child_exceptions) = exceptions_from_error(
|
||||
exc_type=type(cause),
|
||||
exc_value=cause,
|
||||
tb=getattr(cause, "__traceback__", None),
|
||||
client_options=client_options,
|
||||
mechanism=mechanism,
|
||||
exception_id=exception_id,
|
||||
source="__cause__",
|
||||
@@ -604,14 +607,17 @@ def exceptions_from_error(
|
||||
else:
|
||||
# Add indirect cause.
|
||||
# The field `__context__` is assigned if another exception occurs while handling the exception.
|
||||
exception_has_content = exc_value and hasattr(exc_value, "__context__") and exc_value.__context__ is not None
|
||||
exception_has_content = (
|
||||
exc_value
|
||||
and hasattr(exc_value, "__context__")
|
||||
and exc_value.__context__ is not None
|
||||
)
|
||||
if exception_has_content:
|
||||
context = exc_value.__context__ # type: ignore
|
||||
(exception_id, child_exceptions) = exceptions_from_error(
|
||||
exc_type=type(context),
|
||||
exc_value=context,
|
||||
tb=getattr(context, "__traceback__", None),
|
||||
client_options=client_options,
|
||||
mechanism=mechanism,
|
||||
exception_id=exception_id,
|
||||
source="__context__",
|
||||
@@ -626,7 +632,6 @@ def exceptions_from_error(
|
||||
exc_type=type(e),
|
||||
exc_value=e,
|
||||
tb=getattr(e, "__traceback__", None),
|
||||
client_options=client_options,
|
||||
mechanism=mechanism,
|
||||
exception_id=exception_id,
|
||||
parent_id=parent_id,
|
||||
@@ -639,20 +644,20 @@ def exceptions_from_error(
|
||||
|
||||
def exceptions_from_error_tuple(
|
||||
exc_info, # type: ExcInfo
|
||||
client_options=None, # type: Optional[Dict[str, Any]]
|
||||
mechanism=None, # type: Optional[Dict[str, Any]]
|
||||
):
|
||||
# type: (...) -> List[Dict[str, Any]]
|
||||
exc_type, exc_value, tb = exc_info
|
||||
|
||||
is_exception_group = BaseExceptionGroup is not None and isinstance(exc_value, BaseExceptionGroup)
|
||||
is_exception_group = BaseExceptionGroup is not None and isinstance(
|
||||
exc_value, BaseExceptionGroup
|
||||
)
|
||||
|
||||
if is_exception_group:
|
||||
(_, exceptions) = exceptions_from_error(
|
||||
exc_type=exc_type,
|
||||
exc_value=exc_value,
|
||||
tb=tb,
|
||||
client_options=client_options,
|
||||
mechanism=mechanism,
|
||||
exception_id=0,
|
||||
parent_id=0,
|
||||
@@ -661,7 +666,9 @@ def exceptions_from_error_tuple(
|
||||
else:
|
||||
exceptions = []
|
||||
for exc_type, exc_value, tb in walk_exception_chain(exc_info):
|
||||
exceptions.append(single_exception_from_error_tuple(exc_type, exc_value, tb, client_options, mechanism))
|
||||
exceptions.append(
|
||||
single_exception_from_error_tuple(exc_type, exc_value, tb, mechanism)
|
||||
)
|
||||
|
||||
exceptions.reverse()
|
||||
|
||||
@@ -749,11 +756,42 @@ def set_in_app_in_frames(frames, in_app_exclude, in_app_include, project_root=No
|
||||
return frames
|
||||
|
||||
|
||||
def exception_is_already_captured(error):
|
||||
# type: (ExceptionArg) -> bool
|
||||
if isinstance(error, BaseException):
|
||||
return hasattr(error, "__posthog_exception_captured")
|
||||
# Autocaptured exceptions are passed as a tuple from our system hooks,
|
||||
# the second item is the exception value (the first is the exception type)
|
||||
elif isinstance(error, tuple) and len(error) > 1:
|
||||
return error[1] is not None and hasattr(
|
||||
error[1], "__posthog_exception_captured"
|
||||
)
|
||||
else:
|
||||
return False # type: ignore[unreachable]
|
||||
|
||||
|
||||
def mark_exception_as_captured(error, uuid):
|
||||
# type: (ExceptionArg, str) -> None
|
||||
if isinstance(error, BaseException):
|
||||
setattr(error, "__posthog_exception_captured", True)
|
||||
setattr(error, "__posthog_exception_uuid", uuid)
|
||||
# Autocaptured exceptions are passed as a tuple from our system hooks,
|
||||
# the second item is the exception value (the first is the exception type)
|
||||
elif isinstance(error, tuple) and len(error) > 1:
|
||||
if error[1] is not None:
|
||||
setattr(error[1], "__posthog_exception_captured", True)
|
||||
setattr(error[1], "__posthog_exception_uuid", uuid)
|
||||
|
||||
|
||||
def exc_info_from_error(error):
|
||||
# type: (Union[BaseException, ExcInfo]) -> ExcInfo
|
||||
# type: (ExceptionArg) -> ExcInfo
|
||||
if isinstance(error, tuple) and len(error) == 3:
|
||||
exc_type, exc_value, tb = error
|
||||
elif isinstance(error, BaseException):
|
||||
try:
|
||||
construct_artificial_traceback(error)
|
||||
except Exception:
|
||||
pass
|
||||
tb = getattr(error, "__traceback__", None)
|
||||
if tb is not None:
|
||||
exc_type = type(error)
|
||||
@@ -778,21 +816,29 @@ def exc_info_from_error(error):
|
||||
return exc_info
|
||||
|
||||
|
||||
def event_from_exception(
|
||||
exc_info, # type: Union[BaseException, ExcInfo]
|
||||
client_options=None, # type: Optional[Dict[str, Any]]
|
||||
mechanism=None, # type: Optional[Dict[str, Any]]
|
||||
):
|
||||
# type: (...) -> Tuple[Event, Dict[str, Any]]
|
||||
exc_info = exc_info_from_error(exc_info)
|
||||
hint = event_hint_with_exc_info(exc_info)
|
||||
return (
|
||||
{
|
||||
"level": "error",
|
||||
"exception": {"values": exceptions_from_error_tuple(exc_info, client_options, mechanism)},
|
||||
},
|
||||
hint,
|
||||
)
|
||||
def construct_artificial_traceback(e):
|
||||
# type: (BaseException) -> None
|
||||
if getattr(e, "__traceback__", None) is not None:
|
||||
return
|
||||
|
||||
depth = 0
|
||||
frames = []
|
||||
while True:
|
||||
try:
|
||||
frame = sys._getframe(depth)
|
||||
depth += 1
|
||||
except ValueError:
|
||||
break
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
frames.reverse()
|
||||
|
||||
tb = None
|
||||
for frame in frames:
|
||||
tb = types.TracebackType(tb, frame, frame.f_lasti, frame.f_lineno)
|
||||
|
||||
setattr(e, "__traceback__", tb)
|
||||
|
||||
|
||||
def _module_in_list(name, items):
|
||||
@@ -813,7 +859,9 @@ def _module_in_list(name, items):
|
||||
def _is_external_source(abs_path):
|
||||
# type: (str) -> bool
|
||||
# check if frame is in 'site-packages' or 'dist-packages'
|
||||
external_source = re.search(r"[\\/](?:dist|site)-packages[\\/]", abs_path) is not None
|
||||
external_source = (
|
||||
re.search(r"[\\/](?:dist|site)-packages[\\/]", abs_path) is not None
|
||||
)
|
||||
return external_source
|
||||
|
||||
|
||||
@@ -874,3 +922,157 @@ def strip_string(value, max_length=None):
|
||||
"rem": [["!limit", "x", max_length - 3, max_length]],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _compile_patterns(patterns):
|
||||
compiled = []
|
||||
for pattern in patterns:
|
||||
try:
|
||||
compiled.append(re.compile(pattern))
|
||||
except:
|
||||
pass
|
||||
return compiled
|
||||
|
||||
|
||||
def _pattern_matches(name, patterns):
|
||||
for pattern in patterns:
|
||||
if pattern.search(name):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _serialize_variable_value(value, limiter, max_length=1024):
|
||||
try:
|
||||
if value is None:
|
||||
result = "None"
|
||||
elif isinstance(value, bool):
|
||||
result = str(value)
|
||||
elif isinstance(value, (int, float)):
|
||||
result_size = len(str(value))
|
||||
if not limiter.can_add(result_size):
|
||||
return None
|
||||
limiter.add(result_size)
|
||||
return value
|
||||
elif isinstance(value, str):
|
||||
result = value
|
||||
else:
|
||||
result = json.dumps(value)
|
||||
|
||||
if len(result) > max_length:
|
||||
result = result[: max_length - 3] + "..."
|
||||
|
||||
result_size = len(result)
|
||||
if not limiter.can_add(result_size):
|
||||
return None
|
||||
limiter.add(result_size)
|
||||
|
||||
return result
|
||||
except Exception:
|
||||
try:
|
||||
fallback = f"<{type(value).__name__}>"
|
||||
fallback_size = len(fallback)
|
||||
if not limiter.can_add(fallback_size):
|
||||
return None
|
||||
limiter.add(fallback_size)
|
||||
return fallback
|
||||
except Exception:
|
||||
fallback = "<unserializable object>"
|
||||
fallback_size = len(fallback)
|
||||
if not limiter.can_add(fallback_size):
|
||||
return None
|
||||
limiter.add(fallback_size)
|
||||
return fallback
|
||||
|
||||
|
||||
def _is_simple_type(value):
|
||||
return isinstance(value, (type(None), bool, int, float, str))
|
||||
|
||||
|
||||
def serialize_code_variables(
|
||||
frame, limiter, mask_patterns=None, ignore_patterns=None, max_length=1024
|
||||
):
|
||||
if mask_patterns is None:
|
||||
mask_patterns = []
|
||||
if ignore_patterns is None:
|
||||
ignore_patterns = []
|
||||
|
||||
compiled_mask = _compile_patterns(mask_patterns)
|
||||
compiled_ignore = _compile_patterns(ignore_patterns)
|
||||
|
||||
try:
|
||||
local_vars = frame.f_locals.copy()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
simple_vars = {}
|
||||
complex_vars = {}
|
||||
|
||||
for name, value in local_vars.items():
|
||||
if _pattern_matches(name, compiled_ignore):
|
||||
continue
|
||||
|
||||
if _is_simple_type(value):
|
||||
simple_vars[name] = value
|
||||
else:
|
||||
complex_vars[name] = value
|
||||
|
||||
result = {}
|
||||
|
||||
all_vars = {**simple_vars, **complex_vars}
|
||||
ordered_names = list(sorted(simple_vars.keys())) + list(sorted(complex_vars.keys()))
|
||||
|
||||
for name in ordered_names:
|
||||
value = all_vars[name]
|
||||
|
||||
if _pattern_matches(name, compiled_mask):
|
||||
redacted_value = CODE_VARIABLES_REDACTED_VALUE
|
||||
redacted_size = len(redacted_value)
|
||||
if not limiter.can_add(redacted_size):
|
||||
break
|
||||
limiter.add(redacted_size)
|
||||
result[name] = redacted_value
|
||||
else:
|
||||
serialized = _serialize_variable_value(value, limiter, max_length)
|
||||
if serialized is None:
|
||||
break
|
||||
result[name] = serialized
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def try_attach_code_variables_to_frames(
|
||||
all_exceptions, exc_info, mask_patterns, ignore_patterns
|
||||
):
|
||||
exc_type, exc_value, traceback = exc_info
|
||||
|
||||
if traceback is None:
|
||||
return
|
||||
|
||||
tb_frames = list(iter_stacks(traceback))
|
||||
|
||||
if not tb_frames:
|
||||
return
|
||||
|
||||
limiter = VariableSizeLimiter()
|
||||
|
||||
for exception in all_exceptions:
|
||||
stacktrace = exception.get("stacktrace")
|
||||
if not stacktrace or "frames" not in stacktrace:
|
||||
continue
|
||||
|
||||
serialized_frames = stacktrace["frames"]
|
||||
|
||||
for serialized_frame, tb_item in zip(serialized_frames, tb_frames):
|
||||
if not serialized_frame.get("in_app"):
|
||||
continue
|
||||
|
||||
variables = serialize_code_variables(
|
||||
tb_item.tb_frame,
|
||||
limiter,
|
||||
mask_patterns=mask_patterns,
|
||||
ignore_patterns=ignore_patterns,
|
||||
max_length=1024,
|
||||
)
|
||||
|
||||
if variables:
|
||||
serialized_frame["code_variables"] = variables
|
||||
|
||||
+314
-33
@@ -22,6 +22,18 @@ 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
|
||||
@@ -43,58 +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) -> FlagValue:
|
||||
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 []
|
||||
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")
|
||||
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) -> bool:
|
||||
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:
|
||||
@@ -103,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
|
||||
@@ -117,7 +324,9 @@ def match_property(property, property_values) -> bool:
|
||||
value = property.get("value")
|
||||
|
||||
if key not in property_values:
|
||||
raise InconclusiveMatchError("can't match properties without a given property value")
|
||||
raise InconclusiveMatchError(
|
||||
"can't match properties without a given property value"
|
||||
)
|
||||
|
||||
if operator == "is_not_set":
|
||||
raise InconclusiveMatchError("can't match properties with operator is_not_set")
|
||||
@@ -131,7 +340,9 @@ def match_property(property, property_values) -> bool:
|
||||
|
||||
def compute_exact_match(value, override_value):
|
||||
if isinstance(value, list):
|
||||
return str(override_value).casefold() in [str(val).casefold() for val in value]
|
||||
return str(override_value).casefold() in [
|
||||
str(val).casefold() for val in value
|
||||
]
|
||||
return utils.str_iequals(value, override_value)
|
||||
|
||||
if operator == "exact":
|
||||
@@ -149,10 +360,16 @@ def match_property(property, property_values) -> bool:
|
||||
return not utils.str_icontains(override_value, value)
|
||||
|
||||
if operator == "regex":
|
||||
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is not None
|
||||
return (
|
||||
is_valid_regex(str(value))
|
||||
and re.compile(str(value)).search(str(override_value)) is not None
|
||||
)
|
||||
|
||||
if operator == "not_regex":
|
||||
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is None
|
||||
return (
|
||||
is_valid_regex(str(value))
|
||||
and re.compile(str(value)).search(str(override_value)) is None
|
||||
)
|
||||
|
||||
if operator in ("gt", "gte", "lt", "lte"):
|
||||
# :TRICKY: We adjust comparison based on the override value passed in,
|
||||
@@ -191,10 +408,14 @@ def match_property(property, property_values) -> bool:
|
||||
parsed_date = parser.parse(str(value))
|
||||
parsed_date = convert_to_datetime_aware(parsed_date)
|
||||
except Exception as e:
|
||||
raise InconclusiveMatchError("The date set on the flag is not a valid format") from e
|
||||
raise InconclusiveMatchError(
|
||||
"The date set on the flag is not a valid format"
|
||||
) from e
|
||||
|
||||
if not parsed_date:
|
||||
raise InconclusiveMatchError("The date set on the flag is not a valid format")
|
||||
raise InconclusiveMatchError(
|
||||
"The date set on the flag is not a valid format"
|
||||
)
|
||||
|
||||
if isinstance(override_value, datetime.datetime):
|
||||
override_date = convert_to_datetime_aware(override_value)
|
||||
@@ -218,13 +439,22 @@ def match_property(property, property_values) -> bool:
|
||||
except Exception:
|
||||
raise InconclusiveMatchError("The date provided is not a valid format")
|
||||
else:
|
||||
raise InconclusiveMatchError("The date provided must be a string or date object")
|
||||
raise InconclusiveMatchError(
|
||||
"The date provided must be a string or date object"
|
||||
)
|
||||
|
||||
# if we get here, we don't know how to handle the operator
|
||||
raise InconclusiveMatchError(f"Unknown operator {operator}")
|
||||
|
||||
|
||||
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": {
|
||||
@@ -236,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
|
||||
|
||||
@@ -259,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
|
||||
@@ -267,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"
|
||||
|
||||
@@ -280,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)
|
||||
|
||||
@@ -298,18 +572,25 @@ 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]:
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
+52
-10
@@ -43,7 +43,12 @@ def determine_server_host(host: Optional[str]) -> str:
|
||||
|
||||
|
||||
def post(
|
||||
api_key: str, host: Optional[str] = None, path=None, gzip: bool = False, timeout: int = 15, **kwargs
|
||||
api_key: str,
|
||||
host: Optional[str] = None,
|
||||
path=None,
|
||||
gzip: bool = False,
|
||||
timeout: int = 15,
|
||||
**kwargs,
|
||||
) -> requests.Response:
|
||||
"""Post the `kwargs` to the API"""
|
||||
log = logging.getLogger("posthog")
|
||||
@@ -100,34 +105,71 @@ def _process_response(
|
||||
raise APIError(res.status_code, res.text)
|
||||
|
||||
|
||||
def decide(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any:
|
||||
def decide(
|
||||
api_key: str,
|
||||
host: Optional[str] = None,
|
||||
gzip: bool = False,
|
||||
timeout: int = 15,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
"""Post the `kwargs to the decide API endpoint"""
|
||||
res = post(api_key, host, "/decide/?v=4", gzip, timeout, **kwargs)
|
||||
return _process_response(res, success_message="Feature flags decided successfully")
|
||||
|
||||
|
||||
def flags(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any:
|
||||
def flags(
|
||||
api_key: str,
|
||||
host: Optional[str] = None,
|
||||
gzip: bool = False,
|
||||
timeout: int = 15,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
"""Post the `kwargs to the flags API endpoint"""
|
||||
res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
|
||||
return _process_response(res, success_message="Feature flags evaluated successfully")
|
||||
return _process_response(
|
||||
res, success_message="Feature flags evaluated successfully"
|
||||
)
|
||||
|
||||
|
||||
def remote_config(personal_api_key: str, host: Optional[str] = None, key: str = "", timeout: int = 15) -> Any:
|
||||
def remote_config(
|
||||
personal_api_key: str,
|
||||
project_api_key: str,
|
||||
host: Optional[str] = None,
|
||||
key: str = "",
|
||||
timeout: int = 15,
|
||||
) -> Any:
|
||||
"""Get remote config flag value from remote_config API endpoint"""
|
||||
return get(personal_api_key, f"/api/projects/@current/feature_flags/{key}/remote_config/", host, timeout)
|
||||
return get(
|
||||
personal_api_key,
|
||||
f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}",
|
||||
host,
|
||||
timeout,
|
||||
)
|
||||
|
||||
|
||||
def batch_post(
|
||||
api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs
|
||||
api_key: str,
|
||||
host: Optional[str] = None,
|
||||
gzip: bool = False,
|
||||
timeout: int = 15,
|
||||
**kwargs,
|
||||
) -> requests.Response:
|
||||
"""Post the `kwargs` to the batch API endpoint for events"""
|
||||
res = post(api_key, host, "/batch/", gzip, timeout, **kwargs)
|
||||
return _process_response(res, success_message="data uploaded successfully", return_json=False)
|
||||
return _process_response(
|
||||
res, success_message="data uploaded successfully", return_json=False
|
||||
)
|
||||
|
||||
|
||||
def get(api_key: str, url: str, host: Optional[str] = None, timeout: Optional[int] = None) -> requests.Response:
|
||||
def get(
|
||||
api_key: str, url: str, host: Optional[str] = None, timeout: Optional[int] = None
|
||||
) -> requests.Response:
|
||||
url = remove_trailing_slash(host or DEFAULT_HOST) + url
|
||||
res = requests.get(url, headers={"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}, timeout=timeout)
|
||||
res = requests.get(
|
||||
url,
|
||||
headers={"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT},
|
||||
timeout=timeout,
|
||||
)
|
||||
return _process_response(res, success_message=f"GET {url} completed successfully")
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -11,7 +11,9 @@ try:
|
||||
except ImportError:
|
||||
GEMINI_AVAILABLE = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(not GEMINI_AVAILABLE, reason="Google Gemini package is not available")
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not GEMINI_AVAILABLE, reason="Google Gemini package is not available"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -29,6 +31,9 @@ def mock_gemini_response():
|
||||
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()
|
||||
@@ -54,7 +59,94 @@ def mock_google_genai_client():
|
||||
yield mock_client_instance
|
||||
|
||||
|
||||
def test_new_client_basic_generation(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
@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."
|
||||
# Make hasattr(part, "text") return True
|
||||
type(mock_text_part1).text = mock_text_part1.text
|
||||
|
||||
# Mock text part 2
|
||||
mock_text_part2 = MagicMock()
|
||||
mock_text_part2.text = " Let me look that up."
|
||||
type(mock_text_part2).text = mock_text_part2.text
|
||||
|
||||
# Mock function call part - need to ensure hasattr() works correctly
|
||||
mock_function_part = MagicMock()
|
||||
mock_function_part.function_call = mock_function_call
|
||||
# Make hasattr(part, "function_call") return True
|
||||
type(mock_function_part).function_call = mock_function_part.function_call
|
||||
# Ensure hasattr(part, "text") returns False for the function part
|
||||
del mock_function_part.text
|
||||
|
||||
# Mock content with 2 text parts and 1 function call part
|
||||
mock_content = MagicMock()
|
||||
mock_content.parts = [mock_text_part1, mock_text_part2, mock_function_part]
|
||||
|
||||
# Mock candidate
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.content = mock_content
|
||||
mock_response.candidates = [mock_candidate]
|
||||
|
||||
return mock_response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gemini_response_function_calls_only():
|
||||
mock_response = MagicMock()
|
||||
|
||||
# Mock usage metadata
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.prompt_token_count = 30
|
||||
mock_usage.candidates_token_count = 12
|
||||
mock_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": "New York", "unit": "fahrenheit"}
|
||||
|
||||
# Mock function call part (no text part) - need to ensure hasattr() works correctly
|
||||
mock_function_part = MagicMock()
|
||||
mock_function_part.function_call = mock_function_call
|
||||
# Make hasattr(part, "function_call") return True
|
||||
type(mock_function_part).function_call = mock_function_part.function_call
|
||||
# Ensure hasattr(part, "text") returns False for the function part
|
||||
del mock_function_part.text
|
||||
|
||||
# Mock content with only function call part
|
||||
mock_content = MagicMock()
|
||||
mock_content.parts = [mock_function_part]
|
||||
|
||||
# Mock candidate
|
||||
mock_candidate = MagicMock()
|
||||
mock_candidate.content = mock_content
|
||||
mock_response.candidates = [mock_candidate]
|
||||
|
||||
return mock_response
|
||||
|
||||
|
||||
def test_new_client_basic_generation(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test the new Client/Models API structure"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
@@ -84,7 +176,9 @@ def test_new_client_basic_generation(mock_client, mock_google_genai_client, mock
|
||||
assert props["$ai_latency"] > 0
|
||||
|
||||
|
||||
def test_new_client_streaming_with_generate_content_stream(mock_client, mock_google_genai_client):
|
||||
def test_new_client_streaming_with_generate_content_stream(
|
||||
mock_client, mock_google_genai_client
|
||||
):
|
||||
"""Test the new generate_content_stream method"""
|
||||
|
||||
def mock_streaming_response():
|
||||
@@ -93,6 +187,8 @@ def test_new_client_streaming_with_generate_content_stream(mock_client, mock_goo
|
||||
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
|
||||
|
||||
mock_chunk2 = MagicMock()
|
||||
@@ -100,13 +196,17 @@ def test_new_client_streaming_with_generate_content_stream(mock_client, mock_goo
|
||||
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_chunk1
|
||||
yield mock_chunk2
|
||||
|
||||
# Mock the generate_content_stream method
|
||||
mock_google_genai_client.models.generate_content_stream.return_value = mock_streaming_response()
|
||||
mock_google_genai_client.models.generate_content_stream.return_value = (
|
||||
mock_streaming_response()
|
||||
)
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
@@ -137,6 +237,91 @@ def test_new_client_streaming_with_generate_content_stream(mock_client, mock_goo
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
|
||||
|
||||
def test_new_client_streaming_with_tools(mock_client, mock_google_genai_client):
|
||||
"""Test that tools are captured in streaming mode"""
|
||||
|
||||
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
|
||||
|
||||
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_chunk1
|
||||
yield mock_chunk2
|
||||
|
||||
# Mock the generate_content_stream method
|
||||
mock_google_genai_client.models.generate_content_stream.return_value = (
|
||||
mock_streaming_response()
|
||||
)
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
# Create mock tools configuration
|
||||
mock_tool = MagicMock()
|
||||
mock_tool.function_declarations = [
|
||||
MagicMock(
|
||||
name="get_current_weather",
|
||||
description="Gets the current weather for a given location.",
|
||||
parameters=MagicMock(
|
||||
type="OBJECT",
|
||||
properties={
|
||||
"location": MagicMock(
|
||||
type="STRING",
|
||||
description="The city and state, e.g. San Francisco, CA",
|
||||
)
|
||||
},
|
||||
required=["location"],
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.tools = [mock_tool]
|
||||
|
||||
response = client.models.generate_content_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 = list(response)
|
||||
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]
|
||||
|
||||
|
||||
def test_new_client_groups(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
"""Test groups functionality with new Client API"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
@@ -154,7 +339,9 @@ def test_new_client_groups(mock_client, mock_google_genai_client, mock_gemini_re
|
||||
assert call_args["groups"] == {"company": "company_123"}
|
||||
|
||||
|
||||
def test_new_client_privacy_mode_local(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
def test_new_client_privacy_mode_local(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test local privacy mode with new Client API"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
@@ -173,7 +360,9 @@ def test_new_client_privacy_mode_local(mock_client, mock_google_genai_client, mo
|
||||
assert props["$ai_output_choices"] is None
|
||||
|
||||
|
||||
def test_new_client_privacy_mode_global(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
def test_new_client_privacy_mode_global(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test global privacy mode with new Client API"""
|
||||
mock_client.privacy_mode = True
|
||||
|
||||
@@ -193,29 +382,57 @@ def test_new_client_privacy_mode_global(mock_client, mock_google_genai_client, m
|
||||
assert props["$ai_output_choices"] is None
|
||||
|
||||
|
||||
def test_new_client_different_input_formats(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
def test_new_client_different_input_formats(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test different input formats with new Client API"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
# Test string input
|
||||
client.models.generate_content(model="gemini-2.0-flash", contents="Hello", posthog_distinct_id="test-id")
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash", contents="Hello", posthog_distinct_id="test-id"
|
||||
)
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
|
||||
|
||||
# Test list input
|
||||
# Test Gemini-specific format with parts array (like in the screenshot)
|
||||
mock_client.reset_mock()
|
||||
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": "hey"}]
|
||||
|
||||
# Test multiple parts in the parts array
|
||||
mock_client.reset_mock()
|
||||
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": "Hello world"}]
|
||||
|
||||
# Test list input with string
|
||||
mock_client.capture.reset_mock()
|
||||
mock_part = MagicMock()
|
||||
mock_part.text = "List item"
|
||||
client.models.generate_content(model="gemini-2.0-flash", contents=[mock_part], posthog_distinct_id="test-id")
|
||||
client.models.generate_content(
|
||||
model="gemini-2.0-flash", contents=["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"}]
|
||||
|
||||
|
||||
def test_new_client_model_parameters(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
def test_new_client_model_parameters(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test model parameters with new Client API"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
@@ -235,7 +452,9 @@ def test_new_client_model_parameters(mock_client, mock_google_genai_client, mock
|
||||
assert props["$ai_model_parameters"]["max_tokens"] == 100
|
||||
|
||||
|
||||
def test_new_client_default_settings(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
def test_new_client_default_settings(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test client with default PostHog settings"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
@@ -259,7 +478,9 @@ def test_new_client_default_settings(mock_client, mock_google_genai_client, mock
|
||||
assert props["team"] == "ai"
|
||||
|
||||
|
||||
def test_new_client_override_defaults(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
def test_new_client_override_defaults(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test overriding client defaults per call"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
@@ -294,3 +515,584 @@ def test_new_client_override_defaults(mock_client, mock_google_genai_client, moc
|
||||
assert props["team"] == "ai" # from defaults
|
||||
assert props["feature"] == "chat" # from call
|
||||
assert props["urgent"] is True # from call
|
||||
|
||||
|
||||
def test_vertex_ai_parameters_passed_through(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test that Vertex AI parameters are properly passed to genai.Client"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
# Mock credentials object
|
||||
mock_credentials = MagicMock()
|
||||
mock_debug_config = MagicMock()
|
||||
mock_http_options = MagicMock()
|
||||
|
||||
# Create client with Vertex AI parameters
|
||||
Client(
|
||||
vertexai=True,
|
||||
credentials=mock_credentials,
|
||||
project="test-project",
|
||||
location="us-central1",
|
||||
debug_config=mock_debug_config,
|
||||
http_options=mock_http_options,
|
||||
posthog_client=mock_client,
|
||||
)
|
||||
|
||||
# Verify genai.Client was called with correct parameters
|
||||
google_genai.Client.assert_called_once_with(
|
||||
vertexai=True,
|
||||
credentials=mock_credentials,
|
||||
project="test-project",
|
||||
location="us-central1",
|
||||
debug_config=mock_debug_config,
|
||||
http_options=mock_http_options,
|
||||
)
|
||||
|
||||
|
||||
def test_api_key_mode(mock_client, mock_google_genai_client):
|
||||
"""Test API key authentication mode"""
|
||||
|
||||
# Create client with just API key (traditional mode)
|
||||
Client(
|
||||
api_key="test-api-key",
|
||||
posthog_client=mock_client,
|
||||
)
|
||||
|
||||
# Verify genai.Client was called with only api_key
|
||||
google_genai.Client.assert_called_once_with(api_key="test-api-key")
|
||||
|
||||
|
||||
def test_vertex_ai_mode_with_optional_api_key(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response
|
||||
):
|
||||
"""Test Vertex AI mode with optional API key"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
mock_credentials = MagicMock()
|
||||
|
||||
# Create client with Vertex AI + API key
|
||||
Client(
|
||||
vertexai=True,
|
||||
api_key="test-api-key",
|
||||
credentials=mock_credentials,
|
||||
project="test-project",
|
||||
posthog_client=mock_client,
|
||||
)
|
||||
|
||||
# Verify genai.Client was called with both Vertex AI params and API key
|
||||
google_genai.Client.assert_called_once_with(
|
||||
vertexai=True,
|
||||
api_key="test-api-key",
|
||||
credentials=mock_credentials,
|
||||
project="test-project",
|
||||
)
|
||||
|
||||
|
||||
def test_tool_use_response(mock_client, mock_google_genai_client, mock_gemini_response):
|
||||
"""Test that tools defined in config are captured in $ai_tools property"""
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
# Create mock tools configuration
|
||||
mock_tool = MagicMock()
|
||||
mock_tool.function_declarations = [
|
||||
MagicMock(
|
||||
name="get_current_weather",
|
||||
description="Gets the current weather for a given location.",
|
||||
parameters=MagicMock(
|
||||
type="OBJECT",
|
||||
properties={
|
||||
"location": MagicMock(
|
||||
type="STRING",
|
||||
description="The city and state, e.g. San Francisco, CA",
|
||||
)
|
||||
},
|
||||
required=["location"],
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.tools = [mock_tool]
|
||||
# Explicitly specify this config doesn't have system_instruction
|
||||
del mock_config.system_instruction
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents=["hey"],
|
||||
config=mock_config,
|
||||
posthog_distinct_id="test-id",
|
||||
posthog_properties={"foo": "bar"},
|
||||
)
|
||||
|
||||
assert response == mock_gemini_response
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "test-id"
|
||||
assert call_args["event"] == "$ai_generation"
|
||||
assert props["$ai_provider"] == "gemini"
|
||||
assert props["$ai_model"] == "gemini-2.5-flash"
|
||||
assert props["$ai_input"] == [{"role": "user", "content": "hey"}]
|
||||
assert props["$ai_output_choices"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "Test response from Gemini"}],
|
||||
}
|
||||
]
|
||||
assert props["$ai_input_tokens"] == 20
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
assert props["$ai_http_status"] == 200
|
||||
assert props["foo"] == "bar"
|
||||
assert isinstance(props["$ai_latency"], float)
|
||||
# Verify that tools are captured in the $ai_tools property
|
||||
assert props["$ai_tools"] == [mock_tool]
|
||||
|
||||
|
||||
def test_function_calls_in_output_choices(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response_with_function_calls
|
||||
):
|
||||
"""Test that function calls are properly included in $ai_output_choices"""
|
||||
mock_google_genai_client.models.generate_content.return_value = (
|
||||
mock_gemini_response_with_function_calls
|
||||
)
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents=["What's the weather in San Francisco?"],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
assert response == mock_gemini_response_with_function_calls
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "test-id"
|
||||
assert call_args["event"] == "$ai_generation"
|
||||
assert props["$ai_provider"] == "gemini"
|
||||
assert props["$ai_model"] == "gemini-2.5-flash"
|
||||
assert props["$ai_output_choices"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "I'll check the weather for you."},
|
||||
{"type": "text", "text": " Let me look that up."},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_current_weather",
|
||||
"arguments": {"location": "San Francisco"},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
# Check token usage
|
||||
assert props["$ai_input_tokens"] == 25
|
||||
assert props["$ai_output_tokens"] == 15
|
||||
assert props["$ai_http_status"] == 200
|
||||
|
||||
|
||||
def test_function_calls_only_no_content(
|
||||
mock_client, mock_google_genai_client, mock_gemini_response_function_calls_only
|
||||
):
|
||||
"""Test function calls without text content in $ai_output_choices"""
|
||||
mock_google_genai_client.models.generate_content.return_value = (
|
||||
mock_gemini_response_function_calls_only
|
||||
)
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents=["Get weather for New York"],
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
assert response == mock_gemini_response_function_calls_only
|
||||
assert mock_client.capture.call_count == 1
|
||||
|
||||
call_args = mock_client.capture.call_args[1]
|
||||
props = call_args["properties"]
|
||||
|
||||
assert call_args["distinct_id"] == "test-id"
|
||||
assert call_args["event"] == "$ai_generation"
|
||||
assert props["$ai_provider"] == "gemini"
|
||||
assert props["$ai_model"] == "gemini-2.5-flash"
|
||||
assert props["$ai_output_choices"] == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_current_weather",
|
||||
"arguments": {"location": "New York", "unit": "fahrenheit"},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
# Check token usage
|
||||
assert props["$ai_input_tokens"] == 30
|
||||
assert props["$ai_output_tokens"] == 12
|
||||
assert props["$ai_http_status"] == 200
|
||||
|
||||
|
||||
def test_cache_and_reasoning_tokens(mock_client, mock_google_genai_client):
|
||||
"""Test that cache and reasoning tokens are properly extracted"""
|
||||
# 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.models.generate_content.return_value = mock_response
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = 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
|
||||
|
||||
|
||||
def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_client):
|
||||
"""Test that cache and reasoning tokens are properly extracted in streaming"""
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
mock_stream = iter([chunk1, chunk2])
|
||||
mock_google_genai_client.models.generate_content_stream.return_value = mock_stream
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = client.models.generate_content_stream(
|
||||
model="gemini-2.5-pro",
|
||||
contents="Test streaming with cache",
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
# Consume the stream
|
||||
result = list(response)
|
||||
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
|
||||
|
||||
|
||||
def test_web_search_grounding(mock_client, mock_google_genai_client):
|
||||
"""Test 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 generate_content method
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_response
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
response = 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
|
||||
|
||||
|
||||
def test_streaming_with_web_search(mock_client, mock_google_genai_client):
|
||||
"""Test that web search count is properly captured in streaming mode."""
|
||||
|
||||
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]
|
||||
|
||||
# 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_chunk1
|
||||
yield mock_chunk2
|
||||
|
||||
# Mock the generate_content_stream method
|
||||
mock_google_genai_client.models.generate_content_stream.return_value = (
|
||||
mock_streaming_response()
|
||||
)
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = client.models.generate_content_stream(
|
||||
model="gemini-2.5-flash",
|
||||
contents="What's the latest news?",
|
||||
posthog_distinct_id="test-id",
|
||||
)
|
||||
|
||||
chunks = list(response)
|
||||
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
|
||||
|
||||
|
||||
def test_empty_grounding_metadata_no_web_search(mock_client, mock_google_genai_client):
|
||||
"""Test that empty grounding_metadata (all null fields) does not count as web search."""
|
||||
|
||||
# Create mock response with empty grounding metadata (all null fields)
|
||||
mock_response = MagicMock()
|
||||
|
||||
# Mock usage metadata
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.prompt_token_count = 10
|
||||
mock_usage.candidates_token_count = 10
|
||||
mock_usage.cached_content_token_count = 0
|
||||
mock_usage.thoughts_token_count = 0
|
||||
mock_response.usage_metadata = mock_usage
|
||||
|
||||
# Mock empty grounding metadata (all fields are None)
|
||||
mock_grounding_metadata = MagicMock()
|
||||
mock_grounding_metadata.web_search_queries = None
|
||||
mock_grounding_metadata.grounding_chunks = None
|
||||
mock_grounding_metadata.grounding_supports = None
|
||||
mock_grounding_metadata.retrieval_metadata = None
|
||||
mock_grounding_metadata.retrieval_queries = None
|
||||
mock_grounding_metadata.search_entry_point = None
|
||||
|
||||
# Mock text part
|
||||
mock_text_part = MagicMock()
|
||||
mock_text_part.text = "Hey there! How can I help you today?"
|
||||
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 empty 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 = "Hey there! How can I help you today?"
|
||||
|
||||
# Mock the generate_content method
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_response
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents="Hello",
|
||||
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 0 (not present in properties when 0)
|
||||
assert "$ai_web_search_count" not in props
|
||||
assert props["$ai_input_tokens"] == 10
|
||||
assert props["$ai_output_tokens"] == 10
|
||||
|
||||
|
||||
def test_empty_array_grounding_metadata_no_web_search(
|
||||
mock_client, mock_google_genai_client
|
||||
):
|
||||
"""Test that grounding_metadata with empty arrays does not count as web search."""
|
||||
|
||||
# Create mock response with grounding metadata having empty arrays
|
||||
mock_response = MagicMock()
|
||||
|
||||
# Mock usage metadata
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.prompt_token_count = 15
|
||||
mock_usage.candidates_token_count = 12
|
||||
mock_usage.cached_content_token_count = 0
|
||||
mock_usage.thoughts_token_count = 0
|
||||
mock_response.usage_metadata = mock_usage
|
||||
|
||||
# Mock grounding metadata with empty arrays
|
||||
mock_grounding_metadata = MagicMock()
|
||||
mock_grounding_metadata.web_search_queries = []
|
||||
mock_grounding_metadata.grounding_chunks = []
|
||||
mock_grounding_metadata.grounding_supports = []
|
||||
|
||||
# Mock text part
|
||||
mock_text_part = MagicMock()
|
||||
mock_text_part.text = "I can help with that."
|
||||
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 containing empty arrays
|
||||
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 = "I can help with that."
|
||||
|
||||
# Mock the generate_content method
|
||||
mock_google_genai_client.models.generate_content.return_value = mock_response
|
||||
|
||||
client = Client(api_key="test-key", posthog_client=mock_client)
|
||||
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.5-flash",
|
||||
contents="What can you do?",
|
||||
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 0 (not present in properties when 0)
|
||||
assert "$ai_web_search_count" not in props
|
||||
assert props["$ai_input_tokens"] == 15
|
||||
assert props["$ai_output_tokens"] == 12
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1368
-274
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
import unittest
|
||||
|
||||
from posthog.ai.sanitization import (
|
||||
redact_base64_data_url,
|
||||
sanitize_openai,
|
||||
sanitize_openai_response,
|
||||
sanitize_anthropic,
|
||||
sanitize_gemini,
|
||||
sanitize_langchain,
|
||||
is_base64_data_url,
|
||||
is_raw_base64,
|
||||
REDACTED_IMAGE_PLACEHOLDER,
|
||||
)
|
||||
|
||||
|
||||
class TestSanitization(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.sample_base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
|
||||
self.sample_base64_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA..."
|
||||
self.regular_url = "https://example.com/image.jpg"
|
||||
self.raw_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUl=="
|
||||
|
||||
def test_is_base64_data_url(self):
|
||||
self.assertTrue(is_base64_data_url(self.sample_base64_image))
|
||||
self.assertTrue(is_base64_data_url(self.sample_base64_png))
|
||||
self.assertFalse(is_base64_data_url(self.regular_url))
|
||||
self.assertFalse(is_base64_data_url("regular text"))
|
||||
|
||||
def test_is_raw_base64(self):
|
||||
self.assertTrue(is_raw_base64(self.raw_base64))
|
||||
self.assertFalse(is_raw_base64("short"))
|
||||
self.assertFalse(is_raw_base64(self.regular_url))
|
||||
self.assertFalse(is_raw_base64("/path/to/file"))
|
||||
|
||||
def test_redact_base64_data_url(self):
|
||||
self.assertEqual(
|
||||
redact_base64_data_url(self.sample_base64_image), REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(
|
||||
redact_base64_data_url(self.sample_base64_png), REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(redact_base64_data_url(self.regular_url), self.regular_url)
|
||||
self.assertEqual(redact_base64_data_url(None), None)
|
||||
self.assertEqual(redact_base64_data_url(123), 123)
|
||||
|
||||
def test_sanitize_openai(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What is in this image?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": self.sample_base64_image,
|
||||
"detail": "high",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
|
||||
self.assertEqual(result[0]["content"][0]["text"], "What is in this image?")
|
||||
self.assertEqual(
|
||||
result[0]["content"][1]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(result[0]["content"][1]["image_url"]["detail"], "high")
|
||||
|
||||
def test_sanitize_openai_preserves_regular_urls(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": self.regular_url},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(result[0]["content"][0]["image_url"]["url"], self.regular_url)
|
||||
|
||||
def test_sanitize_openai_response(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": self.sample_base64_image,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_openai_response(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["image_url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_sanitize_anthropic(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What is in this image?"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": "base64data",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_anthropic(input_data)
|
||||
|
||||
self.assertEqual(result[0]["content"][0]["text"], "What is in this image?")
|
||||
self.assertEqual(
|
||||
result[0]["content"][1]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(result[0]["content"][1]["source"]["type"], "base64")
|
||||
self.assertEqual(result[0]["content"][1]["source"]["media_type"], "image/jpeg")
|
||||
|
||||
def test_sanitize_gemini(self):
|
||||
input_data = [
|
||||
{
|
||||
"parts": [
|
||||
{"text": "What is in this image?"},
|
||||
{
|
||||
"inline_data": {
|
||||
"mime_type": "image/jpeg",
|
||||
"data": "base64data",
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_gemini(input_data)
|
||||
|
||||
self.assertEqual(result[0]["parts"][0]["text"], "What is in this image?")
|
||||
self.assertEqual(
|
||||
result[0]["parts"][1]["inline_data"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
self.assertEqual(
|
||||
result[0]["parts"][1]["inline_data"]["mime_type"], "image/jpeg"
|
||||
)
|
||||
|
||||
def test_sanitize_langchain_openai_style(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": self.sample_base64_image},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_langchain(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_sanitize_langchain_anthropic_style(self):
|
||||
input_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {"data": "base64data"},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = sanitize_langchain(input_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_sanitize_with_data_url_format(self):
|
||||
# Test that data URLs are properly detected and redacted across providers
|
||||
data_url = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD"
|
||||
|
||||
# OpenAI format
|
||||
openai_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "image_url", "image_url": {"url": data_url}}],
|
||||
}
|
||||
]
|
||||
result = sanitize_openai(openai_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
# Anthropic format
|
||||
anthropic_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": data_url,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
result = sanitize_anthropic(anthropic_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
# LangChain format
|
||||
langchain_data = [
|
||||
{"role": "user", "content": [{"type": "image", "data": data_url}]}
|
||||
]
|
||||
result = sanitize_langchain(langchain_data)
|
||||
self.assertEqual(result[0]["content"][0]["data"], REDACTED_IMAGE_PLACEHOLDER)
|
||||
|
||||
def test_sanitize_with_raw_base64(self):
|
||||
# Test that raw base64 strings (without data URL prefix) are detected
|
||||
raw_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUl=="
|
||||
|
||||
# Test with Anthropic format
|
||||
anthropic_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": raw_base64,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
result = sanitize_anthropic(anthropic_data)
|
||||
self.assertEqual(
|
||||
result[0]["content"][0]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
# Test with Gemini format
|
||||
gemini_data = [
|
||||
{"parts": [{"inline_data": {"mime_type": "image/png", "data": raw_base64}}]}
|
||||
]
|
||||
result = sanitize_gemini(gemini_data)
|
||||
self.assertEqual(
|
||||
result[0]["parts"][0]["inline_data"]["data"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
def test_sanitize_preserves_regular_content(self):
|
||||
# Ensure non-base64 content is preserved across all providers
|
||||
regular_url = "https://example.com/image.jpg"
|
||||
text_content = "What do you see?"
|
||||
|
||||
# OpenAI
|
||||
openai_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": text_content},
|
||||
{"type": "image_url", "image_url": {"url": regular_url}},
|
||||
],
|
||||
}
|
||||
]
|
||||
result = sanitize_openai(openai_data)
|
||||
self.assertEqual(result[0]["content"][0]["text"], text_content)
|
||||
self.assertEqual(result[0]["content"][1]["image_url"]["url"], regular_url)
|
||||
|
||||
# Anthropic
|
||||
anthropic_data = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": text_content},
|
||||
{"type": "image", "source": {"type": "url", "url": regular_url}},
|
||||
],
|
||||
}
|
||||
]
|
||||
result = sanitize_anthropic(anthropic_data)
|
||||
self.assertEqual(result[0]["content"][0]["text"], text_content)
|
||||
# URL-based images should remain unchanged
|
||||
self.assertEqual(result[0]["content"][1]["source"]["url"], regular_url)
|
||||
|
||||
def test_sanitize_handles_non_dict_content(self):
|
||||
input_data = [{"role": "user", "content": "Just text"}]
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(result, input_data)
|
||||
|
||||
def test_sanitize_handles_none_input(self):
|
||||
self.assertIsNone(sanitize_openai(None))
|
||||
self.assertIsNone(sanitize_anthropic(None))
|
||||
self.assertIsNone(sanitize_gemini(None))
|
||||
self.assertIsNone(sanitize_langchain(None))
|
||||
|
||||
def test_sanitize_handles_single_message(self):
|
||||
input_data = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": self.sample_base64_image},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
result = sanitize_openai(input_data)
|
||||
self.assertEqual(
|
||||
result["content"][0]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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"])
|
||||
@@ -1,3 +0,0 @@
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("django")
|
||||
@@ -1,68 +0,0 @@
|
||||
from posthog.exception_integrations.django import DjangoRequestExtractor
|
||||
|
||||
DEFAULT_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
|
||||
)
|
||||
|
||||
|
||||
def mock_request_factory(override_headers):
|
||||
class Request:
|
||||
META = {}
|
||||
# TRICKY: Actual django request dict object has case insensitive matching, and strips http from the names
|
||||
headers = {
|
||||
"User-Agent": DEFAULT_USER_AGENT,
|
||||
"Referrer": "http://example.com",
|
||||
"X-Forwarded-For": "193.4.5.12",
|
||||
**(override_headers or {}),
|
||||
}
|
||||
|
||||
return Request()
|
||||
|
||||
|
||||
def test_request_extractor_with_no_trace():
|
||||
request = mock_request_factory(None)
|
||||
extractor = DjangoRequestExtractor(request)
|
||||
assert extractor.extract_person_data() == {
|
||||
"ip": "193.4.5.12",
|
||||
"user_agent": DEFAULT_USER_AGENT,
|
||||
"traceparent": None,
|
||||
"distinct_id": None,
|
||||
}
|
||||
|
||||
|
||||
def test_request_extractor_with_trace():
|
||||
request = mock_request_factory({"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"})
|
||||
extractor = DjangoRequestExtractor(request)
|
||||
assert extractor.extract_person_data() == {
|
||||
"ip": "193.4.5.12",
|
||||
"user_agent": DEFAULT_USER_AGENT,
|
||||
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
|
||||
"distinct_id": None,
|
||||
}
|
||||
|
||||
|
||||
def test_request_extractor_with_tracestate():
|
||||
request = mock_request_factory(
|
||||
{
|
||||
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
|
||||
"tracestate": "posthog-distinct-id=1234",
|
||||
}
|
||||
)
|
||||
extractor = DjangoRequestExtractor(request)
|
||||
assert extractor.extract_person_data() == {
|
||||
"ip": "193.4.5.12",
|
||||
"user_agent": DEFAULT_USER_AGENT,
|
||||
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
|
||||
"distinct_id": "1234",
|
||||
}
|
||||
|
||||
|
||||
def test_request_extractor_with_complicated_tracestate():
|
||||
request = mock_request_factory({"tracestate": "posthog-distinct-id=alohaMountainsXUYZ,rojo=00f067aa0ba902b7"})
|
||||
extractor = DjangoRequestExtractor(request)
|
||||
assert extractor.extract_person_data() == {
|
||||
"ip": "193.4.5.12",
|
||||
"user_agent": DEFAULT_USER_AGENT,
|
||||
"traceparent": None,
|
||||
"distinct_id": "alohaMountainsXUYZ",
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
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
|
||||
request_filter = lambda req: 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")
|
||||
+1762
-625
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
|
||||
@@ -34,17 +34,51 @@ def test_excepthook(tmpdir):
|
||||
)
|
||||
|
||||
|
||||
def test_trying_to_use_django_integration(tmpdir):
|
||||
def test_code_variables_capture(tmpdir):
|
||||
app = tmpdir.join("app.py")
|
||||
app.write(
|
||||
dedent(
|
||||
"""
|
||||
from posthog import Posthog, Integrations
|
||||
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, exception_autocapture_integrations=[Integrations.Django], debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
|
||||
|
||||
# frame_value = "LOL"
|
||||
|
||||
1/0
|
||||
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_obj = UnserializableObject()
|
||||
my_password = "secret123" # Should be masked by default
|
||||
__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()
|
||||
"""
|
||||
)
|
||||
)
|
||||
@@ -55,9 +89,246 @@ def test_trying_to_use_django_integration(tmpdir):
|
||||
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
|
||||
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'"my_obj": "<UnserializableObject>"' in output
|
||||
assert b"'my_password': '$$_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
|
||||
|
||||
@@ -10,8 +10,17 @@ class TestFeatureFlag(unittest.TestCase):
|
||||
"key": "test-flag",
|
||||
"enabled": True,
|
||||
"variant": "test-variant",
|
||||
"reason": {"code": "matched_condition", "condition_index": 0, "description": "Matched condition set 1"},
|
||||
"metadata": {"id": 1, "payload": '{"some": "json"}', "version": 2, "description": "test-description"},
|
||||
"reason": {
|
||||
"code": "matched_condition",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
"metadata": {
|
||||
"id": 1,
|
||||
"payload": '{"some": "json"}',
|
||||
"version": 2,
|
||||
"description": "test-description",
|
||||
},
|
||||
}
|
||||
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
@@ -20,10 +29,21 @@ class TestFeatureFlag(unittest.TestCase):
|
||||
self.assertEqual(flag.variant, "test-variant")
|
||||
self.assertEqual(flag.get_value(), "test-variant")
|
||||
self.assertEqual(
|
||||
flag.reason, FlagReason(code="matched_condition", condition_index=0, description="Matched condition set 1")
|
||||
flag.reason,
|
||||
FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
flag.metadata, FlagMetadata(id=1, payload='{"some": "json"}', version=2, description="test-description")
|
||||
flag.metadata,
|
||||
FlagMetadata(
|
||||
id=1,
|
||||
payload='{"some": "json"}',
|
||||
version=2,
|
||||
description="test-description",
|
||||
),
|
||||
)
|
||||
|
||||
def test_feature_flag_from_json_minimal(self):
|
||||
@@ -44,7 +64,11 @@ class TestFeatureFlag(unittest.TestCase):
|
||||
"key": "test-flag",
|
||||
"enabled": True,
|
||||
"variant": "test-variant",
|
||||
"reason": {"code": "matched_condition", "condition_index": 0, "description": "Matched condition set 1"},
|
||||
"reason": {
|
||||
"code": "matched_condition",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1",
|
||||
},
|
||||
}
|
||||
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
@@ -53,13 +77,22 @@ class TestFeatureFlag(unittest.TestCase):
|
||||
self.assertEqual(flag.variant, "test-variant")
|
||||
self.assertEqual(flag.get_value(), "test-variant")
|
||||
self.assertEqual(
|
||||
flag.reason, FlagReason(code="matched_condition", condition_index=0, description="Matched condition set 1")
|
||||
flag.reason,
|
||||
FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None))
|
||||
|
||||
def test_flag_reason_from_json(self):
|
||||
# Test with complete data
|
||||
resp = {"code": "user_in_segment", "condition_index": 1, "description": "User is in segment 'beta_users'"}
|
||||
resp = {
|
||||
"code": "user_in_segment",
|
||||
"condition_index": 1,
|
||||
"description": "User is in segment 'beta_users'",
|
||||
}
|
||||
reason = FlagReason.from_json(resp)
|
||||
self.assertEqual(reason.code, "user_in_segment")
|
||||
self.assertEqual(reason.condition_index, 1)
|
||||
@@ -77,7 +110,12 @@ class TestFeatureFlag(unittest.TestCase):
|
||||
|
||||
def test_flag_metadata_from_json(self):
|
||||
# Test with complete data
|
||||
resp = {"id": 123, "payload": {"key": "value"}, "version": 1, "description": "Test flag"}
|
||||
resp = {
|
||||
"id": 123,
|
||||
"payload": {"key": "value"},
|
||||
"version": 1,
|
||||
"description": "Test flag",
|
||||
}
|
||||
metadata = FlagMetadata.from_json(resp)
|
||||
self.assertEqual(metadata.id, 123)
|
||||
self.assertEqual(metadata.payload, {"key": "value"})
|
||||
@@ -106,7 +144,12 @@ class TestFeatureFlag(unittest.TestCase):
|
||||
"condition_index": 1,
|
||||
"description": "User is in segment 'beta_users'",
|
||||
},
|
||||
"metadata": {"id": 123, "payload": {"key": "value"}, "version": 1, "description": "Test flag"},
|
||||
"metadata": {
|
||||
"id": 123,
|
||||
"payload": {"key": "value"},
|
||||
"version": 1,
|
||||
"description": "Test flag",
|
||||
},
|
||||
}
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
@@ -131,7 +174,11 @@ class TestFeatureFlag(unittest.TestCase):
|
||||
|
||||
def test_feature_flag_from_json_with_reason(self):
|
||||
# Test with reason but no metadata
|
||||
resp = {"key": "test-flag", "enabled": True, "reason": {"code": "user_in_segment"}}
|
||||
resp = {
|
||||
"key": "test-flag",
|
||||
"enabled": True,
|
||||
"reason": {"code": "user_in_segment"},
|
||||
}
|
||||
flag = FeatureFlag.from_json(resp)
|
||||
self.assertEqual(flag.key, "test-flag")
|
||||
self.assertTrue(flag.enabled)
|
||||
|
||||
@@ -9,7 +9,9 @@ from posthog.types import FeatureFlag, FeatureFlagResult, FlagMetadata, FlagReas
|
||||
|
||||
class TestFeatureFlagResult(unittest.TestCase):
|
||||
def test_from_bool_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload("test-flag", True, "[1, 2, 3]")
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", True, "[1, 2, 3]"
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
@@ -17,7 +19,9 @@ class TestFeatureFlagResult(unittest.TestCase):
|
||||
self.assertEqual(result.payload, [1, 2, 3])
|
||||
|
||||
def test_from_false_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload("test-flag", False, '{"some": "value"}')
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", False, '{"some": "value"}'
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, False)
|
||||
@@ -25,7 +29,9 @@ class TestFeatureFlagResult(unittest.TestCase):
|
||||
self.assertEqual(result.payload, {"some": "value"})
|
||||
|
||||
def test_from_variant_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload("test-flag", "control", "true")
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", "control", "true"
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
@@ -33,7 +39,9 @@ class TestFeatureFlagResult(unittest.TestCase):
|
||||
self.assertEqual(result.payload, True)
|
||||
|
||||
def test_from_none_value_and_payload(self):
|
||||
result = FeatureFlagResult.from_value_and_payload("test-flag", None, '{"some": "value"}')
|
||||
result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", None, '{"some": "value"}'
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_from_boolean_flag_details(self):
|
||||
@@ -41,8 +49,12 @@ class TestFeatureFlagResult(unittest.TestCase):
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='"Some string"'),
|
||||
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='"Some string"'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
@@ -57,11 +69,17 @@ class TestFeatureFlagResult(unittest.TestCase):
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='"Some string"'),
|
||||
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='"Some string"'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value="control")
|
||||
result = FeatureFlagResult.from_flag_details(
|
||||
flag_details, override_match_value="control"
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
@@ -73,11 +91,17 @@ class TestFeatureFlagResult(unittest.TestCase):
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant="control",
|
||||
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
|
||||
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='{"some": "value"}'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value=True)
|
||||
result = FeatureFlagResult.from_flag_details(
|
||||
flag_details, override_match_value=True
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, True)
|
||||
@@ -89,11 +113,17 @@ class TestFeatureFlagResult(unittest.TestCase):
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant="control",
|
||||
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
|
||||
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='{"some": "value"}'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value=False)
|
||||
result = FeatureFlagResult.from_flag_details(
|
||||
flag_details, override_match_value=False
|
||||
)
|
||||
|
||||
self.assertEqual(result.key, "test-flag")
|
||||
self.assertEqual(result.enabled, False)
|
||||
@@ -105,8 +135,12 @@ class TestFeatureFlagResult(unittest.TestCase):
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant="control",
|
||||
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
|
||||
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload='{"some": "value"}'
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
@@ -126,8 +160,12 @@ class TestFeatureFlagResult(unittest.TestCase):
|
||||
key="test-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload=None),
|
||||
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
|
||||
metadata=FlagMetadata(
|
||||
id=1, version=1, description="test-flag", payload=None
|
||||
),
|
||||
reason=FlagReason(
|
||||
code="test-reason", description="test-reason", condition_index=0
|
||||
),
|
||||
)
|
||||
|
||||
result = FeatureFlagResult.from_flag_details(flag_details)
|
||||
@@ -191,9 +229,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
self.assertEqual(flag_result.variant, None)
|
||||
self.assertEqual(flag_result.payload, 300)
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": True,
|
||||
@@ -245,9 +283,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
self.assertEqual(flag_result.payload, {"some": "value"})
|
||||
|
||||
patch_capture.assert_called_with(
|
||||
"distinct_id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
distinct_id="distinct_id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-1",
|
||||
"locally_evaluated": True,
|
||||
@@ -267,9 +305,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
self.assertIsNone(another_flag_result.payload)
|
||||
|
||||
patch_capture.assert_called_with(
|
||||
"another-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
distinct_id="another-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-2",
|
||||
"locally_evaluated": True,
|
||||
@@ -300,14 +338,16 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("person-flag", "some-distinct-id")
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"person-flag", "some-distinct-id"
|
||||
)
|
||||
self.assertEqual(flag_result.enabled, True)
|
||||
self.assertEqual(flag_result.variant, None)
|
||||
self.assertEqual(flag_result.payload, 300)
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": True,
|
||||
"locally_evaluated": False,
|
||||
@@ -348,9 +388,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
self.assertEqual(flag_result.get_value(), "variant-1")
|
||||
self.assertEqual(flag_result.payload, [1, 2, 3])
|
||||
patch_capture.assert_called_with(
|
||||
"distinct_id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
distinct_id="distinct_id",
|
||||
properties={
|
||||
"$feature_flag": "person-flag",
|
||||
"$feature_flag_response": "variant-1",
|
||||
"locally_evaluated": False,
|
||||
@@ -385,13 +425,15 @@ class TestGetFeatureFlagResult(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
|
||||
flag_result = self.client.get_feature_flag_result("no-person-flag", "some-distinct-id")
|
||||
flag_result = self.client.get_feature_flag_result(
|
||||
"no-person-flag", "some-distinct-id"
|
||||
)
|
||||
|
||||
self.assertIsNone(flag_result)
|
||||
patch_capture.assert_called_with(
|
||||
"some-distinct-id",
|
||||
"$feature_flag_called",
|
||||
{
|
||||
distinct_id="some-distinct-id",
|
||||
properties={
|
||||
"$feature_flag": "no-person-flag",
|
||||
"$feature_flag_response": None,
|
||||
"locally_evaluated": False,
|
||||
|
||||
+1948
-236
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -6,20 +6,35 @@ import mock
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from posthog.request import DatetimeSerializer, QuotaLimitError, batch_post, decide, determine_server_host
|
||||
from posthog.request import (
|
||||
DatetimeSerializer,
|
||||
QuotaLimitError,
|
||||
batch_post,
|
||||
decide,
|
||||
determine_server_host,
|
||||
)
|
||||
from posthog.test.test_utils import TEST_API_KEY
|
||||
|
||||
|
||||
class TestRequests(unittest.TestCase):
|
||||
def test_valid_request(self):
|
||||
res = batch_post(TEST_API_KEY, batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}])
|
||||
res = batch_post(
|
||||
TEST_API_KEY,
|
||||
batch=[
|
||||
{"distinct_id": "distinct_id", "event": "python event", "type": "track"}
|
||||
],
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_invalid_request_error(self):
|
||||
self.assertRaises(Exception, batch_post, "testsecret", "https://t.posthog.com", False, "[{]")
|
||||
self.assertRaises(
|
||||
Exception, batch_post, "testsecret", "https://t.posthog.com", False, "[{]"
|
||||
)
|
||||
|
||||
def test_invalid_host(self):
|
||||
self.assertRaises(Exception, batch_post, "testsecret", "t.posthog.com/", batch=[])
|
||||
self.assertRaises(
|
||||
Exception, batch_post, "testsecret", "t.posthog.com/", batch=[]
|
||||
)
|
||||
|
||||
def test_datetime_serialization(self):
|
||||
data = {"created": datetime(2012, 3, 4, 5, 6, 7, 891011)}
|
||||
@@ -35,14 +50,26 @@ class TestRequests(unittest.TestCase):
|
||||
|
||||
def test_should_not_timeout(self):
|
||||
res = batch_post(
|
||||
TEST_API_KEY, batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=15
|
||||
TEST_API_KEY,
|
||||
batch=[
|
||||
{"distinct_id": "distinct_id", "event": "python event", "type": "track"}
|
||||
],
|
||||
timeout=15,
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_should_timeout(self):
|
||||
with self.assertRaises(requests.ReadTimeout):
|
||||
batch_post(
|
||||
"key", batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=0.0001
|
||||
"key",
|
||||
batch=[
|
||||
{
|
||||
"distinct_id": "distinct_id",
|
||||
"event": "python event",
|
||||
"type": "track",
|
||||
}
|
||||
],
|
||||
timeout=0.0001,
|
||||
)
|
||||
|
||||
def test_quota_limited_response(self):
|
||||
@@ -68,7 +95,11 @@ class TestRequests(unittest.TestCase):
|
||||
mock_response = requests.Response()
|
||||
mock_response.status_code = 200
|
||||
mock_response._content = json.dumps(
|
||||
{"featureFlags": {"flag1": True}, "featureFlagPayloads": {}, "errorsWhileComputingFlags": False}
|
||||
{
|
||||
"featureFlags": {"flag1": True},
|
||||
"featureFlagPayloads": {},
|
||||
"errorsWhileComputingFlags": False,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
with mock.patch("posthog.request._session.post", return_value=mock_response):
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import unittest
|
||||
|
||||
from parameterized import parameterized
|
||||
|
||||
from posthog import utils
|
||||
|
||||
|
||||
class TestSizeLimitedDict(unittest.TestCase):
|
||||
@parameterized.expand([(10, 100), (5, 20), (20, 200)])
|
||||
def test_size_limited_dict(self, size: int, iterations: int) -> None:
|
||||
values = utils.SizeLimitedDict(size, lambda _: -1)
|
||||
|
||||
for i in range(iterations):
|
||||
values[i] = i
|
||||
|
||||
assert values[i] == i
|
||||
assert len(values) == i % size + 1
|
||||
|
||||
if i % size == 0:
|
||||
# old numbers should've been removed
|
||||
self.assertIsNone(values.get(i - 1))
|
||||
self.assertIsNone(values.get(i - 3))
|
||||
self.assertIsNone(values.get(i - 5))
|
||||
self.assertIsNone(values.get(i - 9))
|
||||
+43
-10
@@ -22,9 +22,16 @@ class TestTypes(unittest.TestCase):
|
||||
enabled=True,
|
||||
variant="test-variant",
|
||||
reason=FlagReason(
|
||||
code="matched_condition", condition_index=0, description="Matched condition set 1"
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
metadata=FlagMetadata(
|
||||
id=1,
|
||||
payload='{"some": "json"}',
|
||||
version=2,
|
||||
description="test-description",
|
||||
),
|
||||
metadata=FlagMetadata(id=1, payload='{"some": "json"}', version=2, description="test-description"),
|
||||
)
|
||||
},
|
||||
"errorsWhileComputingFlags": has_errors,
|
||||
@@ -39,10 +46,21 @@ class TestTypes(unittest.TestCase):
|
||||
self.assertEqual(flag.variant, "test-variant")
|
||||
self.assertEqual(flag.get_value(), "test-variant")
|
||||
self.assertEqual(
|
||||
flag.reason, FlagReason(code="matched_condition", condition_index=0, description="Matched condition set 1")
|
||||
flag.reason,
|
||||
FlagReason(
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
flag.metadata, FlagMetadata(id=1, payload='{"some": "json"}', version=2, description="test-description")
|
||||
flag.metadata,
|
||||
FlagMetadata(
|
||||
id=1,
|
||||
payload='{"some": "json"}',
|
||||
version=2,
|
||||
description="test-description",
|
||||
),
|
||||
)
|
||||
self.assertEqual(result["errorsWhileComputingFlags"], has_errors)
|
||||
self.assertEqual(result["requestId"], "test-id")
|
||||
@@ -64,7 +82,9 @@ class TestTypes(unittest.TestCase):
|
||||
self.assertEqual(flag.variant, "test-variant")
|
||||
self.assertEqual(flag.get_value(), "test-variant")
|
||||
self.assertIsNone(flag.reason)
|
||||
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload='{"some": "json-payload"}'))
|
||||
self.assertEqual(
|
||||
flag.metadata, LegacyFlagMetadata(payload='{"some": "json-payload"}')
|
||||
)
|
||||
self.assertFalse(result["errorsWhileComputingFlags"])
|
||||
self.assertEqual(result["requestId"], "test-id")
|
||||
# Verify legacy fields are removed
|
||||
@@ -99,18 +119,29 @@ class TestTypes(unittest.TestCase):
|
||||
enabled=True,
|
||||
variant="test-variant",
|
||||
reason=FlagReason(
|
||||
code="matched_condition", condition_index=0, description="Matched condition set 1"
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
metadata=FlagMetadata(
|
||||
id=1,
|
||||
payload='{"some": "json"}',
|
||||
version=2,
|
||||
description="test-description",
|
||||
),
|
||||
metadata=FlagMetadata(id=1, payload='{"some": "json"}', version=2, description="test-description"),
|
||||
),
|
||||
"my-boolean-flag": FeatureFlag(
|
||||
key="my-boolean-flag",
|
||||
enabled=True,
|
||||
variant=None,
|
||||
reason=FlagReason(
|
||||
code="matched_condition", condition_index=0, description="Matched condition set 1"
|
||||
code="matched_condition",
|
||||
condition_index=0,
|
||||
description="Matched condition set 1",
|
||||
),
|
||||
metadata=FlagMetadata(
|
||||
id=1, payload=None, version=2, description="test-description"
|
||||
),
|
||||
metadata=FlagMetadata(id=1, payload=None, version=2, description="test-description"),
|
||||
),
|
||||
"disabled-flag": FeatureFlag(
|
||||
key="disabled-flag",
|
||||
@@ -129,7 +160,9 @@ class TestTypes(unittest.TestCase):
|
||||
self.assertEqual(result["featureFlags"]["my-variant-flag"], "test-variant")
|
||||
self.assertEqual(result["featureFlags"]["my-boolean-flag"], True)
|
||||
self.assertEqual(result["featureFlags"]["disabled-flag"], False)
|
||||
self.assertEqual(result["featureFlagPayloads"]["my-variant-flag"], '{"some": "json"}')
|
||||
self.assertEqual(
|
||||
result["featureFlagPayloads"]["my-variant-flag"], '{"some": "json"}'
|
||||
)
|
||||
self.assertNotIn("my-boolean-flag", result["featureFlagPayloads"])
|
||||
self.assertNotIn("disabled-flag", result["featureFlagPayloads"])
|
||||
|
||||
|
||||
+190
-68
@@ -1,3 +1,4 @@
|
||||
import time
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
@@ -7,27 +8,41 @@ 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 = {
|
||||
@@ -54,12 +69,12 @@ class TestUtils(unittest.TestCase):
|
||||
pre_clean_keys = combined.keys()
|
||||
|
||||
utils.clean(combined)
|
||||
self.assertEqual(combined.keys(), pre_clean_keys)
|
||||
assert combined.keys() == pre_clean_keys
|
||||
|
||||
# test UUID separately, as the UUID object doesn't equal its string representation according to Python
|
||||
self.assertEqual(
|
||||
utils.clean(UUID("12345678123456781234567812345678")),
|
||||
"12345678-1234-5678-1234-567812345678",
|
||||
assert (
|
||||
utils.clean(UUID("12345678123456781234567812345678"))
|
||||
== "12345678-1234-5678-1234-567812345678"
|
||||
)
|
||||
|
||||
def test_clean_with_dates(self):
|
||||
@@ -67,26 +82,27 @@ class TestUtils(unittest.TestCase):
|
||||
"birthdate": date(1980, 1, 1),
|
||||
"registration": datetime.now(tz=tzutc()),
|
||||
}
|
||||
self.assertEqual(dict_with_dates, utils.clean(dict_with_dates))
|
||||
assert dict_with_dates == utils.clean(dict_with_dates)
|
||||
|
||||
def test_bytes(self):
|
||||
if six.PY3:
|
||||
item = bytes(10)
|
||||
else:
|
||||
item = bytearray(10)
|
||||
|
||||
item = bytes(10)
|
||||
utils.clean(item)
|
||||
assert utils.clean(item) == "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
|
||||
def test_clean_fn(self):
|
||||
cleaned = utils.clean({"fn": lambda x: x, "number": 4})
|
||||
self.assertEqual(cleaned["number"], 4)
|
||||
# TODO: fixme, different behavior on python 2 and 3
|
||||
if "fn" in cleaned:
|
||||
self.assertEqual(cleaned["fn"], None)
|
||||
assert cleaned == {"fn": None, "number": 4}
|
||||
|
||||
def test_remove_slash(self):
|
||||
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io/"))
|
||||
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io"))
|
||||
@parameterized.expand(
|
||||
[
|
||||
("http://posthog.io/", "http://posthog.io"),
|
||||
("http://posthog.io", "http://posthog.io"),
|
||||
("https://example.com/path/", "https://example.com/path"),
|
||||
("https://example.com/path", "https://example.com/path"),
|
||||
]
|
||||
)
|
||||
def test_remove_slash(self, input_url, expected_url):
|
||||
assert expected_url == utils.remove_trailing_slash(input_url)
|
||||
|
||||
def test_clean_pydantic(self):
|
||||
class ModelV2(BaseModel):
|
||||
@@ -101,19 +117,26 @@ class TestUtils(unittest.TestCase):
|
||||
class NestedModel(BaseModel):
|
||||
foo: ModelV2
|
||||
|
||||
self.assertEqual(utils.clean(ModelV2(foo="1", bar=2)), {"foo": "1", "bar": 2, "baz": None})
|
||||
self.assertEqual(utils.clean(ModelV1(foo=1, bar="2")), {"foo": 1, "bar": "2"})
|
||||
self.assertEqual(
|
||||
utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))),
|
||||
{"foo": {"foo": "1", "bar": 2, "baz": "3"}},
|
||||
)
|
||||
assert utils.clean(ModelV2(foo="1", bar=2)) == {
|
||||
"foo": "1",
|
||||
"bar": 2,
|
||||
"baz": None,
|
||||
}
|
||||
assert utils.clean(ModelV1(foo=1, bar="2")) == {"foo": 1, "bar": "2"}
|
||||
assert utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))) == {
|
||||
"foo": {"foo": "1", "bar": 2, "baz": "3"}
|
||||
}
|
||||
|
||||
def test_clean_pydantic_like_class(self) -> None:
|
||||
class Dummy:
|
||||
def model_dump(self, required_param):
|
||||
pass
|
||||
def model_dump(self, required_param: str) -> dict:
|
||||
return {}
|
||||
|
||||
# Skips a class with a defined non-Pydantic `model_dump` method.
|
||||
self.assertEqual(utils.clean({"test": Dummy()}), {})
|
||||
# previously python 2 code would cause an error while cleaning,
|
||||
# and this entire object would be None, and we would log an error
|
||||
# let's allow ourselves to clean `Dummy` as None,
|
||||
# without blatting the `test` key
|
||||
assert utils.clean({"test": Dummy()}) == {"test": None}
|
||||
|
||||
def test_clean_dataclass(self):
|
||||
@dataclass
|
||||
@@ -130,47 +153,146 @@ class TestUtils(unittest.TestCase):
|
||||
bar: int
|
||||
nested: InnerDataClass
|
||||
|
||||
self.assertEqual(
|
||||
utils.clean(
|
||||
TestDataClass(
|
||||
foo="1",
|
||||
bar=2,
|
||||
nested=InnerDataClass(
|
||||
inner_foo="3",
|
||||
inner_bar=4,
|
||||
inner_uuid=UUID("12345678123456781234567812345678"),
|
||||
inner_date=datetime(2025, 1, 1),
|
||||
),
|
||||
)
|
||||
),
|
||||
{
|
||||
"foo": "1",
|
||||
"bar": 2,
|
||||
"nested": {
|
||||
"inner_foo": "3",
|
||||
"inner_bar": 4,
|
||||
"inner_uuid": "12345678-1234-5678-1234-567812345678",
|
||||
"inner_date": datetime(2025, 1, 1),
|
||||
"inner_optional": None,
|
||||
},
|
||||
assert utils.clean(
|
||||
TestDataClass(
|
||||
foo="1",
|
||||
bar=2,
|
||||
nested=InnerDataClass(
|
||||
inner_foo="3",
|
||||
inner_bar=4,
|
||||
inner_uuid=UUID("12345678123456781234567812345678"),
|
||||
inner_date=datetime(2025, 1, 1),
|
||||
),
|
||||
)
|
||||
) == {
|
||||
"foo": "1",
|
||||
"bar": 2,
|
||||
"nested": {
|
||||
"inner_foo": "3",
|
||||
"inner_bar": 4,
|
||||
"inner_uuid": "12345678-1234-5678-1234-567812345678",
|
||||
"inner_date": datetime(2025, 1, 1),
|
||||
"inner_optional": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestFlagCache(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.cache = utils.FlagCache(max_size=3, default_ttl=1)
|
||||
self.flag_result = FeatureFlagResult.from_value_and_payload(
|
||||
"test-flag", True, None
|
||||
)
|
||||
|
||||
def test_cache_basic_operations(self):
|
||||
distinct_id = "user123"
|
||||
flag_key = "test-flag"
|
||||
flag_version = 1
|
||||
|
||||
class TestSizeLimitedDict(unittest.TestCase):
|
||||
def test_size_limited_dict(self):
|
||||
size = 10
|
||||
values = utils.SizeLimitedDict(size, lambda _: -1)
|
||||
# Test cache miss
|
||||
result = self.cache.get_cached_flag(distinct_id, flag_key, flag_version)
|
||||
assert result is None
|
||||
|
||||
for i in range(100):
|
||||
values[i] = i
|
||||
# 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()
|
||||
|
||||
self.assertEqual(values[i], i)
|
||||
self.assertEqual(len(values), i % size + 1)
|
||||
def test_cache_ttl_expiration(self):
|
||||
distinct_id = "user123"
|
||||
flag_key = "test-flag"
|
||||
flag_version = 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))
|
||||
# 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
|
||||
|
||||
+51
-9
@@ -1,9 +1,34 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, TypedDict, Union, cast
|
||||
from typing import Any, Callable, List, Optional, TypedDict, Union, cast
|
||||
|
||||
FlagValue = Union[bool, str]
|
||||
|
||||
# Type alias for the before_send callback function
|
||||
# Takes an event dictionary and returns the modified event or None to drop it
|
||||
BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]]
|
||||
|
||||
|
||||
# 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:
|
||||
@@ -78,7 +103,9 @@ class FeatureFlag:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_value_and_payload(cls, key: str, value: FlagValue, payload: Any) -> "FeatureFlag":
|
||||
def from_value_and_payload(
|
||||
cls, key: str, value: FlagValue, payload: Any
|
||||
) -> "FeatureFlag":
|
||||
enabled, variant = (True, value) if isinstance(value, str) else (value, None)
|
||||
return cls(
|
||||
key=key,
|
||||
@@ -86,7 +113,7 @@ class FeatureFlag:
|
||||
variant=variant,
|
||||
reason=None,
|
||||
metadata=LegacyFlagMetadata(
|
||||
payload=payload if payload else None,
|
||||
payload=payload,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -154,13 +181,17 @@ class FeatureFlagResult:
|
||||
key=key,
|
||||
enabled=enabled,
|
||||
variant=variant,
|
||||
payload=json.loads(payload) if isinstance(payload, str) else payload,
|
||||
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
|
||||
cls,
|
||||
details: Union[FeatureFlag, None],
|
||||
override_match_value: Optional[FlagValue] = None,
|
||||
) -> "FeatureFlagResult | None":
|
||||
"""
|
||||
Create a FeatureFlagResult from a FeatureFlag object.
|
||||
@@ -179,7 +210,9 @@ class FeatureFlagResult:
|
||||
|
||||
if override_match_value is not None:
|
||||
enabled, variant = (
|
||||
(True, override_match_value) if isinstance(override_match_value, str) else (override_match_value, None)
|
||||
(True, override_match_value)
|
||||
if isinstance(override_match_value, str)
|
||||
else (override_match_value, None)
|
||||
)
|
||||
else:
|
||||
enabled, variant = (details.enabled, details.variant)
|
||||
@@ -191,6 +224,7 @@ class FeatureFlagResult:
|
||||
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,
|
||||
@@ -226,7 +260,9 @@ def normalize_flags_response(resp: Any) -> FlagsResponse:
|
||||
# look at each key in featureFlags and create a FeatureFlag object
|
||||
flags = {}
|
||||
for key, value in featureFlags.items():
|
||||
flags[key] = FeatureFlag.from_value_and_payload(key, value, featureFlagPayloads.get(key, None))
|
||||
flags[key] = FeatureFlag.from_value_and_payload(
|
||||
key, value, featureFlagPayloads.get(key, None)
|
||||
)
|
||||
resp["flags"] = flags
|
||||
return cast(FlagsResponse, resp)
|
||||
|
||||
@@ -252,7 +288,11 @@ def to_values(response: FlagsResponse) -> Optional[dict[str, FlagValue]]:
|
||||
return None
|
||||
|
||||
flags = response.get("flags", {})
|
||||
return {key: value.get_value() for key, value in flags.items() if isinstance(value, FeatureFlag)}
|
||||
return {
|
||||
key: value.get_value()
|
||||
for key, value in flags.items()
|
||||
if isinstance(value, FeatureFlag)
|
||||
}
|
||||
|
||||
|
||||
def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]:
|
||||
@@ -262,5 +302,7 @@ def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]:
|
||||
return {
|
||||
key: value.metadata.payload
|
||||
for key, value in response.get("flags", {}).items()
|
||||
if isinstance(value, FeatureFlag) and value.enabled and value.metadata.payload
|
||||
if isinstance(value, FeatureFlag)
|
||||
and value.enabled
|
||||
and value.metadata.payload is not None
|
||||
}
|
||||
|
||||
+349
-6
@@ -1,11 +1,17 @@
|
||||
import json
|
||||
import logging
|
||||
import numbers
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
import sys
|
||||
import platform
|
||||
import distro # For Linux OS detection
|
||||
|
||||
import six
|
||||
from dateutil.tz import tzlocal, tzutc
|
||||
@@ -52,7 +58,9 @@ def clean(item):
|
||||
return float(item)
|
||||
if isinstance(item, UUID):
|
||||
return str(item)
|
||||
if isinstance(item, (six.string_types, bool, numbers.Number, datetime, date, type(None))):
|
||||
if isinstance(
|
||||
item, (six.string_types, bool, numbers.Number, datetime, date, type(None))
|
||||
):
|
||||
return item
|
||||
if isinstance(item, (set, list, tuple)):
|
||||
return _clean_list(item)
|
||||
@@ -99,14 +107,35 @@ def _clean_dataclass(dataclass_):
|
||||
return data
|
||||
|
||||
|
||||
def _coerce_unicode(cmplx):
|
||||
def _coerce_unicode(cmplx: Any) -> Optional[str]:
|
||||
"""
|
||||
In theory, this method is only called
|
||||
after many isinstance checks are carried out in `utils.clean`.
|
||||
When we supported Python 2 it was safe to call `decode` on a `str`
|
||||
but in Python 3 that will throw.
|
||||
So, we check if the input is bytes and only call `decode` in that case.
|
||||
|
||||
Previously we would always call `decode` on the input
|
||||
That would throw an error.
|
||||
Then we would call `decode` on the stringified error
|
||||
That would throw an error.
|
||||
And then we would return `None`
|
||||
|
||||
To avoid a breaking change, we can maintain the behavior
|
||||
that anything which did not have `decode` in Python 2
|
||||
returns None.
|
||||
"""
|
||||
item = None
|
||||
try:
|
||||
item = cmplx.decode("utf-8", "strict")
|
||||
except AttributeError as exception:
|
||||
item = ":".join(exception)
|
||||
item.decode("utf-8", "strict")
|
||||
if isinstance(cmplx, bytes):
|
||||
item = cmplx.decode("utf-8", "strict")
|
||||
elif isinstance(cmplx, str):
|
||||
item = cmplx
|
||||
except Exception as exception:
|
||||
item = ":".join(map(str, exception.args))
|
||||
log.warning("Error decoding: %s", item)
|
||||
return None
|
||||
|
||||
return item
|
||||
|
||||
|
||||
@@ -130,6 +159,266 @@ class SizeLimitedDict(defaultdict):
|
||||
super().__setitem__(key, value)
|
||||
|
||||
|
||||
class FlagCacheEntry:
|
||||
def __init__(self, flag_result, flag_definition_version, timestamp=None):
|
||||
self.flag_result = flag_result
|
||||
self.flag_definition_version = flag_definition_version
|
||||
self.timestamp = timestamp or time.time()
|
||||
|
||||
def is_valid(self, current_time, ttl, current_flag_version):
|
||||
time_valid = (current_time - self.timestamp) < ttl
|
||||
version_valid = self.flag_definition_version == current_flag_version
|
||||
return time_valid and version_valid
|
||||
|
||||
def is_stale_but_usable(self, current_time, max_stale_age=3600):
|
||||
return (current_time - self.timestamp) < max_stale_age
|
||||
|
||||
|
||||
class FlagCache:
|
||||
def __init__(self, max_size=10000, default_ttl=300):
|
||||
self.cache = {} # distinct_id -> {flag_key: FlagCacheEntry}
|
||||
self.access_times = {} # distinct_id -> last_access_time
|
||||
self.max_size = max_size
|
||||
self.default_ttl = default_ttl
|
||||
|
||||
def get_cached_flag(self, distinct_id, flag_key, current_flag_version):
|
||||
current_time = time.time()
|
||||
|
||||
if distinct_id not in self.cache:
|
||||
return None
|
||||
|
||||
user_flags = self.cache[distinct_id]
|
||||
if flag_key not in user_flags:
|
||||
return None
|
||||
|
||||
entry = user_flags[flag_key]
|
||||
if entry.is_valid(current_time, self.default_ttl, current_flag_version):
|
||||
self.access_times[distinct_id] = current_time
|
||||
return entry.flag_result
|
||||
|
||||
return None
|
||||
|
||||
def get_stale_cached_flag(self, distinct_id, flag_key, max_stale_age=3600):
|
||||
current_time = time.time()
|
||||
|
||||
if distinct_id not in self.cache:
|
||||
return None
|
||||
|
||||
user_flags = self.cache[distinct_id]
|
||||
if flag_key not in user_flags:
|
||||
return None
|
||||
|
||||
entry = user_flags[flag_key]
|
||||
if entry.is_stale_but_usable(current_time, max_stale_age):
|
||||
return entry.flag_result
|
||||
|
||||
return None
|
||||
|
||||
def set_cached_flag(
|
||||
self, distinct_id, flag_key, flag_result, flag_definition_version
|
||||
):
|
||||
current_time = time.time()
|
||||
|
||||
# Evict LRU users if we're at capacity
|
||||
if distinct_id not in self.cache and len(self.cache) >= self.max_size:
|
||||
self._evict_lru()
|
||||
|
||||
# Initialize user cache if needed
|
||||
if distinct_id not in self.cache:
|
||||
self.cache[distinct_id] = {}
|
||||
|
||||
# Store the flag result
|
||||
self.cache[distinct_id][flag_key] = FlagCacheEntry(
|
||||
flag_result, flag_definition_version, current_time
|
||||
)
|
||||
self.access_times[distinct_id] = current_time
|
||||
|
||||
def invalidate_version(self, old_version):
|
||||
users_to_remove = []
|
||||
|
||||
for distinct_id, user_flags in self.cache.items():
|
||||
flags_to_remove = []
|
||||
for flag_key, entry in user_flags.items():
|
||||
if entry.flag_definition_version == old_version:
|
||||
flags_to_remove.append(flag_key)
|
||||
|
||||
# Remove invalidated flags
|
||||
for flag_key in flags_to_remove:
|
||||
del user_flags[flag_key]
|
||||
|
||||
# Remove user entirely if no flags remain
|
||||
if not user_flags:
|
||||
users_to_remove.append(distinct_id)
|
||||
|
||||
# Clean up empty users
|
||||
for distinct_id in users_to_remove:
|
||||
del self.cache[distinct_id]
|
||||
if distinct_id in self.access_times:
|
||||
del self.access_times[distinct_id]
|
||||
|
||||
def _evict_lru(self):
|
||||
if not self.access_times:
|
||||
return
|
||||
|
||||
# Remove 20% of least recently used entries
|
||||
sorted_users = sorted(self.access_times.items(), key=lambda x: x[1])
|
||||
to_remove = max(1, len(sorted_users) // 5)
|
||||
|
||||
for distinct_id, _ in sorted_users[:to_remove]:
|
||||
if distinct_id in self.cache:
|
||||
del self.cache[distinct_id]
|
||||
if distinct_id in self.access_times:
|
||||
del self.access_times[distinct_id]
|
||||
|
||||
def clear(self):
|
||||
self.cache.clear()
|
||||
self.access_times.clear()
|
||||
|
||||
|
||||
class RedisFlagCache:
|
||||
def __init__(
|
||||
self, redis_client, default_ttl=300, stale_ttl=3600, key_prefix="posthog:flags:"
|
||||
):
|
||||
self.redis = redis_client
|
||||
self.default_ttl = default_ttl
|
||||
self.stale_ttl = stale_ttl
|
||||
self.key_prefix = key_prefix
|
||||
self.version_key = f"{key_prefix}version"
|
||||
|
||||
def _get_cache_key(self, distinct_id, flag_key):
|
||||
return f"{self.key_prefix}{distinct_id}:{flag_key}"
|
||||
|
||||
def _serialize_entry(self, flag_result, flag_definition_version, timestamp=None):
|
||||
if timestamp is None:
|
||||
timestamp = time.time()
|
||||
|
||||
# Use clean to make flag_result JSON-serializable for cross-platform compatibility
|
||||
serialized_result = clean(flag_result)
|
||||
|
||||
entry = {
|
||||
"flag_result": serialized_result,
|
||||
"flag_version": flag_definition_version,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
return json.dumps(entry)
|
||||
|
||||
def _deserialize_entry(self, data):
|
||||
try:
|
||||
entry = json.loads(data)
|
||||
flag_result = entry["flag_result"]
|
||||
return FlagCacheEntry(
|
||||
flag_result=flag_result,
|
||||
flag_definition_version=entry["flag_version"],
|
||||
timestamp=entry["timestamp"],
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, ValueError):
|
||||
# If deserialization fails, treat as cache miss
|
||||
return None
|
||||
|
||||
def get_cached_flag(self, distinct_id, flag_key, current_flag_version):
|
||||
try:
|
||||
cache_key = self._get_cache_key(distinct_id, flag_key)
|
||||
data = self.redis.get(cache_key)
|
||||
|
||||
if data:
|
||||
entry = self._deserialize_entry(data)
|
||||
if entry and entry.is_valid(
|
||||
time.time(), self.default_ttl, current_flag_version
|
||||
):
|
||||
return entry.flag_result
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
# Redis error - return None to fall back to normal evaluation
|
||||
return None
|
||||
|
||||
def get_stale_cached_flag(self, distinct_id, flag_key, max_stale_age=None):
|
||||
try:
|
||||
if max_stale_age is None:
|
||||
max_stale_age = self.stale_ttl
|
||||
|
||||
cache_key = self._get_cache_key(distinct_id, flag_key)
|
||||
data = self.redis.get(cache_key)
|
||||
|
||||
if data:
|
||||
entry = self._deserialize_entry(data)
|
||||
if entry and entry.is_stale_but_usable(time.time(), max_stale_age):
|
||||
return entry.flag_result
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
# Redis error - return None
|
||||
return None
|
||||
|
||||
def set_cached_flag(
|
||||
self, distinct_id, flag_key, flag_result, flag_definition_version
|
||||
):
|
||||
try:
|
||||
cache_key = self._get_cache_key(distinct_id, flag_key)
|
||||
serialized_entry = self._serialize_entry(
|
||||
flag_result, flag_definition_version
|
||||
)
|
||||
|
||||
# Set with TTL for automatic cleanup (use stale_ttl for total lifetime)
|
||||
self.redis.setex(cache_key, self.stale_ttl, serialized_entry)
|
||||
|
||||
# Update the current version
|
||||
self.redis.set(self.version_key, flag_definition_version)
|
||||
|
||||
except Exception:
|
||||
# Redis error - silently fail, don't break flag evaluation
|
||||
pass
|
||||
|
||||
def invalidate_version(self, old_version):
|
||||
try:
|
||||
# For Redis, we use a simple approach: scan for keys with old version
|
||||
# and delete them. This could be expensive with many keys, but it's
|
||||
# necessary for correctness.
|
||||
|
||||
cursor = 0
|
||||
pattern = f"{self.key_prefix}*"
|
||||
|
||||
while True:
|
||||
cursor, keys = self.redis.scan(cursor, match=pattern, count=100)
|
||||
|
||||
for key in keys:
|
||||
if key.decode() == self.version_key:
|
||||
continue
|
||||
|
||||
try:
|
||||
data = self.redis.get(key)
|
||||
if data:
|
||||
entry_dict = json.loads(data)
|
||||
if entry_dict.get("flag_version") == old_version:
|
||||
self.redis.delete(key)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
# If we can't parse the entry, delete it to be safe
|
||||
self.redis.delete(key)
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
except Exception:
|
||||
# Redis error - silently fail
|
||||
pass
|
||||
|
||||
def clear(self):
|
||||
try:
|
||||
# Delete all keys matching our pattern
|
||||
cursor = 0
|
||||
pattern = f"{self.key_prefix}*"
|
||||
|
||||
while True:
|
||||
cursor, keys = self.redis.scan(cursor, match=pattern, count=100)
|
||||
if keys:
|
||||
self.redis.delete(*keys)
|
||||
if cursor == 0:
|
||||
break
|
||||
except Exception:
|
||||
# Redis error - silently fail
|
||||
pass
|
||||
|
||||
|
||||
def convert_to_datetime_aware(date_obj):
|
||||
if date_obj.tzinfo is None:
|
||||
date_obj = date_obj.replace(tzinfo=timezone.utc)
|
||||
@@ -174,3 +463,57 @@ def str_iequals(value, comparand):
|
||||
False
|
||||
"""
|
||||
return str(value).casefold() == str(comparand).casefold()
|
||||
|
||||
|
||||
def get_os_info():
|
||||
"""
|
||||
Returns standardized OS name and version information.
|
||||
Similar to how user agent parsing works in JS.
|
||||
"""
|
||||
os_name = ""
|
||||
os_version = ""
|
||||
|
||||
platform_name = sys.platform
|
||||
|
||||
if platform_name.startswith("win"):
|
||||
os_name = "Windows"
|
||||
if hasattr(platform, "win32_ver"):
|
||||
win_version = platform.win32_ver()[0]
|
||||
if win_version:
|
||||
os_version = win_version
|
||||
|
||||
elif platform_name == "darwin":
|
||||
os_name = "Mac OS X"
|
||||
if hasattr(platform, "mac_ver"):
|
||||
mac_version = platform.mac_ver()[0]
|
||||
if mac_version:
|
||||
os_version = mac_version
|
||||
|
||||
elif platform_name.startswith("linux"):
|
||||
os_name = "Linux"
|
||||
linux_info = distro.info()
|
||||
if linux_info["version"]:
|
||||
os_version = linux_info["version"]
|
||||
|
||||
elif platform_name.startswith("freebsd"):
|
||||
os_name = "FreeBSD"
|
||||
if hasattr(platform, "release"):
|
||||
os_version = platform.release()
|
||||
|
||||
else:
|
||||
os_name = platform_name
|
||||
if hasattr(platform, "release"):
|
||||
os_version = platform.release()
|
||||
|
||||
return os_name, os_version
|
||||
|
||||
|
||||
def system_context() -> dict[str, Any]:
|
||||
os_name, os_version = get_os_info()
|
||||
|
||||
return {
|
||||
"$python_runtime": platform.python_implementation(),
|
||||
"$python_version": "%s.%s.%s" % (sys.version_info[:3]),
|
||||
"$os": os_name,
|
||||
"$os_version": os_version,
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
VERSION = "4.2.0"
|
||||
VERSION = "6.9.0"
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(VERSION, end="") # noqa: T201
|
||||
|
||||
+99
-9
@@ -1,10 +1,100 @@
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.isort]
|
||||
multi_line_output = 3
|
||||
include_trailing_comma = true
|
||||
force_grid_wrap = 8
|
||||
ensure_newline_before_comments = true
|
||||
line_length = 120
|
||||
virtual_env = "env"
|
||||
[project]
|
||||
name = "posthog"
|
||||
dynamic = ["version"]
|
||||
description = "Integrate PostHog into any python application."
|
||||
authors = [{ name = "PostHog", email = "hey@posthog.com" }]
|
||||
maintainers = [{ name = "PostHog", email = "hey@posthog.com" }]
|
||||
license = { text = "MIT" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Operating System :: OS Independent",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
dependencies = [
|
||||
"requests>=2.7,<3.0",
|
||||
"six>=1.5",
|
||||
"python-dateutil>=2.2",
|
||||
"backoff>=1.10.0",
|
||||
"distro>=1.5.0",
|
||||
"typing-extensions>=4.2.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/posthog/posthog-python"
|
||||
Repository = "https://github.com/posthog/posthog-python"
|
||||
|
||||
[project.optional-dependencies]
|
||||
langchain = ["langchain>=0.2.0"]
|
||||
dev = [
|
||||
"django-stubs",
|
||||
"lxml",
|
||||
"mypy",
|
||||
"mypy-baseline",
|
||||
"types-mock",
|
||||
"types-python-dateutil",
|
||||
"types-requests",
|
||||
"types-setuptools",
|
||||
"types-six",
|
||||
"pre-commit",
|
||||
"pydantic",
|
||||
"ruff",
|
||||
"setuptools",
|
||||
"packaging",
|
||||
"wheel",
|
||||
"twine",
|
||||
"tomli",
|
||||
"tomli_w",
|
||||
]
|
||||
test = [
|
||||
"mock>=2.0.0",
|
||||
"freezegun==1.5.1",
|
||||
"coverage",
|
||||
"pytest",
|
||||
"pytest-timeout",
|
||||
"pytest-asyncio",
|
||||
"django",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"langgraph>=0.4.8",
|
||||
"langchain-core>=0.3.65",
|
||||
"langchain-community>=0.3.25",
|
||||
"langchain-openai>=0.3.22",
|
||||
"langchain-anthropic>=0.3.15",
|
||||
"google-genai",
|
||||
"pydantic",
|
||||
"parameterized>=0.8.1",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
packages = [
|
||||
"posthog",
|
||||
"posthog.ai",
|
||||
"posthog.ai.langchain",
|
||||
"posthog.ai.openai",
|
||||
"posthog.ai.anthropic",
|
||||
"posthog.ai.gemini",
|
||||
"posthog.test",
|
||||
"posthog.integrations",
|
||||
]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = { attr = "posthog.version.VERSION" }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
testpaths = ["posthog/test"]
|
||||
norecursedirs = ["integration_tests"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user